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 { 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 { 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 }>; } 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 }[]).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); }); });