5e9be16f65
Completes the anti-fraud/safety core coverage on a fresh in-memory DB: - occupancy.test.ts (12): the ledger-fold count, the capacity/full gate, and the reserved-subscriber-spots model — never double-count a parked subscriber, reserve tightens only the TRANSIENT gate. - pay-station.test.ts (12): quote math against the frozen tariff, the signed-payment side effect (+ chain verify), no-session / no-tariff errors, the booth lookup view, active-session listing. - exit-flow.test.ts (9): the GATE — refuse unknown / unpaid / grace-expired (no exit signed); a paid-within-grace session signs the exit; the booth transient path has NO subscription bypass; a prepaid subscriber leaves via the assist (reopenBarrier) path. - shift-service.test.ts (14): site-wide single-open invariant, the takings SPLIT by source (subscription sales vs out-of-window vs transient tickets), drawer carry- forward + cash_in/out vouchers, Z-report sign + listShifts read-back. - entry-flow.test.ts (5): the exported validateTicketCode Luhn typo-guard. (The capacity-gate/print-hold/sign-before-open paths need device fakes — covered in the device + route phases.) Adds test-helpers.ts (real EventLog, silent logger, tariff seeder). server 68/68 green. Note: apps/vision has 2 PRE-EXISTING failures (test_app.py) — environment drift now that fast_alpr + the ONNX model are installed (the "stub mode" assertions are stale). Untouched here; to be fixed in the vision phase. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
119 lines
5.5 KiB
TypeScript
119 lines
5.5 KiB
TypeScript
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<string, unknown>) {
|
|
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);
|
|
});
|
|
});
|