feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user