e67f0ccef0
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
647 lines
37 KiB
TypeScript
647 lines
37 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||
import { createTestDb } from "@parking/db/testing";
|
||
import { deviceEvents, type Db } from "@parking/db";
|
||
import type { FastifyInstance } from "fastify";
|
||
import { buildServer } from "../../server.js";
|
||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||
|
||
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||
// settings → intake against an open parking session → done applies the sponsorship
|
||
// validation → bay payment settles the parking session at zero (what the exit reader
|
||
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||
|
||
let db: Db;
|
||
let close: () => void;
|
||
let app: FastifyInstance;
|
||
|
||
beforeEach(async () => {
|
||
delete process.env.MODULES_ENTITLED;
|
||
const t = createTestDb();
|
||
db = t.db;
|
||
close = t.close;
|
||
app = await buildServer({ db });
|
||
await app.ready();
|
||
});
|
||
afterEach(async () => {
|
||
await app.close();
|
||
close();
|
||
});
|
||
|
||
type Auth = { cookie: string; csrf: string };
|
||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||
|
||
async function admin(): Promise<Auth> {
|
||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||
return login(app, username, password);
|
||
}
|
||
|
||
/** An open transient session that has been parked long enough to owe money. */
|
||
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||
await makeLog(db).append({
|
||
type: "vehicle_entry",
|
||
source: "manual",
|
||
identity,
|
||
occurredAt: minutesAgo(enteredMinutesAgo),
|
||
payload: { sessionRef: identity, category: "default" },
|
||
});
|
||
}
|
||
|
||
async function seedSettings(a: Auth) {
|
||
const res = await app.inject({
|
||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||
payload: {
|
||
categories: [{ name: "Car" }, { name: "SUV" }],
|
||
services: [{ name: "Standard" }, { name: "Inside" }],
|
||
prices: [],
|
||
},
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const s = res.json();
|
||
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||
const priced = await app.inject({
|
||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||
payload: {
|
||
categories: s.categories, services: s.services,
|
||
prices: [
|
||
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||
],
|
||
},
|
||
});
|
||
expect(priced.statusCode).toBe(200);
|
||
expect(priced.json().prices).toHaveLength(3);
|
||
return { car, suv, std, inside };
|
||
}
|
||
|
||
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json().payAt).toBe(payAt);
|
||
}
|
||
|
||
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||
const res = await app.inject({
|
||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||
});
|
||
expect(res.statusCode).toBeLessThan(300);
|
||
}
|
||
|
||
async function events(a: Auth) {
|
||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||
}
|
||
|
||
describe("settings", () => {
|
||
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||
const a = await admin();
|
||
const ids = await seedSettings(a);
|
||
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||
const bad = await app.inject({
|
||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||
});
|
||
expect(bad.statusCode).toBe(400);
|
||
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
});
|
||
|
||
describe("orders", () => {
|
||
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
const noSession = await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||
});
|
||
expect(noSession.statusCode).toBe(404);
|
||
|
||
await openSession("T-1");
|
||
const noPrice = await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||
});
|
||
expect(noPrice.statusCode).toBe(409);
|
||
expect(noPrice.json().code).toBe("no_price");
|
||
|
||
const created = await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||
});
|
||
expect(created.statusCode).toBe(201);
|
||
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||
|
||
await openSession("T-2");
|
||
await setPayAt(a, "bay");
|
||
await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||
});
|
||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||
|
||
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||
expect(chain).toHaveLength(2);
|
||
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||
});
|
||
|
||
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||
const a = await admin();
|
||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||
const ids = await seedSettings(a);
|
||
await openSession("T-B");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||
})).json();
|
||
|
||
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||
const s = look.json();
|
||
expect(s.chargeLines).toHaveLength(1);
|
||
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||
expect(s.chargesMinor).toBe(50000);
|
||
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||
|
||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||
expect(pay.statusCode).toBeLessThan(300);
|
||
|
||
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||
expect(payment.payload.chargesMinor).toBe(50000);
|
||
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||
|
||
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||
expect(o.paidAt).toBeTruthy();
|
||
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||
expect(o.tender).toBe("cash");
|
||
// A second lookup no longer carries the line (it's settled).
|
||
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||
expect(again.json().chargeLines).toEqual([]);
|
||
|
||
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
|
||
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
|
||
const parking = payment.payload.parkingMinor as number;
|
||
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
|
||
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
|
||
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
|
||
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
|
||
});
|
||
|
||
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||
const a = await admin();
|
||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||
const ids = await seedSettings(a);
|
||
await seedSponsorship(a, "comp");
|
||
await openSession("T-Y");
|
||
await setPayAt(a, "bay");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||
})).json();
|
||
|
||
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||
expect(noShift.statusCode).toBe(409);
|
||
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||
expect(openWash.statusCode).toBe(200);
|
||
|
||
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
expect(done.statusCode).toBe(200);
|
||
expect(done.json().status).toBe("done");
|
||
expect(done.json().validationEventId).toBeTruthy();
|
||
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||
expect(mid.json().amountMinor).toBe(0);
|
||
expect(mid.json().paidAt).toBeNull();
|
||
|
||
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||
expect(paid.statusCode).toBe(200);
|
||
expect(paid.json().closed).toBe(true);
|
||
|
||
const evs = await events(a);
|
||
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||
expect(boothZ.till).toBe("booth");
|
||
expect(boothZ.cardTotalMinor).toBe(0);
|
||
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||
expect(parkingPay).toBeTruthy();
|
||
expect(parkingPay.payload.amountMinor).toBe(0);
|
||
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||
expect(after.json().paidAt).toBeTruthy();
|
||
expect(after.json().withinGrace).toBe(true);
|
||
|
||
// The queue is empty (done + paid = closed).
|
||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||
expect(queue.json().orders).toEqual([]);
|
||
});
|
||
|
||
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||
const a = await admin();
|
||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||
const ids = await seedSettings(a);
|
||
await seedSponsorship(a, "percent");
|
||
await openSession("T-P");
|
||
await setPayAt(a, "bay");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||
})).json();
|
||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||
expect(s.paidAt).toBeNull();
|
||
expect(s.amountMinor).toBeGreaterThan(0);
|
||
expect(s.discountMinor).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
await seedSponsorship(a, "comp");
|
||
await openSession("T-V");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||
})).json();
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||
expect(before.validationLines).toHaveLength(1);
|
||
|
||
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||
expect(voided.statusCode).toBe(200);
|
||
expect(voided.json().status).toBe("void");
|
||
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||
expect(after.validationLines).toEqual([]);
|
||
expect(after.chargeLines).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("wash-only discount modes", () => {
|
||
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||
const a = await admin();
|
||
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||
const ids = await seedSettings(a);
|
||
await seedSponsorship(a, "doneTolerance", 15);
|
||
await openSession("T-D", 95);
|
||
await setPayAt(a, "bay");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||
})).json();
|
||
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||
expect(before.amountMinor).toBe(20000);
|
||
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||
expect(v.payload.mode).toBe("timeCredit");
|
||
expect(v.payload.programMode).toBe("doneTolerance");
|
||
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||
expect(after.amountMinor).toBe(20000);
|
||
expect(after.discountMinor).toBe(0);
|
||
});
|
||
|
||
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||
const a = await admin();
|
||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||
const ids = await seedSettings(a);
|
||
await seedSponsorship(a, "doneTolerance", 120);
|
||
await openSession("T-D2", 95);
|
||
await setPayAt(a, "bay");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||
})).json();
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||
expect(after.amountMinor).toBe(0);
|
||
});
|
||
|
||
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||
const a = await admin();
|
||
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||
const ids = await seedSettings(a);
|
||
await seedSponsorship(a, "washPrice");
|
||
await openSession("T-W", 95);
|
||
await setPayAt(a, "bay");
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||
})).json();
|
||
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||
expect(after.discountMinor).toBe(50000);
|
||
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||
});
|
||
|
||
it("a merchant scan cannot apply a wash-only program", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
await seedSponsorship(a, "washPrice");
|
||
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||
await app.inject({
|
||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||
});
|
||
await openSession("T-M");
|
||
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||
expect(res.statusCode).toBe(400);
|
||
expect(res.json().error).toMatch(/car wash order/);
|
||
});
|
||
});
|
||
|
||
describe("module gate", () => {
|
||
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
await openSession("T-G");
|
||
await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||
});
|
||
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||
expect(q.statusCode).toBe(403);
|
||
expect(q.json().code).toBe("module_disabled");
|
||
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||
expect(look.chargeLines).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("where the money is taken is a SITE setting", () => {
|
||
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||
await openSession("T-S1");
|
||
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||
expect(o1.payAt).toBe("booth");
|
||
|
||
await setPayAt(a, "bay");
|
||
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||
await openSession("T-S2");
|
||
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||
expect(o2.payAt).toBe("bay");
|
||
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||
|
||
// A stale client insisting on the other place is refused, never silently overridden.
|
||
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||
expect(stale.statusCode).toBe(409);
|
||
expect(stale.json().code).toBe("pay_at_policy");
|
||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||
expect(bad.statusCode).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe("tills are gated by the module permission", () => {
|
||
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
await seedSettings(a);
|
||
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||
const washer = await seedUser(db, {
|
||
username: "lavazhier", roleId: "washer",
|
||
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||
});
|
||
const w = await login(app, washer.username, washer.password);
|
||
// The desk's category/service pickers come from the settings read — the job has no
|
||
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
|
||
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
|
||
expect(list.statusCode).toBe(200);
|
||
expect(list.json().categories.length).toBeGreaterThan(0);
|
||
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
|
||
// What the UI offers: only the wash till.
|
||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||
// The booth's shift is refused outright (the role holds no shift:*).
|
||
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||
expect(booth.statusCode).toBe(403);
|
||
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||
expect(boothState.statusCode).toBe(403);
|
||
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||
expect(boothCash.statusCode).toBe(403);
|
||
// The wash till works.
|
||
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||
expect(wash.statusCode).toBe(200);
|
||
expect(wash.json().till).toBe("carwash");
|
||
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||
expect(washCash.statusCode).toBe(200);
|
||
|
||
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||
// sees the state and gets canWork=false; opening is refused.
|
||
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||
const l = await login(app, looker.username, looker.password);
|
||
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||
|
||
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||
const booth1 = await seedUser(db, {
|
||
username: "boothie", roleId: "booth-op",
|
||
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||
});
|
||
const b = await login(app, booth1.username, booth1.password);
|
||
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||
expect(noWash.statusCode).toBe(403);
|
||
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||
});
|
||
});
|
||
|
||
describe("a role reassignment takes effect without re-login", () => {
|
||
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
await openSession("T-R");
|
||
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||
const l = await login(app, looker.username, looker.password);
|
||
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||
expect(before.statusCode).toBe(403);
|
||
|
||
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||
expect(moved.statusCode).toBe(200);
|
||
|
||
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||
expect(after.statusCode).toBe(201);
|
||
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||
expect(me.roleId).toBe("wash-op");
|
||
});
|
||
});
|
||
|
||
describe("a shift's activity log is per till", () => {
|
||
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
|
||
const a = await admin();
|
||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||
const ids = await seedSettings(a);
|
||
await openSession("T-L");
|
||
await setPayAt(a, "bay");
|
||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||
const order = (await app.inject({
|
||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
|
||
})).json();
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
|
||
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
|
||
|
||
const types = async (qs: string, auth: Auth = a) => {
|
||
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
|
||
expect(r.statusCode).toBe(200);
|
||
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
|
||
};
|
||
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
|
||
// its bay payment and its voucher — none of the booth's.
|
||
const wash = await types("&till=carwash");
|
||
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
|
||
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
|
||
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
|
||
const booth = await types("&till=booth");
|
||
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
|
||
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
|
||
// No till → everything (unchanged).
|
||
const all = await types("");
|
||
expect(all.length).toBe(wash.length + booth.length);
|
||
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
|
||
|
||
// A wash operator holds carwash:read but not event:read: the log opens for them
|
||
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
|
||
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
|
||
const w = await login(app, washer.username, washer.password);
|
||
const mine = await types("&till=carwash", w);
|
||
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
|
||
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
|
||
// A role with neither event:read nor any module feed permission reads nothing.
|
||
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
|
||
const c = await login(app, clerk.username, clerk.password);
|
||
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
|
||
});
|
||
});
|
||
|
||
describe("vision category — advisory, flagged, never authoritative", () => {
|
||
/** What snapshot.ts records when vision classifies the entry frame. */
|
||
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
|
||
db.insert(deviceEvents).values({
|
||
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
|
||
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
|
||
occurredAt: new Date().toISOString(),
|
||
}).run();
|
||
}
|
||
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
|
||
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
|
||
const r = await app.inject({
|
||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||
payload: {
|
||
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
|
||
visionThreshold: 0.75,
|
||
},
|
||
});
|
||
expect(r.statusCode).toBe(200);
|
||
return r.json();
|
||
}
|
||
|
||
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
const saved = await mapClasses(a, ids);
|
||
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
|
||
expect(saved.visionThreshold).toBe(0.75);
|
||
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
|
||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
|
||
expect(bad.statusCode).toBe(400);
|
||
|
||
await openSession("T-V1");
|
||
seeVehicle("T-V1", "suv", 0.91);
|
||
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
||
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||
expect(look.suggestedCategoryId).toBe(ids.suv);
|
||
// Unmapped class → shown, nothing suggested.
|
||
await openSession("T-V2");
|
||
seeVehicle("T-V2", "bus", 0.99);
|
||
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
|
||
expect(look2.vision.bodyType).toBe("bus");
|
||
expect(look2.suggestedCategoryId).toBeNull();
|
||
});
|
||
|
||
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
|
||
const a = await admin();
|
||
seedTariff(db);
|
||
const ids = await seedSettings(a);
|
||
await mapClasses(a, ids);
|
||
const order = async (identity: string, categoryId: string) => {
|
||
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
|
||
expect(r.statusCode).toBe(201);
|
||
return r.json();
|
||
};
|
||
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
|
||
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
|
||
const down = await order("T-D1", ids.car);
|
||
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
|
||
expect(down.downgradeEventId).toBeTruthy();
|
||
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
|
||
expect(flag).toBeTruthy();
|
||
expect(flag.payload).toMatchObject({
|
||
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
|
||
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
|
||
});
|
||
// Same category as the camera → nothing.
|
||
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
|
||
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
|
||
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
|
||
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
|
||
const up = await order("T-D3", ids.suv);
|
||
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
|
||
// Below the site threshold → shown, never flagged.
|
||
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
|
||
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
|
||
// No read at all → nulls.
|
||
await openSession("T-D5");
|
||
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
|
||
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
|
||
});
|
||
});
|