Files
parking_solution/apps/server/src/pay-station.test.ts
T
julian 5e9be16f65 test(server): Phase 1 — server-core suites (occupancy, pay, exit, shift)
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
2026-06-21 16:15:56 +02:00

138 lines
5.3 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 { 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<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
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);
});
});