feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||
|
||||
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||||
// settings → intake against an open parking session → done applies the sponsorship
|
||||
// validation → bay payment settles the parking session at zero (what the exit reader
|
||||
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
|
||||
async function admin(): Promise<Auth> {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
|
||||
/** An open transient session that has been parked long enough to owe money. */
|
||||
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||||
await makeLog(db).append({
|
||||
type: "vehicle_entry",
|
||||
source: "manual",
|
||||
identity,
|
||||
occurredAt: minutesAgo(enteredMinutesAgo),
|
||||
payload: { sessionRef: identity, category: "default" },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSettings(a: Auth) {
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: [{ name: "Car" }, { name: "SUV" }],
|
||||
services: [{ name: "Standard" }, { name: "Inside" }],
|
||||
prices: [],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const s = res.json();
|
||||
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||||
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||||
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||||
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||||
const priced = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: s.categories, services: s.services,
|
||||
prices: [
|
||||
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||||
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||||
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(priced.statusCode).toBe(200);
|
||||
expect(priced.json().prices).toHaveLength(3);
|
||||
return { car, suv, std, inside };
|
||||
}
|
||||
|
||||
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||||
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||||
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().payAt).toBe(payAt);
|
||||
}
|
||||
|
||||
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||||
});
|
||||
expect(res.statusCode).toBeLessThan(300);
|
||||
}
|
||||
|
||||
async function events(a: Auth) {
|
||||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
describe("settings", () => {
|
||||
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||||
const a = await admin();
|
||||
const ids = await seedSettings(a);
|
||||
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||||
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||||
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||||
const bad = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||||
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orders", () => {
|
||||
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
const noSession = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
expect(noSession.statusCode).toBe(404);
|
||||
|
||||
await openSession("T-1");
|
||||
const noPrice = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||||
});
|
||||
expect(noPrice.statusCode).toBe(409);
|
||||
expect(noPrice.json().code).toBe("no_price");
|
||||
|
||||
const created = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||||
|
||||
await openSession("T-2");
|
||||
await setPayAt(a, "bay");
|
||||
await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||||
|
||||
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||||
});
|
||||
|
||||
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-B");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
|
||||
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
const s = look.json();
|
||||
expect(s.chargeLines).toHaveLength(1);
|
||||
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||||
expect(s.chargesMinor).toBe(50000);
|
||||
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||||
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||||
expect(pay.statusCode).toBeLessThan(300);
|
||||
|
||||
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||||
expect(payment.payload.chargesMinor).toBe(50000);
|
||||
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||||
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||||
|
||||
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||||
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||||
expect(o.paidAt).toBeTruthy();
|
||||
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||||
expect(o.tender).toBe("cash");
|
||||
// A second lookup no longer carries the line (it's settled).
|
||||
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
expect(again.json().chargeLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "comp");
|
||||
await openSession("T-Y");
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||||
})).json();
|
||||
|
||||
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
expect(noShift.statusCode).toBe(409);
|
||||
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||||
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
expect(openWash.statusCode).toBe(200);
|
||||
|
||||
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
expect(done.statusCode).toBe(200);
|
||||
expect(done.json().status).toBe("done");
|
||||
expect(done.json().validationEventId).toBeTruthy();
|
||||
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||||
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||
expect(mid.json().amountMinor).toBe(0);
|
||||
expect(mid.json().paidAt).toBeNull();
|
||||
|
||||
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||||
expect(paid.statusCode).toBe(200);
|
||||
expect(paid.json().closed).toBe(true);
|
||||
|
||||
const evs = await events(a);
|
||||
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||||
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||||
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||||
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||||
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||||
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||
expect(boothZ.till).toBe("booth");
|
||||
expect(boothZ.cardTotalMinor).toBe(0);
|
||||
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||||
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||||
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||||
expect(parkingPay).toBeTruthy();
|
||||
expect(parkingPay.payload.amountMinor).toBe(0);
|
||||
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||
expect(after.json().paidAt).toBeTruthy();
|
||||
expect(after.json().withinGrace).toBe(true);
|
||||
|
||||
// The queue is empty (done + paid = closed).
|
||||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(queue.json().orders).toEqual([]);
|
||||
});
|
||||
|
||||
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "percent");
|
||||
await openSession("T-P");
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||||
expect(s.paidAt).toBeNull();
|
||||
expect(s.amountMinor).toBeGreaterThan(0);
|
||||
expect(s.discountMinor).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "comp");
|
||||
await openSession("T-V");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||
expect(before.validationLines).toHaveLength(1);
|
||||
|
||||
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||||
expect(voided.statusCode).toBe(200);
|
||||
expect(voided.json().status).toBe("void");
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.validationLines).toEqual([]);
|
||||
expect(after.chargeLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wash-only discount modes", () => {
|
||||
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||||
const a = await admin();
|
||||
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "doneTolerance", 15);
|
||||
await openSession("T-D", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||
expect(before.amountMinor).toBe(20000);
|
||||
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||||
expect(v.payload.mode).toBe("timeCredit");
|
||||
expect(v.payload.programMode).toBe("doneTolerance");
|
||||
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||||
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||||
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.amountMinor).toBe(20000);
|
||||
expect(after.discountMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "doneTolerance", 120);
|
||||
await openSession("T-D2", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.amountMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||||
const a = await admin();
|
||||
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||||
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "washPrice");
|
||||
await openSession("T-W", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.discountMinor).toBe(50000);
|
||||
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||||
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||||
});
|
||||
|
||||
it("a merchant scan cannot apply a wash-only program", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSponsorship(a, "washPrice");
|
||||
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||||
});
|
||||
await openSession("T-M");
|
||||
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/car wash order/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module gate", () => {
|
||||
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-G");
|
||||
await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||||
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||||
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(q.statusCode).toBe(403);
|
||||
expect(q.json().code).toBe("module_disabled");
|
||||
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||||
expect(look.chargeLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the money is taken is a SITE setting", () => {
|
||||
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||||
await openSession("T-S1");
|
||||
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||
expect(o1.payAt).toBe("booth");
|
||||
|
||||
await setPayAt(a, "bay");
|
||||
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||||
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||||
await openSession("T-S2");
|
||||
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||
expect(o2.payAt).toBe("bay");
|
||||
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||||
|
||||
// A stale client insisting on the other place is refused, never silently overridden.
|
||||
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||||
expect(stale.statusCode).toBe(409);
|
||||
expect(stale.json().code).toBe("pay_at_policy");
|
||||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tills are gated by the module permission", () => {
|
||||
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSettings(a);
|
||||
const washer = await seedUser(db, {
|
||||
username: "lavazhier", roleId: "washer",
|
||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "shift:read", "shift:create", "drawer:create"],
|
||||
});
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
// What the UI offers: only the wash till.
|
||||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||
// The booth's shift is refused outright (the role lacks session:read).
|
||||
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||
expect(booth.statusCode).toBe(403);
|
||||
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||||
expect(boothState.statusCode).toBe(403);
|
||||
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||||
expect(boothCash.statusCode).toBe(403);
|
||||
// The wash till works.
|
||||
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||||
expect(wash.statusCode).toBe(200);
|
||||
expect(wash.json().till).toBe("carwash");
|
||||
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||
expect(washCash.statusCode).toBe(200);
|
||||
|
||||
// A booth operator (session:read, no carwash:read) cannot touch the wash till.
|
||||
const booth1 = await seedUser(db, {
|
||||
username: "boothie", roleId: "booth-op",
|
||||
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||
});
|
||||
const b = await login(app, booth1.username, booth1.password);
|
||||
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||||
expect(noWash.statusCode).toBe(403);
|
||||
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user