import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import sharp from "sharp"; import { createTestDb } from "@parking/db/testing"; import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db"; import type { FastifyInstance } from "fastify"; import { deviceEvents as deviceEventBus } from "../../device-events.js"; import { buildServer } from "../../server.js"; import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js"; import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js"; // The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred // vehicle crop + the operator's choice, queued off the intake path, drained one-way with // backoff, never blocking the wash, never naming the site. /** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */ async function frame(): Promise { return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } }) .composite([ { input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 }, { input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 }, ]) .jpeg() .toBuffer(); } const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 }; const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 }; /** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a * blurred plate drops far below 255 as the red bleeds in. */ async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise { const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true }); let sum = 0; for (let i = 1; i < data.length; i += info.channels) sum += data[i]!; return sum / (data.length / info.channels); } describe("makeReviewCrop", () => { it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => { const shot = await frame(); const crop = await makeReviewCrop(shot, CAR, PLATE); expect(crop.plateBlurred).toBe(true); // Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling. expect(crop.width).toBeGreaterThanOrEqual(228); expect(crop.width).toBeLessThanOrEqual(236); expect(crop.height).toBeGreaterThanOrEqual(135); // The white plate is gone: over the plate strip (crop coords: the frame's 170..230 × // 190..206 shifted by the crop origin 84,90) the same region cut straight from the // frame is white, the review crop is the red bleeding in. const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer(); const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 }; expect(await meanGreen(plain, strip)).toBeGreaterThan(240); expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180); // Without a plate box: same crop, nothing blurred. const noPlate = await makeReviewCrop(shot, CAR, null); expect(noPlate.plateBlurred).toBe(false); // A big frame is capped to the max edge. const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer(); const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null); expect(Math.max(capped.width, capped.height)).toBe(640); }); }); describe("config + pseudonyms", () => { it("needs url, token and booth id together; the operator ref is a keyed hash", () => { expect(reviewUploadConfigFromEnv({})).toBeNull(); expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull(); const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" }); expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default expect(operatorRef("b7", "lavazhier")).toHaveLength(16); expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier")); expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier"); }); }); describe("queue + drain", () => { let db: Db; let close: () => void; beforeEach(() => { const t = createTestDb(); db = t.db; close = t.close; }); afterEach(() => close()); const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 }; const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE }; const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false }; async function seed(): Promise { db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run(); db.insert(carwashOrders).values({ id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard", priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier", }).run(); } it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => { await seed(); const calls: { url: string; init: RequestInit }[] = []; const fetchFn = vi.fn(async (url: string, init: RequestInit) => { calls.push({ url, init }); return new Response("ok", { status: 200 }); }); const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn); expect(await ob.enqueue(item, read)).toBe(true); const row = db.select().from(carwashReviewOutbox).all()[0]!; expect(row.status).toBe("queued"); expect(row.image!.length).toBeGreaterThan(500); expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } }); expect(JSON.stringify(row.payload)).not.toContain("lavazhier"); expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 }); expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe(cfg.url); expect((calls[0]!.init.headers as Record).authorization).toBe("Bearer secret-1"); const form = calls[0]!.init.body as FormData; expect(JSON.parse(form.get("meta") as string).item).toBe(row.id); expect((form.get("image") as File).type).toBe("image/jpeg"); const after = db.select().from(carwashReviewOutbox).all()[0]!; expect(after.status).toBe("sent"); expect(after.image).toBeNull(); expect(after.sentAt).toBeTruthy(); expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 }); }); it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => { await seed(); let status = 503; const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status }))); const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn); await ob.enqueue(item, read); expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 }); let row = db.select().from(carwashReviewOutbox).all()[0]!; expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" }); expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000); // Not due yet → untouched. expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 }); // Due again: a network error defers too; a 422 abandons. db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run(); status = 0; expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 }); db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run(); status = 422; expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 }); row = db.select().from(carwashReviewOutbox).all()[0]!; expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" }); expect(row.image).toBeNull(); // A voided order is not a sample. status = 200; await ob.enqueue({ ...item, orderId: "o-1" }, read); db.update(carwashOrders).set({ status: "void" }).run(); expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 }); // Expired items are abandoned without a request. await ob.enqueue(item, read); db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run(); db.update(carwashOrders).set({ status: "open" }).run(); const before = fetchFn.mock.calls.length; expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 }); expect(fetchFn.mock.calls.length).toBe(before); expect(ob.status().failed).toBe(3); // Entry sampling: one in N entry reads becomes a package with the crop and the // camera's class only — no order, no operator, no category. const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn); expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]); expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off expect(await sampler.enqueueEntry(read)).toBe(true); const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!; expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } }); expect(entryRow.payload).not.toHaveProperty("operator"); expect(entryRow.payload).not.toHaveProperty("operatorCategory"); expect(entryRow.image!.length).toBeGreaterThan(500); // No vehicle box, no snapshot, or upload off → nothing queued. expect(await ob.enqueue(item, { ...read, box: null })).toBe(false); expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false); expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false); }); }); import { eq } from "@parking/db"; describe("through the app", () => { let db: Db; let close: () => void; let app: FastifyInstance; const saved = { ...process.env }; beforeEach(async () => { delete process.env.MODULES_ENTITLED; process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest"; process.env.CARWASH_REVIEW_TOKEN = "tok"; process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9"; process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1"; const t = createTestDb(); db = t.db; close = t.close; app = await buildServer({ db }); await app.ready(); }); afterEach(async () => { await app.close(); close(); for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => { const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); const a = await login(app, username, password); const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf }; seedTariff(db); const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json(); const cat = s.categories[0].id, svc = s.services[0].id; await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } }); await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } }); db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run(); db.insert(deviceEvents).values({ id: "read-r", deviceId: "cam-1", category: "camera", kind: "read", detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE }, occurredAt: new Date().toISOString(), }).run(); const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } }); expect(order.statusCode).toBe(201); // Enqueue is fire-and-forget: give the crop a moment. await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1)); const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json(); expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 }); // An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module // (1 in 1 here) into an entry package; an exit read is not. deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } }); deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } }); await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2)); const rows = db.select().from(carwashReviewOutbox).all(); expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]); }); });