f4b806a538
The reviewer host's collector.sqlite was created by an earlier build, before the `kind` column. CREATE TABLE IF NOT EXISTS shapes only a new database, so every query naming the column failed: the collector's /health (container unhealthy), every booth ingest, and the trainer's readiness — whose stdlib server printed the traceback and dropped the socket, which the collector could only render as "trainer not reachable: fetch failed". Nine days like that. - CollectorDb.#migrate(): PRAGMA table_info against the list of columns added since the first deploy; ALTER TABLE ADD COLUMN for each missing one (all nullable or defaulted). Append to that list whenever a column joins the CREATE. Test replays the original schema: health, ingest, stats, a legacy row reads back with the defaults. - Trainer Handler._guarded(): any unexpected exception → 500 JSON naming it, never a dropped connection; /health keeps answering. Test drives readiness against an old-schema DB. - The collector's training status proxy includes the trainer's error text. Wiki: the incident and the schema rule (vision-review-outbox), what the message means (bodytype-classifier-training), log. Deploy: the new collector migrates on start; nothing manual. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
197 lines
13 KiB
TypeScript
197 lines
13 KiB
TypeScript
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<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, 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/);
|
|
});
|
|
});
|
|
|
|
describe("schema migration", () => {
|
|
it("opens a database created before the `kind` column and adds the missing columns, so ingest and stats work", async () => {
|
|
// art-docker-station, 2026-09-16: the volume's DB predated `kind`; CREATE TABLE IF NOT
|
|
// EXISTS left it alone, and /health, every ingest and the trainer's readiness failed
|
|
// with "no such column: kind". Replay: a file with the ORIGINAL column set.
|
|
const { default: Database } = await import("better-sqlite3");
|
|
const file = path.join(dir, "old.sqlite");
|
|
const old = new Database(file);
|
|
old.exec(`CREATE TABLE items (
|
|
id TEXT PRIMARY KEY, booth TEXT NOT NULL, order_ref TEXT NOT NULL, at TEXT NOT NULL,
|
|
service TEXT NOT NULL, vision_class TEXT NOT NULL, vision_confidence REAL NOT NULL,
|
|
image_width INTEGER NOT NULL, image_height INTEGER NOT NULL, plate_blurred INTEGER NOT NULL,
|
|
image_path TEXT NOT NULL, received_at TEXT NOT NULL, review_label TEXT, reviewed_at TEXT, reviewer TEXT)`);
|
|
old.prepare(
|
|
"INSERT INTO items (id, booth, order_ref, at, service, vision_class, vision_confidence, image_width, image_height, plate_blurred, image_path, received_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
).run("legacy-1", "booth-7", "o-0", "2026-09-01T00:00:00.000Z", "Standard", "suv", 0.8, 100, 100, 1, "crops/legacy-1.jpg", "2026-09-01T00:00:00.000Z");
|
|
old.close();
|
|
|
|
const legacy = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER, trainerUrl: null }, { dbFile: file });
|
|
await legacy.ready();
|
|
try {
|
|
const health = await legacy.inject({ method: "GET", url: "/health" });
|
|
expect(health.statusCode).toBe(200);
|
|
expect(health.json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
|
|
|
|
const { body, type } = multipart({ meta: JSON.stringify(meta({ item: "item-new" })) }, JPEG);
|
|
const r = await legacy.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${TOKENS.get("booth-7")!}`, "content-type": type }, payload: body });
|
|
expect(r.statusCode).toBe(201);
|
|
|
|
const stats = await legacy.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } });
|
|
expect(stats.statusCode).toBe(200);
|
|
|
|
// The legacy row reads back with the defaults the new columns carry.
|
|
const cols = new Database(file, { readonly: true }).prepare("PRAGMA table_info(items)").all() as { name: string }[];
|
|
expect(cols.map((c) => c.name)).toEqual(expect.arrayContaining(["kind", "operator_ref", "operator_classes", "vision_category_id", "downgraded"]));
|
|
const legacyRow = new Database(file, { readonly: true }).prepare("SELECT kind, operator_classes, downgraded FROM items WHERE id = 'legacy-1'").get() as Record<string, unknown>;
|
|
expect(legacyRow).toEqual({ kind: "wash", operator_classes: "[]", downgraded: 0 });
|
|
} finally {
|
|
await legacy.close();
|
|
}
|
|
});
|
|
});
|