144 lines
4.7 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|