// @vitest-environment node // Pure-logic unit tests for the TASK-0103 upload state machine + kind matrix. // The network is faked: `filesApi` is mocked and the XHR is a structural // double, so session-retry, cancel, progress monotonicity and poll timeout are // proven without a browser or the object store. import { beforeEach, describe, expect, it, vi } from "vitest"; import { effectiveMaxFileSize, filesApi, humanSize, kindForMime, type FileDetail, } from "../api"; import { uploadFile, isTerminalStatus } from "../upload"; vi.mock("../api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, filesApi: { openSession: vi.fn(), complete: vi.fn(), cancel: vi.fn(), get: vi.fn(), list: vi.fn(), }, }; }); const MB = 1024 * 1024; function fileDetail(overrides: Partial): FileDetail { return { id: "file_1", workspaceId: "ws", blockId: null, pageId: null, name: "a.png", size: 10, mimeType: "image/png", status: "SCANNING", metadata: {}, thumbnailKey: null, thumbnailUrl: null, scanReport: null, uploadedBy: "u1", version: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", downloadUrl: "https://cdn/d", previewUrl: "https://cdn/p", expiresAt: "2026-01-01T00:15:00.000Z", versions: [], ...overrides, }; } interface FakeXhrCtor { putResults: number[]; // status per sequential PUT (shifts) putCalls: { url: string; headers: Record; bytes: Blob }[]; progressSteps: number[]; // loaded values emitted per PUT aborted: boolean; openSessionCalls: number; } function makeXhrFactory(spec: Partial = {}): { factory: () => import("../upload").XhrLike; state: FakeXhrCtor } { const state: FakeXhrCtor = { putResults: [200], putCalls: [], progressSteps: [10], aborted: false, openSessionCalls: 0, ...spec, }; let call = 0; const factory = () => { const xhr: import("../upload").XhrLike = { status: 0, responseText: "", onload: null, onerror: null, onabort: null, open() {}, setRequestHeader(name, value) { const h = (xhr as unknown as { _h: Record })._h ?? {}; h[name] = value; (xhr as unknown as { _h: Record })._h = h; }, upload: { addEventListener(type, cb) { (xhr as unknown as { _prog?: (e: { loaded: number; total: number }) => void })._prog = type === "progress" ? cb : undefined; }, }, send(body: Blob) { const idx = call++; const status = state.putResults[idx] ?? 200; state.putCalls.push({ url: (xhr as unknown as { _url: string })._url, headers: (xhr as unknown as { _h: Record })._h ?? {}, bytes: body, }); // emit progress steps synchronously (loaded is monotonic within a PUT) for (const loaded of state.progressSteps) { (xhr as unknown as { _prog?: (e: { loaded: number; total: number }) => void })._prog?.({ loaded, total: body.size || 10 }); } setTimeout(() => { xhr.status = status; xhr.responseText = status === 200 ? "" : JSON.stringify({ error: { code: "EXPIRED_TOKEN", message: "The signature has expired." } }); // XHR fires `onload` for ANY completed response (incl. 403); abort // is the separate onabort path (called from abort() above). xhr.onload?.(); }, 0); }, abort() { state.aborted = true; xhr.onabort?.(); }, }; const origOpen = xhr.open.bind(xhr); xhr.open = (m: string, u: string) => { (xhr as unknown as { _url: string })._url = u; origOpen(m, u); }; return xhr; }; return { factory, state }; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); beforeEach(() => { vi.clearAllMocks(); // Stable default implementations (clearAllMocks wipes prior mockResolvedValue // calls, so re-establish the base contract every test; per-test `Once` // values layer on top of these). vi.mocked(filesApi.openSession).mockImplementation(async () => ({ file: fileDetail({ status: "UPLOADING" }), uploadUrl: "https://s3/put", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "x", })); vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" }); vi.mocked(filesApi.cancel).mockResolvedValue({ deleted: true }); vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "READY" })); }); describe("effectiveMaxFileSize (mirrors the server)", () => { it("free → 50 MB; paid → 500 MB", () => { expect(effectiveMaxFileSize("free", null)).toBe(50 * MB); expect(effectiveMaxFileSize("paid", null)).toBe(500 * MB); }); it("an admin override may only tighten, never exceed", () => { expect(effectiveMaxFileSize("paid", { maxFileSize: 10 * MB })).toBe(10 * MB); expect(effectiveMaxFileSize("free", { maxFileSize: 100 * MB })).toBe(50 * MB); }); it("unknown plan → free default", () => { expect(effectiveMaxFileSize("enterprise", null)).toBe(50 * MB); }); }); describe("kindForMime (server-verified mime, never the extension)", () => { it("maps the known mimes to their kinds", () => { expect(kindForMime("image/png")).toBe("image"); expect(kindForMime("video/mp4")).toBe("video"); expect(kindForMime("audio/mpeg")).toBe("audio"); expect(kindForMime("application/pdf")).toBe("pdf"); expect(kindForMime("text/markdown")).toBe("doc"); expect(kindForMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("doc"); }); it("is case/parameter-insensitive and falls back to other", () => { expect(kindForMime("IMAGE/PNG")).toBe("image"); expect(kindForMime("video/mp4;codecs=avc1")).toBe("video"); expect(kindForMime("application/zip")).toBe("other"); expect(kindForMime("")).toBe("other"); expect(kindForMime(null)).toBe("other"); }); }); describe("humanSize", () => { it("formats bytes → human", () => { expect(humanSize(0)).toBe("0 B"); expect(humanSize(512)).toBe("512 B"); expect(humanSize(1024)).toBe("1.0 KB"); expect(humanSize(50 * 1024 * 1024)).toBe("50.0 MB"); expect(humanSize(1.2 * 1024 * 1024 * 1024)).toBe("1.2 GB"); }); }); describe("uploadFile — plan pre-check", () => { it("rejects an over-limit file with NO request sent (the E2E assertion)", async () => { const f = new File([new ArrayBuffer(100)], "big.png", { type: "image/png" }); // 100 B const h = uploadFile(f, { maxFileSize: 10 }); // 10-byte limit → 100 > 10 await expect(h.promise).rejects.toMatchObject({ code: "FILE_TOO_LARGE" }); expect(filesApi.openSession).not.toHaveBeenCalled(); expect(filesApi.complete).not.toHaveBeenCalled(); expect(filesApi.cancel).not.toHaveBeenCalled(); }); }); describe("uploadFile — happy path (session → PUT → complete → poll → READY)", () => { it("completes and resolves with the READY detail", async () => { const { factory } = makeXhrFactory({ progressSteps: [2, 5, 10] }); vi.mocked(filesApi.openSession).mockResolvedValue({ file: fileDetail({ status: "UPLOADING" }), uploadUrl: "https://s3/put?sig=1", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "2026-01-01T00:15:00.000Z", }); vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" }); vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "READY" })); const fractions: number[] = []; const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), { maxFileSize: 10 * MB, onProgress: (p) => fractions.push(p.fraction), poll: { intervalMs: 1, timeoutMs: 1000 }, }, factory); const result = await h.promise; expect(result.file.status).toBe("READY"); expect(result.stillProcessing).toBe(false); expect(filesApi.complete).toHaveBeenCalledWith("file_1", expect.anything()); // progress is monotonic non-decreasing and ends at 1 expect(fractions.every((v, i) => i === 0 || v >= (fractions[i - 1] ?? 0))).toBe(true); expect(fractions[fractions.length - 1] ?? 0).toBe(1); }); }); describe("uploadFile — 403 (expired signature) auto-retries ONCE", () => { it("re-opens a session and the retry PUT succeeds", async () => { const { factory, state } = makeXhrFactory({ putResults: [403, 200], progressSteps: [5] }); vi.mocked(filesApi.openSession) .mockResolvedValueOnce({ file: fileDetail({ id: "file_old", status: "UPLOADING" }), uploadUrl: "https://s3/old?sig=1", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "x", }) .mockResolvedValueOnce({ file: fileDetail({ id: "file_new", status: "UPLOADING" }), uploadUrl: "https://s3/new?sig=2", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "x", }); vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_new", status: "SCANNING", verifiedBy: "worker" }); vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ id: "file_new", status: "READY" })); const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), { maxFileSize: 10 * MB, poll: { intervalMs: 1, timeoutMs: 1000 }, }, factory); await h.promise; expect(state.putCalls).toHaveLength(2); const [firstPut, secondPut] = state.putCalls; expect(firstPut?.url).toContain("/old"); expect(secondPut?.url).toContain("/new"); // complete targets the FRESH session's file id expect(filesApi.complete).toHaveBeenCalledWith("file_new", expect.anything()); }); }); describe("uploadFile — cancel", () => { it("before complete: aborts the XHR + calls the cancel API + rejects CANCELLED", async () => { // a PUT that never resolves its onload (a real in-flight transfer) const pending: import("../upload").XhrLike[] = []; const factory = () => { const xhr: import("../upload").XhrLike = { status: 0, responseText: "", onload: null, onerror: null, onabort: null, open() {}, setRequestHeader() {}, upload: { addEventListener() {} }, send() { pending.push(xhr); }, abort() { xhr.onabort?.(); }, }; return xhr; }; vi.mocked(filesApi.openSession).mockResolvedValue({ file: fileDetail({ status: "UPLOADING" }), uploadUrl: "https://s3/put", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "x", }); const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), { maxFileSize: 10 * MB }, factory); await sleep(5); // let the session open + PUT send h.cancel(); await expect(h.promise).rejects.toMatchObject({ code: "CANCELLED" }); expect(pending.length).toBeGreaterThan(0); expect(pending[0]?.onabort).toBeTruthy(); // the XHR was aborted expect(filesApi.cancel).toHaveBeenCalledWith("file_1", expect.anything()); expect(filesApi.complete).not.toHaveBeenCalled(); }); }); describe("uploadFile — poll timeout (file still processing)", () => { it("returns stillProcessing=true after the window, without throwing", async () => { const { factory } = makeXhrFactory(); vi.mocked(filesApi.openSession).mockResolvedValue({ file: fileDetail({ status: "UPLOADING" }), uploadUrl: "https://s3/put", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "x", }); vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" }); // the worker never finishes within the (tiny) window vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "SCANNING" })); const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), { maxFileSize: 10 * MB, poll: { intervalMs: 2, timeoutMs: 20 }, }, factory); const result = await h.promise; expect(result.stillProcessing).toBe(true); expect(result.file.status).toBe("SCANNING"); }); }); describe("uploadFile — virus / fatal", () => { it("throws UploadError with the terminal status after poll sees it", async () => { const { factory } = makeXhrFactory(); vi.mocked(filesApi.openSession).mockResolvedValue({ file: fileDetail({ status: "UPLOADING" }), uploadUrl: "https://s3/put", method: "PUT", headers: { "Content-Type": "image/png" }, expiresAt: "x", }); vi.mocked(filesApi.complete).mockResolvedValue({ id: "file_1", status: "SCANNING", verifiedBy: "worker" }); vi.mocked(filesApi.get).mockResolvedValue(fileDetail({ status: "VIRUS_DETECTED" })); const h = uploadFile(new File([new ArrayBuffer(10)], "a.png", { type: "image/png" }), { maxFileSize: 10 * MB, poll: { intervalMs: 1, timeoutMs: 1000 }, }, factory); await expect(h.promise).rejects.toMatchObject({ code: "VIRUS_DETECTED" }); }); }); describe("isTerminalStatus", () => { it("READY / VIRUS_DETECTED / FAILED are terminal; UPLOADING / SCANNING are not", () => { expect(isTerminalStatus("READY")).toBe(true); expect(isTerminalStatus("VIRUS_DETECTED")).toBe(true); expect(isTerminalStatus("FAILED")).toBe(true); expect(isTerminalStatus("UPLOADING")).toBe(false); expect(isTerminalStatus("SCANNING")).toBe(false); }); });