Skip to content

Commit a576a5b

Browse files
committed
[SDK] Test: Adds storage mocks and basic tests (#4833)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces new features and improvements related to file uploads and downloads using IPFS and Arweave, along with enhancements to testing with `msw`. It also updates dependencies and modifies some configurations. ### Detailed summary - Updated `.gitignore` to include `.aider*`. - Modified `web-node.ts` to streamline header handling. - Added `msw` as a dependency in `package.json`. - Enhanced `download.ts` to better handle request timeouts. - Implemented `msw` handlers in `storage.ts` for IPFS and Arweave. - Expanded tests in `download.test.ts` for various download scenarios. - Added tests in `web-node.test.ts` for file uploads and error handling. > The following files were skipped due to too many changes: `pnpm-lock.yaml` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 5e3be73 commit a576a5b

File tree

8 files changed

+455
-1538
lines changed

8 files changed

+455
-1538
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ sitemap*.xml
2727
packages/*/typedoc/*
2828

2929
*storybook.log
30-
storybook-static
30+
storybook-static
31+
.aider*

packages/thirdweb/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@
348348
"expo-web-browser": "13.0.3",
349349
"happy-dom": "^15.7.4",
350350
"knip": "^5.30.5",
351+
"msw": "^2.4.9",
351352
"prettier": "^3.3.2",
352353
"react": "18.3.1",
353354
"react-native": "0.75.3",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { http, HttpResponse } from "msw";
2+
import { setupServer } from "msw/node";
3+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
4+
import { handlers as storageHandlers } from "../../test/src/mocks/storage.js";
5+
import type { ThirdwebClient } from "../client/client.js";
6+
import { download } from "./download.js";
7+
8+
const server = setupServer(...storageHandlers);
9+
10+
describe("download", () => {
11+
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
12+
afterAll(() => server.close());
13+
afterEach(() => server.resetHandlers());
14+
15+
const mockClient: ThirdwebClient = {
16+
clientId: "test-client-id",
17+
secretKey: "test-secret-key",
18+
};
19+
20+
it("should download IPFS file successfully", async () => {
21+
const mockContent = "Test IPFS content";
22+
server.use(
23+
http.get("https://*.ipfscdn.io/ipfs/:hash/:id", () => {
24+
return new HttpResponse(mockContent, {
25+
status: 200,
26+
headers: { "Content-Type": "text/plain" },
27+
});
28+
}),
29+
);
30+
31+
const result = await download({
32+
client: mockClient,
33+
uri: "ipfs://QmTest1234567890TestHash/file.txt",
34+
});
35+
36+
expect(result.ok).toBe(true);
37+
expect(await result.text()).toBe(mockContent);
38+
});
39+
40+
it("should download Arweave file successfully", async () => {
41+
const mockContent = "Test Arweave content";
42+
server.use(
43+
http.get("https://arweave.net/:id", () => {
44+
return new HttpResponse(mockContent, {
45+
status: 200,
46+
headers: { "Content-Type": "text/plain" },
47+
});
48+
}),
49+
);
50+
51+
const result = await download({
52+
client: mockClient,
53+
uri: "ar://TestArweaveTransactionId",
54+
});
55+
56+
expect(result.ok).toBe(true);
57+
expect(await result.text()).toBe(mockContent);
58+
});
59+
60+
it("should download HTTP file successfully", async () => {
61+
const mockContent = "Test HTTP content";
62+
server.use(
63+
http.get("https://example.com/file.txt", () => {
64+
return new HttpResponse(mockContent, {
65+
status: 200,
66+
headers: { "Content-Type": "text/plain" },
67+
});
68+
}),
69+
);
70+
71+
const result = await download({
72+
client: mockClient,
73+
uri: "https://example.com/file.txt",
74+
});
75+
76+
expect(result.ok).toBe(true);
77+
expect(await result.text()).toBe(mockContent);
78+
});
79+
80+
it("should throw an error for failed downloads", async () => {
81+
server.use(
82+
http.get("https://*.ipfscdn.io/ipfs/:hash/:id", () => {
83+
return new HttpResponse(null, { status: 404 });
84+
}),
85+
);
86+
87+
await expect(
88+
download({
89+
client: mockClient,
90+
uri: "ipfs://QmTest1234567890TestHash/nonexistent.txt",
91+
}),
92+
).rejects.toThrow("Failed to download file: Not Found");
93+
});
94+
95+
it("should respect custom timeout", async () => {
96+
server.use(
97+
http.get("https://*.ipfscdn.io/ipfs/:hash/:id", async () => {
98+
await new Promise((resolve) => setTimeout(resolve, 10000));
99+
return new HttpResponse("Delayed response", { status: 200 });
100+
}),
101+
);
102+
103+
await expect(
104+
download({
105+
client: mockClient,
106+
uri: "ipfs://QmTest1234567890TestHash/file.txt",
107+
requestTimeoutMs: 500,
108+
}),
109+
).rejects.toThrow("This operation was aborted");
110+
});
111+
112+
it("should respect custom client timeout", async () => {
113+
server.use(
114+
http.get("https://*.ipfscdn.io/ipfs/:hash/:id", async () => {
115+
await new Promise((resolve) => setTimeout(resolve, 10000));
116+
return new HttpResponse("Delayed response", { status: 200 });
117+
}),
118+
);
119+
120+
await expect(
121+
download({
122+
client: {
123+
...mockClient,
124+
config: { storage: { fetch: { requestTimeoutMs: 500 } } },
125+
},
126+
uri: "ipfs://QmTest1234567890TestHash/file.txt",
127+
}),
128+
).rejects.toThrow("This operation was aborted");
129+
});
130+
});

packages/thirdweb/src/storage/download.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export async function download(options: DownloadOptions) {
7676
keepalive: options.client.config?.storage?.fetch?.keepalive,
7777
headers: options.client.config?.storage?.fetch?.headers,
7878
requestTimeoutMs:
79-
options.client.config?.storage?.fetch?.requestTimeoutMs ?? 60000,
79+
options.requestTimeoutMs ??
80+
options.client.config?.storage?.fetch?.requestTimeoutMs ??
81+
60000,
8082
});
8183

8284
if (!res.ok) {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { http, HttpResponse } from "msw";
2+
import { setupServer } from "msw/node";
3+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
4+
import { handlers as storageHandlers } from "../../../test/src/mocks/storage.js";
5+
import type { ThirdwebClient } from "../../client/client.js";
6+
import { getThirdwebDomains } from "../../utils/domains.js";
7+
import { uploadBatch } from "./web-node.js";
8+
9+
const server = setupServer(...storageHandlers);
10+
11+
describe("uploadBatch", () => {
12+
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
13+
afterAll(() => server.close());
14+
afterEach(() => server.resetHandlers());
15+
16+
const mockClient: ThirdwebClient = {
17+
clientId: "test-client-id",
18+
secretKey: "test-secret-key",
19+
};
20+
21+
it("should upload files successfully", async () => {
22+
const form = new FormData();
23+
form.append("file1", new Blob(["test content"]), "file1.txt");
24+
form.append("file2", new Blob(["another test"]), "file2.txt");
25+
26+
const result = await uploadBatch(mockClient, form, [
27+
"file1.txt",
28+
"file2.txt",
29+
]);
30+
31+
expect(result).toHaveLength(2);
32+
expect(result[0]).toBe("ipfs://QmTest1234567890TestHash/file1.txt");
33+
expect(result[1]).toBe("ipfs://QmTest1234567890TestHash/file2.txt");
34+
});
35+
36+
it("should throw an error for unauthorized access", async () => {
37+
server.use(
38+
http.post(
39+
`https://${getThirdwebDomains().storage}/ipfs/upload`,
40+
() => new HttpResponse(null, { status: 401 }),
41+
),
42+
);
43+
44+
const form = new FormData();
45+
form.append("file", new Blob(["test"]), "file.txt");
46+
47+
await expect(uploadBatch(mockClient, form, ["file.txt"])).rejects.toThrow(
48+
"Unauthorized - You don't have permission to use this service.",
49+
);
50+
});
51+
52+
it("should throw an error for storage limit reached", async () => {
53+
server.use(
54+
http.post(
55+
`https://${getThirdwebDomains().storage}/ipfs/upload`,
56+
() => new HttpResponse(null, { status: 402 }),
57+
),
58+
);
59+
60+
const form = new FormData();
61+
form.append("file", new Blob(["test"]), "file.txt");
62+
63+
await expect(uploadBatch(mockClient, form, ["file.txt"])).rejects.toThrow(
64+
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
65+
);
66+
});
67+
68+
it("should throw an error for forbidden access", async () => {
69+
server.use(
70+
http.post(
71+
`https://${getThirdwebDomains().storage}/ipfs/upload`,
72+
() => new HttpResponse(null, { status: 403 }),
73+
),
74+
);
75+
76+
const form = new FormData();
77+
form.append("file", new Blob(["test"]), "file.txt");
78+
79+
await expect(uploadBatch(mockClient, form, ["file.txt"])).rejects.toThrow(
80+
"Forbidden - You don't have permission to use this service.",
81+
);
82+
});
83+
84+
it("should throw an error for other HTTP errors", async () => {
85+
server.use(
86+
http.post(
87+
`https://${getThirdwebDomains().storage}/ipfs/upload`,
88+
() =>
89+
new HttpResponse(null, {
90+
status: 500,
91+
statusText: "Internal Server Error",
92+
}),
93+
),
94+
);
95+
96+
const form = new FormData();
97+
form.append("file", new Blob(["test"]), "file.txt");
98+
99+
await expect(uploadBatch(mockClient, form, ["file.txt"])).rejects.toThrow(
100+
"Failed to upload files to IPFS - 500 - Internal Server Error",
101+
);
102+
});
103+
104+
it("should throw an error for missing CID", async () => {
105+
server.use(
106+
http.post(`https://${getThirdwebDomains().storage}/ipfs/upload`, () =>
107+
HttpResponse.json({}),
108+
),
109+
);
110+
111+
const form = new FormData();
112+
form.append("file", new Blob(["test"]), "file.txt");
113+
114+
await expect(uploadBatch(mockClient, form, ["file.txt"])).rejects.toThrow(
115+
"Failed to upload files to IPFS - Bad CID",
116+
);
117+
});
118+
});

packages/thirdweb/src/storage/upload/web-node.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ export async function uploadBatch<const TFiles extends UploadableFile[]>(
1515
`https://${getThirdwebDomains().storage}/ipfs/upload`,
1616
{
1717
method: "POST",
18-
headers: {
19-
...headers,
20-
// ...form.getHeaders(),
21-
},
18+
headers,
2219
body: form,
2320
},
2421
);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { http, HttpResponse } from "msw";
2+
import { getThirdwebDomains } from "../../../src/utils/domains.js";
3+
4+
export const handlers = [
5+
http.post(
6+
`https://${getThirdwebDomains().storage}/ipfs/upload`,
7+
async ({ request }) => {
8+
console.log("MSW handler hit for IPFS upload");
9+
const formData = await request.formData();
10+
const files = formData.getAll('file');
11+
const fileNames = files.map((file: any) => file.name);
12+
13+
const mockIpfsHash = "QmTest1234567890TestHash";
14+
15+
if (fileNames.length === 1 && fileNames[0] === "file.txt") {
16+
return HttpResponse.json({ IpfsHash: mockIpfsHash });
17+
} else {
18+
return HttpResponse.json({
19+
IpfsHash: mockIpfsHash,
20+
files: fileNames.reduce((acc, fileName) => {
21+
acc[fileName] = { cid: mockIpfsHash };
22+
return acc;
23+
}, {})
24+
});
25+
}
26+
},
27+
),
28+
http.get("https://*.ipfscdn.io/ipfs/:hash/:id", async (req) => {
29+
console.log("MSW handler hit for IPFS get");
30+
return new HttpResponse("IPFS file content", {
31+
status: 200,
32+
headers: { "Content-Type": "text/plain" },
33+
});
34+
}),
35+
http.get("https://arweave.net/:id", async (req) => {
36+
console.log("MSW handler hit for Arweave get");
37+
return new HttpResponse("Arweave file content", {
38+
status: 200,
39+
headers: { "Content-Type": "text/plain" },
40+
});
41+
}),
42+
];

0 commit comments

Comments
 (0)