1
2
3
4
5
6import { beforeEach, describe, expect, it, vi } from "vitest";
7import {
8 effectiveMaxFileSize,
9 filesApi,
10 humanSize,
11 kindForMime,
12 type FileDetail,
13} from "../api";
14import { uploadFile, isTerminalStatus } from "../upload";
15
16vi.mock("../api", async (importOriginal) => {
17 const actual = await importOriginal<typeof import("../api")>();
18 return {
19 ...actual,
20 filesApi: {
21 openSession: vi.fn(),
22 complete: vi.fn(),
23 cancel: vi.fn(),
24 get: vi.fn(),
25 list: vi.fn(),
26 },
27 };
28});
29
30const MB = 1024 * 1024;
31
32function fileDetail(overrides: Partial<FileDetail>): FileDetail {
33 return {
34 id: "file_1",
35 workspaceId: "ws",
36 blockId: null,
37 pageId: null,
38 name: "a.png",
39 size: 10,
40 mimeType: "image/png",
41 status: "SCANNING",
42 metadata: {},
43 thumbnailKey: null,
44 thumbnailUrl: null,
45 scanReport: null,
46 uploadedBy: "u1",
47 version: 1,
48 createdAt: "2026-01-01T00:00:00.000Z",
49 updatedAt: "2026-01-01T00:00:00.000Z",
50 downloadUrl: "https://cdn/d",
51 previewUrl: "https://cdn/p",
52 expiresAt: "2026-01-01T00:15:00.000Z",
53 versions: [],
54 ...overrides,
55 };
56}
57
58interface FakeXhrCtor {
59 putResults: number[];
60 putCalls: { url: string; headers: Record<string, string>; bytes: Blob }[];
61 progressSteps: number[];
62 aborted: boolean;
63 openSessionCalls: number;
64}
65
66function makeXhrFactory(spec: Partial<FakeXhrCtor> = {}): { factory: () => import("../upload").XhrLike; state: FakeXhrCtor } {
67 const state: FakeXhrCtor = {
68 putResults: [200],
69 putCalls: [],
70 progressSteps: [10],
71 aborted: false,
72 openSessionCalls: 0,
73 ...spec,
74 };
75 let call = 0;
76 const factory = () => {
77 const xhr: import("../upload").XhrLike = {
78 status: 0,
79 responseText: "",
80 onload: null,
81 onerror: null,
82 onabort: null,
83 open() {},
84 setRequestHeader(name, value) {
85 const h = (xhr as unknown as { _h: Record<string, string> })._h ?? {};
86 h[name] = value;
87 (xhr as unknown as { _h: Record<string, string> })._h = h;
88 },
89 upload: {
90 addEventListener(type, cb) {
91 (xhr as unknown as { _prog?: (e: { loaded: number; total: number }) => void })._prog =
92 type === "progress" ? cb : undefined;
93 },
94 },
95 send(body: Blob) {
96 const idx = call++;
97 const status = state.putResults[idx] ?? 200;
98 state.putCalls.push({
99 url: (xhr as unknown as { _url: string })._url,
100 headers: (xhr as unknown as { _h: Record<string, string> })._h ?? {},
101 bytes: body,
102 });
103
104 for (const loaded of state.progressSteps) {
105 (xhr as unknown as { _prog?: (e: { loaded: number; total: number }) => void })._prog?.({ loaded, total: body.size || 10 });
106 }
107 setTimeout(() => {
108 xhr.status = status;
109 xhr.responseText = status === 200 ? "" : JSON.stringify({ error: { code: "EXPIRED_TOKEN", message: "The signature has expired." } });
110
111
112 xhr.onload?.();
113 }, 0);
114 },
115 abort() {
116 state.aborted = true;
117 xhr.onabort?.();
118 },
119 };
120 const origOpen = xhr.open.bind(xhr);
121 xhr.open = (m: string, u: string) => {
122 (xhr as unknown as { _url: string })._url = u;
123 origOpen(m, u);
124 };
125 return xhr;
126 };
127 return { factory, state };
128}
129
130const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
131
132beforeEach(() => {
133 vi.clearAllMocks();
134
135
136
137 vi.mocked(filesApi.openSession).mockImplementation(async () => ({
138 file: fileDetail({ status: "UPLOADING" }),
139 uploadUrl: "https://s3/put",
140 method: "PUT",
141 headers: { "Content-Type": "image/png" },
142 expiresAt: "x",
143 }));
144 vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" });
145 vi.mocked(filesApi.cancel).mockResolvedValue({ deleted: true });
146 vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "READY" }));
147});
148
149describe("effectiveMaxFileSize (mirrors the server)", () => {
150 it("free → 50 MB; paid → 500 MB", () => {
151 expect(effectiveMaxFileSize("free", null)).toBe(50 * MB);
152 expect(effectiveMaxFileSize("paid", null)).toBe(500 * MB);
153 });
154 it("an admin override may only tighten, never exceed", () => {
155 expect(effectiveMaxFileSize("paid", { maxFileSize: 10 * MB })).toBe(10 * MB);
156 expect(effectiveMaxFileSize("free", { maxFileSize: 100 * MB })).toBe(50 * MB);
157 });
158 it("unknown plan → free default", () => {
159 expect(effectiveMaxFileSize("enterprise", null)).toBe(50 * MB);
160 });
161});
162
163describe("kindForMime (server-verified mime, never the extension)", () => {
164 it("maps the known mimes to their kinds", () => {
165 expect(kindForMime("image/png")).toBe("image");
166 expect(kindForMime("video/mp4")).toBe("video");
167 expect(kindForMime("audio/mpeg")).toBe("audio");
168 expect(kindForMime("application/pdf")).toBe("pdf");
169 expect(kindForMime("text/markdown")).toBe("doc");
170 expect(kindForMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("doc");
171 });
172 it("is case/parameter-insensitive and falls back to other", () => {
173 expect(kindForMime("IMAGE/PNG")).toBe("image");
174 expect(kindForMime("video/mp4;codecs=avc1")).toBe("video");
175 expect(kindForMime("application/zip")).toBe("other");
176 expect(kindForMime("")).toBe("other");
177 expect(kindForMime(null)).toBe("other");
178 });
179});
180
181describe("humanSize", () => {
182 it("formats bytes → human", () => {
183 expect(humanSize(0)).toBe("0 B");
184 expect(humanSize(512)).toBe("512 B");
185 expect(humanSize(1024)).toBe("1.0 KB");
186 expect(humanSize(50 * 1024 * 1024)).toBe("50.0 MB");
187 expect(humanSize(1.2 * 1024 * 1024 * 1024)).toBe("1.2 GB");
188 });
189});
190
191describe("uploadFile — plan pre-check", () => {
192 it("rejects an over-limit file with NO request sent (the E2E assertion)", async () => {
193 const f = new File([new ArrayBuffer(100)], "big.png", { type: "image/png" });
194 const h = uploadFile(f, { maxFileSize: 10 });
195 await expect(h.promise).rejects.toMatchObject({ code: "FILE_TOO_LARGE" });
196 expect(filesApi.openSession).not.toHaveBeenCalled();
197 expect(filesApi.complete).not.toHaveBeenCalled();
198 expect(filesApi.cancel).not.toHaveBeenCalled();
199 });
200});
201
202describe("uploadFile — happy path (session → PUT → complete → poll → READY)", () => {
203 it("completes and resolves with the READY detail", async () => {
204 const { factory } = makeXhrFactory({ progressSteps: [2, 5, 10] });
205 vi.mocked(filesApi.openSession).mockResolvedValue({
206 file: fileDetail({ status: "UPLOADING" }),
207 uploadUrl: "https://s3/put?sig=1",
208 method: "PUT",
209 headers: { "Content-Type": "image/png" },
210 expiresAt: "2026-01-01T00:15:00.000Z",
211 });
212 vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" });
213 vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "READY" }));
214
215 const fractions: number[] = [];
216 const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), {
217 maxFileSize: 10 * MB,
218 onProgress: (p) => fractions.push(p.fraction),
219 poll: { intervalMs: 1, timeoutMs: 1000 },
220 }, factory);
221
222 const result = await h.promise;
223 expect(result.file.status).toBe("READY");
224 expect(result.stillProcessing).toBe(false);
225 expect(filesApi.complete).toHaveBeenCalledWith("file_1", expect.anything());
226
227 expect(fractions.every((v, i) => i === 0 || v >= (fractions[i - 1] ?? 0))).toBe(true);
228 expect(fractions[fractions.length - 1] ?? 0).toBe(1);
229 });
230});
231
232describe("uploadFile — 403 (expired signature) auto-retries ONCE", () => {
233 it("re-opens a session and the retry PUT succeeds", async () => {
234 const { factory, state } = makeXhrFactory({ putResults: [403, 200], progressSteps: [5] });
235 vi.mocked(filesApi.openSession)
236 .mockResolvedValueOnce({
237 file: fileDetail({ id: "file_old", status: "UPLOADING" }),
238 uploadUrl: "https://s3/old?sig=1",
239 method: "PUT",
240 headers: { "Content-Type": "image/png" },
241 expiresAt: "x",
242 })
243 .mockResolvedValueOnce({
244 file: fileDetail({ id: "file_new", status: "UPLOADING" }),
245 uploadUrl: "https://s3/new?sig=2",
246 method: "PUT",
247 headers: { "Content-Type": "image/png" },
248 expiresAt: "x",
249 });
250 vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_new", status: "SCANNING", verifiedBy: "worker" });
251 vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ id: "file_new", status: "READY" }));
252
253 const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), {
254 maxFileSize: 10 * MB,
255 poll: { intervalMs: 1, timeoutMs: 1000 },
256 }, factory);
257
258 await h.promise;
259 expect(state.putCalls).toHaveLength(2);
260 const [firstPut, secondPut] = state.putCalls;
261 expect(firstPut?.url).toContain("/old");
262 expect(secondPut?.url).toContain("/new");
263
264 expect(filesApi.complete).toHaveBeenCalledWith("file_new", expect.anything());
265 });
266});
267
268describe("uploadFile — cancel", () => {
269 it("before complete: aborts the XHR + calls the cancel API + rejects CANCELLED", async () => {
270
271 const pending: import("../upload").XhrLike[] = [];
272 const factory = () => {
273 const xhr: import("../upload").XhrLike = {
274 status: 0,
275 responseText: "",
276 onload: null,
277 onerror: null,
278 onabort: null,
279 open() {},
280 setRequestHeader() {},
281 upload: { addEventListener() {} },
282 send() {
283 pending.push(xhr);
284 },
285 abort() {
286 xhr.onabort?.();
287 },
288 };
289 return xhr;
290 };
291 vi.mocked(filesApi.openSession).mockResolvedValue({
292 file: fileDetail({ status: "UPLOADING" }),
293 uploadUrl: "https://s3/put",
294 method: "PUT",
295 headers: { "Content-Type": "image/png" },
296 expiresAt: "x",
297 });
298
299 const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), { maxFileSize: 10 * MB }, factory);
300 await sleep(5);
301 h.cancel();
302 await expect(h.promise).rejects.toMatchObject({ code: "CANCELLED" });
303 expect(pending.length).toBeGreaterThan(0);
304 expect(pending[0]?.onabort).toBeTruthy();
305 expect(filesApi.cancel).toHaveBeenCalledWith("file_1", expect.anything());
306 expect(filesApi.complete).not.toHaveBeenCalled();
307 });
308});
309
310describe("uploadFile — poll timeout (file still processing)", () => {
311 it("returns stillProcessing=true after the window, without throwing", async () => {
312 const { factory } = makeXhrFactory();
313 vi.mocked(filesApi.openSession).mockResolvedValue({
314 file: fileDetail({ status: "UPLOADING" }),
315 uploadUrl: "https://s3/put",
316 method: "PUT",
317 headers: { "Content-Type": "image/png" },
318 expiresAt: "x",
319 });
320 vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" });
321
322 vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "SCANNING" }));
323
324 const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), {
325 maxFileSize: 10 * MB,
326 poll: { intervalMs: 2, timeoutMs: 20 },
327 }, factory);
328 const result = await h.promise;
329 expect(result.stillProcessing).toBe(true);
330 expect(result.file.status).toBe("SCANNING");
331 });
332});
333
334describe("uploadFile — virus / fatal", () => {
335 it("throws UploadError with the terminal status after poll sees it", async () => {
336 const { factory } = makeXhrFactory();
337 vi.mocked(filesApi.openSession).mockResolvedValue({
338 file: fileDetail({ status: "UPLOADING" }),
339 uploadUrl: "https://s3/put",
340 method: "PUT",
341 headers: { "Content-Type": "image/png" },
342 expiresAt: "x",
343 });
344 vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" });
345 vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "VIRUS_DETECTED" }));
346 const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), {
347 maxFileSize: 10 * MB,
348 poll: { intervalMs: 1, timeoutMs: 1000 },
349 }, factory);
350 await expect(h.promise).rejects.toMatchObject({ code: "VIRUS_DETECTED" });
351 });
352});
353
354describe("isTerminalStatus", () => {
355 it("READY / VIRUS_DETECTED / FAILED are terminal; UPLOADING / SCANNING are not", () => {
356 expect(isTerminalStatus("READY")).toBe(true);
357 expect(isTerminalStatus("VIRUS_DETECTED")).toBe(true);
358 expect(isTerminalStatus("FAILED")).toBe(true);
359 expect(isTerminalStatus("UPLOADING")).toBe(false);
360 expect(isTerminalStatus("SCANNING")).toBe(false);
361 });
362});
363