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,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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user