diff --git a/src/app/lib/selectPibiMeasures.test.ts b/src/app/lib/selectPibiMeasures.test.ts new file mode 100644 index 0000000..ae228e5 --- /dev/null +++ b/src/app/lib/selectPibiMeasures.test.ts @@ -0,0 +1,194 @@ +/** + * Unit tests for the PIBI-selection service (issue #254). + * + * These tests exercise the orchestration logic — DB transaction + + * post-commit HubSpot push — by injecting lightweight fakes for the DB + * hooks. The tests never touch the real DB or HubSpot client. + * + * Key properties verified: + * - Happy path: rows inserted, HubSpot push called with correct property, + * pushed_at stamped. + * - No approval rows are created or modified. + * - Sync semantics: pushed_at null when HubSpot fails. + * - DB failure: returns ok=false, no HubSpot call. + * - Empty selection: clears all rows and pushes an empty list. + */ +import { describe, expect, it, vi } from "vitest"; +import { + PIBI_MEASURES_PROP, + selectPibiMeasures, +} from "./selectPibiMeasures"; +import type { + RunPibiTx, + StampPushedAt, + SyncMeasuresField, +} from "./selectPibiMeasures"; + +function makeDeps(overrides?: { + txResult?: { insertedRowIds: bigint[] }; + txError?: Error; + syncResult?: { ok: true } | { ok: false; error: string }; + stampError?: Error; +}) { + const txResult = overrides?.txResult ?? { insertedRowIds: [1n, 2n] }; + + const runPibiTx: RunPibiTx = vi.fn(async () => { + if (overrides?.txError) throw overrides.txError; + return txResult; + }); + + const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { + return overrides?.syncResult ?? ({ ok: true } as const); + }); + + const stampPushedAt: StampPushedAt = vi.fn(async () => { + if (overrides?.stampError) throw overrides.stampError; + }); + + return { runPibiTx, syncMeasuresField, stampPushedAt }; +} + +describe("selectPibiMeasures — happy path", () => { + it("commits the tx, pushes to HubSpot under the PIBI property, stamps pushed_at", async () => { + const deps = makeDeps({ + txResult: { insertedRowIds: [10n, 11n] }, + }); + + const result = await selectPibiMeasures({ + dealId: "deal-1", + measureNames: ["ASHP", "Solar PV"], + userId: 5n, + deps, + }); + + expect(result).toMatchObject({ + ok: true, + insertedRowIds: [10n, 11n], + hubspotSync: "ok", + }); + + expect(deps.runPibiTx).toHaveBeenCalledWith({ + dealId: "deal-1", + measureNames: ["ASHP", "Solar PV"], + userId: 5n, + }); + + // Must push to the PIBI property specifically, not instructed_measures. + expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1); + expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: PIBI_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + + expect(deps.stampPushedAt).toHaveBeenCalledWith([10n, 11n]); + }); + + it("handles an empty selection — clears rows and pushes an empty list", async () => { + const deps = makeDeps({ + txResult: { insertedRowIds: [] }, + }); + + const result = await selectPibiMeasures({ + dealId: "deal-2", + measureNames: [], + userId: 3n, + deps, + }); + + expect(result).toMatchObject({ + ok: true, + insertedRowIds: [], + hubspotSync: "ok", + }); + + expect(deps.syncMeasuresField).toHaveBeenCalledWith({ + hubspotDealId: "deal-2", + propName: PIBI_MEASURES_PROP, + measureNames: [], + }); + + // Nothing to stamp when no rows were inserted. + expect(deps.stampPushedAt).toHaveBeenCalledWith([]); + }); +}); + +describe("selectPibiMeasures — no approval rows touched", () => { + it("never calls any approval-related function", async () => { + // The deps object only exposes pibi-specific hooks; if the service + // called any approval function it would have to import it separately. + // We simply confirm the service returns ok=true and only the three + // expected hooks were invoked — no approval side-effects possible. + const deps = makeDeps(); + const result = await selectPibiMeasures({ + dealId: "deal-3", + measureNames: ["EWI"], + userId: 1n, + deps, + }); + expect(result.ok).toBe(true); + // Only these three hooks should exist / be called. + expect(Object.keys(deps)).toEqual(["runPibiTx", "syncMeasuresField", "stampPushedAt"]); + }); +}); + +describe("selectPibiMeasures — DB transaction failure", () => { + it("returns ok=false and skips HubSpot when the tx throws", async () => { + const deps = makeDeps({ txError: new Error("insert failed") }); + + const result = await selectPibiMeasures({ + dealId: "deal-x", + measureNames: ["ASHP"], + userId: 1n, + deps, + }); + + expect(result).toEqual({ ok: false, error: "insert failed" }); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); + +describe("selectPibiMeasures — HubSpot push failure leaves pushed_at null", () => { + it("returns ok=true with hubspotSync=failed and does NOT stamp pushed_at", async () => { + const deps = makeDeps({ + txResult: { insertedRowIds: [20n] }, + syncResult: { ok: false, error: "hubspot 503" }, + }); + + const result = await selectPibiMeasures({ + dealId: "deal-h", + measureNames: ["CWI"], + userId: 2n, + deps, + }); + + expect(result).toMatchObject({ + ok: true, + insertedRowIds: [20n], + hubspotSync: "failed", + hubspotError: "hubspot 503", + }); + + // DB was committed (tx called) but pushed_at NOT stamped. + expect(deps.runPibiTx).toHaveBeenCalledTimes(1); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); + +describe("selectPibiMeasures — sync called with correct property name", () => { + it("always passes measures_for_pibi_ordered as propName, never instructed_measures", async () => { + const deps = makeDeps(); + await selectPibiMeasures({ + dealId: "deal-prop", + measureNames: ["Loft insulation", "CWI"], + userId: 7n, + deps, + }); + + const callArg = (deps.syncMeasuresField as ReturnType).mock + .calls[0][0] as { propName: string }; + expect(callArg.propName).toBe("measures_for_pibi_ordered"); + expect(callArg.propName).not.toBe("instructed_measures"); + }); +});