import { describe, expect, it } from "vitest"; import { createUploadQueue, type UploadFile, type UploadItem } from "../src/browser/uploads.js"; function file(name: string, size: number, type = "image/png"): UploadFile { return { name, size, type }; } async function settle(): Promise { for (let index = 0; index < 8; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } describe("upload queue", () => { it("sends one request per file, and never more than the concurrency at once", async () => { let inFlight = 0; let peak = 0; const requests: string[] = []; const release: (() => void)[] = []; const queue = createUploadQueue({ concurrency: 3, maxBytes: 25 * 1024 * 1024, maxFiles: 20, newId: () => `id-${requests.length}-${release.length}`, onChange: () => undefined, upload: (candidate) => { requests.push(candidate.name); inFlight += 1; peak = Math.max(peak, inFlight); return new Promise((resolve) => { release.push(() => { inFlight -= 1; resolve(); }); }); }, }); queue.enqueue(Array.from({ length: 20 }, (_, index) => file(`slide-${index}.png`, 1024))); await settle(); // Twenty files are twenty requests — never one aggregate upload, which the platform's per-request // ceiling could not express and whose failure would waste every byte already sent. expect(peak).toBe(3); expect(requests).toHaveLength(3); while (release.length > 0) { release.shift()?.(); await settle(); } await queue.drain(); expect(requests).toHaveLength(20); expect(new Set(requests).size).toBe(20); }); it("fails one file alone and finishes the rest of the batch", async () => { const items: UploadItem[][] = []; const queue = createUploadQueue({ concurrency: 2, maxBytes: 25 * 1024 * 1024, maxFiles: 20, newId: () => `id-${items.length}-${Math.random()}`, onChange: (next) => items.push([...next]), upload: (candidate) => candidate.name === "broken.png" ? Promise.reject({ failure: { code: "SIGNAGE_IMAGE_MALFORMED", message: "Bad image." } }) : Promise.resolve(), }); queue.enqueue([file("a.png", 10), file("broken.png", 10), file("c.png", 10)]); await queue.drain(); await settle(); const final = items.at(-1) ?? []; expect(final.filter((item) => item.status === "done")).toHaveLength(2); const failed = final.find((item) => item.status === "failed"); expect(failed).toMatchObject({ code: "SIGNAGE_IMAGE_MALFORMED", name: "broken.png" }); }); it("refuses an oversized or unsupported file before a byte is sent", async () => { const sent: string[] = []; const seen: UploadItem[][] = []; const queue = createUploadQueue({ concurrency: 3, maxBytes: 1024, maxFiles: 20, newId: () => `id-${seen.length}-${sent.length}-${Math.random()}`, onChange: (next) => seen.push([...next]), upload: (candidate) => { sent.push(candidate.name); return Promise.resolve(); }, }); queue.enqueue([ file("huge.png", 2048), file("document.pdf", 10, "application/pdf"), file("fine.png", 10), ]); await queue.drain(); await settle(); // Only the acceptable one was ever sent. The server checks both rules again — a browser is not a // security boundary — but telling the operator now costs them nothing. expect(sent).toEqual(["fine.png"]); const final = seen.at(-1) ?? []; expect(final.find((item) => item.name === "huge.png")).toMatchObject({ code: "SIGNAGE_IMAGE_TOO_LARGE", status: "rejected", }); expect(final.find((item) => item.name === "document.pdf")).toMatchObject({ code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE", status: "rejected", }); }); it("refuses the overflow of a batch larger than the dashboard offers", async () => { const sent: string[] = []; const seen: UploadItem[][] = []; const queue = createUploadQueue({ concurrency: 5, maxBytes: 1024, maxFiles: 2, newId: () => `id-${sent.length}-${seen.length}-${Math.random()}`, onChange: (next) => seen.push([...next]), upload: (candidate) => { sent.push(candidate.name); return Promise.resolve(); }, }); queue.enqueue([file("a.png", 10), file("b.png", 10), file("c.png", 10)]); await queue.drain(); await settle(); expect(sent).toEqual(["a.png", "b.png"]); expect((seen.at(-1) ?? []).find((item) => item.name === "c.png")).toMatchObject({ code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE", status: "rejected", }); }); });