# Summary This PR introduces a **Case Lifecycle Domain Boundary** to centralize lifecycle decision logic and reduce coupling within the appeals application. The work is **behaviour-preserving** and introduces no intentional changes to business rules, CRM integrations, translations, dashboards, API routes, or user-facing functionality. ## What was added New lifecycle boundary: ```text lib/domain/case-lifecycle/ ``` Key responsibilities extracted: - Specialist process normalization - Appeal type mapping - Specialist process stage override mapping - Stage case-type resolution - Stage catalogue lookup - Closed-case status recognition - Lifecycle stage index resolution - Lifecycle stage status assignment ## Behaviour preserved Characterization tests were added before each extraction to preserve: - Appeal type mapping and aliases - Specialist process handling - Lifecycle stage progression - Closed-case handling - Status assignment (`complete`, `in-progress`, `not-started`) - Existing ROW behaviour - Existing `statuscode` lifecycle semantics Closed-case recognition remains unchanged for: ```text 1000 5 6 846040013 846040059 846040060 ``` ## Documentation Added: ```text lib/domain/case-lifecycle/README.md ``` Documenting: - Boundary ownership - Non-goals - Lifecycle invariants - Known architectural constraints - Future extraction roadmap ## Testing Added lifecycle characterization coverage for: - Stage wrapper behaviour - Specialist process normalization - Appeal type mapping - Specialist process stage mapping - Stage case-type resolution - Stage catalogue lookup - Progress behaviour - Closed-case status handling - Stage index resolution - Stage status assignment ## Validation - Lifecycle characterization tests passed - `npm run lint` passed with no errors ## Out of Scope No changes to: - Stage catalogue ownership - Representation eligibility - Dashboard calculations - CRM/OData queries - API routes - Redux state - EN/CY translations - Event visibility logic ## Risk **Low risk** The refactor was delivered through small, characterization-first slices with no functional changes intended. Related work items: #23527
472 lines
14 KiB
JavaScript
472 lines
14 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const vm = require("vm");
|
|
const assert = require("assert");
|
|
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
|
|
const loadClosedCaseStatusModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"isClosedCaseStatus.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(
|
|
/export function\s+isClosedCaseStatus/,
|
|
"function isClosedCaseStatus"
|
|
);
|
|
source += "\nmodule.exports = { isClosedCaseStatus };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadGetLifecycleStageIndexModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"getLifecycleStageIndex.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
|
|
source = source.replace(
|
|
/export function\s+getLifecycleStageIndex/,
|
|
"function getLifecycleStageIndex"
|
|
);
|
|
source += "\nmodule.exports = { getLifecycleStageIndex };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require,
|
|
isClosedCaseStatus: closedCaseStatusModule.isClosedCaseStatus
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadGetLifecycleStageStatusModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"getLifecycleStageStatus.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(
|
|
/export function\s+getLifecycleStageStatus/,
|
|
"function getLifecycleStageStatus"
|
|
);
|
|
source += "\nmodule.exports = { getLifecycleStageStatus };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadMapAppealTypeModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"mapAppealType.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/export const\s+/g, "const ");
|
|
source = source.replace(
|
|
/export function\s+mapAppealType/,
|
|
"function mapAppealType"
|
|
);
|
|
source += `
|
|
module.exports = {
|
|
caseTypeAliases,
|
|
caseTypeKeyByAppealTypeId,
|
|
normaliseCaseType,
|
|
mapAppealType
|
|
};
|
|
`;
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadMapSpecialistProcessStageTypeModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"mapSpecialistProcessStageType.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/export const\s+/g, "const ");
|
|
source = source.replace(
|
|
/export function\s+mapSpecialistProcessStageType/,
|
|
"function mapSpecialistProcessStageType"
|
|
);
|
|
source += `
|
|
module.exports = {
|
|
specialistProcessStageTypeByCaseType,
|
|
mapSpecialistProcessStageType
|
|
};
|
|
`;
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadGetStageCaseTypeKeyModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"getStageCaseTypeKey.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
|
|
source = source.replace(
|
|
/export function\s+getStageCaseTypeKey/,
|
|
"function getStageCaseTypeKey"
|
|
);
|
|
source += "\nmodule.exports = { getStageCaseTypeKey };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require,
|
|
caseTypeAliases: mapAppealTypeModule.caseTypeAliases,
|
|
caseTypeKeyByAppealTypeId:
|
|
mapAppealTypeModule.caseTypeKeyByAppealTypeId,
|
|
mapAppealType: mapAppealTypeModule.mapAppealType,
|
|
normaliseCaseType: mapAppealTypeModule.normaliseCaseType,
|
|
mapSpecialistProcessStageType:
|
|
mapSpecialistProcessStageTypeModule.mapSpecialistProcessStageType
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadStageCatalogueModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"components",
|
|
"case",
|
|
"summary",
|
|
"utils",
|
|
"caseStagesByAppealType.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
|
|
source = source.replace(/export const\s+/g, "const ");
|
|
source += "\nmodule.exports = { stagesByCaseType };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require,
|
|
getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey,
|
|
getStagesForCaseTypeKey: () => [],
|
|
isClosedCaseStatus: closedCaseStatusModule.isClosedCaseStatus,
|
|
getLifecycleStageIndex:
|
|
getLifecycleStageIndexModule.getLifecycleStageIndex,
|
|
getLifecycleStageStatus:
|
|
getLifecycleStageStatusModule.getLifecycleStageStatus
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadGetStagesForCaseTypeKeyModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"lib",
|
|
"domain",
|
|
"case-lifecycle",
|
|
"getStagesForCaseTypeKey.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
|
|
source = source.replace(
|
|
/export function\s+getStagesForCaseTypeKey/,
|
|
"function getStagesForCaseTypeKey"
|
|
);
|
|
source += "\nmodule.exports = { getStagesForCaseTypeKey };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require: (modulePath) => {
|
|
if (
|
|
modulePath ===
|
|
"../../../components/case/summary/utils/caseStagesByAppealType"
|
|
) {
|
|
return stageCatalogueModule;
|
|
}
|
|
|
|
return require(modulePath);
|
|
}
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadStageHelperModule = () => {
|
|
const filePath = path.join(
|
|
rootDir,
|
|
"components",
|
|
"case",
|
|
"summary",
|
|
"utils",
|
|
"caseStagesByAppealType.js"
|
|
);
|
|
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
|
|
source = source.replace(/export const\s+/g, "const ");
|
|
source +=
|
|
"\nmodule.exports = { getStagesForAppealType, stagesByCaseType };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require,
|
|
getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey,
|
|
getStagesForCaseTypeKey:
|
|
getStagesForCaseTypeKeyModule.getStagesForCaseTypeKey,
|
|
isClosedCaseStatus: closedCaseStatusModule.isClosedCaseStatus,
|
|
getLifecycleStageIndex:
|
|
getLifecycleStageIndexModule.getLifecycleStageIndex,
|
|
getLifecycleStageStatus:
|
|
getLifecycleStageStatusModule.getLifecycleStageStatus
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const tests = [];
|
|
const test = (name, fn) => tests.push({ name, fn });
|
|
|
|
const normalizeForAssertion = (value) =>
|
|
JSON.parse(JSON.stringify(value ?? null));
|
|
|
|
const closedCaseStatusModule = loadClosedCaseStatusModule();
|
|
const getLifecycleStageIndexModule = loadGetLifecycleStageIndexModule();
|
|
const getLifecycleStageStatusModule = loadGetLifecycleStageStatusModule();
|
|
const mapAppealTypeModule = loadMapAppealTypeModule();
|
|
const mapSpecialistProcessStageTypeModule =
|
|
loadMapSpecialistProcessStageTypeModule();
|
|
const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule();
|
|
const stageCatalogueModule = loadStageCatalogueModule();
|
|
const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule();
|
|
const stageHelperModule = loadStageHelperModule();
|
|
|
|
const { getLifecycleStageIndex } = getLifecycleStageIndexModule;
|
|
const { getStagesForAppealType, stagesByCaseType } = stageHelperModule;
|
|
|
|
test("direct stage ID match returns the correct index", () => {
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, 846040014),
|
|
0
|
|
);
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.DNS, 846040006),
|
|
5
|
|
);
|
|
});
|
|
|
|
test("numeric and string stage ID forms preserve the same current index", () => {
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, 846040018),
|
|
5
|
|
);
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, "846040018"),
|
|
5
|
|
);
|
|
});
|
|
|
|
test("unknown stage or status preserves -1 fallback", () => {
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, 999999999),
|
|
-1
|
|
);
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, "UNKNOWN"),
|
|
-1
|
|
);
|
|
});
|
|
|
|
test("closed-case statuses resolve to the case-closed index where present", () => {
|
|
const closedStatuses = [1000, 5, 6, 846040013, 846040059, 846040060];
|
|
|
|
for (const closedStatus of closedStatuses) {
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, closedStatus),
|
|
10
|
|
);
|
|
}
|
|
});
|
|
|
|
test("missing case-closed stage preserves current -1 fallback for closed statuses", () => {
|
|
const stagesWithoutClosed = stagesByCaseType.PLANNING_S78.filter(
|
|
(stage) => stage.titleKey !== "case-closed"
|
|
);
|
|
|
|
assert.strictEqual(getLifecycleStageIndex(stagesWithoutClosed, 1000), -1);
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesWithoutClosed, 846040059),
|
|
-1
|
|
);
|
|
});
|
|
|
|
test("null and undefined current stage input preserve current -1 fallback", () => {
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, null),
|
|
-1
|
|
);
|
|
assert.strictEqual(
|
|
getLifecycleStageIndex(stagesByCaseType.PLANNING_S78, undefined),
|
|
-1
|
|
);
|
|
});
|
|
|
|
test("integration parity preserves existing lifecycle outputs for representative inputs", () => {
|
|
const scenarios = [
|
|
[846040000, 846040018, undefined],
|
|
[846040011, 846040006, undefined],
|
|
[846040004, 846040021, undefined],
|
|
[846040010, 846040049, undefined],
|
|
[846040000, 846040059, undefined],
|
|
[846040000, 999999999, undefined]
|
|
];
|
|
|
|
const baselineGetStageIndex = (stages, currentStageId) => {
|
|
const currentStageNumber = Number(currentStageId);
|
|
|
|
const exactIndex = stages.findIndex(
|
|
(stage) => Number(stage.stageId) === currentStageNumber
|
|
);
|
|
|
|
if (exactIndex !== -1) return exactIndex;
|
|
|
|
if (closedCaseStatusModule.isClosedCaseStatus(currentStageNumber)) {
|
|
return stages.findIndex(
|
|
(stage) => stage.titleKey === "case-closed"
|
|
);
|
|
}
|
|
|
|
return -1;
|
|
};
|
|
|
|
const baselineGetStagesForAppealType = (
|
|
caseType,
|
|
currentStageId,
|
|
specialistProcess
|
|
) => {
|
|
const stages = normalizeForAssertion(
|
|
getStagesForAppealType(caseType, currentStageId, specialistProcess)
|
|
).map(({ status, ...stage }) => stage);
|
|
const freshStages = normalizeForAssertion(
|
|
getStagesForAppealType(caseType, currentStageId, specialistProcess)
|
|
).map(({ status, ...stage }) => stage);
|
|
const currentIndex = baselineGetStageIndex(freshStages, currentStageId);
|
|
|
|
return freshStages.map((stage, index) => ({
|
|
...stage,
|
|
status:
|
|
currentIndex === -1
|
|
? "not-started"
|
|
: index < currentIndex
|
|
? "complete"
|
|
: index === currentIndex
|
|
? "in-progress"
|
|
: "not-started"
|
|
}));
|
|
};
|
|
|
|
for (const [caseType, currentStageId, specialistProcess] of scenarios) {
|
|
assert.deepStrictEqual(
|
|
normalizeForAssertion(
|
|
getStagesForAppealType(
|
|
caseType,
|
|
currentStageId,
|
|
specialistProcess
|
|
)
|
|
),
|
|
normalizeForAssertion(
|
|
baselineGetStagesForAppealType(
|
|
caseType,
|
|
currentStageId,
|
|
specialistProcess
|
|
)
|
|
)
|
|
);
|
|
}
|
|
});
|
|
|
|
const run = async () => {
|
|
let passed = 0;
|
|
|
|
for (const currentTest of tests) {
|
|
await currentTest.fn();
|
|
passed += 1;
|
|
}
|
|
|
|
console.log(
|
|
`Phase 22 case-lifecycle-stage-index tests passed (${passed}/${tests.length}).`
|
|
);
|
|
};
|
|
|
|
module.exports = run;
|
|
|
|
if (require.main === module) {
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|