Files
parking_solution/packages/devices/src/printer-routing.test.ts
T
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:37:26 +02:00

92 lines
4.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
orderForRole,
printWithFailover,
NoPrinterAvailableError,
type PrinterInstance,
} from "./printer-routing.js";
import type { PrinterDevice } from "./interfaces.js";
// Printer routing is pure selection over (config, health): which printer prints a job,
// best-first, with failover. The key business rules: the booth printer is a FALLBACK for
// entry tickets but a receipt NEVER prints on the outside dispenser; rank then id break
// ties deterministically; printWithFailover walks the order and surfaces all failures.
function inst(id: string, role: PrinterInstance["role"], failoverRank = 0, device?: PrinterDevice): PrinterInstance {
return { id, role, failoverRank, device: device ?? ({} as PrinterDevice) };
}
describe("orderForRole", () => {
it("entry-dispenser job: dispensers first, booth-receipt as fallback", () => {
const printers = [inst("booth", "booth-receipt"), inst("disp", "entry-dispenser")];
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["disp", "booth"]);
});
it("booth-receipt job: NEVER falls back to the outside dispenser", () => {
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt")];
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
});
it("wash-desk job: the desk printer first, the booth printer as fallback, never the dispenser", () => {
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt"), inst("desk", "wash-desk")];
expect(orderForRole(printers, "wash-desk").map((p) => p.id)).toEqual(["desk", "booth"]);
// A site without a desk printer keeps printing wash slips in the booth.
expect(orderForRole(printers.slice(0, 2), "wash-desk").map((p) => p.id)).toEqual(["booth"]);
});
it("nothing ever falls back TO the wash desk (booth receipts and entry tickets stay off it)", () => {
const printers = [inst("desk", "wash-desk")];
expect(orderForRole(printers, "booth-receipt")).toEqual([]);
expect(orderForRole(printers, "entry-dispenser")).toEqual([]);
});
it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [
inst("b", "entry-dispenser", 1),
inst("a", "entry-dispenser", 1),
inst("c", "entry-dispenser", 5),
];
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["c", "a", "b"]);
});
it("excludes printers of no relevant role", () => {
const printers = [inst("booth", "booth-receipt")];
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
// For a receipt job, an entry dispenser is excluded entirely.
expect(orderForRole([inst("disp", "entry-dispenser")], "booth-receipt")).toEqual([]);
});
});
describe("printWithFailover", () => {
function device(behavior: "ok" | "fail"): PrinterDevice {
return {
printTicket: vi.fn(behavior === "ok" ? async () => {} : async () => { throw new Error("offline"); }),
} as unknown as PrinterDevice;
}
it("prints on the first healthy candidate and returns its id", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("ok")), inst("booth", "booth-receipt", 0, device("ok"))];
const job = vi.fn(async (d: PrinterDevice) => d.printTicket({} as never));
const used = await printWithFailover(printers, "entry-dispenser", job);
expect(used).toBe("disp");
expect(job).toHaveBeenCalledTimes(1);
});
it("fails over to the booth printer when the dispenser throws", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("ok"))];
const used = await printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never));
expect(used).toBe("booth");
});
it("throws NoPrinterAvailableError listing every failed attempt", async () => {
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("fail"))];
await expect(printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never)))
.rejects.toBeInstanceOf(NoPrinterAvailableError);
});
it("throws when no printer is configured for the role", async () => {
await expect(printWithFailover([], "entry-dispenser", async () => {}))
.rejects.toBeInstanceOf(NoPrinterAvailableError);
});
});