import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { ledgerEvents, eq, type Db } from "@parking/db"; import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js"; import type { EventLog } from "./event-log.js"; import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js"; // The pay station prices an open session against the tariff frozen at entry and writes // a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math, // the signed-payment side effect, the no-session / no-tariff errors, and the lookup // view the booth modal reads. let db: Db; let close: () => void; let log: EventLog; let pay: PayStation; beforeEach(() => { const t = createTestDb(); db = t.db; close = t.close; log = makeLog(db); 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 }); } describe("PayStation.quote", () => { it("throws NoOpenSessionError for an unknown ticket", () => { seedTariff(db); expect(() => pay.quote("nope")).toThrow(NoOpenSessionError); }); it("throws NoTariffError when no site tariff is configured", async () => { await enter("T1", minutesAgo(120)); expect(() => pay.quote("T1")).toThrow(NoTariffError); }); it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => { // 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few // ms of test runtime can't tip it into a 3rd increment. seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 }); await enter("T1", minutesAgo(90)); const q = pay.quote("T1"); expect(q.amountMinor).toBe(20000); expect(q.currency).toBe("ALL"); expect(q.overstay).toBe(false); }); it("prices 0 within the entry grace (quick in-and-out)", async () => { seedTariff(db, { gracePeriodEntryMin: 10 }); await enter("T1", minutesAgo(5)); expect(pay.quote("T1").amountMinor).toBe(0); }); }); describe("PayStation.pay — signed payment side effect", () => { it("appends a signed payment event carrying amount, currency, tender, grace", async () => { const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("T1", minutesAgo(90)); const res = await pay.pay("T1", "cash"); expect(res.amountMinor).toBe(20000); expect(res.currency).toBe(currency); const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all(); expect(payments).toHaveLength(1); const pl = payments[0].payload as Record; expect(pl.amountMinor).toBe(20000); expect(pl.tender).toBe("cash"); expect(pl.graceExitMin).toBe(15); // It must be a real signed chain event. expect(log.verifyChain()).toEqual({ ok: true }); }); it("honours an operator override amount (lost ticket / dispute)", async () => { seedTariff(db); await enter("T1", minutesAgo(120)); const res = await pay.pay("T1", "card", 99900); expect(res.amountMinor).toBe(99900); const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record; expect(pl.amountMinor).toBe(99900); expect(pl.reason).toBe("operator-set amount"); }); }); describe("PayStation.lookup — booth modal view", () => { it("reports not-found for an unknown ticket", () => { const v = pay.lookup("ghost"); expect(v.found).toBe(false); expect(v.open).toBe(false); }); it("shows an open unpaid transient with the amount owed", async () => { seedTariff(db, { pricePerIncrementMinor: 10000 }); await enter("T1", minutesAgo(90)); const v = pay.lookup("T1"); expect(v.found).toBe(true); expect(v.open).toBe(true); expect(v.paidAt).toBeNull(); expect(v.amountMinor).toBe(20000); expect(v.subscription).toBe(false); }); it("after payment shows paid + within grace, amount cleared", async () => { seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 }); await enter("T1", minutesAgo(120)); await pay.pay("T1", "cash"); const v = pay.lookup("T1"); expect(v.paidAt).not.toBeNull(); expect(v.withinGrace).toBe(true); expect(v.overstay).toBe(false); }); it("flags a subscription occurrence (prepaid — never a transient charge)", async () => { seedTariff(db); await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" }); const v = pay.lookup("SUBSESS-1"); expect(v.subscription).toBe(true); expect(v.subscriptionId).toBe("sub-1"); expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed }); }); describe("PayStation.activeSessions", () => { it("lists open sessions newest-first and omits exited-past-grace", async () => { seedTariff(db, { pricePerIncrementMinor: 10000 }); await enter("OLD", minutesAgo(200)); await enter("NEW", minutesAgo(30)); const list = pay.activeSessions(); expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]); expect(list.every((s) => s.open)).toBe(true); }); });