mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
489 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|