feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in d9e6c13) and the "Flat price / hour" relabel.
5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user