e14e31a840
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
92 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|