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
165 lines
6.3 KiB
TypeScript
165 lines
6.3 KiB
TypeScript
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"]);
|
||
});
|
||
});
|