import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { ledgerEvents, eq, type Db } from "@parking/db"; import { VoidFlow } from "./void-flow.js"; import { PayStation } from "./pay-station.js"; import { occupancyCount } from "./occupancy.js"; import type { EventLog } from "./event-log.js"; import { makeLog, silentLogger, seedTariff } from "./test-helpers.js"; // Cancel (void) a wrongly-printed ticket: a SIGNED `void` event that references the entry // and folds the session CLOSED. The entry itself is never edited/deleted (append-only). let db: Db; let close: () => void; let log: EventLog; let voidFlow: VoidFlow; let pay: PayStation; beforeEach(() => { const t = createTestDb(); db = t.db; close = t.close; log = makeLog(db); voidFlow = new VoidFlow(db, log, silentLogger()); pay = new PayStation(db, log, silentLogger()); }); afterEach(() => close()); async function enter(identity: string, payload?: Record) { await log.append({ type: "vehicle_entry", direction: "entry", identity, payload: payload ?? null }); } function voids(identity: string) { return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "void"); } describe("VoidFlow.voidTicket", () => { it("voids an open transient ticket: signs a void, closes the session, drops occupancy", async () => { await enter("T1"); expect(occupancyCount(db)).toBe(1); const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" }); expect(r.ok).toBe(true); const v = voids("T1"); expect(v).toHaveLength(1); const pl = v[0]!.payload as Record; expect(pl.voidReason).toBe("misprint"); expect(pl.operator).toBe("alice"); expect(pl.voidedEntryRef).toBeDefined(); expect(pl.reasonCode).toBe("void.ticketCancelled"); // Folds: not inside, not an active session, no longer "open". expect(occupancyCount(db)).toBe(1 - 1); expect(pay.activeSessions().some((s) => s.identity === "T1")).toBe(false); expect(pay.lookup("T1").open).toBe(false); }); it("requires a reason", async () => { await enter("T1"); const r = await voidFlow.voidTicket({ identity: "T1", reason: " ", operator: "alice" }); expect(r.ok).toBe(false); expect(voids("T1")).toHaveLength(0); }); it("refuses an unknown ticket", async () => { const r = await voidFlow.voidTicket({ identity: "ghost", reason: "misprint", operator: "alice" }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/no such ticket/i); }); it("refuses a second void (already cancelled)", async () => { await enter("T1"); await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" }); const r = await voidFlow.voidTicket({ identity: "T1", reason: "again", operator: "alice" }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/already cancelled/i); expect(voids("T1")).toHaveLength(1); }); it("refuses an already-exited session", async () => { await enter("T1"); await log.append({ type: "vehicle_exit", direction: "exit", identity: "T1" }); const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/already exited/i); }); it("refuses a PAID ticket (refund is a separate action)", async () => { await enter("T1"); await log.append({ type: "payment", identity: "T1", payload: { sessionRef: "T1", amountMinor: 100, currency: "ALL" } }); const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/already paid/i); }); it("refuses a subscription occurrence (closed via its own flow)", async () => { await enter("SUBSESS-x", { permit: true, permitId: "sub-1" }); const r = await voidFlow.voidTicket({ identity: "SUBSESS-x", reason: "misprint", operator: "alice" }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/subscription/i); }); it("keeps the signed chain verifiable after a void", async () => { seedTariff(db); await enter("T1"); await voidFlow.voidTicket({ identity: "T1", reason: "test", operator: "alice" }); // The void is the newest signed row; the chain is intact (verifier is exercised by // the event-log on append — a broken chain would have thrown). const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const last = rows[rows.length - 1]!; expect(last.type).toBe("void"); expect(last.prevHash).toBeTruthy(); expect(last.signature).toBeTruthy(); }); });