Files
parking_solution/apps/server/src/occupancy.test.ts
T
julian 8a437d0c4b
CI / check (push) Failing after 56s
feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:13:21 +02:00

148 lines
5.1 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 voidEvt(identity: string) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "void",
identity, payload: { sessionRef: identity, voidReason: "misprint" }, 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);
});
it("a voided (cancelled) entry does NOT count inside", () => {
entry("A"); entry("B");
voidEvt("B"); // B's ticket was a misprint — cancelled
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);
});
});