1de209be48
The admin operator filter was a free-text input that broke three ways at once: its visibility hangs off the query response (scope === "all") and its value is part of the query key, so every keystroke started a new query, data went undefined for the round-trip, and the input UNMOUNTED mid-keystroke (lost focus, list blanking that read as a page reload). Filtering also silently failed — the server matches the operator by exact username, so partial text matched nothing. - keepPreviousData on the shifts query: previous data (and scope) stays live during refetch, so filter controls never unmount and the list never blanks on preset/filter changes. - The filter is now a <select> of operators that HAVE shifts: the server returns the distinct list (signed z-reports + the open shift's holder) on GET /api/shifts, admin scope only — operators still can't see other names. Exact match by construction. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
245 lines
10 KiB
TypeScript
245 lines
10 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 movements adjust the drawer", async () => {
|
||
await shift.open("alice");
|
||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" });
|
||
await shift.recordVoucher({ type: "cash_out", operator: "alice", 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 movement amount", async () => {
|
||
await shift.open("alice");
|
||
await expect(
|
||
shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }),
|
||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||
await expect(
|
||
shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }),
|
||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||
});
|
||
});
|
||
|
||
describe("drawer review (operator records, admin reviews after)", () => {
|
||
it("a new movement starts pending; review sets authorized/denied", async () => {
|
||
await shift.open("alice");
|
||
const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" });
|
||
// Find the movement's ledger id via the status list.
|
||
let list = shift.movementsWithStatus({ operator: "alice" });
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].status).toBe("pending");
|
||
expect(list[0].voucherNo).toBe(m.voucherNo);
|
||
|
||
await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" });
|
||
list = shift.movementsWithStatus({ operator: "alice" });
|
||
expect(list[0].status).toBe("denied");
|
||
expect(list[0].reviewedBy).toBe("admin");
|
||
expect(list[0].reviewNote).toBe("not genuine");
|
||
});
|
||
|
||
it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => {
|
||
await shift.open("alice");
|
||
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" });
|
||
const before = shift.drawerBalance().balanceMinor;
|
||
expect(before).toBe(-10000); // the disbursement counted immediately
|
||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||
// Balance UNCHANGED by the denial — the correction is settled outside the app.
|
||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||
});
|
||
|
||
it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => {
|
||
// The regression that motivated the redesign: op1 disburses, shift closes, op2
|
||
// inherits; op1's disbursement is later DENIED. op2's drawer must be untouched.
|
||
await shift.open("op1");
|
||
await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" });
|
||
const closed = await shift.close("op1");
|
||
expect(closed.expectedDrawerMinor).toBe(-10000);
|
||
|
||
const next = await shift.open("op2");
|
||
expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance
|
||
|
||
const id = shift.movementsWithStatus({ operator: "op1" })[0].id;
|
||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||
|
||
// op2's drawer is STILL -10000 — the denial added no reversing cash.
|
||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||
expect(shift.currentReport()!.openingFloatMinor).toBe(-10000);
|
||
});
|
||
|
||
it("rejects reviewing a non-movement or an already-reviewed movement", async () => {
|
||
await shift.open("alice");
|
||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" });
|
||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||
await expect(
|
||
shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }),
|
||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||
await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" });
|
||
await expect(
|
||
shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }),
|
||
).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed
|
||
});
|
||
|
||
it("scopes movements by operator", async () => {
|
||
await shift.open("alice");
|
||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" });
|
||
await shift.close("alice");
|
||
await shift.open("bob");
|
||
await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" });
|
||
expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1);
|
||
expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1);
|
||
expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all
|
||
expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
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"]);
|
||
});
|
||
|
||
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
|
||
await shift.open("bob"); await shift.close("bob");
|
||
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
|
||
await shift.open("alice"); // open, no z-report yet
|
||
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||
});
|
||
});
|