From 9595ad86df093d69398b86591aae399727baf309 Mon Sep 17 00:00:00 2001 From: Robert Bond Date: Mon, 8 Jun 2026 09:34:30 +0000 Subject: [PATCH] Merged PR 2374: addeding domain layer extrraction # 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 --- components/case/status.js | 4 +- .../summary/utils/caseStagesByAppealType.js | 145 +--- lib/domain/case-lifecycle/README.md | 150 +++++ .../case-lifecycle/getLifecycleStageIndex.js | 17 + .../case-lifecycle/getLifecycleStageStatus.js | 9 + .../getLifecycleStagesForCase.js | 9 + .../case-lifecycle/getStageCaseTypeKey.js | 26 + .../case-lifecycle/getStagesForCaseTypeKey.js | 16 + lib/domain/case-lifecycle/index.js | 9 + .../case-lifecycle/isClosedCaseStatus.js | 5 + lib/domain/case-lifecycle/mapAppealType.js | 81 +++ .../mapSpecialistProcessStageType.js | 19 + .../normalizeSpecialistProcess.js | 7 + lib/representation/pageLoaders.js | 12 +- pages/case/[ticketnumber].js | 12 +- pages/myportal/case/[ticketnumber].js | 11 +- ...ase-lifecycle-appeal-type-mapping.test.cjs | 296 +++++++++ ...case-lifecycle-closed-case-status.test.cjs | 370 +++++++++++ ...case-lifecycle-progress-behaviour.test.cjs | 623 ++++++++++++++++++ ...-specialist-process-normalization.test.cjs | 112 ++++ ...-specialist-process-stage-mapping.test.cjs | 369 +++++++++++ ...ecycle-stage-case-type-resolution.test.cjs | 337 ++++++++++ ...-lifecycle-stage-catalogue-lookup.test.cjs | 377 +++++++++++ .../case-lifecycle-stage-index.test.cjs | 471 +++++++++++++ .../case-lifecycle-stage-status.test.cjs | 421 ++++++++++++ .../case-lifecycle-stage-wrapper.test.cjs | 459 +++++++++++++ 26 files changed, 4202 insertions(+), 165 deletions(-) create mode 100644 lib/domain/case-lifecycle/README.md create mode 100644 lib/domain/case-lifecycle/getLifecycleStageIndex.js create mode 100644 lib/domain/case-lifecycle/getLifecycleStageStatus.js create mode 100644 lib/domain/case-lifecycle/getLifecycleStagesForCase.js create mode 100644 lib/domain/case-lifecycle/getStageCaseTypeKey.js create mode 100644 lib/domain/case-lifecycle/getStagesForCaseTypeKey.js create mode 100644 lib/domain/case-lifecycle/index.js create mode 100644 lib/domain/case-lifecycle/isClosedCaseStatus.js create mode 100644 lib/domain/case-lifecycle/mapAppealType.js create mode 100644 lib/domain/case-lifecycle/mapSpecialistProcessStageType.js create mode 100644 lib/domain/case-lifecycle/normalizeSpecialistProcess.js create mode 100644 tests/phase22/case-lifecycle-appeal-type-mapping.test.cjs create mode 100644 tests/phase22/case-lifecycle-closed-case-status.test.cjs create mode 100644 tests/phase22/case-lifecycle-progress-behaviour.test.cjs create mode 100644 tests/phase22/case-lifecycle-specialist-process-normalization.test.cjs create mode 100644 tests/phase22/case-lifecycle-specialist-process-stage-mapping.test.cjs create mode 100644 tests/phase22/case-lifecycle-stage-case-type-resolution.test.cjs create mode 100644 tests/phase22/case-lifecycle-stage-catalogue-lookup.test.cjs create mode 100644 tests/phase22/case-lifecycle-stage-index.test.cjs create mode 100644 tests/phase22/case-lifecycle-stage-status.test.cjs create mode 100644 tests/phase22/case-lifecycle-stage-wrapper.test.cjs diff --git a/components/case/status.js b/components/case/status.js index e636b6ed..e681316b 100644 --- a/components/case/status.js +++ b/components/case/status.js @@ -4,7 +4,7 @@ import { connect } from "react-redux"; import useTranslation from "next-translate/useTranslation"; import { formatDates } from "../utils"; import { useState } from "react"; -import { getStagesForAppealType } from "./summary/utils/caseStagesByAppealType"; +import { getLifecycleStagesForCase } from "../../lib/domain/case-lifecycle"; const statusClassMap = { complete: "govuk-tag govuk-!-margin-top-2", @@ -29,7 +29,7 @@ const StatusDetails = (props) => { console.log(props.currentView?.caseReference?.statuscode); - const stages = getStagesForAppealType( + const stages = getLifecycleStagesForCase( props.currentView?.caseReference?.appealType, props.currentView?.caseReference?.statuscode, specialistProcess diff --git a/components/case/summary/utils/caseStagesByAppealType.js b/components/case/summary/utils/caseStagesByAppealType.js index 4dec2c20..c4a8377e 100644 --- a/components/case/summary/utils/caseStagesByAppealType.js +++ b/components/case/summary/utils/caseStagesByAppealType.js @@ -1,3 +1,8 @@ +import { getStageCaseTypeKey } from "../../../../lib/domain/case-lifecycle/getStageCaseTypeKey"; +import { getStagesForCaseTypeKey } from "../../../../lib/domain/case-lifecycle/getStagesForCaseTypeKey"; +import { getLifecycleStageIndex } from "../../../../lib/domain/case-lifecycle/getLifecycleStageIndex"; +import { getLifecycleStageStatus } from "../../../../lib/domain/case-lifecycle/getLifecycleStageStatus"; + const slugify = (value) => value .toString() @@ -771,106 +776,10 @@ export const stagesByCaseType = { ]) }; -export const caseTypeAliases = { - // Planning S78 group - S78: "PLANNING_S78", - PLANNING_78: "PLANNING_S78", - PLANNING_S78: "PLANNING_S78", - CONDITIONS_73_79: "CONDITIONS_73_79", - LBCAC: "LBCAC", - LDCS: "LDCS", - PLANNING_OBLIGATIONS_S106: "PLANNING_OBLIGATIONS_S106", - PRIOR_NOTIFICATION: "PRIOR_NOTIFICATION", - - // Other types - HAS: "HAS", - CALL_INS: "CALL_INS", - CALL_IN: "CALL_INS", - ENFORCEMENT: "ENFORCEMENT", - ENFORCEMENT_LISTED_BUILDING: "ENFORCEMENT_LISTED_BUILDING", - MAINTENANCE_OF_LAND: "MAINTENANCE_OF_LAND", - RIGHTS_OF_WAY_SCHEDULE_14: "RIGHTS_OF_WAY_SCHEDULE_14", - RIGHTS_OF_WAY_ORDERS: "RIGHTS_OF_WAY_ORDERS", - REQUESTS_FOR_DIRECTION: "REQUESTS_FOR_DIRECTION", - ADVERTS: "ADVERTS", - HEDGEROW_TPO: "HEDGEROW_TPO", - COMMON_LAND: "COMMON_LAND", - COMPULSORY_PURCHASE_ORDERS: "COMPULSORY_PURCHASE_ORDERS", - ELECTRICITY_ACT: "ELECTRICITY_ACT", - HARBOUR_REVISION_ORDER: "HARBOUR_REVISION_ORDER", - TRANSPORT_WORKS: "TRANSPORT_WORKS", - WAYLEAVE: "WAYLEAVE", - NON_VALIDATION: "NON_VALIDATION", - DNS: "DNS", - SIP: "SIP" -}; - -const normaliseCaseType = (caseType) => - caseType - ?.toString() - .trim() - .toUpperCase() - .replace(/&/g, "AND") - .replace(/[^A-Z0-9]+/g, "_") - .replace(/^_|_$/g, ""); - -const specialistProcessKeyById = { - // TODO: replace these with the real CRM option-set values - 846040000: "RIGHTS_OF_WAY_ORDERS", - 846040100: "RIGHTS_OF_WAY_ORDERS", - 846040001: "RIGHTS_OF_WAY_ORDERS", - 846040101: "RIGHTS_OF_WAY_ORDERS", - 846040002: "RIGHTS_OF_WAY_SCHEDULE_14", - 846040102: "RIGHTS_OF_WAY_SCHEDULE_14", - 846040003: "REQUESTS_FOR_DIRECTION", - 846040103: "REQUESTS_FOR_DIRECTION" -}; - -const appealTypeSpecialistProcessStageMap = { - 846040015: specialistProcessKeyById -}; - const resolveStages = (caseTypeOrAppealTypeId, specialistProcess) => { - const appealTypeId = Number(caseTypeOrAppealTypeId); + const key = getStageCaseTypeKey(caseTypeOrAppealTypeId, specialistProcess); - const specialistProcessKey = - appealTypeSpecialistProcessStageMap[appealTypeId]?.[specialistProcess]; - - const appealTypeKey = - specialistProcessKey || caseTypeKeyByAppealTypeId[appealTypeId]; - - const normalised = normaliseCaseType(caseTypeOrAppealTypeId); - - const key = appealTypeKey || caseTypeAliases[normalised] || normalised; - - const entry = stagesByCaseType[key]; - - if (!entry) return []; - - if (typeof entry === "string") { - return stagesByCaseType[entry] || []; - } - - return entry; -}; - -const caseClosedStatusIds = [1000, 5, 6, 846040013, 846040060, 846040059]; - -const isCaseClosedStatus = (stageId) => - caseClosedStatusIds.includes(Number(stageId)); - -const getStageIndex = (stages, currentStageNumber) => { - const exactIndex = stages.findIndex( - (stage) => Number(stage.stageId) === currentStageNumber - ); - - if (exactIndex !== -1) return exactIndex; - - if (isCaseClosedStatus(currentStageNumber)) { - return stages.findIndex((stage) => stage.titleKey === "case-closed"); - } - - return -1; + return getStagesForCaseTypeKey(key); }; export const getStagesForAppealType = ( @@ -879,46 +788,10 @@ export const getStagesForAppealType = ( specialistProcess ) => { const stages = resolveStages(caseType, specialistProcess); - const currentStageNumber = Number(currentStageId); - - const currentIndex = getStageIndex(stages, currentStageNumber); + const currentIndex = getLifecycleStageIndex(stages, currentStageId); return stages.map((stage, index) => ({ ...stage, - status: - currentIndex === -1 - ? "not-started" - : index < currentIndex - ? "complete" - : index === currentIndex - ? "in-progress" - : "not-started" + status: getLifecycleStageStatus(index, currentIndex) })); }; - -export const caseTypeKeyByAppealTypeId = { - 846040000: "PLANNING_S78", - 846040001: "CONDITIONS_73_79", - 846040002: "SIP", - 846040003: "PLANNING_OBLIGATIONS_S106", - 846040004: "HAS", - 846040005: "ENFORCEMENT", - 846040006: "ENFORCEMENT_LISTED_BUILDING", - 846040007: "MAINTENANCE_OF_LAND", - 846040008: "LDCS", - 846040009: "PLANNING_S78", - 846040010: "CALL_INS", - 846040011: "DNS", - 846040012: "ELECTRICITY_ACT", - 846040013: "TRANSPORT_WORKS", - 846040014: "HARBOUR_REVISION_ORDER", - 846040015: "RIGHTS_OF_WAY_SCHEDULE_14", - 846040016: "COMMON_LAND", - 846040017: "HEDGEROW_TPO", - 846040018: "ADVERTS", - 846040019: "COMPULSORY_PURCHASE_ORDERS", - 846040020: "PLANNING_S78", - 846040021: "WAYLEAVE", - 846040024: "NON_VALIDATION", - 846040025: "LBCAC" -}; diff --git a/lib/domain/case-lifecycle/README.md b/lib/domain/case-lifecycle/README.md new file mode 100644 index 00000000..8fd99c1b --- /dev/null +++ b/lib/domain/case-lifecycle/README.md @@ -0,0 +1,150 @@ +# Case Lifecycle Domain Boundary + +## Purpose + +This directory contains a narrow **case lifecycle decision boundary**, not a full domain model. + +Its current purpose is to centralise **behaviour-preserving interpretation logic** that was previously embedded in UI-adjacent helpers, while keeping live behaviour unchanged during refactor. + +This boundary should be treated as an incremental extraction seam for read-only lifecycle interpretation, not as a place to redesign lifecycle behaviour. + +## Current ownership + +### `getLifecycleStagesForCase(...)` + +- thin wrapper around the existing stage/progress helper +- preserves existing output shape for current consumers +- does not define new lifecycle semantics + +### `normalizeSpecialistProcess(...)` + +- raw CRM field fallback only +- preserves current canonical and misspelled field fallback behaviour +- does not validate or reinterpret CRM values + +### `mapAppealType(...)` + +- current appeal-type-to-case-type mapping +- current alias handling +- current unknown input behaviour + +### `mapSpecialistProcessStageType(...)` + +- specialist-process stage override mapping +- preserves current placeholder-like CRM specialist process values +- preserves current override target keys and fallback behaviour + +### `getStageCaseTypeKey(...)` + +- current stage case-type resolution decision +- combines: + - appeal type mapping + - specialist-process stage override mapping + - alias normalization + - current fallback behaviour + +### `getStagesForCaseTypeKey(...)` + +- current stage catalogue lookup only +- preserves: + - direct key lookup + - string indirection resolution + - fallback to `[]` +- does not own stage catalogue contents + +## Stage catalogue ownership and lookup + +Stage catalogue arrays still live in: + +- `components/case/summary/utils/caseStagesByAppealType.js` + +The domain boundary currently owns **lookup behaviour only**: + +- direct key lookup +- string indirection resolution +- fallback to `[]` + +`getStagesForCaseTypeKey(...)` must not be treated as stage catalogue ownership yet. + +Lazy access in `getStagesForCaseTypeKey(...)` is intentional: + +- it avoids circular dependency issues while stage arrays remain in the legacy helper +- the domain helper must not eagerly import catalogue contents while ownership remains outside the domain boundary + +Any future catalogue migration must be a separate, explicitly scoped slice. + +Until catalogue ownership moves: + +- stage array contents remain the source of truth in the legacy helper +- the domain lookup helper must preserve reference identity +- the domain lookup helper must preserve current fallback behaviour + +## Explicit non-goals + +This boundary does **not** currently own: + +- stage catalogue arrays +- progress/status calculation +- closed-case handling +- representation eligibility +- dashboard layout or calculations +- translations / EN-CY labels +- CRM/OData query construction +- API routes +- Redux state shape +- upload, submit, finalisation, or notification logic + +## Behaviour-preservation invariants + +Future slices must preserve: + +- current stage IDs +- translation keys +- alias behaviour +- unknown input behaviour +- current fallback semantics +- current specialist-process override semantics +- status-vs-stage ambiguity +- current closed-case behaviour + +## Known oddities to preserve + +1. `statuscode` is currently passed into stage/progress logic. + - Do not rename or reinterpret this without a separate behaviour-change decision. + +2. Specialist-process overrides are intentionally narrow. + - They currently apply only through the existing appeal-type-based resolution path. + - Do not broaden this behaviour during refactor. + +3. Some specialist process values appear placeholder-like. + - Do not “correct” them without CRM/product confirmation. + +4. Some ROW inputs currently produce non-intuitive stage output. + - Preserve characterized behaviour unless explicitly changing business rules. + +5. The test harnesses are VM-based and import-sensitive. + - Future extractions must update harness injections carefully and avoid broad rewrites. + +## Testing expectations + +Future slices should run: + +```bash +node tests/phase22/case-lifecycle-stage-case-type-resolution.test.cjs +node tests/phase22/case-lifecycle-specialist-process-stage-mapping.test.cjs +node tests/phase22/case-lifecycle-appeal-type-mapping.test.cjs +node tests/phase22/case-lifecycle-specialist-process-normalization.test.cjs +node tests/phase22/case-lifecycle-stage-wrapper.test.cjs +node tests/phase22/representation-build-reps-arr-rules.test.cjs +npm run lint +``` + +## Future extraction order + +Recommended future order: + +1. stage catalogue lookup helper +2. stage catalogue ownership only after enough characterization coverage +3. progress/status calculation extraction +4. additional read-only consumers +5. representation eligibility only as a separate policy slice after characterization diff --git a/lib/domain/case-lifecycle/getLifecycleStageIndex.js b/lib/domain/case-lifecycle/getLifecycleStageIndex.js new file mode 100644 index 00000000..9aacfba7 --- /dev/null +++ b/lib/domain/case-lifecycle/getLifecycleStageIndex.js @@ -0,0 +1,17 @@ +import { isClosedCaseStatus } from "./isClosedCaseStatus"; + +export function getLifecycleStageIndex(stages, currentStageId) { + const currentStageNumber = Number(currentStageId); + + const exactIndex = stages.findIndex( + (stage) => Number(stage.stageId) === currentStageNumber + ); + + if (exactIndex !== -1) return exactIndex; + + if (isClosedCaseStatus(currentStageNumber)) { + return stages.findIndex((stage) => stage.titleKey === "case-closed"); + } + + return -1; +} diff --git a/lib/domain/case-lifecycle/getLifecycleStageStatus.js b/lib/domain/case-lifecycle/getLifecycleStageStatus.js new file mode 100644 index 00000000..92a59d3e --- /dev/null +++ b/lib/domain/case-lifecycle/getLifecycleStageStatus.js @@ -0,0 +1,9 @@ +export function getLifecycleStageStatus(index, currentIndex) { + return currentIndex === -1 + ? "not-started" + : index < currentIndex + ? "complete" + : index === currentIndex + ? "in-progress" + : "not-started"; +} diff --git a/lib/domain/case-lifecycle/getLifecycleStagesForCase.js b/lib/domain/case-lifecycle/getLifecycleStagesForCase.js new file mode 100644 index 00000000..0e506627 --- /dev/null +++ b/lib/domain/case-lifecycle/getLifecycleStagesForCase.js @@ -0,0 +1,9 @@ +import { getStagesForAppealType } from "../../../components/case/summary/utils/caseStagesByAppealType"; + +export function getLifecycleStagesForCase( + caseType, + currentStageId, + specialistProcess +) { + return getStagesForAppealType(caseType, currentStageId, specialistProcess); +} diff --git a/lib/domain/case-lifecycle/getStageCaseTypeKey.js b/lib/domain/case-lifecycle/getStageCaseTypeKey.js new file mode 100644 index 00000000..966846a6 --- /dev/null +++ b/lib/domain/case-lifecycle/getStageCaseTypeKey.js @@ -0,0 +1,26 @@ +import { + caseTypeAliases, + caseTypeKeyByAppealTypeId, + mapAppealType, + normaliseCaseType +} from "./mapAppealType"; +import { mapSpecialistProcessStageType } from "./mapSpecialistProcessStageType"; + +export function getStageCaseTypeKey(caseTypeOrAppealTypeId, specialistProcess) { + const appealTypeId = Number(caseTypeOrAppealTypeId); + const mappedCaseType = mapAppealType(caseTypeOrAppealTypeId); + + const specialistProcessKey = + caseTypeKeyByAppealTypeId[appealTypeId] === mappedCaseType + ? mapSpecialistProcessStageType(mappedCaseType, specialistProcess) + : undefined; + + const normalised = normaliseCaseType(caseTypeOrAppealTypeId); + + return ( + specialistProcessKey || + mappedCaseType || + caseTypeAliases[normalised] || + normalised + ); +} diff --git a/lib/domain/case-lifecycle/getStagesForCaseTypeKey.js b/lib/domain/case-lifecycle/getStagesForCaseTypeKey.js new file mode 100644 index 00000000..75df9922 --- /dev/null +++ b/lib/domain/case-lifecycle/getStagesForCaseTypeKey.js @@ -0,0 +1,16 @@ +const getStagesByCaseType = () => + require("../../../components/case/summary/utils/caseStagesByAppealType") + .stagesByCaseType; + +export function getStagesForCaseTypeKey(stageCaseTypeKey) { + const stagesByCaseType = getStagesByCaseType(); + const entry = stagesByCaseType[stageCaseTypeKey]; + + if (!entry) return []; + + if (typeof entry === "string") { + return stagesByCaseType[entry] || []; + } + + return entry; +} diff --git a/lib/domain/case-lifecycle/index.js b/lib/domain/case-lifecycle/index.js new file mode 100644 index 00000000..0214df55 --- /dev/null +++ b/lib/domain/case-lifecycle/index.js @@ -0,0 +1,9 @@ +export { getLifecycleStagesForCase } from "./getLifecycleStagesForCase"; +export { isClosedCaseStatus } from "./isClosedCaseStatus"; +export { getLifecycleStageIndex } from "./getLifecycleStageIndex"; +export { getLifecycleStageStatus } from "./getLifecycleStageStatus"; +export { getStageCaseTypeKey } from "./getStageCaseTypeKey"; +export { getStagesForCaseTypeKey } from "./getStagesForCaseTypeKey"; +export { mapAppealType } from "./mapAppealType"; +export { mapSpecialistProcessStageType } from "./mapSpecialistProcessStageType"; +export { normalizeSpecialistProcess } from "./normalizeSpecialistProcess"; diff --git a/lib/domain/case-lifecycle/isClosedCaseStatus.js b/lib/domain/case-lifecycle/isClosedCaseStatus.js new file mode 100644 index 00000000..76933335 --- /dev/null +++ b/lib/domain/case-lifecycle/isClosedCaseStatus.js @@ -0,0 +1,5 @@ +const closedCaseStatusIds = [1000, 5, 6, 846040013, 846040060, 846040059]; + +export function isClosedCaseStatus(statusOrStageId) { + return closedCaseStatusIds.includes(Number(statusOrStageId)); +} diff --git a/lib/domain/case-lifecycle/mapAppealType.js b/lib/domain/case-lifecycle/mapAppealType.js new file mode 100644 index 00000000..f6927e07 --- /dev/null +++ b/lib/domain/case-lifecycle/mapAppealType.js @@ -0,0 +1,81 @@ +export const caseTypeAliases = { + // Planning S78 group + S78: "PLANNING_S78", + PLANNING_78: "PLANNING_S78", + PLANNING_S78: "PLANNING_S78", + CONDITIONS_73_79: "CONDITIONS_73_79", + LBCAC: "LBCAC", + LDCS: "LDCS", + PLANNING_OBLIGATIONS_S106: "PLANNING_OBLIGATIONS_S106", + PRIOR_NOTIFICATION: "PRIOR_NOTIFICATION", + + // Other types + HAS: "HAS", + CALL_INS: "CALL_INS", + CALL_IN: "CALL_INS", + ENFORCEMENT: "ENFORCEMENT", + ENFORCEMENT_LISTED_BUILDING: "ENFORCEMENT_LISTED_BUILDING", + MAINTENANCE_OF_LAND: "MAINTENANCE_OF_LAND", + RIGHTS_OF_WAY_SCHEDULE_14: "RIGHTS_OF_WAY_SCHEDULE_14", + RIGHTS_OF_WAY_ORDERS: "RIGHTS_OF_WAY_ORDERS", + REQUESTS_FOR_DIRECTION: "REQUESTS_FOR_DIRECTION", + ADVERTS: "ADVERTS", + HEDGEROW_TPO: "HEDGEROW_TPO", + COMMON_LAND: "COMMON_LAND", + COMPULSORY_PURCHASE_ORDERS: "COMPULSORY_PURCHASE_ORDERS", + ELECTRICITY_ACT: "ELECTRICITY_ACT", + HARBOUR_REVISION_ORDER: "HARBOUR_REVISION_ORDER", + TRANSPORT_WORKS: "TRANSPORT_WORKS", + WAYLEAVE: "WAYLEAVE", + NON_VALIDATION: "NON_VALIDATION", + DNS: "DNS", + SIP: "SIP" +}; + +export const caseTypeKeyByAppealTypeId = { + 846040000: "PLANNING_S78", + 846040001: "CONDITIONS_73_79", + 846040002: "SIP", + 846040003: "PLANNING_OBLIGATIONS_S106", + 846040004: "HAS", + 846040005: "ENFORCEMENT", + 846040006: "ENFORCEMENT_LISTED_BUILDING", + 846040007: "MAINTENANCE_OF_LAND", + 846040008: "LDCS", + 846040009: "PLANNING_S78", + 846040010: "CALL_INS", + 846040011: "DNS", + 846040012: "ELECTRICITY_ACT", + 846040013: "TRANSPORT_WORKS", + 846040014: "HARBOUR_REVISION_ORDER", + 846040015: "RIGHTS_OF_WAY_SCHEDULE_14", + 846040016: "COMMON_LAND", + 846040017: "HEDGEROW_TPO", + 846040018: "ADVERTS", + 846040019: "COMPULSORY_PURCHASE_ORDERS", + 846040020: "PLANNING_S78", + 846040021: "WAYLEAVE", + 846040024: "NON_VALIDATION", + 846040025: "LBCAC" +}; + +export const normaliseCaseType = (caseType) => + caseType + ?.toString() + .trim() + .toUpperCase() + .replace(/&/g, "AND") + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_|_$/g, ""); + +export function mapAppealType(appealTypeId) { + const mappedCaseType = caseTypeKeyByAppealTypeId[Number(appealTypeId)]; + + if (mappedCaseType) { + return mappedCaseType; + } + + const normalised = normaliseCaseType(appealTypeId); + + return caseTypeAliases[normalised] || normalised; +} diff --git a/lib/domain/case-lifecycle/mapSpecialistProcessStageType.js b/lib/domain/case-lifecycle/mapSpecialistProcessStageType.js new file mode 100644 index 00000000..158314c3 --- /dev/null +++ b/lib/domain/case-lifecycle/mapSpecialistProcessStageType.js @@ -0,0 +1,19 @@ +export const specialistProcessStageTypeByCaseType = { + RIGHTS_OF_WAY_SCHEDULE_14: { + // TODO: replace these with the real CRM option-set values + 846040000: "RIGHTS_OF_WAY_ORDERS", + 846040100: "RIGHTS_OF_WAY_ORDERS", + 846040001: "RIGHTS_OF_WAY_ORDERS", + 846040101: "RIGHTS_OF_WAY_ORDERS", + 846040002: "RIGHTS_OF_WAY_SCHEDULE_14", + 846040102: "RIGHTS_OF_WAY_SCHEDULE_14", + 846040003: "REQUESTS_FOR_DIRECTION", + 846040103: "REQUESTS_FOR_DIRECTION" + } +}; + +export function mapSpecialistProcessStageType(caseTypeKey, specialistProcess) { + return specialistProcessStageTypeByCaseType[caseTypeKey]?.[ + specialistProcess + ]; +} diff --git a/lib/domain/case-lifecycle/normalizeSpecialistProcess.js b/lib/domain/case-lifecycle/normalizeSpecialistProcess.js new file mode 100644 index 00000000..9d5efe8b --- /dev/null +++ b/lib/domain/case-lifecycle/normalizeSpecialistProcess.js @@ -0,0 +1,7 @@ +export function normalizeSpecialistProcess(source) { + return ( + source?.pinswg_specialistcaseprocess || + source?.pinswg_speacialistcaseprocess || + "" + ); +} diff --git a/lib/representation/pageLoaders.js b/lib/representation/pageLoaders.js index f436807c..60b5b4f4 100644 --- a/lib/representation/pageLoaders.js +++ b/lib/representation/pageLoaders.js @@ -10,6 +10,7 @@ import { import { getRepsFromBlob } from "../../actions/services/documentService"; import { getBasicSearch } from "../../actions/services/searchService"; import { consoleLogger } from "../../actions/core/logger"; +import { normalizeSpecialistProcess } from "../domain/case-lifecycle"; import { getFormCollectionByID, getSearchDetails @@ -280,16 +281,9 @@ export const loadNewRepresentation = async ({ store, ctx, bootstrap }) => { "currentType": "myRepresentations", "incidentid": searchResultsObj?.value?.[0]?.incidentid, "appealType": searchResultsObj?.value?.[0]?.pinswg_appealcasetype, - "specialistProcess": + "specialistProcess": normalizeSpecialistProcess( searchDetailsObj?.[0]?.value?.[0] - ?.pinswg_speacialistcaseprocess != null || - searchDetailsObj?.[0]?.value?.[0] - ?.pinswg_specialistcaseprocess != null - ? searchDetailsObj?.[0]?.value?.[0] - .pinswg_speacialistcaseprocess || - searchDetailsObj?.[0]?.value?.[0] - .pinswg_specialistcaseprocess - : "" + ) }) ); diff --git a/pages/case/[ticketnumber].js b/pages/case/[ticketnumber].js index 3f764fcf..c1b3bdcb 100644 --- a/pages/case/[ticketnumber].js +++ b/pages/case/[ticketnumber].js @@ -16,6 +16,7 @@ import { import { getBasicSearch } from "../../actions/services/searchService"; import Breadcrumbs from "../../components/breadcrumbs"; import Case from "../../components/case"; +import { normalizeSpecialistProcess } from "../../lib/domain/case-lifecycle"; import CookieBanner from "../../components/cookieBanner"; import Footer from "../../components/footer"; import Header from "../../components/header"; @@ -221,16 +222,9 @@ export const getServerSideProps = wrapper.getServerSideProps( "appealType": searchResultsObj.value[0].pinswg_appealcasetype, "showLoginCheck": true, - "specialistProcess": + "specialistProcess": normalizeSpecialistProcess( searchDetailsObj[0].value[0] - ?.pinswg_speacialistcaseprocess != null || - searchDetailsObj[0].value[0] - ?.pinswg_specialistcaseprocess != null - ? searchDetailsObj[0].value[0] - ?.pinswg_speacialistcaseprocess || - searchDetailsObj[0].value[0] - ?.pinswg_specialistcaseprocess - : "" + ) }) ); } diff --git a/pages/myportal/case/[ticketnumber].js b/pages/myportal/case/[ticketnumber].js index 03262cc2..a05a9223 100644 --- a/pages/myportal/case/[ticketnumber].js +++ b/pages/myportal/case/[ticketnumber].js @@ -368,16 +368,9 @@ export const getServerSideProps = wrapper.getServerSideProps( "appellant": searchResultsObj?.value[0].customerid_contact ?.emailaddress1, - "specialistProcess": + "specialistProcess": normalizeSpecialistProcess( searchDetailsObj[0].value[0] - .pinswg_speacialistcaseprocess != null || - searchDetailsObj[0].value[0] - .pinswg_specialistcaseprocess != null - ? searchDetailsObj[0].value[0] - .pinswg_speacialistcaseprocess || - searchDetailsObj[0].value[0] - .pinswg_specialistcaseprocess - : "" + ) }) ); diff --git a/tests/phase22/case-lifecycle-appeal-type-mapping.test.cjs b/tests/phase22/case-lifecycle-appeal-type-mapping.test.cjs new file mode 100644 index 00000000..193c7be4 --- /dev/null +++ b/tests/phase22/case-lifecycle-appeal-type-mapping.test.cjs @@ -0,0 +1,296 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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 += ` +module.exports = { + getStageCaseTypeKey +}; +`; + + 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 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 };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey, + getStagesForCaseTypeKey: + getStagesForCaseTypeKeyModule.getStagesForCaseTypeKey + }; + + 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: () => [] + }; + + 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 tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); + +const { mapAppealType, caseTypeKeyByAppealTypeId, caseTypeAliases } = + mapAppealTypeModule; +const { getStagesForAppealType } = stageHelperModule; + +test("known mappings are preserved for representative appeal types", () => { + assert.strictEqual(mapAppealType(846040000), "PLANNING_S78"); + assert.strictEqual(mapAppealType(846040011), "DNS"); + assert.strictEqual(mapAppealType(846040004), "HAS"); + assert.strictEqual(mapAppealType(846040010), "CALL_INS"); + assert.strictEqual(mapAppealType(846040015), "RIGHTS_OF_WAY_SCHEDULE_14"); +}); + +test("aliases are preserved exactly", () => { + assert.strictEqual(mapAppealType("S78"), "PLANNING_S78"); + assert.strictEqual(mapAppealType("PLANNING_78"), "PLANNING_S78"); + assert.strictEqual(mapAppealType("CALL_IN"), "CALL_INS"); + assert.strictEqual(mapAppealType("DNS"), "DNS"); +}); + +test("unknown appeal types preserve current behaviour", () => { + assert.strictEqual(mapAppealType(999999999), "999999999"); + assert.strictEqual(mapAppealType("UNKNOWN_CASE_TYPE"), "UNKNOWN_CASE_TYPE"); +}); + +test("null and undefined preserve current behaviour", () => { + assert.strictEqual(mapAppealType(null), undefined); + assert.strictEqual(mapAppealType(undefined), undefined); +}); + +test("mapping helper remains consistent with extracted mapping tables", () => { + for (const [appealTypeId, caseTypeKey] of Object.entries( + caseTypeKeyByAppealTypeId + )) { + assert.strictEqual(mapAppealType(Number(appealTypeId)), caseTypeKey); + } + + for (const [alias, caseTypeKey] of Object.entries(caseTypeAliases)) { + assert.strictEqual(mapAppealType(alias), caseTypeKey); + } +}); + +test("mapping helper returns keys that resolve to the same stage lists currently used by the source-of-truth helper", () => { + const representativeIds = [846040000, 846040011, 846040004, 846040010]; + + for (const appealTypeId of representativeIds) { + const mappedKey = mapAppealType(appealTypeId); + assert.deepStrictEqual( + normalizeForAssertion( + getStagesForAppealType(appealTypeId, 999999999) + ), + normalizeForAssertion(getStagesForAppealType(mappedKey, 999999999)) + ); + } +}); + +test("specialist-process-sensitive appeal type preserves extracted mapping without asserting stage-list equivalence outside specialist context", () => { + assert.strictEqual(mapAppealType(846040015), "RIGHTS_OF_WAY_SCHEDULE_14"); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 case-lifecycle-appeal-type-mapping tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-closed-case-status.test.cjs b/tests/phase22/case-lifecycle-closed-case-status.test.cjs new file mode 100644 index 00000000..8e160953 --- /dev/null +++ b/tests/phase22/case-lifecycle-closed-case-status.test.cjs @@ -0,0 +1,370 @@ +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: isClosedCaseStatusModule.isClosedCaseStatus + }; + + 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 += ` +module.exports = { + getStageCaseTypeKey +}; +`; + + 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: isClosedCaseStatusModule.isClosedCaseStatus, + getLifecycleStageIndex: + getLifecycleStageIndexModule.getLifecycleStageIndex + }; + + 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 += ` +module.exports = { + getStagesForCaseTypeKey +}; +`; + + 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 };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey, + getStagesForCaseTypeKey: + getStagesForCaseTypeKeyModule.getStagesForCaseTypeKey, + isClosedCaseStatus: isClosedCaseStatusModule.isClosedCaseStatus, + getLifecycleStageIndex: + getLifecycleStageIndexModule.getLifecycleStageIndex + }; + + 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 isClosedCaseStatusModule = loadClosedCaseStatusModule(); +const getLifecycleStageIndexModule = loadGetLifecycleStageIndexModule(); +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); + +const { isClosedCaseStatus } = isClosedCaseStatusModule; +const { getStagesForAppealType } = stageHelperModule; + +test("current closed statuses are recognized exactly", () => { + const closedStatuses = [1000, 5, 6, 846040013, 846040059, 846040060]; + + for (const closedStatus of closedStatuses) { + assert.strictEqual(isClosedCaseStatus(closedStatus), true); + } +}); + +test("representative non-closed statuses remain false", () => { + const nonClosedStatuses = [ + 1, 846040014, 846040018, 846040021, 846040049, 846040062 + ]; + + for (const nonClosedStatus of nonClosedStatuses) { + assert.strictEqual(isClosedCaseStatus(nonClosedStatus), false); + } +}); + +test("unknown values preserve current false fallback", () => { + assert.strictEqual(isClosedCaseStatus(999999999), false); + assert.strictEqual(isClosedCaseStatus("UNKNOWN"), false); +}); + +test("null and undefined preserve current false fallback", () => { + assert.strictEqual(isClosedCaseStatus(null), false); + assert.strictEqual(isClosedCaseStatus(undefined), false); +}); + +test("string and number handling preserves current Number-based closed-status behaviour", () => { + assert.strictEqual(isClosedCaseStatus("1000"), true); + assert.strictEqual(isClosedCaseStatus("5"), true); + assert.strictEqual(isClosedCaseStatus("6"), true); + assert.strictEqual(isClosedCaseStatus("846040013"), true); + assert.strictEqual(isClosedCaseStatus("846040059"), true); + assert.strictEqual(isClosedCaseStatus("846040060"), true); + assert.strictEqual(isClosedCaseStatus("846040018"), false); +}); + +test("integration parity preserves existing closed-case progress outputs", () => { + const closedStatuses = [1000, 5, 6, 846040013, 846040059, 846040060]; + + for (const closedStatus of closedStatuses) { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, closedStatus) + ); + const closedStage = result.find( + (stage) => stage.titleKey === "case-closed" + ); + + assert.ok(closedStage, `Expected closed stage for ${closedStatus}`); + assert.strictEqual(closedStage.status, "in-progress"); + assert.ok( + result + .slice( + 0, + result.findIndex( + (stage) => stage.titleKey === "case-closed" + ) + ) + .every((stage) => stage.status === "complete") + ); + } +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 case-lifecycle-closed-case-status tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-progress-behaviour.test.cjs b/tests/phase22/case-lifecycle-progress-behaviour.test.cjs new file mode 100644 index 00000000..4d5a10c2 --- /dev/null +++ b/tests/phase22/case-lifecycle-progress-behaviour.test.cjs @@ -0,0 +1,623 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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 += ` +module.exports = { + getStageCaseTypeKey +}; +`; + + 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 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 += ` +module.exports = { + isClosedCaseStatus +}; +`; + + 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 += ` +module.exports = { + getLifecycleStageIndex +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + isClosedCaseStatus: isClosedCaseStatusModule.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 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: isClosedCaseStatusModule.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 += ` +module.exports = { + getStagesForCaseTypeKey +}; +`; + + 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: isClosedCaseStatusModule.isClosedCaseStatus, + getLifecycleStageIndex: + getLifecycleStageIndexModule.getLifecycleStageIndex, + getLifecycleStageStatus: + getLifecycleStageStatusModule.getLifecycleStageStatus + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadWrapperModule = (stageHelperModule) => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "getLifecycleStagesForCase.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*getStagesForAppealType\s*\}\s+from\s+"[^"]+";\n?/, + "" + ); + source = source.replace( + /export function\s+getLifecycleStagesForCase/, + "function getLifecycleStagesForCase" + ); + source += "\nmodule.exports = { getLifecycleStagesForCase };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStagesForAppealType: stageHelperModule.getStagesForAppealType + }; + + 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 getStatusSummary = (stages) => + stages.map(({ stageId, titleKey, status }) => ({ + stageId, + titleKey, + status + })); + +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const isClosedCaseStatusModule = loadClosedCaseStatusModule(); +const getLifecycleStageIndexModule = loadGetLifecycleStageIndexModule(); +const getLifecycleStageStatusModule = loadGetLifecycleStageStatusModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); +const wrapperModule = loadWrapperModule(stageHelperModule); + +const { getStagesForAppealType } = stageHelperModule; +const { getLifecycleStagesForCase } = wrapperModule; + +test("S78 progression preserves complete/current/future status assignment", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, 846040018) + ); + + assert.deepStrictEqual(getStatusSummary(result).slice(0, 7), [ + { + stageId: 846040014, + titleKey: "appeal-submitted", + status: "complete" + }, + { + stageId: 846040015, + titleKey: "appeal-registered", + status: "complete" + }, + { + stageId: 846040016, + titleKey: "appeal-validated", + status: "complete" + }, + { stageId: 1, titleKey: "case-in-progress", status: "complete" }, + { + stageId: 846040017, + titleKey: "questionnaire-lpa-to-notify", + status: "complete" + }, + { + stageId: 846040018, + titleKey: "representations-period", + status: "in-progress" + }, + { + stageId: 846040019, + titleKey: "final-comments", + status: "not-started" + } + ]); +}); + +test("DNS progression preserves current stage and surrounding status split", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040011, 846040006) + ); + + assert.deepStrictEqual(getStatusSummary(result).slice(3, 8), [ + { + stageId: 846040004, + titleKey: "acceptance-checking", + status: "complete" + }, + { + stageId: 846040005, + titleKey: "invalid-submission", + status: "complete" + }, + { stageId: 846040006, titleKey: "consultation", status: "in-progress" }, + { + stageId: 846040007, + titleKey: "re-consultation", + status: "not-started" + }, + { stageId: 846040008, titleKey: "reporting", status: "not-started" } + ]); +}); + +test("Householder progression preserves shortened lifecycle status behaviour", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040004, 846040021) + ); + + assert.deepStrictEqual(getStatusSummary(result), [ + { + stageId: 846040014, + titleKey: "appeal-submitted", + status: "complete" + }, + { + stageId: 846040015, + titleKey: "appeal-registered", + status: "complete" + }, + { + stageId: 846040016, + titleKey: "appeal-validated", + status: "complete" + }, + { stageId: 1, titleKey: "case-in-progress", status: "complete" }, + { stageId: 846040021, titleKey: "site-visit", status: "in-progress" }, + { + stageId: 846040022, + titleKey: "decision-issued", + status: "not-started" + }, + { stageId: 846040013, titleKey: "case-closed", status: "not-started" } + ]); +}); + +test("Call-In progression preserves current event-stage behaviour", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040010, 846040049) + ); + + const currentStage = result.find((stage) => stage.stageId === 846040049); + const completedCount = result.filter( + (stage) => stage.status === "complete" + ).length; + + assert.ok(currentStage); + assert.strictEqual(currentStage.titleKey, "event"); + assert.strictEqual(currentStage.status, "in-progress"); + assert.strictEqual(completedCount, 7); + assert.ok( + result + .slice(result.indexOf(currentStage) + 1) + .every((stage) => stage.status === "not-started") + ); +}); + +test("ROW progression preserves current all-not-started behaviour for representative input", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040015, 846040032, 846040000) + ); + + assert.ok(result.every((stage) => stage.status === "not-started")); + assert.deepStrictEqual( + result.map((stage) => stage.stageId), + [ + 846040033, 846040034, 846040035, 846040036, 846040062, 846040055, + 846040037, 846040022, 846040013 + ] + ); +}); + +test("closed-case fallback preserves current case-closed mapping for all recognised closed statuses", () => { + const closedStatuses = [1000, 5, 6, 846040013, 846040059, 846040060]; + + for (const closedStatus of closedStatuses) { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, closedStatus) + ); + const closedStage = result.find( + (stage) => stage.titleKey === "case-closed" + ); + + assert.ok( + closedStage, + `Expected case-closed stage for ${closedStatus}` + ); + assert.strictEqual(closedStage.status, "in-progress"); + assert.ok( + result + .slice( + 0, + result.findIndex( + (stage) => stage.titleKey === "case-closed" + ) + ) + .every((stage) => stage.status === "complete") + ); + } +}); + +test("unknown status handling preserves all-not-started fallback", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, 999999999) + ); + + assert.ok(result.every((stage) => stage.status === "not-started")); +}); + +test("status assignment preserves complete, in-progress and not-started semantics from index position", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, 846040020) + ); + + const statusSequence = result.map((stage) => stage.status); + + assert.deepStrictEqual(statusSequence, [ + "complete", + "complete", + "complete", + "complete", + "complete", + "complete", + "complete", + "in-progress", + "not-started", + "not-started", + "not-started" + ]); +}); + +test("statuscode ambiguity is preserved when statuscode-like values are passed into stage logic", () => { + const exactStageMatch = normalizeForAssertion( + getStagesForAppealType(846040000, 846040013) + ); + const closedStatusFallback = normalizeForAssertion( + getStagesForAppealType(846040000, 846040059) + ); + + assert.deepStrictEqual( + getStatusSummary(exactStageMatch), + getStatusSummary(closedStatusFallback) + ); +}); + +test("wrapper parity preserves current runtime outputs for representative progress scenarios", () => { + const scenarios = [ + [846040000, 846040018, undefined], + [846040011, 846040006, undefined], + [846040004, 846040021, undefined], + [846040010, 846040049, undefined], + [846040015, 846040032, 846040000], + [846040000, 846040059, undefined], + [846040000, 999999999, undefined] + ]; + + for (const [caseType, currentStageId, specialistProcess] of scenarios) { + assert.deepStrictEqual( + normalizeForAssertion( + getLifecycleStagesForCase( + caseType, + currentStageId, + specialistProcess + ) + ), + normalizeForAssertion( + getStagesForAppealType( + 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-progress-behaviour tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-specialist-process-normalization.test.cjs b/tests/phase22/case-lifecycle-specialist-process-normalization.test.cjs new file mode 100644 index 00000000..46bd8729 --- /dev/null +++ b/tests/phase22/case-lifecycle-specialist-process-normalization.test.cjs @@ -0,0 +1,112 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadNormalizationModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "normalizeSpecialistProcess.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+normalizeSpecialistProcess/, + "function normalizeSpecialistProcess" + ); + source += "\nmodule.exports = { normalizeSpecialistProcess };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const { normalizeSpecialistProcess } = loadNormalizationModule(); + +test("returns pinswg_specialistcaseprocess when present", () => { + assert.strictEqual( + normalizeSpecialistProcess({ pinswg_specialistcaseprocess: 846040001 }), + 846040001 + ); +}); + +test("returns misspelled fallback when canonical field is absent", () => { + assert.strictEqual( + normalizeSpecialistProcess({ + pinswg_speacialistcaseprocess: 846040000 + }), + 846040000 + ); +}); + +test("canonical field wins when both fields exist", () => { + assert.strictEqual( + normalizeSpecialistProcess({ + pinswg_specialistcaseprocess: 846040001, + pinswg_speacialistcaseprocess: 846040000 + }), + 846040001 + ); +}); + +test("returns empty string when neither field exists", () => { + assert.strictEqual(normalizeSpecialistProcess({}), ""); +}); + +test("returns empty string for null or undefined source", () => { + assert.strictEqual(normalizeSpecialistProcess(null), ""); + assert.strictEqual(normalizeSpecialistProcess(undefined), ""); +}); + +test("preserves number values without coercing to strings", () => { + const result = normalizeSpecialistProcess({ + pinswg_specialistcaseprocess: 846040001 + }); + + assert.strictEqual(typeof result, "number"); + assert.strictEqual(result, 846040001); +}); + +test("preserves string values without coercing to numbers", () => { + const result = normalizeSpecialistProcess({ + pinswg_specialistcaseprocess: "846040001" + }); + + assert.strictEqual(typeof result, "string"); + assert.strictEqual(result, "846040001"); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 case-lifecycle-specialist-process-normalization tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-specialist-process-stage-mapping.test.cjs b/tests/phase22/case-lifecycle-specialist-process-stage-mapping.test.cjs new file mode 100644 index 00000000..7800845f --- /dev/null +++ b/tests/phase22/case-lifecycle-specialist-process-stage-mapping.test.cjs @@ -0,0 +1,369 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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 += ` +module.exports = { + getStageCaseTypeKey +}; +`; + + 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 loadStageHelperModule = ( + mapAppealTypeModule, + mapSpecialistProcessStageTypeModule +) => { + 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 };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey, + getStagesForCaseTypeKey: + getStagesForCaseTypeKeyModule.getStagesForCaseTypeKey + }; + + 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: () => [] + }; + + 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 tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule( + mapAppealTypeModule, + mapSpecialistProcessStageTypeModule +); + +const { mapAppealType } = mapAppealTypeModule; +const { specialistProcessStageTypeByCaseType, mapSpecialistProcessStageType } = + mapSpecialistProcessStageTypeModule; +const { getStagesForAppealType } = stageHelperModule; + +test("known specialist-process override mappings are preserved exactly", () => { + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040000), + "RIGHTS_OF_WAY_ORDERS" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040001), + "RIGHTS_OF_WAY_ORDERS" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040002), + "RIGHTS_OF_WAY_SCHEDULE_14" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040003), + "REQUESTS_FOR_DIRECTION" + ); +}); + +test("placeholder duplicate specialist-process IDs preserve the same override behaviour", () => { + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040100), + "RIGHTS_OF_WAY_ORDERS" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040101), + "RIGHTS_OF_WAY_ORDERS" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040102), + "RIGHTS_OF_WAY_SCHEDULE_14" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040103), + "REQUESTS_FOR_DIRECTION" + ); +}); + +test("case types without an override table preserve current undefined fallback", () => { + assert.strictEqual( + mapSpecialistProcessStageType("PLANNING_S78", 846040000), + undefined + ); +}); + +test("unknown specialist process preserves current undefined fallback", () => { + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 999999999), + undefined + ); +}); + +test("null and undefined specialist process preserve current undefined fallback", () => { + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", null), + undefined + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", undefined), + undefined + ); +}); + +test("numeric and string specialist process values preserve current lookup behaviour", () => { + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", 846040000), + "RIGHTS_OF_WAY_ORDERS" + ); + assert.strictEqual( + mapSpecialistProcessStageType("RIGHTS_OF_WAY_SCHEDULE_14", "846040000"), + "RIGHTS_OF_WAY_ORDERS" + ); +}); + +test("extracted helper remains consistent with the exported override table", () => { + for (const [caseTypeKey, specialistProcessMap] of Object.entries( + specialistProcessStageTypeByCaseType + )) { + for (const [specialistProcess, stageTypeKey] of Object.entries( + specialistProcessMap + )) { + assert.strictEqual( + mapSpecialistProcessStageType(caseTypeKey, specialistProcess), + stageTypeKey + ); + } + } +}); + +test("stage helper output remains unchanged for representative specialist override scenarios", () => { + const specialistAppealType = 846040015; + + const rightsOfWayOrders = normalizeForAssertion( + getStagesForAppealType(specialistAppealType, 846040032, 846040000) + ); + const requestsForDirection = normalizeForAssertion( + getStagesForAppealType(specialistAppealType, 846040032, 846040003) + ); + + assert.deepStrictEqual( + rightsOfWayOrders.map((stage) => stage.stageId), + [ + 846040033, 846040034, 846040035, 846040036, 846040062, 846040055, + 846040037, 846040022, 846040013 + ] + ); + assert.deepStrictEqual( + requestsForDirection.map((stage) => stage.stageId), + [ + 846040042, 846040043, 846040054, 846040010, 846040063, 846040022, + 846040013 + ] + ); + assert.ok( + rightsOfWayOrders.every((stage) => stage.status === "not-started") + ); + assert.ok( + requestsForDirection.every((stage) => stage.status === "not-started") + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 case-lifecycle-specialist-process-stage-mapping tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-stage-case-type-resolution.test.cjs b/tests/phase22/case-lifecycle-stage-case-type-resolution.test.cjs new file mode 100644 index 00000000..f540f8d7 --- /dev/null +++ b/tests/phase22/case-lifecycle-stage-case-type-resolution.test.cjs @@ -0,0 +1,337 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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 += ` +module.exports = { + getStageCaseTypeKey +}; +`; + + 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 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 };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey, + getStagesForCaseTypeKey: + getStagesForCaseTypeKeyModule.getStagesForCaseTypeKey + }; + + 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: () => [] + }; + + 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 tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const normalizeForAssertion = (value) => + JSON.parse(JSON.stringify(value ?? null)); + +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); + +const { getStageCaseTypeKey } = getStageCaseTypeKeyModule; +const { getStagesForAppealType } = stageHelperModule; + +test("appeal type resolution is preserved for representative cases", () => { + assert.strictEqual(getStageCaseTypeKey(846040000), "PLANNING_S78"); + assert.strictEqual(getStageCaseTypeKey(846040011), "DNS"); + assert.strictEqual(getStageCaseTypeKey(846040004), "HAS"); + assert.strictEqual(getStageCaseTypeKey(846040010), "CALL_INS"); + assert.strictEqual( + getStageCaseTypeKey(846040015), + "RIGHTS_OF_WAY_SCHEDULE_14" + ); +}); + +test("alias resolution is preserved exactly", () => { + assert.strictEqual(getStageCaseTypeKey("S78"), "PLANNING_S78"); + assert.strictEqual(getStageCaseTypeKey("PLANNING_78"), "PLANNING_S78"); + assert.strictEqual(getStageCaseTypeKey("CALL_IN"), "CALL_INS"); +}); + +test("specialist-process override resolution is preserved exactly", () => { + assert.strictEqual( + getStageCaseTypeKey(846040015, 846040000), + "RIGHTS_OF_WAY_ORDERS" + ); + assert.strictEqual( + getStageCaseTypeKey(846040015, 846040002), + "RIGHTS_OF_WAY_SCHEDULE_14" + ); + assert.strictEqual( + getStageCaseTypeKey(846040015, 846040003), + "REQUESTS_FOR_DIRECTION" + ); +}); + +test("no-override fallback preserves the original resolved case type key", () => { + assert.strictEqual( + getStageCaseTypeKey(846040000, 846040000), + "PLANNING_S78" + ); + assert.strictEqual( + getStageCaseTypeKey("PLANNING_S78", 846040000), + "PLANNING_S78" + ); +}); + +test("unknown appeal type handling is preserved", () => { + assert.strictEqual(getStageCaseTypeKey(999999999), "999999999"); + assert.strictEqual( + getStageCaseTypeKey("UNKNOWN_CASE_TYPE"), + "UNKNOWN_CASE_TYPE" + ); +}); + +test("null and undefined inputs preserve current behaviour", () => { + assert.strictEqual(getStageCaseTypeKey(null), undefined); + assert.strictEqual(getStageCaseTypeKey(undefined), undefined); +}); + +test("stage outputs remain identical for representative inputs after extraction", () => { + const scenarios = [ + [846040000, 846040014, undefined], + [846040011, 846040006, undefined], + [846040004, 846040021, undefined], + [846040010, 846040049, undefined], + [846040015, 846040032, 846040000], + [846040015, 846040032, 846040003], + ["S78", 846040018, undefined], + ["CALL_IN", 846040049, undefined] + ]; + + for (const [ + caseTypeOrAppealTypeId, + currentStageId, + specialistProcess + ] of scenarios) { + const resolvedKey = getStageCaseTypeKey( + caseTypeOrAppealTypeId, + specialistProcess + ); + + assert.deepStrictEqual( + normalizeForAssertion( + getStagesForAppealType( + caseTypeOrAppealTypeId, + currentStageId, + specialistProcess + ) + ), + normalizeForAssertion( + getStagesForAppealType( + resolvedKey, + 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-case-type-resolution tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-stage-catalogue-lookup.test.cjs b/tests/phase22/case-lifecycle-stage-catalogue-lookup.test.cjs new file mode 100644 index 00000000..234e8a76 --- /dev/null +++ b/tests/phase22/case-lifecycle-stage-catalogue-lookup.test.cjs @@ -0,0 +1,377 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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: () => [] + }; + + 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 + }; + + 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 mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); + +const { getStageCaseTypeKey } = getStageCaseTypeKeyModule; +const { getStagesForCaseTypeKey } = getStagesForCaseTypeKeyModule; +const { getStagesForAppealType, stagesByCaseType } = stageHelperModule; + +test("known catalogue lookups preserve current catalogue resolution", () => { + const representativeKeys = [ + "PLANNING_S78", + "DNS", + "HAS", + "CALL_INS", + "RIGHTS_OF_WAY_SCHEDULE_14" + ]; + + for (const key of representativeKeys) { + assert.deepStrictEqual( + normalizeForAssertion(getStagesForCaseTypeKey(key)), + normalizeForAssertion(stagesByCaseType[key]) + ); + } +}); + +test("indirection behaviour preserves current target catalogue references", () => { + assert.deepStrictEqual( + normalizeForAssertion(getStagesForCaseTypeKey("SIP")), + normalizeForAssertion(stagesByCaseType.DNS) + ); + assert.deepStrictEqual( + normalizeForAssertion(getStagesForCaseTypeKey("CONDITIONS_73_79")), + normalizeForAssertion(stagesByCaseType.PLANNING_S78) + ); + assert.deepStrictEqual( + normalizeForAssertion( + getStagesForCaseTypeKey("ENFORCEMENT_LISTED_BUILDING") + ), + normalizeForAssertion(stagesByCaseType.ENFORCEMENT) + ); +}); + +test("unknown key handling preserves empty-array fallback", () => { + const result = getStagesForCaseTypeKey("UNKNOWN_CASE_TYPE"); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 0); +}); + +test("null and undefined inputs preserve current empty-array fallback", () => { + assert.deepStrictEqual( + normalizeForAssertion(getStagesForCaseTypeKey(null)), + [] + ); + assert.deepStrictEqual( + normalizeForAssertion(getStagesForCaseTypeKey(undefined)), + [] + ); +}); + +test("reference integrity is preserved for direct and indirect catalogue lookups", () => { + const direct = getStagesForCaseTypeKey("PLANNING_S78"); + const indirect = getStagesForCaseTypeKey("CONDITIONS_73_79"); + + assert.deepStrictEqual( + normalizeForAssertion(direct), + normalizeForAssertion(stagesByCaseType.PLANNING_S78) + ); + assert.deepStrictEqual( + normalizeForAssertion(indirect), + normalizeForAssertion(stagesByCaseType.PLANNING_S78) + ); + assert.strictEqual(direct, indirect); +}); + +test("lazy lookup preserves current catalogue access without cloning", () => { + const firstLookup = getStagesForCaseTypeKey("DNS"); + const secondLookup = getStagesForCaseTypeKey("DNS"); + + assert.strictEqual(firstLookup, secondLookup); + assert.deepStrictEqual( + normalizeForAssertion(firstLookup), + normalizeForAssertion(stageCatalogueModule.stagesByCaseType.DNS) + ); +}); + +test("lazy lookup preserves alias-target reference identity for indirect catalogues", () => { + const directTarget = getStagesForCaseTypeKey("DNS"); + const indirectTarget = getStagesForCaseTypeKey("SIP"); + + assert.strictEqual(indirectTarget, directTarget); + assert.deepStrictEqual( + normalizeForAssertion(indirectTarget), + normalizeForAssertion(stageCatalogueModule.stagesByCaseType.DNS) + ); +}); + +test("integration parity preserves stage outputs for representative lifecycle inputs", () => { + const scenarios = [ + [846040000, 846040018, undefined], + [846040011, 846040006, undefined], + [846040004, 846040021, undefined], + [846040010, 846040049, undefined], + [846040015, 846040032, 846040000], + [846040015, 846040032, 846040003], + ["S78", 846040018, undefined], + ["CALL_IN", 846040049, undefined], + [999999999, 846040018, undefined], + [null, 846040018, undefined], + [undefined, 846040018, undefined] + ]; + + for (const [ + caseTypeOrAppealTypeId, + currentStageId, + specialistProcess + ] of scenarios) { + const resolvedKey = getStageCaseTypeKey( + caseTypeOrAppealTypeId, + specialistProcess + ); + const catalogue = getStagesForCaseTypeKey(resolvedKey); + const result = normalizeForAssertion( + getStagesForAppealType( + caseTypeOrAppealTypeId, + currentStageId, + specialistProcess + ) + ); + + if (!catalogue.length) { + assert.deepStrictEqual(result, []); + continue; + } + + assert.deepStrictEqual( + result.map(({ id, stageId, titleKey, descriptionKey }) => ({ + id, + stageId, + titleKey, + descriptionKey + })), + normalizeForAssertion(catalogue) + ); + } +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 case-lifecycle-stage-catalogue-lookup tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-stage-index.test.cjs b/tests/phase22/case-lifecycle-stage-index.test.cjs new file mode 100644 index 00000000..f01c7c91 --- /dev/null +++ b/tests/phase22/case-lifecycle-stage-index.test.cjs @@ -0,0 +1,471 @@ +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); + }); +} diff --git a/tests/phase22/case-lifecycle-stage-status.test.cjs b/tests/phase22/case-lifecycle-stage-status.test.cjs new file mode 100644 index 00000000..187a1b17 --- /dev/null +++ b/tests/phase22/case-lifecycle-stage-status.test.cjs @@ -0,0 +1,421 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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 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 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 getLifecycleStageStatusModule = loadGetLifecycleStageStatusModule(); +const getLifecycleStageIndexModule = loadGetLifecycleStageIndexModule(); +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); + +const { getLifecycleStageStatus } = getLifecycleStageStatusModule; +const { getLifecycleStageIndex } = getLifecycleStageIndexModule; +const { getStagesForAppealType, stagesByCaseType } = stageHelperModule; + +test("returns complete when index is less than current index", () => { + assert.strictEqual(getLifecycleStageStatus(2, 5), "complete"); +}); + +test("returns in-progress when index matches current index", () => { + assert.strictEqual(getLifecycleStageStatus(5, 5), "in-progress"); +}); + +test("returns not-started when index is greater than current index", () => { + assert.strictEqual(getLifecycleStageStatus(6, 5), "not-started"); +}); + +test("preserves current all-not-started behaviour when currentIndex is -1", () => { + assert.strictEqual(getLifecycleStageStatus(0, -1), "not-started"); + assert.strictEqual(getLifecycleStageStatus(4, -1), "not-started"); + assert.strictEqual(getLifecycleStageStatus(-3, -1), "not-started"); +}); + +test("preserves current comparison behaviour for unknown and negative indexes", () => { + assert.strictEqual(getLifecycleStageStatus(-2, 3), "complete"); + assert.strictEqual(getLifecycleStageStatus(-2, -2), "in-progress"); + assert.strictEqual(getLifecycleStageStatus(3, -2), "not-started"); +}); + +test("preserves current null and undefined coercion behaviour", () => { + assert.strictEqual(getLifecycleStageStatus(null, 2), "complete"); + assert.strictEqual(getLifecycleStageStatus(2, null), "not-started"); + assert.strictEqual(getLifecycleStageStatus(null, null), "in-progress"); + assert.strictEqual(getLifecycleStageStatus(undefined, 2), "not-started"); + assert.strictEqual(getLifecycleStageStatus(2, undefined), "not-started"); + assert.strictEqual( + getLifecycleStageStatus(undefined, undefined), + "in-progress" + ); +}); + +test("integration parity preserves lifecycle outputs after status extraction", () => { + const scenarios = [ + [846040000, 846040018, undefined], + [846040011, 846040006, undefined], + [846040004, 846040021, undefined], + [846040010, 846040049, undefined], + [846040015, 846040032, 846040000], + [846040000, 846040059, undefined], + [846040000, 999999999, undefined] + ]; + + const baselineGetStagesForAppealType = ( + caseType, + currentStageId, + specialistProcess + ) => { + const stages = normalizeForAssertion( + getStagesForAppealType(caseType, currentStageId, specialistProcess) + ).map(({ status, ...stage }) => stage); + const currentIndex = getLifecycleStageIndex(stages, currentStageId); + + return stages.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-status tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/case-lifecycle-stage-wrapper.test.cjs b/tests/phase22/case-lifecycle-stage-wrapper.test.cjs new file mode 100644 index 00000000..86ef0651 --- /dev/null +++ b/tests/phase22/case-lifecycle-stage-wrapper.test.cjs @@ -0,0 +1,459 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +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 += ` +module.exports = { + getStagesForAppealType, + stagesByCaseType +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStageCaseTypeKey: getStageCaseTypeKeyModule.getStageCaseTypeKey, + getStagesForCaseTypeKey: + getStagesForCaseTypeKeyModule.getStagesForCaseTypeKey + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadWrapperModule = (stageHelperModule) => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "getLifecycleStagesForCase.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*getStagesForAppealType\s*\}\s+from\s+"[^"]+";\n?/, + "" + ); + source = source.replace( + /export function\s+getLifecycleStagesForCase/, + "function getLifecycleStagesForCase" + ); + source += "\nmodule.exports = { getLifecycleStagesForCase };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + getStagesForAppealType: stageHelperModule.getStagesForAppealType + }; + + 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 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 += ` +module.exports = { + getStageCaseTypeKey +}; +`; + + 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 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 += ` +module.exports = { + getStagesForCaseTypeKey +}; +`; + + 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 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: () => [] + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const mapAppealTypeModule = loadMapAppealTypeModule(); +const mapSpecialistProcessStageTypeModule = + loadMapSpecialistProcessStageTypeModule(); +const getStageCaseTypeKeyModule = loadGetStageCaseTypeKeyModule(); +const stageCatalogueModule = loadStageCatalogueModule(); +const getStagesForCaseTypeKeyModule = loadGetStagesForCaseTypeKeyModule(); +const stageHelperModule = loadStageHelperModule(); +const wrapperModule = loadWrapperModule(stageHelperModule); + +const { getStagesForAppealType } = stageHelperModule; +const { getLifecycleStagesForCase } = wrapperModule; + +test("S78 early-stage progression preserves stage ordering and statuses", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, 846040014) + ); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result[0].stageId, 846040014); + assert.strictEqual(result[0].titleKey, "appeal-submitted"); + assert.strictEqual(result[0].status, "in-progress"); + assert.strictEqual(result[1].status, "not-started"); + assert.strictEqual(result[result.length - 1].titleKey, "case-closed"); +}); + +test("S78 mid-stage progression preserves completed/current/future status split", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, 846040018) + ); + + const currentStage = result.find((stage) => stage.stageId === 846040018); + + assert.ok(currentStage); + assert.strictEqual(result[0].status, "complete"); + assert.strictEqual(currentStage.status, "in-progress"); + assert.strictEqual(result[result.length - 1].status, "not-started"); +}); + +test("DNS stage mapping remains unchanged", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040011, 846040006) + ); + + assert.deepStrictEqual( + result.map((stage) => stage.stageId), + [ + 846040001, 846040002, 846040003, 846040004, 846040005, 846040006, + 846040007, 846040008, 846040009, 846040011, 846040012, 846040013 + ] + ); + assert.strictEqual( + result.find((stage) => stage.stageId === 846040006).status, + "in-progress" + ); +}); + +test("Householder stage mapping remains unchanged", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040004, 846040021) + ); + + assert.deepStrictEqual( + result.map((stage) => stage.stageId), + [846040014, 846040015, 846040016, 1, 846040021, 846040022, 846040013] + ); + assert.strictEqual( + result.find((stage) => stage.stageId === 846040021).status, + "in-progress" + ); +}); + +test("Specialist-process-sensitive ROW mapping remains unchanged for representative process IDs", () => { + const writtenReps = normalizeForAssertion( + getStagesForAppealType(846040015, 846040032, 846040000) + ); + const hearing = normalizeForAssertion( + getStagesForAppealType(846040015, 846040032, 846040001) + ); + + assert.deepStrictEqual(writtenReps, hearing); + assert.deepStrictEqual( + writtenReps.map((stage) => stage.stageId), + [ + 846040033, 846040034, 846040035, 846040036, 846040062, 846040055, + 846040037, 846040022, 846040013 + ] + ); + assert.ok( + writtenReps.every((stage) => stage.status === "not-started"), + "Expected representative ROW input to preserve current all-not-started behaviour" + ); +}); + +test("Closed-case fallback behaviour remains unchanged for all recognised closed statuses", () => { + const closedStatuses = [1000, 5, 6, 846040013, 846040060, 846040059]; + + for (const closedStatus of closedStatuses) { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, closedStatus) + ); + + const closedStage = result.find( + (stage) => stage.titleKey === "case-closed" + ); + assert.ok( + closedStage, + `Expected case-closed stage for status ${closedStatus}` + ); + assert.strictEqual( + closedStage.status, + "in-progress", + `Expected case-closed to be in-progress for status ${closedStatus}` + ); + assert.strictEqual(result[0].status, "complete"); + } +}); + +test("Unknown stage/status behaviour remains unchanged", () => { + const result = normalizeForAssertion( + getStagesForAppealType(846040000, 999999999) + ); + + assert.ok(result.every((stage) => stage.status === "not-started")); +}); + +test("Alias behaviour remains unchanged for representative aliases", () => { + const planningAlias = normalizeForAssertion( + getStagesForAppealType("PLANNING_S78", 846040018) + ); + const planningCanonical = normalizeForAssertion( + getStagesForAppealType("S78", 846040018) + ); + const callInAlias = normalizeForAssertion( + getStagesForAppealType("CALL_IN", 846040049) + ); + const callInCanonical = normalizeForAssertion( + getStagesForAppealType("CALL_INS", 846040049) + ); + + assert.deepStrictEqual(planningAlias, planningCanonical); + assert.deepStrictEqual(callInAlias, callInCanonical); +}); + +test("Wrapper parity matches existing helper for representative inputs", () => { + const scenarios = [ + [846040000, 846040014, undefined], + [846040000, 846040018, undefined], + [846040011, 846040006, undefined], + [846040004, 846040021, undefined], + [846040015, 846040032, 846040000], + [846040015, 846040032, 846040001], + [846040000, 846040013, undefined], + [846040000, 999999999, undefined] + ]; + + for (const [caseType, currentStageId, specialistProcess] of scenarios) { + assert.deepStrictEqual( + normalizeForAssertion( + getLifecycleStagesForCase( + caseType, + currentStageId, + specialistProcess + ) + ), + normalizeForAssertion( + getStagesForAppealType( + caseType, + currentStageId, + specialistProcess + ) + ) + ); + } +}); + +test("Wrapper preserves returned object shape for status consumer", () => { + const result = normalizeForAssertion( + getLifecycleStagesForCase(846040000, 846040014) + ); + + assert.ok(result.length > 0); + assert.ok(Object.prototype.hasOwnProperty.call(result[0], "id")); + assert.ok(Object.prototype.hasOwnProperty.call(result[0], "stageId")); + assert.ok(Object.prototype.hasOwnProperty.call(result[0], "titleKey")); + assert.ok( + Object.prototype.hasOwnProperty.call(result[0], "descriptionKey") + ); + assert.ok(Object.prototype.hasOwnProperty.call(result[0], "status")); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 case-lifecycle-stage-wrapper tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +}