Files
parking_solution/apps/server/src/occupancy.test.ts
T
julian 5e9be16f65 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
2026-06-21 16:15:56 +02:00

134 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});