Add test coverage for transforms.ts stage classification logic
Some checks failed
Test Suite / unit-tests (push) Has been cancelled

49 tests across all exported functions: resolveDisplayStage (STAGE_ID_MAP
lookups, AFTER_ASSESSMENT sub-classification, POST_DESIGN precedence chain,
RA ISSUE overrides), classifyDeals, computeDampMouldRisk, computeFunnelStages,
computeProjectProgress (Queries exclusion from percentages), computeOutcomeSlices,
and computeLiveTrackerData (__ALL__ synthetic project behaviour).
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-12 13:29:00 +00:00
parent 7841e4a556
commit 1345f36d8d
3 changed files with 620 additions and 0 deletions

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { deriveEffectiveRemovalState, computeRemovalStatusByDeal } from "./removalState";
describe("deriveEffectiveRemovalState", () => {
it("pending removal → pending_removal", () => {
expect(deriveEffectiveRemovalState({ type: "removal", status: "pending" })).toBe("pending_removal");
});
it("pending re_addition → pending_re_addition", () => {
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "pending" })).toBe("pending_re_addition");
});
it("approved removal → removed", () => {
expect(deriveEffectiveRemovalState({ type: "removal", status: "approved" })).toBe("removed");
});
it("declined re_addition → removed (re-addition refused means still out)", () => {
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "declined" })).toBe("removed");
});
it("approved re_addition → none (back in, no active removal state)", () => {
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "approved" })).toBe("none");
});
it("declined removal → none (removal refused, property stays in)", () => {
expect(deriveEffectiveRemovalState({ type: "removal", status: "declined" })).toBe("none");
});
});
describe("computeRemovalStatusByDeal", () => {
it("returns empty map for no rows", () => {
expect(computeRemovalStatusByDeal([])).toEqual({});
});
it("maps a single active removal row", () => {
const rows = [{ hubspotDealId: "d1", type: "removal", status: "pending" }];
expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "pending_removal" });
});
it("omits deals whose most recent state is none", () => {
const rows = [{ hubspotDealId: "d1", type: "removal", status: "declined" }];
expect(computeRemovalStatusByDeal(rows)).toEqual({});
});
it("deduplicates by deal — first row wins (most recent, caller provides DESC order)", () => {
const rows = [
{ hubspotDealId: "d1", type: "removal", status: "approved" }, // most recent
{ hubspotDealId: "d1", type: "removal", status: "pending" }, // older, ignored
];
expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "removed" });
});
it("handles multiple deals independently", () => {
const rows = [
{ hubspotDealId: "d1", type: "removal", status: "pending" },
{ hubspotDealId: "d2", type: "re_addition", status: "pending" },
{ hubspotDealId: "d3", type: "re_addition", status: "approved" },
];
expect(computeRemovalStatusByDeal(rows)).toEqual({
d1: "pending_removal",
d2: "pending_re_addition",
});
});
});

View file

@ -0,0 +1,31 @@
import type { EffectiveRemovalState, RemovalStatusByDeal } from "./types";
export function deriveEffectiveRemovalState(row: {
type: string;
status: string;
}): EffectiveRemovalState {
if (row.status === "pending") {
return row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
}
if (row.type === "removal" && row.status === "approved") return "removed";
if (row.type === "re_addition" && row.status === "declined") return "removed";
return "none";
}
// Rows must be ordered by requestedAt DESC so the first row per deal is the most recent.
export function computeRemovalStatusByDeal(
rows: { hubspotDealId: string; type: string; status: string }[],
): RemovalStatusByDeal {
const result: RemovalStatusByDeal = {};
const seen = new Set<string>();
for (const row of rows) {
if (seen.has(row.hubspotDealId)) continue;
seen.add(row.hubspotDealId);
const state = deriveEffectiveRemovalState(row);
if (state !== "none") result[row.hubspotDealId] = state;
}
return result;
}

View file

