Skip to content

Commit e0eea7d

Browse files
committed
Add support for expires_at to upload and update for public and private files
1 parent cdc0c06 commit e0eea7d

File tree

6 files changed

+209
-2
lines changed

6 files changed

+209
-2
lines changed

src/core/functions/files/updateFile.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ export const updateFile = async (
2121

2222
if (
2323
!options.name &&
24-
(!options.keyvalues || Object.keys(options.keyvalues).length === 0)
24+
(!options.keyvalues || Object.keys(options.keyvalues).length === 0) &&
25+
options.expires_at === undefined
2526
) {
2627
throw new ValidationError(
27-
"At least one of 'name' or 'keyvalues' must be provided",
28+
"At least one of 'name', 'keyvalues', or 'expires_at' must be provided",
2829
);
2930
}
3031

@@ -36,6 +37,9 @@ export const updateFile = async (
3637
if (options.keyvalues && Object.keys(options.keyvalues).length > 0) {
3738
data.keyvalues = options.keyvalues;
3839
}
40+
if (options.expires_at !== undefined) {
41+
data.expires_at = options.expires_at;
42+
}
3943

4044
const body = JSON.stringify(data);
4145

src/core/functions/uploads/file.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export const uploadFile = async (
6363
metadata += `,cid_version ${btoa(options.cid_version)}`;
6464
}
6565

66+
if (options?.expires_at !== undefined) {
67+
metadata += `,expires_at ${btoa(options.expires_at.toString())}`;
68+
}
69+
6670
// Build URL with query parameters for chunked uploads
6771
let updatedEndpoint: string = `${endpoint}/files`;
6872
if (options?.url) {
@@ -264,6 +268,10 @@ export const uploadFile = async (
264268
data.append("cid_version", options.cid_version.toString());
265269
}
266270

271+
if (options?.expires_at !== undefined) {
272+
data.append("expires_at", options.expires_at.toString());
273+
}
274+
267275
if (options?.url) {
268276
try {
269277
const url = new URL(options.url);

src/core/types/files.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export type UpdateFileOptions = {
1717
id: string;
1818
name?: string;
1919
keyvalues?: Record<string, string>;
20+
/**
21+
* Unix timestamp (in seconds) when the file should expire (must be in the future)
22+
*/
23+
expires_at?: number;
2024
};
2125

2226
export type DeleteResponse = {

src/core/types/uploads.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export type UploadOptions = {
3232
* CID version "v1" or "v0" (defaults to v1 if falsy)
3333
*/
3434
cid_version?: CidVersion;
35+
/**
36+
* Unix timestamp (in seconds) when the file should expire (must be in the future)
37+
*/
38+
expires_at?: number;
3539
};
3640

3741
export type SignedUploadUrlOptions = {

tests/files/updateFile.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,97 @@ describe("updateFile function", () => {
249249
expect.any(Object),
250250
);
251251
});
252+
253+
it("should update file with only expires_at", async () => {
254+
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now
255+
const expiresAtOptions: UpdateFileOptions = {
256+
id: "testId",
257+
expires_at: futureTimestamp,
258+
};
259+
260+
const mockResponse: FileListItem = {
261+
id: "testId",
262+
name: "Test File",
263+
cid: "QmTest...",
264+
size: 1000,
265+
number_of_files: 1,
266+
mime_type: "text/plain",
267+
group_id: "groupId",
268+
created_at: "2023-01-01T00:00:00Z",
269+
keyvalues: {},
270+
};
271+
272+
global.fetch = jest.fn().mockResolvedValueOnce({
273+
ok: true,
274+
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
275+
});
276+
277+
const result = await updateFile(mockConfig, expiresAtOptions, "public");
278+
279+
expect(global.fetch).toHaveBeenCalledWith(
280+
"https://api.pinata.cloud/v3/files/public/testId",
281+
expect.objectContaining({
282+
method: "PUT",
283+
headers: expect.objectContaining({
284+
Authorization: `Bearer ${mockConfig.pinataJwt}`,
285+
}),
286+
body: JSON.stringify({
287+
expires_at: futureTimestamp,
288+
}),
289+
}),
290+
);
291+
expect(result).toEqual(mockResponse);
292+
});
293+
294+
it("should update file with expires_at combined with name and keyvalues", async () => {
295+
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400;
296+
const combinedOptions: UpdateFileOptions = {
297+
id: "testId",
298+
name: "Updated Name",
299+
keyvalues: { key: "value" },
300+
expires_at: futureTimestamp,
301+
};
302+
303+
const mockResponse: FileListItem = {
304+
id: "testId",
305+
name: "Updated Name",
306+
cid: "QmTest...",
307+
size: 1000,
308+
number_of_files: 1,
309+
mime_type: "text/plain",
310+
group_id: "groupId",
311+
created_at: "2023-01-01T00:00:00Z",
312+
keyvalues: { key: "value" },
313+
};
314+
315+
global.fetch = jest.fn().mockResolvedValueOnce({
316+
ok: true,
317+
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
318+
});
319+
320+
const result = await updateFile(mockConfig, combinedOptions, "private");
321+
322+
expect(global.fetch).toHaveBeenCalledWith(
323+
"https://api.pinata.cloud/v3/files/private/testId",
324+
expect.objectContaining({
325+
method: "PUT",
326+
body: JSON.stringify({
327+
name: "Updated Name",
328+
keyvalues: { key: "value" },
329+
expires_at: futureTimestamp,
330+
}),
331+
}),
332+
);
333+
expect(result).toEqual(mockResponse);
334+
});
335+
336+
it("should throw ValidationError if no name, keyvalues, or expires_at provided", async () => {
337+
const invalidOptions: UpdateFileOptions = {
338+
id: "testId",
339+
};
340+
341+
await expect(
342+
updateFile(mockConfig, invalidOptions, "public"),
343+
).rejects.toThrow(ValidationError);
344+
});
252345
});

tests/uploads/file.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,63 @@ describe("uploadFile function", () => {
341341
expect(formData.get("streamable")).toBe("true");
342342
});
343343

344+
it("should upload file with expires_at timestamp", async () => {
345+
global.fetch = jest.fn().mockResolvedValueOnce({
346+
ok: true,
347+
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
348+
});
349+
350+
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now
351+
const mockOptions: UploadOptions = {
352+
expires_at: futureTimestamp,
353+
};
354+
355+
await uploadFile(mockConfig, mockFile, "public", mockOptions);
356+
357+
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
358+
const formData = fetchCall[1].body as FormData;
359+
360+
expect(formData.get("expires_at")).toBe(futureTimestamp.toString());
361+
expect(global.fetch).toHaveBeenCalledWith(
362+
"https://uploads.pinata.cloud/v3/files",
363+
expect.objectContaining({
364+
method: "POST",
365+
headers: {
366+
Source: "sdk/file",
367+
Authorization: "Bearer test-jwt",
368+
},
369+
body: expect.any(FormData),
370+
}),
371+
);
372+
});
373+
374+
it("should upload file with expires_at combined with other options", async () => {
375+
global.fetch = jest.fn().mockResolvedValueOnce({
376+
ok: true,
377+
json: jest.fn().mockResolvedValueOnce({ data: mockResponse }),
378+
});
379+
380+
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400;
381+
const mockOptions: UploadOptions = {
382+
expires_at: futureTimestamp,
383+
groupId: "test-group",
384+
metadata: {
385+
name: "Test File",
386+
keyvalues: { key: "value" },
387+
},
388+
};
389+
390+
await uploadFile(mockConfig, mockFile, "private", mockOptions);
391+
392+
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
393+
const formData = fetchCall[1].body as FormData;
394+
395+
expect(formData.get("expires_at")).toBe(futureTimestamp.toString());
396+
expect(formData.get("group_id")).toBe("test-group");
397+
expect(formData.get("name")).toBe("Test File");
398+
expect(formData.get("keyvalues")).toBe(JSON.stringify({ key: "value" }));
399+
});
400+
344401
it("should include CID version in metadata for large file upload", async () => {
345402
// Create a large file (over 90MB) to trigger chunked upload metadata path
346403
const largeFileSize = 94371841; // Just over the threshold
@@ -378,4 +435,41 @@ describe("uploadFile function", () => {
378435
expect(uploadMetadata).toContain("cid_version");
379436
expect(uploadMetadata).toContain(btoa("v1"));
380437
});
438+
439+
it("should include expires_at in metadata for large file upload", async () => {
440+
// Create a large file (over 90MB) to trigger chunked upload metadata path
441+
const largeFileSize = 94371841; // Just over the threshold
442+
const largeFile = new File(
443+
[new ArrayBuffer(largeFileSize)],
444+
"large-test.txt",
445+
{
446+
type: "text/plain",
447+
},
448+
);
449+
450+
// Mock the first call (initial upload request) to verify metadata
451+
global.fetch = jest.fn().mockImplementationOnce(() => {
452+
return Promise.reject(new Error("Test stopped after metadata check"));
453+
});
454+
455+
const futureTimestamp = Math.floor(Date.now() / 1000) + 86400;
456+
const mockOptions: UploadOptions = {
457+
expires_at: futureTimestamp,
458+
};
459+
460+
try {
461+
await uploadFile(mockConfig, largeFile, "private", mockOptions);
462+
} catch (error) {
463+
// Expected to fail, we just want to check the metadata
464+
}
465+
466+
// Check that the initial request contains expires_at in Upload-Metadata
467+
expect(global.fetch).toHaveBeenCalledTimes(1);
468+
const initialCall = (global.fetch as jest.Mock).mock.calls[0];
469+
const uploadMetadata = initialCall[1].headers["Upload-Metadata"];
470+
471+
// The metadata should contain base64 encoded expires_at
472+
expect(uploadMetadata).toContain("expires_at");
473+
expect(uploadMetadata).toContain(btoa(futureTimestamp.toString()));
474+
});
381475
});

0 commit comments

Comments
 (0)