import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db"; import { randomUUID } from "node:crypto"; import { ExitFlow } from "./exit-flow.js"; import { PayStation } from "./pay-station.js"; import type { EventLog } from "./event-log.js"; import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js"; // The exit flow is the anti-fraud GATE: no car leaves without a covering payment within // the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has // no bypass. With no relay configured a clean exit returns { opened:false } — we assert // the DECISION (refuse vs. sign the exit), not the hardware open. let db: Db; let close: () => void; let log: EventLog; let exit: ExitFlow; let pay: PayStation; beforeEach(() => { const t = createTestDb(); db = t.db; close = t.close; log = makeLog(db); exit = new ExitFlow(db, log, silentLogger()); pay = new PayStation(db, log, silentLogger()); }); afterEach(() => close()); async function enter(identity: string, enteredAt: string, payload?: Record) { await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null }); } function exitsSigned(identity: string) { return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit"); } function anomalies(reason?: string) { return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all() .filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason)); } /** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the * plate-reconciliation check can see this identity's plate against open sessions. */ function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) { db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run(); db.insert(deviceEventsTable).values({ id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt, detail: { identity, direction: "entry", plate, confidence }, }).run(); } describe("exitForBooth — refusal gates", () => { it("refuses an unknown ticket (no session) and signs an anomaly", async () => { const r = await exit.exitForBooth("ghost"); expect(r).toMatchObject({ ok: false, status: "no_session" }); const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all(); expect(anomalies).toHaveLength(1); expect(exitsSigned("ghost")).toHaveLength(0); }); it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => { seedTariff(db, { pricePerIncrementMinor: 10000 }); await enter("T1", minutesAgo(90)); const r = await exit.exitForBooth("T1"); expect(r).toMatchObject({ ok: false, status: "unpaid" }); expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave }); it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("T1", minutesAgo(200)); // A payment made 60 min ago → its 15-min walk-back grace lapsed long ago. await log.append({ type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60), payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 }, }); const r = await exit.exitForBooth("T1"); expect(r).toMatchObject({ ok: false, status: "grace_expired" }); expect(exitsSigned("T1")).toHaveLength(0); }); }); describe("exitForBooth — valid exit signs the vehicle_exit", () => { it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("T1", minutesAgo(90)); await pay.pay("T1", "cash"); // fresh payment → within grace const r = await exit.exitForBooth("T1"); expect(r.ok).toBe(true); if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain expect(log.verifyChain()).toEqual({ ok: true }); }); // NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential // path), not exitForBooth — the booth's transient exit has no subscription bypass and // applies the same paid/grace gate to any identity it's handed. Asserting that here so // the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a // subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead. it("does NOT give the booth transient-exit path a subscription bypass", async () => { await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" }); const r = await exit.exitForBooth("SUBSESS-1"); expect(r).toMatchObject({ ok: false, status: "unpaid" }); expect(exitsSigned("SUBSESS-1")).toHaveLength(0); }); it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => { await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" }); const r = await exit.reopenBarrier("SUBSESS-1", "op1"); expect(r.ok).toBe(true); expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence }); }); describe("reopenBarrier — no unpaid re-open", () => { it("refuses to re-open an unpaid transient session", async () => { seedTariff(db, { pricePerIncrementMinor: 10000 }); await enter("T1", minutesAgo(90)); const r = await exit.reopenBarrier("T1", "op1"); expect(r.ok).toBe(false); expect(exitsSigned("T1")).toHaveLength(0); }); it("re-opening a paid OPEN session also closes it (signs the exit)", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("T1", minutesAgo(90)); await pay.pay("T1", "cash"); const r = await exit.reopenBarrier("T1", "op1"); expect(r.ok).toBe(true); // The open session is closed by the human-intervention exit so it leaves the list. expect(exitsSigned("T1")).toHaveLength(1); }); }); describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => { // The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside". // The plate is the invariant — the exiting car's plate is already open under the old ticket. it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); // Original car entered on 1234, plate AA123BB, still open (never paid/exited). await enter("1234", minutesAgo(120)); seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120)); // A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit. await enter("1237", minutesAgo(1)); seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1)); await pay.pay("1237", "cash"); const r = await exit.exitForBooth("1237"); expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" }); expect(exitsSigned("1237")).toHaveLength(0); // NOT let out expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1); }); it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("1234", minutesAgo(120)); seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120)); await enter("1237", minutesAgo(1)); seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1)); await pay.pay("1237", "cash"); const r = await exit.exitForBooth("1237", { override: true, operator: "op1" }); expect(r.ok).toBe(true); expect(exitsSigned("1237")).toHaveLength(1); // released const ov = anomalies("released a suspected ticket-swap"); expect(ov.length).toBe(1); expect((ov[0].payload as { operator?: string }).operator).toBe("op1"); }); it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("1234", minutesAgo(120)); seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf await enter("1237", minutesAgo(1)); seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf await pay.pay("1237", "cash"); const r = await exit.exitForBooth("1237"); expect(r.ok).toBe(true); // no warning — exits normally expect(exitsSigned("1237")).toHaveLength(1); }); it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("1237", minutesAgo(90)); seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90)); await pay.pay("1237", "cash"); const r = await exit.exitForBooth("1237"); expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap expect(exitsSigned("1237")).toHaveLength(1); }); });