Files
Signage/tests/uploads.test.ts
T
2026-08-28 16:41:54 -05:00

144 lines
4.7 KiB
TypeScript

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<void> {
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<void>((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",
});
});
});