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
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateTicketCode } from "./entry-flow.js";
|
||||||
|
|
||||||
|
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
|
||||||
|
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
|
||||||
|
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
|
||||||
|
// before-open paths of EntryFlow need device fakes and are exercised in the device +
|
||||||
|
// route phases; here we pin the pure, exported checksum contract.
|
||||||
|
|
||||||
|
describe("validateTicketCode (Luhn)", () => {
|
||||||
|
it("accepts a well-formed 11-digit id", () => {
|
||||||
|
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a single-digit typo", () => {
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-digit and out-of-length strings", () => {
|
||||||
|
expect(validateTicketCode("abc")).toBe(false);
|
||||||
|
expect(validateTicketCode("123")).toBe(false); // too short
|
||||||
|
expect(validateTicketCode("123456789012345")).toBe(false); // too long
|
||||||
|
expect(validateTicketCode("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
|
||||||
|
// Construct a valid code: pick a body, compute its check the same way the issuer does.
|
||||||
|
const body = "4992739871";
|
||||||
|
// brute the check digit 0..9 — exactly one makes a valid code.
|
||||||
|
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
|
||||||
|
expect(valid).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a legacy 13-digit id shape", () => {
|
||||||
|
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
|
||||||
|
expect(validateTicketCode("0000000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
|
||||||
|
|
||||||
|
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
|
||||||
|
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
|
||||||
|
// subscriber-spots model (its trickiest invariant — never double-count a parked
|
||||||
|
// subscriber, and never gate the subscriber's own entry).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
|
||||||
|
let idx = 0;
|
||||||
|
function entry(identity: string, payload?: Record<string, unknown>) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
|
||||||
|
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function exit(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
|
||||||
|
identity, payload: null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("occupancyCount", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("is 0 with no events", () => {
|
||||||
|
expect(occupancyCount(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts open sessions (entries minus matching exits)", () => {
|
||||||
|
entry("A"); entry("B"); entry("C");
|
||||||
|
exit("B");
|
||||||
|
expect(occupancyCount(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a re-entry after exit counts again", () => {
|
||||||
|
entry("A"); exit("A"); entry("A");
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("uncapped: never full, free/effectiveFree null", () => {
|
||||||
|
setSite({ capacity: null });
|
||||||
|
entry("A");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(false);
|
||||||
|
expect(o.free).toBeNull();
|
||||||
|
expect(o.effectiveFree).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capped: full when count reaches capacity", () => {
|
||||||
|
setSite({ capacity: 2 });
|
||||||
|
entry("A");
|
||||||
|
expect(getOccupancy(db).full).toBe(false);
|
||||||
|
entry("B");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
expect(o.free).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reservedSubscriberSpots", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
|
||||||
|
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is 0 when the toggle is off (default)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: false });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds quantity spots for an active, not-parked subscription", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
|
||||||
|
entry("SUBSESS-1", { permitId: "s1" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores suspended/revoked and out-of-window subscriptions", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("active", { quantity: 1 });
|
||||||
|
addSub("suspended", { quantity: 5, status: "suspended" });
|
||||||
|
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — reserved tightens the transient gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("transient sees full once count + reserved ≥ capacity", () => {
|
||||||
|
setSite({ capacity: 3, reserveSubscriberSpots: true });
|
||||||
|
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
|
||||||
|
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.reserved).toBe(2);
|
||||||
|
expect(o.effectiveFree).toBe(0);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
ShiftService,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
NoOpenShiftError,
|
||||||
|
NoShiftOpenError,
|
||||||
|
InvalidCashMovementError,
|
||||||
|
} from "./shift-service.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The shift is an operator's accountability period — signed shift_open … shift_z_report,
|
||||||
|
// no mutable table. These tests pin: the site-wide single-open invariant, the takings
|
||||||
|
// SPLIT by source (subscription sales vs out-of-window charges vs transient tickets — the
|
||||||
|
// 2026-06-21 work), the drawer carry-forward, and that close signs a Z-report with the
|
||||||
|
// right figures.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let shift: ShiftService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
shift = new ShiftService(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
/** Append a signed payment with source-split flags, as the booth/pay paths do. */
|
||||||
|
async function payment(
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
) {
|
||||||
|
await log.append({
|
||||||
|
type: "payment", source: "manual", identity: "T",
|
||||||
|
payload: {
|
||||||
|
sessionRef: "T", amountMinor, currency: "ALL", tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("single-open invariant", () => {
|
||||||
|
it("opens a shift and reports it as the current open one", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
const cur = shift.currentOpenShift();
|
||||||
|
expect(cur?.identity).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second open while one is already open (even another operator)", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(shift.open("alice")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a new shift after the prior one closes", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.close("alice");
|
||||||
|
await expect(shift.open("bob")).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close without an open shift throws", async () => {
|
||||||
|
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requireOpenShift throws when none is open", () => {
|
||||||
|
expect(() => shift.requireOpenShift()).toThrow(NoShiftOpenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("takings split by source", () => {
|
||||||
|
it("separates subscription sales, out-of-window charges, and transient tickets", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(50000, { subscriptionSale: true }); // monthly fee
|
||||||
|
await payment(20000, { subscriptionWindowCharge: true }); // out-of-window
|
||||||
|
await payment(10000); // transient ticket
|
||||||
|
await payment(30000, { tender: "card" }); // transient ticket, card
|
||||||
|
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.subscriptionSalesMinor).toBe(50000);
|
||||||
|
expect(r.subscriptionWindowMinor).toBe(20000);
|
||||||
|
expect(r.subscriptionTotalMinor).toBe(70000);
|
||||||
|
expect(r.ticketTotalMinor).toBe(40000); // 10000 cash + 30000 card
|
||||||
|
// The split must reconcile to the cash+card grand total.
|
||||||
|
expect(r.cashTotalMinor + r.cardTotalMinor).toBe(
|
||||||
|
r.ticketTotalMinor + r.subscriptionTotalMinor,
|
||||||
|
);
|
||||||
|
expect(r.cashTotalMinor).toBe(80000); // 50000 + 20000 + 10000
|
||||||
|
expect(r.cardTotalMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("drawer carry-forward", () => {
|
||||||
|
it("cash payments enter the drawer; card does not", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(10000, { tender: "cash" });
|
||||||
|
await payment(50000, { tender: "card" });
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.cashTotalMinor).toBe(10000);
|
||||||
|
// Expected drawer = opening(0) + cash(10000) + added(0) − removed(0).
|
||||||
|
expect(r.expectedDrawerMinor).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a closed shift's expected drawer becomes the next shift's opening float", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(25000, { tender: "cash" });
|
||||||
|
const closed = await shift.close("alice");
|
||||||
|
expect(closed.expectedDrawerMinor).toBe(25000);
|
||||||
|
|
||||||
|
const next = await shift.open("bob");
|
||||||
|
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.cashAddedMinor).toBe(100000);
|
||||||
|
expect(r.cashRemovedMinor).toBe(30000);
|
||||||
|
expect(r.expectedDrawerMinor).toBe(70000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive voucher amount", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(
|
||||||
|
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
await expect(
|
||||||
|
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||||
|
it("a closed shift appears in history with its split figures", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(50000, { subscriptionSale: true });
|
||||||
|
await payment(10000); // ticket
|
||||||
|
await shift.close("alice");
|
||||||
|
|
||||||
|
const history = shift.listShifts();
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
const s = history[0];
|
||||||
|
expect(s.operator).toBe("alice");
|
||||||
|
expect(s.subscriptionSalesMinor).toBe(50000);
|
||||||
|
expect(s.ticketTotalMinor).toBe(10000);
|
||||||
|
expect(s.cashTotalMinor).toBe(60000);
|
||||||
|
// The Z-report is a signed chain event.
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters history by operator", async () => {
|
||||||
|
await shift.open("alice"); await shift.close("alice");
|
||||||
|
await shift.open("bob"); await shift.close("bob");
|
||||||
|
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { tariffs, tariffVersions, type Db } from "@parking/db";
|
||||||
|
import type { TariffStructure } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { EventLog } from "./event-log.js";
|
||||||
|
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// Shared scaffolding for server tests (NOT a *.test file, so it is not collected as a
|
||||||
|
// suite and stays out of shipped dist via the tsconfig test-exclude). Builds the real
|
||||||
|
// EventLog over a fresh test DB, a silent logger, and a minimal active tariff so the
|
||||||
|
// pay/exit flows have something to price against.
|
||||||
|
|
||||||
|
const SECRET = "test-event-signing-key-0123456789";
|
||||||
|
|
||||||
|
/** Real EventLog (real signer + per-keyId verifier) over a test DB. */
|
||||||
|
export function makeLog(db: Db): EventLog {
|
||||||
|
return new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A logger that swallows everything — flows log liberally; tests don't care. */
|
||||||
|
export function silentLogger(): FastifyBaseLogger {
|
||||||
|
const noop = () => {};
|
||||||
|
const l: Record<string, unknown> = {
|
||||||
|
info: noop, warn: noop, error: noop, debug: noop, fatal: noop, trace: noop,
|
||||||
|
silent: noop, level: "silent",
|
||||||
|
};
|
||||||
|
l.child = () => l;
|
||||||
|
return l as unknown as FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A simple flat-rate V1 tariff: free under the entry grace, then a fixed price per
|
||||||
|
* increment, with a walk-back exit grace. Returns the tariffVersionId + currency. */
|
||||||
|
export function seedTariff(
|
||||||
|
db: Db,
|
||||||
|
opts: { pricePerIncrementMinor?: number; incrementMin?: number; gracePeriodEntryMin?: number; gracePeriodExitMin?: number; currency?: string; effectiveFrom?: string } = {},
|
||||||
|
): { tariffVersionId: string; currency: string } {
|
||||||
|
const tariffId = randomUUID();
|
||||||
|
const versionId = randomUUID();
|
||||||
|
const currency = opts.currency ?? "ALL";
|
||||||
|
const structure: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: opts.gracePeriodEntryMin ?? 10,
|
||||||
|
incrementMin: opts.incrementMin ?? 60,
|
||||||
|
blocks: [{ uptoMin: null, priceMinorPerIncrement: opts.pricePerIncrementMinor ?? 10000 }],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
lostTicketMinor: 50000,
|
||||||
|
gracePeriodExitMin: opts.gracePeriodExitMin ?? 15,
|
||||||
|
overstay: "reprice",
|
||||||
|
};
|
||||||
|
db.insert(tariffs).values({ id: tariffId, scope: "site", name: "Test" }).run();
|
||||||
|
db.insert(tariffVersions).values({
|
||||||
|
id: versionId,
|
||||||
|
tariffId,
|
||||||
|
effectiveFrom: opts.effectiveFrom ?? "2000-01-01T00:00:00.000Z",
|
||||||
|
currency,
|
||||||
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
|
}).run();
|
||||||
|
return { tariffVersionId: versionId, currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO string `minutes` ago from now (for entries that should already owe a fee). */
|
||||||
|
export function minutesAgo(minutes: number): string {
|
||||||
|
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user