assessment-model/src/app/lib/instructMeasure.test.ts
2026-05-06 18:00:06 +00:00

489 lines
15 KiB
TypeScript

/**
* Unit tests for the instruct-measure service.
*
* 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.
*/
import { describe, expect, it, vi } from "vitest";
import {
INSTRUCTED_MEASURES_PROP,
PROPOSED_MEASURES_PROP,
APPROVED_MEASURES_PROP,
instructMeasure,
instructMeasures,
} from "./instructMeasure";
import type {
InstructTxOutcome,
InstructMeasuresTxOutcome,
RunInstructTx,
RunInstructMeasuresTx,
ReadInstructedMeasureNames,
StampPushedAt,
SyncMeasuresField,
} from "./instructMeasure";
function makeDeps(overrides?: {
txOutcome?: Partial<InstructTxOutcome>;
txError?: Error;
instructedAfter?: string[];
syncResults?: Array<
{ ok: true } | { ok: false; error: string }
>;
stampError?: Error;
}) {
const txOutcome: InstructTxOutcome = {
instructedRowId: 42n,
existingProposedMeasures: [],
allApprovedMeasureNames: [],
...overrides?.txOutcome,
};
const runInstructTx: RunInstructTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txOutcome;
});
const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
async () => overrides?.instructedAfter ?? ["ASHP"],
);
const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return syncQueue.shift() ?? ({ ok: true } as const);
});
const stampPushedAt: StampPushedAt = vi.fn(async () => {
if (overrides?.stampError) throw overrides.stampError;
});
return {
runInstructTx,
readInstructedMeasureNames,
syncMeasuresField,
stampPushedAt,
};
}
describe("instructMeasure — input validation", () => {
it("rejects an unknown measure name without touching the DB or HubSpot", async () => {
const deps = makeDeps();
const result = await instructMeasure({
dealId: "deal-1",
measureName: "Not a real measure",
userId: 1n,
deps,
});
expect(result).toEqual({
ok: false,
error: "Unknown measure: Not a real measure",
});
expect(deps.runInstructTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
it("rejects an empty measure name", async () => {
const deps = makeDeps();
const result = await instructMeasure({
dealId: "deal-1",
measureName: " ",
userId: 1n,
deps,
});
expect(result.ok).toBe(false);
expect(deps.runInstructTx).not.toHaveBeenCalled();
});
});
describe("instructMeasure — happy path", () => {
it("commits tx, pushes instructed + proposed + approved, stamps pushed_at", async () => {
const deps = makeDeps({
instructedAfter: ["ASHP", "Solar PV"],
txOutcome: {
instructedRowId: 99n,
existingProposedMeasures: ["ASHP"],
allApprovedMeasureNames: ["ASHP", "Solar PV"],
},
});
const result = await instructMeasure({
dealId: "deal-42",
measureName: "Solar PV",
userId: 7n,
deps,
});
expect(result).toMatchObject({
ok: true,
instructedRowId: 99n,
hubspotSync: "ok",
});
expect(deps.runInstructTx).toHaveBeenCalledWith({
dealId: "deal-42",
measureName: "Solar PV",
userId: 7n,
notes: null,
});
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
hubspotDealId: "deal-42",
propName: INSTRUCTED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-42",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
hubspotDealId: "deal-42",
propName: APPROVED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.stampPushedAt).toHaveBeenCalledWith(99n);
});
it("merges new measure into existing proposed (deduped)", async () => {
const deps = makeDeps({
instructedAfter: ["ASHP", "EWI"],
txOutcome: {
instructedRowId: 1n,
existingProposedMeasures: ["ASHP", "Solar PV"],
allApprovedMeasureNames: ["ASHP", "EWI"],
},
});
await instructMeasure({
dealId: "deal-merge",
measureName: "EWI",
userId: 3n,
deps,
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-merge",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV", "EWI"],
});
});
it("adds to proposed even when deal already had proposed measures", async () => {
const deps = makeDeps({
instructedAfter: ["EWI"],
txOutcome: {
instructedRowId: 1n,
existingProposedMeasures: ["ASHP", "Solar PV"],
allApprovedMeasureNames: ["EWI"],
},
});
const result = await instructMeasure({
dealId: "deal-with-proposed",
measureName: "EWI",
userId: 3n,
deps,
});
expect(result.ok).toBe(true);
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2,
expect.objectContaining({
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV", "EWI"],
}),
);
});
it("adds to proposed when deal has no existing proposed measures", async () => {
const deps = makeDeps({
instructedAfter: ["EWI"],
txOutcome: {
instructedRowId: 1n,
existingProposedMeasures: [],
allApprovedMeasureNames: ["EWI"],
},
});
await instructMeasure({
dealId: "deal-blank",
measureName: "EWI",
userId: 3n,
deps,
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-blank",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["EWI"],
});
});
});
describe("instructMeasure — DB transaction failure", () => {
it("returns an error and skips HubSpot when the tx throws", async () => {
const deps = makeDeps({ txError: new Error("insert failed") });
const result = await instructMeasure({
dealId: "deal-x",
measureName: "ASHP",
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "insert failed" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.readInstructedMeasureNames).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
it("returns ok=true with hubspotSync=failed when instructed push fails, does NOT stamp", async () => {
const deps = makeDeps({
instructedAfter: ["ASHP"],
txOutcome: {
instructedRowId: 11n,
existingProposedMeasures: [],
allApprovedMeasureNames: ["ASHP"],
},
syncResults: [
{ ok: false, error: "hubspot 500" },
{ ok: true },
{ ok: true },
],
});
const result = await instructMeasure({
dealId: "deal-h",
measureName: "ASHP",
userId: 1n,
deps,
});
expect(result).toMatchObject({
ok: true,
instructedRowId: 11n,
hubspotSync: "failed",
hubspotError: "hubspot 500",
});
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
it("returns hubspotSync=failed when proposed push fails", async () => {
const deps = makeDeps({
instructedAfter: ["EWI"],
txOutcome: {
instructedRowId: 12n,
existingProposedMeasures: [],
allApprovedMeasureNames: ["EWI"],
},
syncResults: [
{ ok: true },
{ ok: false, error: "proposed push failed" },
{ ok: true },
],
});
const result = await instructMeasure({
dealId: "deal-blank",
measureName: "EWI",
userId: 3n,
deps,
});
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "proposed push failed",
});
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
it("returns hubspotSync=failed when approved push fails", async () => {
const deps = makeDeps({
instructedAfter: ["EWI"],
txOutcome: {
instructedRowId: 13n,
existingProposedMeasures: [],
allApprovedMeasureNames: ["EWI"],
},
syncResults: [
{ ok: true },
{ ok: true },
{ ok: false, error: "approved push failed" },
],
});
const result = await instructMeasure({
dealId: "deal-blank",
measureName: "EWI",
userId: 3n,
deps,
});
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "approved push failed",
});
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// instructMeasures (plural) — batch variant
// ---------------------------------------------------------------------------
function makeBatchDeps(overrides?: {
txOutcome?: Partial<InstructMeasuresTxOutcome>;
txError?: Error;
instructedAfter?: string[];
syncResults?: Array<{ ok: true } | { ok: false; error: string }>;
stampError?: Error;
}) {
const txOutcome: InstructMeasuresTxOutcome = {
instructedRowIds: [1n, 2n],
existingProposedMeasures: [],
allApprovedMeasureNames: [],
...overrides?.txOutcome,
};
const runInstructMeasuresTx: RunInstructMeasuresTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txOutcome;
});
const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
async () => overrides?.instructedAfter ?? ["ASHP", "Solar PV"],
);
const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return syncQueue.shift() ?? ({ ok: true } as const);
});
const stampPushedAt: StampPushedAt = vi.fn(async () => {
if (overrides?.stampError) throw overrides.stampError;
});
return {
runInstructMeasuresTx,
readInstructedMeasureNames,
syncMeasuresField,
stampPushedAt,
};
}
describe("instructMeasures — input validation", () => {
it("rejects when measureNames is empty", async () => {
const deps = makeBatchDeps();
const result = await instructMeasures({
dealId: "deal-1",
measureNames: [],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "measureNames must not be empty" });
expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
it("rejects when any measureName is unknown", async () => {
const deps = makeBatchDeps();
const result = await instructMeasures({
dealId: "deal-1",
measureNames: ["ASHP", "Not a real measure"],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "Unknown measure: Not a real measure" });
expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
});
describe("instructMeasures — happy path", () => {
it("commits single tx, pushes instructed + proposed + approved, stamps all rowIds", async () => {
const deps = makeBatchDeps({
instructedAfter: ["ASHP", "Solar PV"],
txOutcome: {
instructedRowIds: [10n, 11n],
existingProposedMeasures: [],
allApprovedMeasureNames: ["ASHP", "Solar PV"],
},
});
const result = await instructMeasures({
dealId: "deal-42",
measureNames: ["ASHP", "Solar PV"],
userId: 7n,
deps,
});
expect(result).toMatchObject({ ok: true, instructedRowIds: [10n, 11n], hubspotSync: "ok" });
expect(deps.runInstructMeasuresTx).toHaveBeenCalledOnce();
expect(deps.runInstructMeasuresTx).toHaveBeenCalledWith({
dealId: "deal-42",
measureNames: ["ASHP", "Solar PV"],
userId: 7n,
notes: null,
});
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
hubspotDealId: "deal-42",
propName: INSTRUCTED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-42",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
hubspotDealId: "deal-42",
propName: APPROVED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.stampPushedAt).toHaveBeenCalledTimes(2);
expect(deps.stampPushedAt).toHaveBeenCalledWith(10n);
expect(deps.stampPushedAt).toHaveBeenCalledWith(11n);
});
it("merges all new measures into existing proposed (deduped)", async () => {
const deps = makeBatchDeps({
instructedAfter: ["ASHP", "EWI", "Solar PV"],
txOutcome: {
instructedRowIds: [20n, 21n],
existingProposedMeasures: ["ASHP", "Loft insulation"],
allApprovedMeasureNames: ["ASHP", "EWI", "Solar PV"],
},
});
await instructMeasures({
dealId: "deal-merge",
measureNames: ["EWI", "Solar PV"],
userId: 3n,
deps,
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-merge",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Loft insulation", "EWI", "Solar PV"],
});
});
});
describe("instructMeasures — DB transaction failure", () => {
it("returns error and skips HubSpot when tx throws", async () => {
const deps = makeBatchDeps({ txError: new Error("batch insert failed") });
const result = await instructMeasures({
dealId: "deal-x",
measureNames: ["ASHP", "EWI"],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "batch insert failed" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("instructMeasures — HubSpot push failure leaves DB committed", () => {
it("returns ok=true with hubspotSync=failed when sync fails, does NOT stamp", async () => {
const deps = makeBatchDeps({
instructedAfter: ["ASHP", "EWI"],
txOutcome: {
instructedRowIds: [30n, 31n],
existingProposedMeasures: [],
allApprovedMeasureNames: ["ASHP", "EWI"],
},
syncResults: [{ ok: false, error: "hubspot 500" }, { ok: true }, { ok: true }],
});
const result = await instructMeasures({
dealId: "deal-h",
measureNames: ["ASHP", "EWI"],
userId: 1n,
deps,
});
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "hubspot 500",
});
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});