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
138 lines
5.3 KiB
TypeScript
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);
|
|
});
|
|
});
|