feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small Fastify + SQLite service in the monorepo (shares the payload contract and the class vocabulary via @parking/shared), delivered to art-docker-station by its own stack so nothing booth-side lands there and nothing of it on a booth. - POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row. - /review + /api/*: the reviewer's screen served by the process (Basic auth, one login): one pending crop at a time, operator's pick and camera's pick beside it, one button/key per vocabulary class + unusable + skip; stats per booth and per hashed operator (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's category). - GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host. - Booth payload now carries operatorCategory.classes so the comparison needs no site setup. - Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one secret per booth referenced from both the collector's token list and the booth's own stack (park-2 lines templated, commented, DNS name for the URL). - Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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 }, { 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<string, unknown> = {}) {
|
||||
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<string, string>, 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<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
|
||||
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 },
|
||||
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1 },
|
||||
]);
|
||||
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,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","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
|
||||
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user