/** * Tests for the pure decision + formatting logic in * `scripts/apply-default-markup.ts`. * * The script's PB I/O is not under test here — only the logic that * decides "given this stock_item, 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. */ import { describe, it, expect } from "vitest"; import { decideMarkupForStockItem, formatDecisionLine, formatPatchLine, type MarkupStockSnapshot, type MarkupDecision, } from "./apply-default-markup"; function stockItem( overrides: Partial = {}, ): MarkupStockSnapshot { return { id: "si_abc", sku: "ABC123", cost_price: 100, selling_price: 0, markup: 0, ...overrides, }; } // ─── Tests ──────────────────────────────────────────────────────────── describe("decideMarkupForStockItem", () => { it("proposes 80% markup patch when cost > 0 and selling = 0", () => { const decision = decideMarkupForStockItem( stockItem({ id: "si_unpriced", sku: "ABC123", cost_price: 100, selling_price: 0, markup: 0, }), ); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.stockItemId).toBe("si_unpriced"); expect(decision.sku).toBe("ABC123"); expect(decision.patch.selling_price).toEqual({ from: 0, to: 180 }); expect(decision.patch.markup).toEqual({ from: 0, to: 80 }); }); it("returns no_match when cost is 0 (no patch possible)", () => { const decision = decideMarkupForStockItem( stockItem({ id: "si_no_cost", sku: "NO-COST", cost_price: 0, selling_price: 0, markup: 0, }), ); expect(decision.kind).toBe("no_match"); if (decision.kind !== "no_match") return; expect(decision.stockItemId).toBe("si_no_cost"); expect(decision.sku).toBe("NO-COST"); }); it("treats null/undefined cost_price as no_match", () => { // PB's typed SDK may surface unset numeric fields as null or // undefined; both should route to no_match. const decisionNull = decideMarkupForStockItem( stockItem({ id: "si_null_cost", cost_price: null }), ); expect(decisionNull.kind).toBe("no_match"); const decisionUndef = decideMarkupForStockItem( stockItem({ id: "si_undef_cost", cost_price: undefined }), ); expect(decisionUndef.kind).toBe("no_match"); }); it("is a no-op (skipped_idempotent) when selling_price already non-zero", () => { const decision = decideMarkupForStockItem( stockItem({ id: "si_already_priced", cost_price: 100, selling_price: 150, markup: 0, }), ); 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.reason).toBe("selling_price_already_set"); }); it("is a no-op (skipped_idempotent) when markup already non-zero (even if selling = 0)", () => { // Edge case: row has cost_price > 0 AND selling = 0 AND a // legacy markup > 0. We never overwrite the explicit markup // field — that's the POS' source of truth. Skipping with // `markup_already_set` is the right move; the operator can // resolve manually if they want the new selling_price to // match. const decision = decideMarkupForStockItem( stockItem({ id: "si_legacy_markup", cost_price: 100, selling_price: 0, markup: 50, }), ); expect(decision.kind).toBe("skipped_idempotent"); if (decision.kind !== "skipped_idempotent") return; expect(decision.reason).toBe("markup_already_set"); }); it("rounds selling_price to 2 decimal places", () => { // 33.33 * 1.80 = 59.994 — round to 59.99, not 60.00. const decision = decideMarkupForStockItem( stockItem({ id: "si_three_dec", cost_price: 33.33 }), ); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.patch.selling_price.to).toBe(59.99); // Markup field is always the integer constant. expect(decision.patch.markup.to).toBe(80); // 50.55 * 1.80 = 90.99 — already 2dp. const decision2 = decideMarkupForStockItem( stockItem({ id: "si_two_dec", cost_price: 50.55 }), ); expect(decision2.kind).toBe("patch"); if (decision2.kind !== "patch") return; expect(decision2.patch.selling_price.to).toBe(90.99); }); it("sets markup to exactly 80 (the constant)", () => { const decision = decideMarkupForStockItem( stockItem({ id: "si_any_cost", cost_price: 42 }), ); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.patch.markup).toEqual({ from: 0, to: 80 }); }); it("proposes selling_price of exactly 0 → 0 when cost is a tiny positive number", () => { // 0.01 * 1.80 = 0.018 → 0.02 (rounded). Sanity check the // scale-shift rounding survives tiny positive costs. const decision = decideMarkupForStockItem( stockItem({ id: "si_tiny", cost_price: 0.01 }), ); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; expect(decision.patch.selling_price.to).toBe(0.02); }); it("handles cost_price as a number that fails Number() defensively (NaN → no_match)", () => { // Defensive: PB shouldn't ever hand us NaN, but a corrupted // row could. `Number(NaN) > 0` is false, so it falls into the // no_match branch — which is the correct behaviour (we // can't compute a markup without a cost). const decision = decideMarkupForStockItem( stockItem({ id: "si_nan", cost_price: Number.NaN }), ); expect(decision.kind).toBe("no_match"); }); }); // ─── formatDecisionLine — secondary surface so the operator's // dry-run output is human-readable and stays stable across refactors. describe("formatDecisionLine", () => { it("renders a no_match as a single short line", () => { const decision: Extract = { kind: "no_match", stockItemId: "si_x", sku: "XYZ", }; const line = formatDecisionLine({ decision }); expect(line).toBe("no_match sku=XYZ"); }); it("renders a skipped_idempotent with the selling_price reason", () => { const decision: Extract = { kind: "skipped_idempotent", stockItemId: "si_x", sku: "XYZ", reason: "selling_price_already_set", }; const line = formatDecisionLine({ decision }); expect(line).toContain("skipped_idempotent sku=XYZ"); expect(line).toContain("reason=selling_price_already_set"); }); it("renders a skipped_idempotent with the markup reason", () => { const decision: Extract = { kind: "skipped_idempotent", stockItemId: "si_x", sku: "XYZ", reason: "markup_already_set", }; const line = formatDecisionLine({ decision }); expect(line).toContain("reason=markup_already_set"); }); }); // ─── formatPatchLine — the operator-facing would_patch line shape. describe("formatPatchLine", () => { it("renders the canonical would_patch line with cost + selling + markup deltas", () => { const decision = decideMarkupForStockItem( stockItem({ id: "si_abc", sku: "ABC123", cost_price: 100 }), ); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; const line = formatPatchLine({ decision, costPrice: 100, verb: "would_patch", }); expect(line).toContain("would_patch sku=ABC123"); expect(line).toContain("cost R 100.00"); expect(line).toContain("selling R 0.00 -> R 180.00"); expect(line).toContain("markup 0 -> 80"); }); it("uses the patched verb in --commit mode", () => { const decision = decideMarkupForStockItem( stockItem({ id: "si_abc", sku: "ABC123", cost_price: 50 }), ); expect(decision.kind).toBe("patch"); if (decision.kind !== "patch") return; const line = formatPatchLine({ decision, costPrice: 50, verb: "patched", }); expect(line.startsWith("patched ")).toBe(true); expect(line).toContain("selling R 0.00 -> R 90.00"); }); });