import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { ledgerEvents, eq, type Db } from "@parking/db"; 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"); } 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); }); });