/** * Tests for the pure `decideBackfill` decision function in * `scripts/backfill-stock-prices.ts`. * * The script's PB I/O is not under test here — only the logic that * decides "given this stock_item and this price_list candidate, * what (if anything) should we patch?" The orchestrator and the * PB plumbing stay untested because they require a live PB admin * client, which the dry-run + `pnpm exec tsx … --limit=10` smoke * test covers. * * Each test pins `nowIso` to a deterministic timestamp so the * cost_history bootstrap entry can be asserted verbatim. */ import { describe, it, expect } from "vitest"; import { decideBackfill, formatDecisionLine, type BackfillStockSnapshot, type BackfillPriceListCandidate, type BackfillDecision, } from "./backfill-stock-prices"; const NOW = "2026-09-14T01:00:00.000Z"; function stockItem(overrides: Partial = {}): BackfillStockSnapshot { return { id: "si_abc", sku: "ABC123", selling_price: 0, markup: 0, cost_price: 0, cost_history: [], ...overrides, }; } function priceListCandidate( overrides: Partial = {}, ): BackfillPriceListCandidate { return { id: "pl_xyz", sku: "ABC123", sku_normalised: "ABC123", cost_price: 65, selling_price: 87.5, markup: 25, updated: "2026-09-13T12:00:00.000Z", ...overrides, }; } // ─── Tests ───────────────────────────────────────────────────────────── describe("decideBackfill", () => { it("proposes both cost + selling patch when both are zero", () => { const decision = decideBackfill({ stockItem: stockItem({ id: "si_unpriced", selling_price: 0, markup: 0, cost_price: 0, }), priceListCandidate: priceListCandidate({ cost_price: 65, selling_price: 87.5, markup: 25, }), multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.stockItemId).toBe("si_unpriced"); expect(decision.sku).toBe("ABC123"); expect(decision.priceListId).toBe("pl_xyz"); expect(decision.multiMatch).toBe(false); // selling + markup + cost + cost_history all proposed. expect(decision.patch.selling_price).toEqual({ from: 0, to: 87.5 }); expect(decision.patch.markup).toEqual({ from: 0, to: 25 }); expect(decision.patch.cost_price).toEqual({ from: 0, to: 65 }); expect(decision.patch.cost_history_append).toEqual({ cost_price: 65, quantity: 0, purchased_at: NOW, purchase_id: "", }); }); it("proposes only selling patch (no cost change) when stock.cost_price already set", () => { const decision = decideBackfill({ stockItem: stockItem({ id: "si_costed", // Selling = 0 (so the till can't read it) but cost is // already set from a prior purchase. We backfill only the // shelf-side fields — never overwrite the cost the buyer // or accountant already locked in. selling_price: 0, markup: 0, cost_price: 80, cost_history: [ { cost_price: 80, quantity: 5, purchased_at: "2026-08-20T00:00:00.000Z", purchase_id: "sp_001", }, ], }), priceListCandidate: priceListCandidate({ cost_price: 65, // different (cheaper) — IGNORE selling_price: 87.5, markup: 25, }), multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.patch.selling_price).toEqual({ from: 0, to: 87.5 }); expect(decision.patch.markup).toEqual({ from: 0, to: 25 }); // Cost side must NOT be touched — the existing cost_price is // already set, and we never overwrite. No cost_history entry // either (the spec says "append only when patching cost for // the first time" — and we're not patching cost here). expect(decision.patch.cost_price).toBeUndefined(); expect(decision.patch.cost_history_append).toBeUndefined(); }); it("is a no-op (skipped_idempotent) when selling_price is already non-zero", () => { // The script reads stock_items WHERE selling_price = 0 so this // case shouldn't normally fire — but it's the defensive guard: // if the row's shelf price was somehow set between the read // and the patch (or the test feeds in a non-zero row // directly), we skip without error. const decision = decideBackfill({ stockItem: stockItem({ id: "si_already_priced", selling_price: 150, markup: 30, cost_price: 100, }), priceListCandidate: priceListCandidate({ cost_price: 65, selling_price: 87.5, markup: 25, }), multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("skipped_idempotent"); if (decision.kind !== "skipped_idempotent") return; expect(decision.stockItemId).toBe("si_already_priced"); expect(decision.sku).toBe("ABC123"); expect(decision.priceListId).toBe("pl_xyz"); expect(decision.reason).toBe("selling_price_already_set"); }); it("returns no_match when no price_list row matches", () => { const decision = decideBackfill({ stockItem: stockItem({ id: "si_lonely", sku: "LONELY-1" }), priceListCandidate: null, multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("no_match"); if (decision.kind !== "no_match") return; expect(decision.stockItemId).toBe("si_lonely"); expect(decision.sku).toBe("LONELY-1"); }); it("returns no_match when the candidate has selling_price = 0 (defensive guard)", () => { // The PB-level filter should exclude these, but a race / // manual edit might surface one. Don't silently write 0. const decision = decideBackfill({ stockItem: stockItem({ id: "si_zero_price_candidate" }), priceListCandidate: priceListCandidate({ selling_price: 0 }), multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("no_match"); }); it("skips markup when candidate has markup = 0 even if stock has selling to patch", () => { // Some price_list rows have selling_price but no markup field // (legacy imports, or rows where the operator typed the price // directly). We patch selling but leave markup alone. const decision = decideBackfill({ stockItem: stockItem({ selling_price: 0, markup: 0, cost_price: 100 }), priceListCandidate: priceListCandidate({ selling_price: 200, markup: 0 }), multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.patch.selling_price).toEqual({ from: 0, to: 200 }); expect(decision.patch.markup).toBeUndefined(); // cost_price already set, so cost side is untouched. expect(decision.patch.cost_price).toBeUndefined(); expect(decision.patch.cost_history_append).toBeUndefined(); }); it("propagates multiMatch = true so the caller can log a warning", () => { const decision = decideBackfill({ stockItem: stockItem({ id: "si_dup_catalogue" }), priceListCandidate: priceListCandidate({ id: "pl_newer" }), multiMatch: true, nowIso: NOW, }); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.multiMatch).toBe(true); expect(decision.priceListId).toBe("pl_newer"); }); it("treats null/undefined numeric fields as 0 (defaults)", () => { // PB returns numeric fields as `undefined` when they're not // set; the script must not crash on missing fields. The // decision function should treat each undefined field as 0. const decision = decideBackfill({ stockItem: { id: "si_sparse", sku: "SPARSE", selling_price: null, markup: undefined, cost_price: null, }, priceListCandidate: priceListCandidate({ cost_price: 10, selling_price: 15, markup: 50, }), multiMatch: false, nowIso: NOW, }); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.patch.selling_price).toEqual({ from: 0, to: 15 }); expect(decision.patch.markup).toEqual({ from: 0, to: 50 }); expect(decision.patch.cost_price).toEqual({ from: 0, to: 10 }); expect(decision.patch.cost_history_append).toEqual({ cost_price: 10, quantity: 0, purchased_at: NOW, purchase_id: "", }); }); }); // ─── formatDecisionLine — secondary surface so the operator's dry-run // output is human-readable and stays stable across refactors. describe("formatDecisionLine", () => { function decide(overrides: { stockItem?: Partial; priceListCandidate?: Partial | null; multiMatch?: boolean; } = {}): BackfillDecision { return decideBackfill({ stockItem: stockItem(overrides.stockItem), priceListCandidate: overrides.priceListCandidate === null ? null : priceListCandidate( overrides.priceListCandidate as Partial, ), multiMatch: overrides.multiMatch ?? false, nowIso: NOW, }); } it("renders a patch with selling + markup + cost + history delta", () => { const decision = decide(); const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); expect(line).toContain("would_patch sku=ABC123"); expect(line).toContain("match=price_list:pl_xyz"); expect(line).toContain("selling: R 0.00 -> R 87.50"); expect(line).toContain("markup: 0% -> 25%"); expect(line).toContain("cost: R 0.00 -> R 65.00"); expect(line).toContain("cost_history+1"); }); it("uses the patched verb in --commit mode", () => { const decision = decide(); const line = formatDecisionLine({ decision, verbWhenPatch: "patched" }); expect(line.startsWith("patched ")).toBe(true); }); it("renders a no_match as a single short line", () => { const decision = decide({ priceListCandidate: null }); const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); expect(line).toBe("no_match sku=ABC123"); }); it("renders a skipped_idempotent with the reason", () => { const decision = decide({ stockItem: { selling_price: 100, markup: 30 } }); const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); expect(line).toContain("skipped_idempotent sku=ABC123"); expect(line).toContain("match=price_list:pl_xyz"); expect(line).toContain("reason=selling_price_already_set"); }); it("annotates multi_match decisions", () => { const decision = decide({ multiMatch: true }); const line = formatDecisionLine({ decision, verbWhenPatch: "would_patch" }); expect(line).toContain("(multi_match)"); }); });