import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { buildCollector, type CollectorApp } from "./app.js"; import { parseBoothTokens } from "./config.js"; // The collector: one ingest surface (bearer per booth, idempotent), one review surface // (Basic), one export. Exercised over app.inject with a hand-built multipart body. let app: CollectorApp; let dir: string; const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]); const REVIEWER = { user: "julian", pass: "review-pass-123" }; const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64"); beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), "collector-")); app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER, trainerUrl: null }, { dbFile: ":memory:" }); await app.ready(); }); afterEach(async () => { await app.close(); await rm(dir, { recursive: true, force: true }); }); /** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */ const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]); function meta(over: Record = {}) { return { v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard", vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true, image: { width: 320, height: 200, plateBlurred: true }, ...over, }; } function multipart(fields: Record, file: Buffer | null): { body: Buffer; type: string } { const b = "----collector-test"; const parts: Buffer[] = []; for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`)); if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n")); parts.push(Buffer.from(`--${b}--\r\n`)); return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` }; } async function ingest(m: Record, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record = {}) { const { body, type } = multipart({ meta: JSON.stringify(m) }, file); return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body }); } describe("ingest", () => { it("stores the crop and the decision under the token's booth; retries are idempotent", async () => { const r = await ingest(meta()); expect(r.statusCode).toBe(201); const row = app.collectorDb.get("item-1")!; expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" }); expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]); const again = await ingest(meta()); expect(again.statusCode).toBe(200); expect(again.json()).toEqual({ ok: true, duplicate: true }); expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 }); }); it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => { expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401); expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403); expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415); expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400); expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422); expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422); expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422); expect(app.collectorDb.stats().booths).toEqual([]); }); }); describe("review + export", () => { it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => { await ingest(meta()); await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false })); await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!); // No login → 401 with a challenge; nothing without a configured reviewer is tested in config. const anon = await app.inject({ method: "GET", url: "/api/items" }); expect(anon.statusCode).toBe(401); expect(anon.headers["www-authenticate"]).toContain("Basic"); expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html"); const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json(); expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]); expect(list.items[0].imagePath).toBeUndefined(); const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } }); expect(img.statusCode).toBe(200); expect(img.headers["content-type"]).toBe("image/jpeg"); expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff])); // item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree. // item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable. const post = (id: string, label: string) => app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } }); expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" }); expect((await post("item-2", "sedan")).statusCode).toBe(200); expect((await post("item-3", "unusable")).statusCode).toBe(200); expect((await post("item-3", "spaceship")).statusCode).toBe(400); expect((await post("nope", "suv")).statusCode).toBe(404); const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json(); expect(stats.booths).toEqual([ { booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 }, { booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 }, ]); expect(stats.operators).toEqual([ { booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 }, { booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 }, ]); const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } }); expect(csv.statusCode).toBe(200); const lines = csv.body.trim().split("\n"); expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at"); expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"'); // An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and // never counted in any operator's agreement. const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } }); expect(entry.statusCode).toBe(201); expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422); expect((await post("entry-1", "suv")).statusCode).toBe(200); const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json(); expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 }); expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 }); const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body; expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"'); // A booth-supplied name that looks like a spreadsheet formula is neutralised in the export. await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } })); await post("item-4", "car"); const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body; expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`); }); }); describe("config", () => { it("parses booth:token pairs and refuses short tokens", () => { expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]); expect(() => parseBoothTokens("a:short")).toThrow(/too short/); expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/); }); });