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 { seedUser, login } from "../test-helpers.js"; // Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable // published versions. The contract under test: drafts are validated + tz-stamped on // save exactly like a publish (so "publish this draft" can never fail on a card that // saved fine), mutations need tariff:update, and publishing a draft goes through the // normal immutable-version path untouched. let db: Db; let close: () => void; let app: FastifyInstance; beforeEach(async () => { const t = createTestDb(); db = t.db; close = t.close; app = await buildServer({ db }); await app.ready(); }); afterEach(async () => { await app.close(); close(); }); const V1_STRUCTURE = { gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 2000, gracePeriodExitMin: 10, overstay: "reprice", blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null, }; // A V2 card with a night package — tz left blank on purpose: the server must stamp it. const V2_STRUCTURE = { version: 2, tz: "", gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 2000, gracePeriodExitMin: 10, overstay: "reprice", defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null }, windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }], }; async function editor() { const { username, password } = await seedUser(db, { username: "editor", roleId: "editor", permissions: ["tariff:read", "tariff:update"], }); return login(app, username, password); } describe("tariff drafts", () => { it("requires auth", async () => { const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" }); expect(res.statusCode).toBe(401); }); it("a tariff:read-only user can list but not create", async () => { const { username, password } = await seedUser(db, { username: "viewer", roleId: "viewer", permissions: ["tariff:read"], }); const { cookie, csrf } = await login(app, username, password); const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); expect(list.statusCode).toBe(200); expect(list.json().drafts).toEqual([]); const create = await app.inject({ method: "POST", url: "/api/tariff/drafts", headers: { cookie, "x-csrf-token": csrf }, payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE }, }); expect(create.statusCode).toBe(403); }); it("create → list → update → delete roundtrip", async () => { const { cookie, csrf } = await editor(); const headers = { cookie, "x-csrf-token": csrf }; const create = await app.inject({ method: "POST", url: "/api/tariff/drafts", headers, payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE }, }); expect(create.statusCode).toBe(201); const draft = create.json(); expect(draft.name).toBe("Winter proposal"); expect(draft.currency).toBe("ALL"); // normalised to upper case expect(draft.createdBy).toBe("editor"); const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); expect(list.json().drafts).toHaveLength(1); const update = await app.inject({ method: "PUT", url: `/api/tariff/drafts/${draft.id}`, headers, payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE }, }); expect(update.statusCode).toBe(200); expect(update.json().name).toBe("Winter v2"); const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers }); expect(del.statusCode).toBe(204); const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); expect(after.json().drafts).toEqual([]); }); it("rejects an invalid structure with problems (validated like a publish)", async () => { const { cookie, csrf } = await editor(); const res = await app.inject({ method: "POST", url: "/api/tariff/drafts", headers: { cookie, "x-csrf-token": csrf }, payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } }, }); expect(res.statusCode).toBe(400); expect(res.json().problems?.length).toBeGreaterThan(0); }); it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => { const { cookie, csrf } = await editor(); const headers = { cookie, "x-csrf-token": csrf }; const create = await app.inject({ method: "POST", url: "/api/tariff/drafts", headers, payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE }, }); expect(create.statusCode).toBe(201); const draft = create.json(); expect(draft.structure.tz).toBe("Europe/Tirane"); // The lab prices the draft by sending its stored structure inline. const sim = await app.inject({ method: "POST", url: "/api/tariff/simulate", headers, payload: { enteredAt: "2026-07-03T21:00:00.000+02:00", asOf: "2026-07-03T23:00:00.000+02:00", structure: draft.structure, currency: draft.currency, }, }); expect(sim.statusCode).toBe(200); expect(sim.json().pricing.amountMinor).toBe(40000); // one night package // "Publish this draft" = the normal immutable-version path with the draft's card; // the draft's name rides along as the version's optional label. const publish = await app.inject({ method: "POST", url: "/api/tariff/versions", headers, payload: { currency: draft.currency, structure: draft.structure, name: draft.name }, }); expect(publish.statusCode).toBe(201); const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } }); expect(state.json().active?.name).toBe("Night package"); expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000); }); });