@dpkit/zenodo
Version:
Fast TypeScript data management framework built on top of the Data Package standard and Polars DataFrames
632 lines (561 loc) • 17.5 kB
text/typescript
import { relative } from "node:path"
import type { Package } from "@dpkit/core"
import { loadPackageDescriptor } from "@dpkit/core"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { savePackageToZenodo } from "./save.ts"
describe("savePackageToZenodo", () => {
const getFixturePath = (name: string) =>
relative(process.cwd(), `${import.meta.dirname}/fixtures/${name}`)
const mockPackage: Package = {
name: "test-package",
title: "Test Package",
description: "A test package",
version: "1.0.0",
resources: [
{
name: "test-resource",
path: getFixturePath("data.csv"),
format: "csv",
bytes: 100,
},
],
}
const mockOptions = {
apiKey: "test-api-key",
sandbox: true,
}
const originalFetch = globalThis.fetch
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock
})
afterEach(() => {
globalThis.fetch = originalFetch
vi.resetAllMocks()
})
it.skip("should save a package", async () => {
const dataPackage = await loadPackageDescriptor(
"core/package/fixtures/package.json",
)
const result = await savePackageToZenodo(dataPackage, {
apiKey: "<key>",
sandbox: true,
})
expect(result).toBeDefined()
})
it("creates a deposition in Zenodo with correct API calls", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "data.csv",
filesize: 100,
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-2",
filename: "datapackage.json",
filesize: 500,
}),
})
const result = await savePackageToZenodo(mockPackage, mockOptions)
expect(fetchMock).toHaveBeenCalledTimes(3)
const depositionCreateCall = fetchMock.mock.calls[0]
expect(depositionCreateCall).toBeDefined()
if (!depositionCreateCall) return
expect(depositionCreateCall[0]).toContain(
"https://sandbox.zenodo.org/api/deposit/depositions",
)
expect(depositionCreateCall[0]).toContain("access_token=test-api-key")
expect(depositionCreateCall[1]).toMatchObject({
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
const depositionPayload = JSON.parse(depositionCreateCall[1].body)
expect(depositionPayload.metadata.title).toEqual("Test Package")
expect(depositionPayload.metadata.description).toEqual("A test package")
expect(result).toEqual({
path: "https://sandbox.zenodo.org/records/12345/files/datapackage.json",
datasetUrl: "https://sandbox.zenodo.org/uploads/12345",
})
})
it("uploads resource files to deposition", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "data.csv",
filesize: 100,
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-2",
filename: "datapackage.json",
filesize: 500,
}),
})
await savePackageToZenodo(mockPackage, mockOptions)
const fileUploadCall = fetchMock.mock.calls[1]
expect(fileUploadCall).toBeDefined()
if (!fileUploadCall) return
expect(fileUploadCall[0]).toContain(
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
)
expect(fileUploadCall[0]).toContain("access_token=test-api-key")
expect(fileUploadCall[1]).toMatchObject({
method: "POST",
})
const formData = fileUploadCall[1].body
expect(formData).toBeInstanceOf(FormData)
})
it("uploads datapackage.json metadata file", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "data.csv",
filesize: 100,
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-2",
filename: "datapackage.json",
filesize: 500,
}),
})
await savePackageToZenodo(mockPackage, mockOptions)
const datapackageUploadCall = fetchMock.mock.calls[2]
expect(datapackageUploadCall).toBeDefined()
if (!datapackageUploadCall) return
expect(datapackageUploadCall[0]).toContain(
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
)
const formData = datapackageUploadCall[1].body
expect(formData).toBeInstanceOf(FormData)
const fileBlob = formData.get("file")
expect(fileBlob).toBeInstanceOf(Blob)
})
it("uses production API when sandbox is false", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://zenodo.org/api/deposit/depositions/12345",
html: "https://zenodo.org/deposit/12345",
files: "https://zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "data.csv",
}),
})
await savePackageToZenodo(mockPackage, {
apiKey: "test-api-key",
sandbox: false,
})
const depositionCreateCall = fetchMock.mock.calls[0]
expect(depositionCreateCall).toBeDefined()
if (!depositionCreateCall) return
expect(depositionCreateCall[0]).toContain("https://zenodo.org/api")
expect(depositionCreateCall[0]).not.toContain("sandbox")
})
it("passes API key as access_token query parameter", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
await savePackageToZenodo(mockPackage, {
apiKey: "custom-api-key",
sandbox: true,
})
const depositionCreateCall = fetchMock.mock.calls[0]
expect(depositionCreateCall).toBeDefined()
if (!depositionCreateCall) return
expect(depositionCreateCall[0]).toContain("access_token=custom-api-key")
})
it("handles API errors from deposition creation", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 400,
statusText: "Bad Request",
text: () => Promise.resolve("Invalid deposition data"),
})
await expect(savePackageToZenodo(mockPackage, mockOptions)).rejects.toThrow(
"Zenodo API error: 400 Bad Request",
)
})
it("handles API errors from file upload", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: () => Promise.resolve("Failed to upload file"),
})
await expect(savePackageToZenodo(mockPackage, mockOptions)).rejects.toThrow(
"Zenodo API error: 500 Internal Server Error",
)
})
it("handles packages with multiple resources", async () => {
const multiResourcePackage: Package = {
...mockPackage,
resources: [
{
name: "resource-1",
path: getFixturePath("data.csv"),
format: "csv",
},
{
name: "resource-2",
path: getFixturePath("data.csv"),
format: "json",
},
],
}
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
await savePackageToZenodo(multiResourcePackage, mockOptions)
expect(fetchMock).toHaveBeenCalledTimes(4)
const secondFileUploadCall = fetchMock.mock.calls[2]
expect(secondFileUploadCall).toBeDefined()
if (!secondFileUploadCall) return
expect(secondFileUploadCall[0]).toContain("/files")
})
it("handles packages with no resources", async () => {
const emptyPackage: Package = {
...mockPackage,
resources: [],
}
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "datapackage.json",
filesize: 500,
}),
})
const result = await savePackageToZenodo(emptyPackage, mockOptions)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.datasetUrl).toEqual(
"https://sandbox.zenodo.org/uploads/12345",
)
})
it("skips resources without path", async () => {
const packageWithoutPath: Package = {
...mockPackage,
resources: [
{
name: "resource-without-path",
format: "csv",
},
],
}
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "datapackage.json",
filesize: 500,
}),
})
await savePackageToZenodo(packageWithoutPath, mockOptions)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it("includes contributors with author role as creators in metadata", async () => {
const packageWithContributors: Package = {
...mockPackage,
contributors: [
{
title: "Alice Smith",
role: "author",
path: "University of Example",
},
{
title: "Bob Jones",
role: "author",
path: "Institute of Testing",
},
{
title: "Charlie Brown",
role: "contributor",
},
],
}
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 12345,
links: {
self: "https://sandbox.zenodo.org/api/deposit/depositions/12345",
html: "https://sandbox.zenodo.org/deposit/12345",
files:
"https://sandbox.zenodo.org/api/deposit/depositions/12345/files",
bucket: "https://sandbox.zenodo.org/api/files/bucket-id",
},
metadata: {
title: "Test Package",
description: "A test package",
upload_type: "dataset",
creators: [
{
name: "Alice Smith",
affiliation: "University of Example",
},
{
name: "Bob Jones",
affiliation: "Institute of Testing",
},
],
},
state: "unsubmitted",
submitted: false,
files: [],
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-1",
filename: "data.csv",
filesize: 100,
}),
})
fetchMock.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "file-2",
filename: "datapackage.json",
filesize: 500,
}),
})
await savePackageToZenodo(packageWithContributors, mockOptions)
const depositionCreateCall = fetchMock.mock.calls[0]
expect(depositionCreateCall).toBeDefined()
if (!depositionCreateCall) return
const depositionPayload = JSON.parse(depositionCreateCall[1].body)
expect(depositionPayload.metadata.creators).toEqual([
{
name: "Alice Smith",
affiliation: "University of Example",
},
{
name: "Bob Jones",
affiliation: "Institute of Testing",
},
])
})
})