@ -0,0 +1,525 @@
import { describe, it, expect } from "vitest";
import {
resolveDisplayStage,
classifyDeals,
computeDampMouldRisk,
computeFunnelStages,
computeProjectProgress,
computeOutcomeSlices,
computeLiveTrackerData,
} from "./transforms";
import type { HubspotDeal, ClassifiedDeal } from "./types";
function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
return {
id: "1",
dealId: "deal-1",
dealname: "Test Property",
dealstage: null,
companyId: null,
projectCode: null,
landlordPropertyId: null,
uprn: null,
outcome: null,
outcomeNotes: null,
majorConditionIssueDescription: null,
majorConditionIssuePhotos: null,
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
dampMouldAndRepairComments: null,
preSapScore: null,
coordinator: null,
ioeV1Date: null,
ioeV2Date: null,
ioeV3Date: null,
proposedMeasures: null,
approvedPackage: null,
designer: null,
designDate: null,
actualMeasuresInstalled: null,
installer: null,
installerHandover: null,
lodgementStatus: null,
measuresLodgementDate: null,
fullLodgementDate: null,
confirmedSurveyDate: null,
confirmedSurveyTime: null,
surveyedDate: null,
designType: null,
eiScore: null,
eiScorePotential: null,
epcSapScore: null,
epcSapScorePotential: null,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
pibiCompletedDate: null,
propertyHaltedDate: null,
propertyHaltedReason: null,
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeClassified(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
return { ...makeDeal(overrides), displayStage: "Scope & Planning", ...overrides };
}
// -----------------------------------------------------------------------
// resolveDisplayStage
// -----------------------------------------------------------------------
describe("resolveDisplayStage — direct STAGE_ID_MAP lookups", () => {
it("returns Scope & Planning for a backlog stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "1617223910" }))).toBe("Scope & Planning");
});
it("returns Booking in Progress for a bookings stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "3589581001" }))).toBe("Booking in Progress");
});
it("returns Assessment in Progress for a survey-in-progress stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "1617223913" }))).toBe("Assessment in Progress");
});
it("returns Queries for a known queries stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "2663668937" }))).toBe("Queries");
});
it("falls through to AFTER_ASSESSMENT path when dealstage is null", () => {
// null dealstage → AFTER_ASSESSMENT → no coord/design status → Coordination in Progress
expect(resolveDisplayStage(makeDeal({ dealstage: null }))).toBe("Coordination in Progress");
});
it("falls through to AFTER_ASSESSMENT path for an unrecognised stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "9999999999" }))).toBe("Coordination in Progress");
});
});
describe("resolveDisplayStage — RA ISSUE override on non-AFTER_ASSESSMENT stages", () => {
it("overrides Scope & Planning to Queries when coordinationStatus is RA ISSUE", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "1617223910", coordinationStatus: "RA ISSUE" }))
).toBe("Queries");
});
it("overrides Assessment in Progress to Queries when coordinationStatus is RA ISSUE", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "RA ISSUE" }))
).toBe("Queries");
});
it("is case-insensitive for RA ISSUE check", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "ra issue" }))
).toBe("Queries");
});
it("does NOT override Booking in Progress with RA ISSUE (only Scope & Planning and Assessment in Progress)", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "3589581001", coordinationStatus: "RA ISSUE" }))
).toBe("Booking in Progress");
});
});
describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
it("returns Queries when coordinationStatus is RA ISSUE", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: "RA ISSUE" }))
).toBe("Queries");
});
it("returns Coordination in Progress by default (no special status)", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: null, designStatus: null }))
).toBe("Coordination in Progress");
});
it("returns Design in Progress when coord is IOE/MTP COMPLETE but design not yet uploaded", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
designStatus: "IN PROGRESS",
}))
).toBe("Design in Progress");
});
it("returns Design in Progress for V2 IOE/MTP COMPLETE variant", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V2) IOE/MTP COMPLETE",
designStatus: null,
}))
).toBe("Design in Progress");
});
it("returns Design in Progress for V3 IOE/MTP COMPLETE variant", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V3) IOE/MTP COMPLETE",
designStatus: null,
}))
).toBe("Design in Progress");
});
it("is case-insensitive for coord/design status checks", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(v1) ioe/mtp complete",
designStatus: "uploaded",
}))
).toBe("Installation in Progress"); // POST_DESIGN, no install fields set
});
});
describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
function makePostDesignDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
return makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
designStatus: "UPLOADED",
...overrides,
});
}
it("returns Installation in Progress when no install fields are set", () => {
expect(resolveDisplayStage(makePostDesignDeal())).toBe("Installation in Progress");
});
it("returns Installation Complete when actualMeasuresInstalled is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" }))
).toBe("Installation Complete");
});
it("returns Installation Complete when installerHandover is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" }))
).toBe("Installation Complete");
});
it("returns At Lodgement when lodgementStatus is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" }))
).toBe("At Lodgement");
});
it("returns At Post Survey when measuresLodgementDate is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() }))
).toBe("At Post Survey");
});
it("returns Project Complete when fullLodgementDate is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() }))
).toBe("Project Complete");
});
it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
fullLodgementDate: new Date(),
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
).toBe("Project Complete");
});
it("measuresLodgementDate takes precedence over lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
).toBe("At Post Survey");
});
});
// -----------------------------------------------------------------------
// classifyDeals
// -----------------------------------------------------------------------
describe("classifyDeals", () => {
it("adds displayStage to each deal", () => {
const deals = [
makeDeal({ dealstage: "1617223910" }),
makeDeal({ dealstage: "1617223913" }),
];
const result = classifyDeals(deals);
expect(result[0].displayStage).toBe("Scope & Planning");
expect(result[1].displayStage).toBe("Assessment in Progress");
});
it("returns an empty array for no deals", () => {
expect(classifyDeals([])).toEqual([]);
});
it("preserves all original deal fields", () => {
const deal = makeDeal({ dealId: "abc-123", dealname: "Test" });
const [result] = classifyDeals([deal]);
expect(result.dealId).toBe("abc-123");
expect(result.dealname).toBe("Test");
});
});
// -----------------------------------------------------------------------
// computeDampMouldRisk
// -----------------------------------------------------------------------
describe("computeDampMouldRisk", () => {
it("counts survey flags from majorConditionIssuePhotosS3", () => {
const deals = [
makeClassified({ majorConditionIssuePhotosS3: "s3://bucket/file.jpg" }),
makeClassified({ majorConditionIssuePhotosS3: null }),
];
const result = computeDampMouldRisk(deals);
expect(result.surveyFlagCount).toBe(1);
});
it("counts coordinator flags from dampMouldFlag", () => {
const deals = [
makeClassified({ dampMouldFlag: "Yes" }),
makeClassified({ dampMouldFlag: null }),
makeClassified({ dampMouldFlag: "Yes" }),
];
const result = computeDampMouldRisk(deals);
expect(result.coordinatorFlagCount).toBe(2);
});
it("counts deals flagged at both stages independently", () => {
const deals = [
makeClassified({ majorConditionIssuePhotosS3: "s3://x", dampMouldFlag: "Yes" }),
makeClassified({ majorConditionIssuePhotosS3: "s3://y", dampMouldFlag: null }),
makeClassified({ majorConditionIssuePhotosS3: null, dampMouldFlag: "Yes" }),
];
const result = computeDampMouldRisk(deals);
expect(result.surveyFlagCount).toBe(2);
expect(result.coordinatorFlagCount).toBe(2);
expect(result.bothFlaggedCount).toBe(1);
});
it("returns zero counts for empty deals", () => {
const result = computeDampMouldRisk([]);
expect(result.surveyFlagCount).toBe(0);
expect(result.coordinatorFlagCount).toBe(0);
expect(result.bothFlaggedCount).toBe(0);
expect(result.totalDeals).toBe(0);
});
});
// -----------------------------------------------------------------------
// computeFunnelStages
// -----------------------------------------------------------------------
describe("computeFunnelStages", () => {
it("excludes Queries deals from counts and percentages", () => {
const deals = [
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Queries" }),
];
const funnel = computeFunnelStages(deals);
const scopeEntry = funnel.find((f) => f.stage === "Scope & Planning")!;
expect(scopeEntry.currentPct).toBe(100); // 1 out of 1 non-query
});
it("returns 0 percentages when all deals are Queries", () => {
const deals = [makeClassified({ displayStage: "Queries" })];
const funnel = computeFunnelStages(deals);
funnel.forEach((f) => {
expect(f.currentPct).toBe(0);
expect(f.cumulativePct).toBe(0);
});
});
it("cumulative count includes all deals at or beyond a given stage", () => {
const deals = [
makeClassified({ displayStage: "Installation in Progress" }),
makeClassified({ displayStage: "Installation Complete" }),
makeClassified({ displayStage: "Project Complete" }),
];
const funnel = computeFunnelStages(deals);
const installEntry = funnel.find((f) => f.stage === "Installation in Progress")!;
// All 3 deals are at or beyond "Installation in Progress"
expect(installEntry.cumulativeCount).toBe(3);
expect(installEntry.currentCount).toBe(1);
});
it("returns an entry for every stage in STAGE_ORDER", () => {
const funnel = computeFunnelStages([]);
expect(funnel.length).toBe(10); // STAGE_ORDER has 10 entries
});
});
// -----------------------------------------------------------------------
// computeProjectProgress
// -----------------------------------------------------------------------
describe("computeProjectProgress", () => {
it("excludes Queries from nonQueryTotal but includes them in totalDeals", () => {
const deals = [
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Queries" }),
];
const result = computeProjectProgress(deals);
expect(result.totalDeals).toBe(2);
expect(result.nonQueryTotal).toBe(1);
expect(result.queriesDeals).toHaveLength(1);
});
it("computes completedPercentage as fraction of non-query total", () => {
const deals = [
makeClassified({ displayStage: "Project Complete" }),
makeClassified({ displayStage: "Project Complete" }),
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Queries" }), // excluded from denominator
];
const result = computeProjectProgress(deals);
expect(result.completedCount).toBe(2);
expect(result.completedPercentage).toBeCloseTo(66.67, 1);
expect(result.nonQueryTotal).toBe(3);
});
it("returns 0 percentages when there are no non-query deals", () => {
const deals = [makeClassified({ displayStage: "Queries" })];
const result = computeProjectProgress(deals);
expect(result.completedPercentage).toBe(0);
result.stageProgress.forEach((s) => expect(s.percentage).toBe(0));
});
it("returns correct stage counts in stageProgress", () => {
const deals = [
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Assessment in Progress" }),
];
const result = computeProjectProgress(deals);
const scopeItem = result.stageProgress.find((s) => s.stage === "Scope & Planning")!;
const assessItem = result.stageProgress.find((s) => s.stage === "Assessment in Progress")!;
expect(scopeItem.count).toBe(2);
expect(assessItem.count).toBe(1);
});
it("stageProgress does not include a Queries entry", () => {
const result = computeProjectProgress([makeClassified({ displayStage: "Queries" })]);
expect(result.stageProgress.find((s) => s.stage === "Queries")).toBeUndefined();
});
});
// -----------------------------------------------------------------------
// computeOutcomeSlices
// -----------------------------------------------------------------------
describe("computeOutcomeSlices", () => {
it("counts only known SURVEYOR_OUTCOMES values", () => {
const deals = [
makeClassified({ outcome: "Surveyed" }),
makeClassified({ outcome: "Surveyed" }),
makeClassified({ outcome: "UNKNOWN_VALUE" }),
makeClassified({ outcome: null }),
];
const slices = computeOutcomeSlices(deals);
expect(slices).toHaveLength(1);
expect(slices[0].name).toBe("Surveyed");
expect(slices[0].amount).toBe(2);
});
it("computes percentage as a formatted string with one decimal place", () => {
const deals = [
makeClassified({ outcome: "Surveyed" }),
makeClassified({ outcome: "Surveyed" }),
makeClassified({ outcome: "Other" }),
makeClassified({ outcome: "Other" }),
];
const slices = computeOutcomeSlices(deals);
const surveyed = slices.find((s) => s.name === "Surveyed")!;
expect(surveyed.percentage).toBe("50.0");
});
it("returns empty array when no deals have matching outcomes", () => {
expect(computeOutcomeSlices([makeClassified({ outcome: null })])).toEqual([]);
});
});
// -----------------------------------------------------------------------
// computeLiveTrackerData
// -----------------------------------------------------------------------
describe("computeLiveTrackerData", () => {
it("returns a single project entry and no __ALL__ when all deals share one project code", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223913" }),
];
const result = computeLiveTrackerData(deals);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].projectCode).toBe("PROJ-A");
});
it("prepends a synthetic __ALL__ entry when deals span multiple project codes", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }),
];
const result = computeLiveTrackerData(deals);
expect(result.projects[0].projectCode).toBe("__ALL__");
expect(result.projects).toHaveLength(3); // __ALL__ + PROJ-A + PROJ-B
});
it("__ALL__ progress covers all deals across project codes", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }),
];
const result = computeLiveTrackerData(deals);
expect(result.projects[0].progress.totalDeals).toBe(2);
});
it("totalDeals is the count of all classified deals", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A" }),
makeDeal({ projectCode: "PROJ-A" }),
makeDeal({ projectCode: "PROJ-A" }),
];
expect(computeLiveTrackerData(deals).totalDeals).toBe(3);
});
it("filters majorConditionDeals by the major condition stage ID", () => {
const deals = [
makeDeal({ dealstage: "3061261536" }), // MAJOR_CONDITION_STAGE_ID
makeDeal({ dealstage: "1617223910" }),
];
const result = computeLiveTrackerData(deals);
expect(result.majorConditionDeals).toHaveLength(1);
expect(result.majorConditionDeals[0].dealstage).toBe("3061261536");
});
it("groups deals without a projectCode under Unknown Project", () => {
const deals = [makeDeal({ projectCode: null })];
const result = computeLiveTrackerData(deals);
expect(result.projects[0].projectCode).toBe("Unknown Project");
});
it("returns empty projects and zero counts for no input deals", () => {
const result = computeLiveTrackerData([]);
expect(result.projects).toHaveLength(0);
expect(result.totalDeals).toBe(0);
expect(result.majorConditionDeals).toHaveLength(0);
});
});