Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/core/functions/files/updateFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ export const updateFile = async (

if (
!options.name &&
(!options.keyvalues || Object.keys(options.keyvalues).length === 0)
(!options.keyvalues || Object.keys(options.keyvalues).length === 0) &&
options.expires_at === undefined
) {
throw new ValidationError(
"At least one of 'name' or 'keyvalues' must be provided",
"At least one of 'name', 'keyvalues', or 'expires_at' must be provided",
);
}

Expand All @@ -36,6 +37,9 @@ export const updateFile = async (
if (options.keyvalues && Object.keys(options.keyvalues).length > 0) {
data.keyvalues = options.keyvalues;
}
if (options.expires_at !== undefined) {
data.expires_at = options.expires_at;
}

const body = JSON.stringify(data);

Expand Down
8 changes: 8 additions & 0 deletions src/core/functions/uploads/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export const uploadFile = async (
metadata += `,cid_version ${btoa(options.cid_version)}`;
}

if (options?.expires_at !== undefined) {
metadata += `,expires_at ${btoa(options.expires_at.toString())}`;
}

// Build URL with query parameters for chunked uploads
let updatedEndpoint: string = `${endpoint}/files`;
if (options?.url) {
Expand Down Expand Up @@ -264,6 +268,10 @@ export const uploadFile = async (
data.append("cid_version", options.cid_version.toString());
}

if (options?.expires_at !== undefined) {
data.append("expires_at", options.expires_at.toString());
}

if (options?.url) {
try {
const url = new URL(options.url);
Expand Down
4 changes: 4 additions & 0 deletions src/core/types/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export type UpdateFileOptions = {
id: string;
name?: string;
keyvalues?: Record<string, string>;
/**
* Unix timestamp (in seconds) when the file should expire (must be in the future)
*/
expires_at?: number;
};

export type DeleteResponse = {
Expand Down
4 changes: 4 additions & 0 deletions src/core/types/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export type UploadOptions = {
* CID version "v1" or "v0" (defaults to v1 if falsy)
*/
cid_version?: CidVersion;
/**
* Unix timestamp (in seconds) when the file should expire (must be in the future)
*/
expires_at?: number;
};

export type SignedUploadUrlOptions = {
Expand Down
93 changes: 93 additions & 0 deletions tests/files/updateFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,97 @@ describe("updateFile function", () => {
expect.any(Object),
);
});

it("should update file with only expires_at", async () => {
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now
const expiresAtOptions: UpdateFileOptions = {
id: "testId",
expires_at: futureTimestamp,
};

const mockResponse: FileListItem = {
id: "testId",
name: "Test File",
cid: "QmTest...",
size: 1000,
number_of_files: 1,
mime_type: "text/plain",
group_id: "groupId",
created_at: "2023-01-01T00:00:00Z",
keyvalues: {},
};

global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
});

const result = await updateFile(mockConfig, expiresAtOptions, "public");

expect(global.fetch).toHaveBeenCalledWith(
"https://api.pinata.cloud/v3/files/public/testId",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({
Authorization: `Bearer ${mockConfig.pinataJwt}`,
}),
body: JSON.stringify({
expires_at: futureTimestamp,
}),
}),
);
expect(result).toEqual(mockResponse);
});

it("should update file with expires_at combined with name and keyvalues", async () => {
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400;
const combinedOptions: UpdateFileOptions = {
id: "testId",
name: "Updated Name",
keyvalues: { key: "value" },
expires_at: futureTimestamp,
};

const mockResponse: FileListItem = {
id: "testId",
name: "Updated Name",
cid: "QmTest...",
size: 1000,
number_of_files: 1,
mime_type: "text/plain",
group_id: "groupId",
created_at: "2023-01-01T00:00:00Z",
keyvalues: { key: "value" },
};

global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
});

const result = await updateFile(mockConfig, combinedOptions, "private");

expect(global.fetch).toHaveBeenCalledWith(
"https://api.pinata.cloud/v3/files/private/testId",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({
name: "Updated Name",
keyvalues: { key: "value" },
expires_at: futureTimestamp,
}),
}),
);
expect(result).toEqual(mockResponse);
});

it("should throw ValidationError if no name, keyvalues, or expires_at provided", async () => {
const invalidOptions: UpdateFileOptions = {
id: "testId",
};

await expect(
updateFile(mockConfig, invalidOptions, "public"),
).rejects.toThrow(ValidationError);
});
});
94 changes: 94 additions & 0 deletions tests/uploads/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,63 @@ describe("uploadFile function", () => {
expect(formData.get("streamable")).toBe("true");
});

it("should upload file with expires_at timestamp", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
});

const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now
const mockOptions: UploadOptions = {
expires_at: futureTimestamp,
};

await uploadFile(mockConfig, mockFile, "public", mockOptions);

const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const formData = fetchCall[1].body as FormData;

expect(formData.get("expires_at")).toBe(futureTimestamp.toString());
expect(global.fetch).toHaveBeenCalledWith(
"https://uploads.pinata.cloud/v3/files",
expect.objectContaining({
method: "POST",
headers: {
Source: "sdk/file",
Authorization: "Bearer test-jwt",
},
body: expect.any(FormData),
}),
);
});

it("should upload file with expires_at combined with other options", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
});

const futureTimestamp = Math.floor(Date.now() / 1000) + 86400;
const mockOptions: UploadOptions = {
expires_at: futureTimestamp,
groupId: "test-group",
metadata: {
name: "Test File",
keyvalues: { key: "value" },
},
};

await uploadFile(mockConfig, mockFile, "private", mockOptions);

const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const formData = fetchCall[1].body as FormData;

expect(formData.get("expires_at")).toBe(futureTimestamp.toString());
expect(formData.get("group_id")).toBe("test-group");
expect(formData.get("name")).toBe("Test File");
expect(formData.get("keyvalues")).toBe(JSON.stringify({ key: "value" }));
});

it("should include CID version in metadata for large file upload", async () => {
// Create a large file (over 90MB) to trigger chunked upload metadata path
const largeFileSize = 94371841; // Just over the threshold
Expand Down Expand Up @@ -378,4 +435,41 @@ describe("uploadFile function", () => {
expect(uploadMetadata).toContain("cid_version");
expect(uploadMetadata).toContain(btoa("v1"));
});

it("should include expires_at in metadata for large file upload", async () => {
// Create a large file (over 90MB) to trigger chunked upload metadata path
const largeFileSize = 94371841; // Just over the threshold
const largeFile = new File(
[new ArrayBuffer(largeFileSize)],
"large-test.txt",
{
type: "text/plain",
},
);

// Mock the first call (initial upload request) to verify metadata
global.fetch = jest.fn().mockImplementationOnce(() => {
return Promise.reject(new Error("Test stopped after metadata check"));
});

const futureTimestamp = Math.floor(Date.now() / 1000) + 86400;
const mockOptions: UploadOptions = {
expires_at: futureTimestamp,
};

try {
await uploadFile(mockConfig, largeFile, "private", mockOptions);
} catch (error) {
// Expected to fail, we just want to check the metadata
}

// Check that the initial request contains expires_at in Upload-Metadata
expect(global.fetch).toHaveBeenCalledTimes(1);
const initialCall = (global.fetch as jest.Mock).mock.calls[0];
const uploadMetadata = initialCall[1].headers["Upload-Metadata"];

// The metadata should contain base64 encoded expires_at
expect(uploadMetadata).toContain("expires_at");
expect(uploadMetadata).toContain(btoa(futureTimestamp.toString()));
});
});