From c39e4bcc9ac844fb736e9b289bc8d4976f6a2736 Mon Sep 17 00:00:00 2001 From: Robert Bond Date: Mon, 8 Jun 2026 10:51:02 +0000 Subject: [PATCH] Merged PR 2375: update representation policy ## Summary Introduces a new `representation-policy` domain boundary and incrementally extracts low-risk representation entry policy logic while preserving existing behaviour. This PR intentionally stops before extracting ROW and Advert entry rules because characterization uncovered behavioural differences between consumers that require a separate business decision. ## What Changed Added: ```text lib/domain/representation-policy/ ``` Including: - `resolveRepresentationWindow(...)` - `isRepresentationWindowOpen(...)` - `isRepresentationWindowClosed(...)` - `canShowRepresentationButtonForAppealType(...)` - `canStartHouseholderRepresentation(...)` - `canStartCpoRepresentation(...)` Updated consumers: ```text components/case/summary/utils/representationEntry.js components/search/repsonresults.js ``` ## Extracted Behaviour ### Representation Window Calculation Centralised shared representation window logic and adopted it in both consumers. ### Appeal Type Entry Gating Centralised excluded appeal-type logic while preserving existing behaviour. ### Householder Rule Centralised Householder (`846040004`) entry rule. Preserved behaviour: ```text Householder representations can only be started by LPAs. ``` ### CPO Rule Centralised CPO (`846040019`) entry rule. Preserved behaviour using: ```text pinswg_startdate pinswg_statementduedate ``` No fallback date broadening introduced. ## Characterization Added ### ROW (`846040015`) Documented: - hearing vs non-hearing behaviour - specialist-process field behaviour - date gating - appeal-type coercion behaviour ### Advert (`846040018`) Documented: - LPA/non-LPA behaviour - specialist-process behaviour - appeal-type coercion behaviour ## Important Findings ### ROW Divergence Summary and Search consumers currently behave differently when only: ```text pinswg_speacialistcaseprocess ``` exists. ### Advert Divergence Summary and Search consumers currently use different specialist-process resolution paths. ### CRM Compatibility Both fields remain in production use and must be preserved: ```text pinswg_specialistcaseprocess pinswg_speacialistcaseprocess ``` ## Documentation Added: ```text lib/domain/representation-policy/README.md ``` Documenting: - ownership - non-goals - CRM compatibility requirements - ROW divergence - Advert divergence - future extraction constraints ## Validation Executed during the slice series: ```bash node tests/phase22/representation-window.test.cjs node tests/phase22/representation-appeal-type-entry-gating.test.cjs node tests/phase22/representation-householder-entry-rule.test.cjs node tests/phase22/representation-cpo-entry-rule.test.cjs node tests/phase22/representation-row-entry-rule.test.cjs node tests/phase22/representation-advert-entry-rule.test.cjs npm run lint ``` All passing. ## Out of Scope No changes to: - submission/finalisation - uploads - dashboards - CRM/OData queries - API routes - Redux state - translations - blocked-message rendering - CTA l... --- .../case/summary/utils/representationEntry.js | 62 +-- components/search/repsonresults.js | 60 +-- lib/domain/representation-policy/README.md | 150 ++++++ ...anShowRepresentationButtonForAppealType.js | 7 + .../canStartCpoRepresentation.js | 19 + .../canStartHouseholderRepresentation.js | 5 + lib/domain/representation-policy/index.js | 9 + .../resolveRepresentationWindow.js | 57 +++ .../representation-advert-entry-rule.test.cjs | 383 +++++++++++++++ ...entation-appeal-type-entry-gating.test.cjs | 126 +++++ .../representation-cpo-entry-rule.test.cjs | 257 ++++++++++ ...esentation-householder-entry-rule.test.cjs | 0 .../representation-row-entry-rule.test.cjs | 457 ++++++++++++++++++ tests/phase22/representation-window.test.cjs | 214 ++++++++ 14 files changed, 1725 insertions(+), 81 deletions(-) create mode 100644 lib/domain/representation-policy/README.md create mode 100644 lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js create mode 100644 lib/domain/representation-policy/canStartCpoRepresentation.js create mode 100644 lib/domain/representation-policy/canStartHouseholderRepresentation.js create mode 100644 lib/domain/representation-policy/index.js create mode 100644 lib/domain/representation-policy/resolveRepresentationWindow.js create mode 100644 tests/phase22/representation-advert-entry-rule.test.cjs create mode 100644 tests/phase22/representation-appeal-type-entry-gating.test.cjs create mode 100644 tests/phase22/representation-cpo-entry-rule.test.cjs create mode 100644 tests/phase22/representation-householder-entry-rule.test.cjs create mode 100644 tests/phase22/representation-row-entry-rule.test.cjs create mode 100644 tests/phase22/representation-window.test.cjs diff --git a/components/case/summary/utils/representationEntry.js b/components/case/summary/utils/representationEntry.js index d9b052ab..4c28d5d7 100644 --- a/components/case/summary/utils/representationEntry.js +++ b/components/case/summary/utils/representationEntry.js @@ -1,18 +1,18 @@ -export function showRepsLocal(startDate, endDate) { - let date = new Date(); - date = new Date(date.toDateString()); - const start = new Date(startDate); - const end = new Date(endDate); +import { + resolveRepresentationWindow, + isRepresentationWindowOpen, + isRepresentationWindowClosed, + canShowRepresentationButtonForAppealType, + canStartHouseholderRepresentation, + canStartCpoRepresentation +} from "../../../../lib/domain/representation-policy"; - return date >= start && date <= end ? true : false; +export function showRepsLocal(startDate, endDate) { + return isRepresentationWindowOpen(startDate, endDate); } export function showRepsEndedLocal(startDate, endDate) { - let date = new Date(); - date = new Date(date.toDateString()); - const start = new Date(startDate); - const end = new Date(endDate); - return date > start && date > end ? true : false; + return isRepresentationWindowClosed(startDate, endDate); } export function isConsultationWindowOpen(detailsObj) { @@ -23,24 +23,7 @@ export function isConsultationWindowOpen(detailsObj) { } export function isGeneralRepresentationWindowOpen(detailsObj) { - return ( - (Object.prototype.hasOwnProperty.call(detailsObj, "pinswg_startdate") || - Object.prototype.hasOwnProperty.call( - detailsObj, - "pinswg_applicationacceptedasvalid" - ) || - Object.prototype.hasOwnProperty.call( - detailsObj, - "pinswg_startdates" - )) && - showRepsLocal( - detailsObj.pinswg_startdate || - detailsObj.pinswg_startdates || - detailsObj.pinswg_applicationacceptedasvalid, - detailsObj.pinswg_finalcommentsduedate || - detailsObj.pinswg_endofrepresentationperiod - ) - ); + return resolveRepresentationWindow(detailsObj).isOpen; } export function canShowRepButtonForAppealType({ @@ -48,18 +31,13 @@ export function canShowRepButtonForAppealType({ isLPA, searchDetailsObj }) { + if (!canShowRepresentationButtonForAppealType(appealType)) { + return false; + } + switch (appealType) { - case 846040012: - case 846040013: - case 846040014: - //for ROW case 846040015: - case 846040020: - case 846040021: - case 846040023: - case 846040024: - return false; case 846040004: - return isLPA ? true : false; + return canStartHouseholderRepresentation(appealType, isLPA); case 846040015: return searchDetailsObj[0].value[0].pinswg_specialistcaseprocess == @@ -83,9 +61,9 @@ export function canShowRepButtonForAppealType({ return shouldShow; case 846040019: - return showRepsLocal( - searchDetailsObj[0].value[0].pinswg_startdate, - searchDetailsObj[0].value[0].pinswg_statementduedate + return canStartCpoRepresentation( + appealType, + searchDetailsObj[0].value[0] ); default: diff --git a/components/search/repsonresults.js b/components/search/repsonresults.js index d17d864c..051c3f18 100644 --- a/components/search/repsonresults.js +++ b/components/search/repsonresults.js @@ -2,6 +2,14 @@ import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import _ from "lodash"; import Link from "next/link"; +import { + resolveRepresentationWindow, + isRepresentationWindowOpen, + isRepresentationWindowClosed, + canShowRepresentationButtonForAppealType, + canStartHouseholderRepresentation, + canStartCpoRepresentation +} from "../../lib/domain/representation-policy"; const RepsOnResults = (props) => { let { t } = useTranslation(); @@ -12,33 +20,20 @@ const RepsOnResults = (props) => { const specialistProcess = detailsObj?.pinswg_speacialistcaseprocess || detailsObj?.pinswg_specialistcaseprocess; - - function showReps(startDate, endDate) { - let date = new Date(); - date = new Date(date.toDateString()); - const start = new Date(startDate); - const end = new Date(endDate); - - return date >= start && date <= end ? true : false; - } + const representationWindow = resolveRepresentationWindow(detailsObj); function showRepButton(appealType) { + if (!canShowRepresentationButtonForAppealType(appealType)) { + return false; + } + switch (appealType) { - case 846040012: - case 846040013: - case 846040014: - //for ROW case 846040015: - case 846040020: - case 846040021: - case 846040023: - case 846040024: - return false; case 846040004: - return isLPA ? true : false; + return canStartHouseholderRepresentation(appealType, isLPA); case 846040015: return specialistProcess == 846040001 - ? showReps( + ? isRepresentationWindowOpen( detailsObj.pinswg_startdate, detailsObj.pinswg_finalcommentsduedate ) @@ -55,24 +50,13 @@ const RepsOnResults = (props) => { return shouldShow; case 846040019: - return showReps( - detailsObj.pinswg_startdate, - detailsObj.pinswg_statementduedate - ); + return canStartCpoRepresentation(appealType, detailsObj); default: return true; } } - function showRepsEnded(startDate, endDate) { - let date = new Date(); - date = new Date(date.toDateString()); - const start = new Date(startDate); - const end = new Date(endDate); - return date > start && date > end ? true : false; - } - function formatDates(dateObj, showTime) { var dateObj = new Date(dateObj); var dd = String(dateObj.getDate()).padStart(2, "0"); @@ -97,14 +81,12 @@ const RepsOnResults = (props) => { {(_.has(detailsObj, "pinswg_startdate") || _.has(detailsObj, "pinswg_applicationacceptedasvalid") || _.has(detailsObj, "pinswg_consultationopen")) && - showReps( - detailsObj.pinswg_startdate || - detailsObj.pinswg_applicationacceptedasvalid || + ((representationWindow.hasStartDate && + representationWindow.isOpen) || + isRepresentationWindowOpen( detailsObj.pinswg_consultationopen, - detailsObj.pinswg_finalcommentsduedate || - detailsObj.pinswg_endofrepresentationperiod || detailsObj.pinswg_consultationclose - ) && ( + )) && ( <> {showRepButton(detailsObj.appealType) ? ( <> @@ -179,7 +161,7 @@ const RepsOnResults = (props) => { {_.has(detailsObj, "pinswg_startdate") && (detailsObj.pinswg_startdate != null || detailsObj.pinswg_finalcommentsduedate != null) && - showRepsEnded( + isRepresentationWindowClosed( detailsObj.pinswg_startdate, detailsObj.pinswg_finalcommentsduedate ) && ( diff --git a/lib/domain/representation-policy/README.md b/lib/domain/representation-policy/README.md new file mode 100644 index 00000000..acd11e00 --- /dev/null +++ b/lib/domain/representation-policy/README.md @@ -0,0 +1,150 @@ +# Representation Entry Policy Boundary + +## Purpose + +This directory contains a narrow **representation entry policy boundary**. + +It currently owns only **behaviour-preserving extracted entry-policy helpers**. + +It is **not** a full representation domain model. + +This boundary should be treated as a small refactor seam for read-only entry-policy interpretation, not as a place to redesign representation behaviour. + +## Current ownership + +### `resolveRepresentationWindow(...)` + +- current general representation window resolution +- preserves current start/end field usage for the extracted general window path +- does not define full representation entry policy + +### `isRepresentationWindowOpen(...)` + +- current inclusive date-window open check +- preserves current date semantics used by extracted consumers + +### `isRepresentationWindowClosed(...)` + +- current date-window closed check +- preserves current `date > start && date > end` semantics + +### `canShowRepresentationButtonForAppealType(...)` + +- current appeal-type exclusion list only +- preserves current excluded IDs +- preserves current permissive unknown behaviour +- preserves current string/number coercion behaviour + +### `canStartHouseholderRepresentation(...)` + +- current Householder LPA-only entry rule only +- preserves current truthy/falsy LPA handling +- does not infer LPA from CRM fields + +### `canStartCpoRepresentation(...)` + +- current CPO entry rule only +- preserves current `pinswg_startdate` → `pinswg_statementduedate` behaviour +- does not broaden to other start-date variants + +## Explicit non-goals + +This boundary does **not** currently own: + +- ROW entry extraction +- Advert entry extraction +- blocked-message rendering +- CTA label selection +- consultation entry policy +- representation type options +- capacity rules +- submission/finalisation rules +- upload logic +- dashboard/worklist grouping +- CRM/OData queries +- API routes +- Redux state +- translations / EN-CY text + +## CRM compatibility requirements + +Both specialist-process fields may exist and must be handled carefully: + +- `pinswg_specialistcaseprocess` +- `pinswg_speacialistcaseprocess` + +The misspelled field exists because of historical CRM schema/data and must not be removed casually. + +Start-date variants also exist and must not be normalized unless explicitly characterized: + +- `pinswg_startdate` +- `pinswg_startdates` +- `pinswg_applicationacceptedasvalid` + +Future slices must treat field compatibility as behavior-sensitive. + +## Known divergences + +### ROW `846040015` + +Summary consumer (`components/case/summary/utils/representationEntry.js`): + +- uses only `pinswg_specialistcaseprocess` in the ROW branch +- if only `pinswg_speacialistcaseprocess` exists, it falls through to non-hearing behaviour +- result can be allowed where search would be date-gated + +Search consumer (`components/search/repsonresults.js`): + +- uses normalized canonical/misspelled specialist-process fallback +- if misspelled field contains hearing value `846040001`, hearing/date gate applies + +Also: + +- ROW hearing uses `pinswg_startdate` → `pinswg_finalcommentsduedate` +- ROW does not currently use `pinswg_startdates` or `pinswg_applicationacceptedasvalid` +- string appeal type `"846040015"` falls through to default allowed behaviour + +### Advert `846040018` + +Summary consumer (`components/case/summary/utils/representationEntry.js`): + +- reads only `pinswg_speacialistcaseprocess` +- ignores canonical-only specialist process values in the Advert branch + +Search consumer (`components/search/repsonresults.js`): + +- uses normalized specialist process fallback +- canonical field takes precedence when both fields exist + +Also: + +- written reps `846040000` is LPA-only +- hearing `846040001` is allowed for LPA and non-LPA +- other specialist process values are blocked +- Advert is not date-gated today +- string appeal type `"846040018"` falls through to default allowed behaviour + +## Behaviour-preservation invariants + +Future slices must preserve: + +- current consumer-specific divergence unless an explicit behaviour-change decision is made +- current string/number appeal type handling +- current specialist-process field precedence where present +- current start-date field usage +- current permissive unknown behaviour +- current CTA/rendering behaviour + +## Future extraction guidance + +Do not extract ROW or Advert helpers until product/CRM decision confirms whether to preserve divergence or standardize behaviour. + +If preserving divergence: + +- helper contracts must support consumer-specific mode explicitly + +If standardizing behaviour: + +- it must be treated as a behaviour change, not a refactor + +The next safe work is likely blocked/suppression rendering characterization, not extraction. diff --git a/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js b/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js new file mode 100644 index 00000000..5f293373 --- /dev/null +++ b/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js @@ -0,0 +1,7 @@ +const EXCLUDED_APPEAL_TYPE_IDS = new Set([ + 846040012, 846040013, 846040014, 846040020, 846040021, 846040023, 846040024 +]); + +export function canShowRepresentationButtonForAppealType(appealType) { + return EXCLUDED_APPEAL_TYPE_IDS.has(Number(appealType)) ? false : true; +} diff --git a/lib/domain/representation-policy/canStartCpoRepresentation.js b/lib/domain/representation-policy/canStartCpoRepresentation.js new file mode 100644 index 00000000..4d3bf990 --- /dev/null +++ b/lib/domain/representation-policy/canStartCpoRepresentation.js @@ -0,0 +1,19 @@ +import { isRepresentationWindowOpen } from "./resolveRepresentationWindow"; + +const CPO_APPEAL_TYPE = 846040019; + +export function canStartCpoRepresentation( + appealType, + caseDetails, + DateCtor = Date +) { + if (Number(appealType) !== CPO_APPEAL_TYPE) { + return true; + } + + return isRepresentationWindowOpen( + caseDetails?.pinswg_startdate, + caseDetails?.pinswg_statementduedate, + DateCtor + ); +} diff --git a/lib/domain/representation-policy/canStartHouseholderRepresentation.js b/lib/domain/representation-policy/canStartHouseholderRepresentation.js new file mode 100644 index 00000000..b154495c --- /dev/null +++ b/lib/domain/representation-policy/canStartHouseholderRepresentation.js @@ -0,0 +1,5 @@ +const HOUSEHOLDER_APPEAL_TYPE = 846040004; + +export function canStartHouseholderRepresentation(appealType, isLpa) { + return Number(appealType) === HOUSEHOLDER_APPEAL_TYPE ? !!isLpa : true; +} diff --git a/lib/domain/representation-policy/index.js b/lib/domain/representation-policy/index.js new file mode 100644 index 00000000..150d84be --- /dev/null +++ b/lib/domain/representation-policy/index.js @@ -0,0 +1,9 @@ +export { + resolveRepresentationWindow, + isRepresentationWindowOpen, + isRepresentationWindowClosed +} from "./resolveRepresentationWindow"; + +export { canShowRepresentationButtonForAppealType } from "./canShowRepresentationButtonForAppealType"; +export { canStartHouseholderRepresentation } from "./canStartHouseholderRepresentation"; +export { canStartCpoRepresentation } from "./canStartCpoRepresentation"; diff --git a/lib/domain/representation-policy/resolveRepresentationWindow.js b/lib/domain/representation-policy/resolveRepresentationWindow.js new file mode 100644 index 00000000..6d8d2a7e --- /dev/null +++ b/lib/domain/representation-policy/resolveRepresentationWindow.js @@ -0,0 +1,57 @@ +const startOfToday = (DateCtor = Date) => + new DateCtor(new DateCtor().toDateString()); + +const toDate = (value, DateCtor = Date) => new DateCtor(value); + +export function isRepresentationWindowOpen( + startDate, + endDate, + DateCtor = Date +) { + const date = startOfToday(DateCtor); + const start = toDate(startDate, DateCtor); + const end = toDate(endDate, DateCtor); + + return date >= start && date <= end ? true : false; +} + +export function isRepresentationWindowClosed( + startDate, + endDate, + DateCtor = Date +) { + const date = startOfToday(DateCtor); + const start = toDate(startDate, DateCtor); + const end = toDate(endDate, DateCtor); + + return date > start && date > end ? true : false; +} + +export function resolveRepresentationWindow(caseDetails, DateCtor = Date) { + const hasStartDate = + Object.prototype.hasOwnProperty.call(caseDetails, "pinswg_startdate") || + Object.prototype.hasOwnProperty.call( + caseDetails, + "pinswg_applicationacceptedasvalid" + ) || + Object.prototype.hasOwnProperty.call(caseDetails, "pinswg_startdates"); + + const startDate = + caseDetails.pinswg_startdate || + caseDetails.pinswg_startdates || + caseDetails.pinswg_applicationacceptedasvalid; + + const endDate = + caseDetails.pinswg_finalcommentsduedate || + caseDetails.pinswg_endofrepresentationperiod; + + return { + hasStartDate, + startDate, + endDate, + isOpen: + hasStartDate && + isRepresentationWindowOpen(startDate, endDate, DateCtor), + isClosed: isRepresentationWindowClosed(startDate, endDate, DateCtor) + }; +} diff --git a/tests/phase22/representation-advert-entry-rule.test.cjs b/tests/phase22/representation-advert-entry-rule.test.cjs new file mode 100644 index 00000000..484a6f87 --- /dev/null +++ b/tests/phase22/representation-advert-entry-rule.test.cjs @@ -0,0 +1,383 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadEsModuleFunctions = ( + relativePath, + exportNames, + contextExtras = {} +) => { + const filePath = path.join(rootDir, ...relativePath.split("/")); + let source = fs.readFileSync(filePath, "utf8"); + + for (const exportName of exportNames) { + source = source.replace( + new RegExp(`export function\\s+${exportName}`, "g"), + `function ${exportName}` + ); + source = source.replace( + new RegExp(`export const\\s+${exportName}\\s*=`, "g"), + `const ${exportName} =` + ); + } + + source = source.replace( + /import\s+\{\s*isRepresentationWindowOpen\s*\}\s+from\s+"\.\/resolveRepresentationWindow";/, + 'const { isRepresentationWindowOpen } = require("./resolveRepresentationWindow");' + ); + + source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + Date, + Number, + Set, + ...contextExtras + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const gatingHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js", + ["canShowRepresentationButtonForAppealType"] +); + +const householderHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/canStartHouseholderRepresentation.js", + ["canStartHouseholderRepresentation"] +); + +const windowHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/resolveRepresentationWindow.js", + [ + "isRepresentationWindowOpen", + "isRepresentationWindowClosed", + "resolveRepresentationWindow" + ] +); + +const cpoHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/canStartCpoRepresentation.js", + ["canStartCpoRepresentation"], + { + require: (modulePath) => { + if (modulePath === "./resolveRepresentationWindow") { + return windowHelpers; + } + return require(modulePath); + } + } +); + +const { normalizeSpecialistProcess } = loadEsModuleFunctions( + "lib/domain/case-lifecycle/normalizeSpecialistProcess.js", + ["normalizeSpecialistProcess"] +); + +const evaluateSummaryAdvertRule = ({ appealType, detailsObj, isLPA }) => { + if (!gatingHelpers.canShowRepresentationButtonForAppealType(appealType)) { + return false; + } + + switch (appealType) { + case 846040004: + return householderHelpers.canStartHouseholderRepresentation( + appealType, + isLPA + ); + + case 846040015: + return detailsObj.pinswg_specialistcaseprocess == 846040001 + ? windowHelpers.isRepresentationWindowOpen( + detailsObj.pinswg_startdate, + detailsObj.pinswg_finalcommentsduedate + ) + : true; + + case 846040018: + return detailsObj.pinswg_speacialistcaseprocess == 846040000 && + isLPA + ? true + : detailsObj.pinswg_speacialistcaseprocess == 846040001 + ? true + : false; + + case 846040019: + return cpoHelpers.canStartCpoRepresentation(appealType, detailsObj); + + default: + return true; + } +}; + +const evaluateSearchAdvertRule = ({ appealType, detailsObj, isLPA }) => { + const specialistProcess = normalizeSpecialistProcess(detailsObj); + + if (!gatingHelpers.canShowRepresentationButtonForAppealType(appealType)) { + return false; + } + + switch (appealType) { + case 846040004: + return householderHelpers.canStartHouseholderRepresentation( + appealType, + isLPA + ); + + case 846040015: + return specialistProcess == 846040001 + ? windowHelpers.isRepresentationWindowOpen( + detailsObj.pinswg_startdate, + detailsObj.pinswg_finalcommentsduedate + ) + : true; + + case 846040018: + return specialistProcess == 846040000 && isLPA + ? true + : specialistProcess == 846040001 + ? true + : false; + + case 846040019: + return cpoHelpers.canStartCpoRepresentation(appealType, detailsObj); + + default: + return true; + } +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +test("Advert written reps is allowed for LPA and blocked for non-LPA in both consumers when misspelled field is used", () => { + const detailsObj = { pinswg_speacialistcaseprocess: 846040000 }; + + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: true + }), + true + ); + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: false + }), + false + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: true + }), + true + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: false + }), + false + ); +}); + +test("Advert hearing is allowed for both LPA and non-LPA in both consumers when supported field is present", () => { + const summaryDetails = { pinswg_speacialistcaseprocess: 846040001 }; + const searchDetails = { pinswg_specialistcaseprocess: 846040001 }; + + for (const isLPA of [true, false]) { + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj: summaryDetails, + isLPA + }), + true + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj: searchDetails, + isLPA + }), + true + ); + } +}); + +test("Advert other specialist process values are blocked in both consumers when supported field is present", () => { + for (const specialistValue of [846040002, 846040003]) { + const summaryDetails = { + pinswg_speacialistcaseprocess: specialistValue + }; + const searchDetails = { pinswg_specialistcaseprocess: specialistValue }; + + for (const isLPA of [true, false]) { + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj: summaryDetails, + isLPA + }), + false + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj: searchDetails, + isLPA + }), + false + ); + } + } +}); + +test("Advert summary consumer depends on misspelled specialist process field, while search consumer supports normalized fallback", () => { + const canonicalOnly = { pinswg_specialistcaseprocess: 846040000 }; + const misspelledOnly = { pinswg_speacialistcaseprocess: 846040000 }; + + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj: canonicalOnly, + isLPA: true + }), + false, + "summary consumer ignores canonical-only field for Advert branch" + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj: canonicalOnly, + isLPA: true + }), + true, + "search consumer uses canonical field via normalizeSpecialistProcess" + ); + + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj: misspelledOnly, + isLPA: true + }), + true + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj: misspelledOnly, + isLPA: true + }), + true + ); +}); + +test("Advert canonical specialist process takes precedence in search consumer when both fields exist", () => { + const detailsObj = { + pinswg_specialistcaseprocess: 846040002, + pinswg_speacialistcaseprocess: 846040001 + }; + + assert.strictEqual(normalizeSpecialistProcess(detailsObj), 846040002); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: true + }), + false + ); + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: true + }), + true, + "summary consumer still reads the misspelled field directly" + ); +}); + +test("Advert entry does not currently depend on date windows in either consumer", () => { + const detailsObj = { + pinswg_speacialistcaseprocess: 846040001, + pinswg_startdate: "2030-01-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2030-01-02T00:00:00.000Z" + }; + + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: false + }), + true + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: 846040018, + detailsObj, + isLPA: false + }), + true + ); +}); + +test("Advert appeal type string form falls through to default allowed behaviour in both consumers", () => { + const detailsObj = { pinswg_speacialistcaseprocess: 846040002 }; + + assert.strictEqual( + evaluateSummaryAdvertRule({ + appealType: "846040018", + detailsObj, + isLPA: false + }), + true + ); + assert.strictEqual( + evaluateSearchAdvertRule({ + appealType: "846040018", + detailsObj, + isLPA: false + }), + true + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 representation-advert-entry-rule 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/representation-appeal-type-entry-gating.test.cjs b/tests/phase22/representation-appeal-type-entry-gating.test.cjs new file mode 100644 index 00000000..27551ac7 --- /dev/null +++ b/tests/phase22/representation-appeal-type-entry-gating.test.cjs @@ -0,0 +1,126 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadGatingModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "representation-policy", + "canShowRepresentationButtonForAppealType.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+canShowRepresentationButtonForAppealType/, + "function canShowRepresentationButtonForAppealType" + ); + source += ` +module.exports = { + canShowRepresentationButtonForAppealType +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + Number, + Set + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const { canShowRepresentationButtonForAppealType } = loadGatingModule(); + +const excludedAppealTypes = [ + 846040012, 846040013, 846040014, 846040020, 846040021, 846040023, 846040024 +]; + +test("excluded appeal types return false", () => { + for (const appealType of excludedAppealTypes) { + assert.strictEqual( + canShowRepresentationButtonForAppealType(appealType), + false, + `Expected excluded appeal type ${appealType} to return false` + ); + } +}); + +test("representative allowed appeal types return true", () => { + for (const appealType of [ + 846040002, 846040004, 846040011, 846040015, 846040018, 846040019 + ]) { + assert.strictEqual( + canShowRepresentationButtonForAppealType(appealType), + true, + `Expected allowed appeal type ${appealType} to return true` + ); + } +}); + +test("unknown appeal types preserve default allowed behaviour", () => { + assert.strictEqual( + canShowRepresentationButtonForAppealType(999999999), + true + ); + assert.strictEqual(canShowRepresentationButtonForAppealType(-1), true); +}); + +test("null and undefined preserve default allowed behaviour", () => { + assert.strictEqual(canShowRepresentationButtonForAppealType(null), true); + assert.strictEqual( + canShowRepresentationButtonForAppealType(undefined), + true + ); +}); + +test("string and number appeal type values behave the same", () => { + assert.strictEqual( + canShowRepresentationButtonForAppealType("846040012"), + false + ); + assert.strictEqual( + canShowRepresentationButtonForAppealType("846040002"), + true + ); +}); + +test("non-numeric strings preserve default allowed behaviour", () => { + assert.strictEqual( + canShowRepresentationButtonForAppealType("unknown"), + true + ); + assert.strictEqual(canShowRepresentationButtonForAppealType(""), true); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 representation-appeal-type-entry-gating 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/representation-cpo-entry-rule.test.cjs b/tests/phase22/representation-cpo-entry-rule.test.cjs new file mode 100644 index 00000000..f8587233 --- /dev/null +++ b/tests/phase22/representation-cpo-entry-rule.test.cjs @@ -0,0 +1,257 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const createFakeDate = (isoString) => { + const RealDate = Date; + + function FakeDate(...args) { + if (!(this instanceof FakeDate)) { + return new RealDate(...args); + } + + if (args.length === 0) { + return new RealDate(isoString); + } + + return new RealDate(...args); + } + + FakeDate.UTC = RealDate.UTC; + FakeDate.parse = RealDate.parse; + FakeDate.now = () => new RealDate(isoString).getTime(); + FakeDate.prototype = RealDate.prototype; + + return FakeDate; +}; + +const loadCpoRuleModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "representation-policy", + "canStartCpoRepresentation.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*isRepresentationWindowOpen\s*\}\s+from\s+"\.\/resolveRepresentationWindow";/, + 'const { isRepresentationWindowOpen } = require("./resolveRepresentationWindow.cjs");' + ); + source = source.replace( + /export function\s+canStartCpoRepresentation/, + "function canStartCpoRepresentation" + ); + source += ` +module.exports = { + canStartCpoRepresentation +}; +`; + + const windowFilePath = path.join( + rootDir, + "lib", + "domain", + "representation-policy", + "resolveRepresentationWindow.js" + ); + + let windowSource = fs.readFileSync(windowFilePath, "utf8"); + windowSource = windowSource.replace( + /export function\s+isRepresentationWindowOpen/, + "function isRepresentationWindowOpen" + ); + windowSource = windowSource.replace( + /export function\s+isRepresentationWindowClosed/, + "function isRepresentationWindowClosed" + ); + windowSource = windowSource.replace( + /export function\s+resolveRepresentationWindow/, + "function resolveRepresentationWindow" + ); + windowSource += ` +module.exports = { + isRepresentationWindowOpen, + isRepresentationWindowClosed, + resolveRepresentationWindow +}; +`; + + const windowModule = { exports: {} }; + vm.runInNewContext( + windowSource, + { + module: windowModule, + exports: windowModule.exports, + require, + Date + }, + { filename: windowFilePath } + ); + + const localRequire = (modulePath) => { + if (modulePath === "./resolveRepresentationWindow.cjs") { + return windowModule.exports; + } + return require(modulePath); + }; + + const context = { + module: { exports: {} }, + exports: {}, + require: localRequire, + Number, + Date + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const { canStartCpoRepresentation } = loadCpoRuleModule(); + +test("CPO open window returns allowed behaviour", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + assert.strictEqual( + canStartCpoRepresentation( + 846040019, + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ), + true + ); +}); + +test("CPO expired window returns blocked behaviour", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + assert.strictEqual( + canStartCpoRepresentation( + 846040019, + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ), + false + ); +}); + +test("CPO future window preserves current blocked behaviour", () => { + const FakeDate = createFakeDate("2026-03-31T10:00:00.000Z"); + assert.strictEqual( + canStartCpoRepresentation( + 846040019, + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ), + false + ); +}); + +test("CPO missing dates preserve current blocked behaviour", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + assert.strictEqual( + canStartCpoRepresentation( + 846040019, + { pinswg_statementduedate: "2026-04-30T00:00:00.000Z" }, + FakeDate + ), + false + ); + assert.strictEqual( + canStartCpoRepresentation( + 846040019, + { pinswg_startdate: "2026-04-01T00:00:00.000Z" }, + FakeDate + ), + false + ); + assert.strictEqual( + canStartCpoRepresentation(846040019, {}, FakeDate), + false + ); +}); + +test("CPO helper does not broaden to fallback start fields", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + assert.strictEqual( + canStartCpoRepresentation( + 846040019, + { + pinswg_startdates: "2026-04-01T00:00:00.000Z", + pinswg_applicationacceptedasvalid: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ), + false + ); +}); + +test("string and number CPO appeal type values behave consistently", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const caseDetails = { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual( + canStartCpoRepresentation(846040019, caseDetails, FakeDate), + true + ); + assert.strictEqual( + canStartCpoRepresentation("846040019", caseDetails, FakeDate), + true + ); +}); + +test("non-CPO appeal types preserve permissive passthrough behaviour", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + assert.strictEqual( + canStartCpoRepresentation( + 846040002, + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ), + true + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 representation-cpo-entry-rule 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/representation-householder-entry-rule.test.cjs b/tests/phase22/representation-householder-entry-rule.test.cjs new file mode 100644 index 00000000..e69de29b diff --git a/tests/phase22/representation-row-entry-rule.test.cjs b/tests/phase22/representation-row-entry-rule.test.cjs new file mode 100644 index 00000000..e899c744 --- /dev/null +++ b/tests/phase22/representation-row-entry-rule.test.cjs @@ -0,0 +1,457 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const createFakeDate = (isoString) => { + const RealDate = Date; + + function FakeDate(...args) { + if (!(this instanceof FakeDate)) { + return new RealDate(...args); + } + + if (args.length === 0) { + return new RealDate(isoString); + } + + return new RealDate(...args); + } + + FakeDate.UTC = RealDate.UTC; + FakeDate.parse = RealDate.parse; + FakeDate.now = () => new RealDate(isoString).getTime(); + FakeDate.prototype = RealDate.prototype; + + return FakeDate; +}; + +const loadEsModuleFunctions = ( + relativePath, + exportNames, + contextExtras = {} +) => { + const filePath = path.join(rootDir, ...relativePath.split("/")); + let source = fs.readFileSync(filePath, "utf8"); + + for (const exportName of exportNames) { + source = source.replace( + new RegExp(`export function\\s+${exportName}`, "g"), + `function ${exportName}` + ); + source = source.replace( + new RegExp(`export const\\s+${exportName}\\s*=`, "g"), + `const ${exportName} =` + ); + } + + source = source.replace( + /import\s+\{\s*isRepresentationWindowOpen\s*\}\s+from\s+"\.\/resolveRepresentationWindow";/, + 'const { isRepresentationWindowOpen } = require("./resolveRepresentationWindow");' + ); + + source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + Date, + Number, + Set, + ...contextExtras + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const windowHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/resolveRepresentationWindow.js", + [ + "isRepresentationWindowOpen", + "isRepresentationWindowClosed", + "resolveRepresentationWindow" + ] +); + +const gatingHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js", + ["canShowRepresentationButtonForAppealType"] +); + +const householderHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/canStartHouseholderRepresentation.js", + ["canStartHouseholderRepresentation"] +); + +const cpoHelpers = loadEsModuleFunctions( + "lib/domain/representation-policy/canStartCpoRepresentation.js", + ["canStartCpoRepresentation"], + { + require: (modulePath) => { + if (modulePath === "./resolveRepresentationWindow") { + return windowHelpers; + } + return require(modulePath); + } + } +); + +const { normalizeSpecialistProcess } = loadEsModuleFunctions( + "lib/domain/case-lifecycle/normalizeSpecialistProcess.js", + ["normalizeSpecialistProcess"] +); + +const evaluateSearchRowRule = ({ + appealType, + detailsObj, + isLPA, + DateCtor = Date +}) => { + const specialistProcess = normalizeSpecialistProcess(detailsObj); + + if (!gatingHelpers.canShowRepresentationButtonForAppealType(appealType)) { + return false; + } + + switch (appealType) { + case 846040004: + return householderHelpers.canStartHouseholderRepresentation( + appealType, + isLPA + ); + + case 846040015: + return specialistProcess == 846040001 + ? windowHelpers.isRepresentationWindowOpen( + detailsObj.pinswg_startdate, + detailsObj.pinswg_finalcommentsduedate, + DateCtor + ) + : true; + + case 846040018: + return specialistProcess == 846040000 && isLPA + ? true + : specialistProcess == 846040001 + ? true + : false; + + case 846040019: + return cpoHelpers.canStartCpoRepresentation( + appealType, + detailsObj, + DateCtor + ); + + default: + return true; + } +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const evaluateSummaryRowRule = ({ + appealType, + detailsObj, + DateCtor = Date +}) => { + if (!gatingHelpers.canShowRepresentationButtonForAppealType(appealType)) { + return false; + } + + switch (appealType) { + case 846040004: + return householderHelpers.canStartHouseholderRepresentation( + appealType, + false + ); + + case 846040015: + return detailsObj.pinswg_specialistcaseprocess == 846040001 + ? windowHelpers.isRepresentationWindowOpen( + detailsObj.pinswg_startdate, + detailsObj.pinswg_finalcommentsduedate, + DateCtor + ) + : true; + + case 846040018: + return detailsObj.pinswg_speacialistcaseprocess == 846040000 + ? false + : detailsObj.pinswg_speacialistcaseprocess == 846040001 + ? true + : false; + + case 846040019: + return cpoHelpers.canStartCpoRepresentation( + appealType, + detailsObj, + DateCtor + ); + + default: + return true; + } +}; + +test("ROW hearing specialist process is date-gated in both consumers", () => { + const openDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const expiredDate = createFakeDate("2026-05-01T10:00:00.000Z"); + const futureDate = createFakeDate("2026-03-31T10:00:00.000Z"); + const detailsObj = { + pinswg_specialistcaseprocess: 846040001, + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: openDate + }), + true + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: openDate + }), + true + ); + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: expiredDate + }), + false + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: expiredDate + }), + false + ); + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: futureDate + }), + false + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: futureDate + }), + false + ); +}); + +test("ROW hearing specialist process with missing dates is blocked in both consumers", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + + for (const detailsObj of [ + { + pinswg_specialistcaseprocess: 846040001, + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }, + { + pinswg_specialistcaseprocess: 846040001, + pinswg_startdate: "2026-04-01T00:00:00.000Z" + }, + { + pinswg_specialistcaseprocess: 846040001 + } + ]) { + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + false + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + false + ); + } +}); + +test("ROW non-hearing specialist process values are allowed without date gating", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + + for (const specialistValue of [846040000, 846040002, 846040003]) { + const detailsObj = { + pinswg_specialistcaseprocess: specialistValue, + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + true + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + true + ); + } +}); + +test("canonical and misspelled specialist process fields differ between consumers for ROW hearing", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + const misspelledOnly = { + pinswg_speacialistcaseprocess: 846040001, + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj: misspelledOnly, + DateCtor: FakeDate + }), + true, + "summary consumer falls through to non-hearing behaviour when only misspelled field exists" + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj: misspelledOnly, + DateCtor: FakeDate + }), + false, + "search consumer applies hearing/date gating via normalizeSpecialistProcess fallback" + ); +}); + +test("canonical specialist process field takes precedence when both specialist fields exist", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + const detailsObj = { + pinswg_specialistcaseprocess: 846040000, + pinswg_speacialistcaseprocess: 846040001, + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual(normalizeSpecialistProcess(detailsObj), 846040000); + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + true + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + true + ); +}); + +test("ROW currently uses only pinswg_startdate and pinswg_finalcommentsduedate for date gating", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const detailsObj = { + pinswg_specialistcaseprocess: 846040001, + pinswg_startdates: "2026-04-01T00:00:00.000Z", + pinswg_applicationacceptedasvalid: "2026-04-01T00:00:00.000Z", + pinswg_statementduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + false + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: 846040015, + detailsObj, + DateCtor: FakeDate + }), + false + ); +}); + +test("ROW appeal type string form falls through to default allowed behaviour in both consumers", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + const detailsObj = { + pinswg_specialistcaseprocess: 846040001, + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }; + + assert.strictEqual( + evaluateSummaryRowRule({ + appealType: "846040015", + detailsObj, + DateCtor: FakeDate + }), + true + ); + assert.strictEqual( + evaluateSearchRowRule({ + appealType: "846040015", + detailsObj, + DateCtor: FakeDate + }), + true + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 representation-row-entry-rule 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/representation-window.test.cjs b/tests/phase22/representation-window.test.cjs new file mode 100644 index 00000000..5befa7bb --- /dev/null +++ b/tests/phase22/representation-window.test.cjs @@ -0,0 +1,214 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const createFakeDate = (isoString) => { + const RealDate = Date; + + function FakeDate(...args) { + if (!(this instanceof FakeDate)) { + return new RealDate(...args); + } + + if (args.length === 0) { + return new RealDate(isoString); + } + + return new RealDate(...args); + } + + FakeDate.UTC = RealDate.UTC; + FakeDate.parse = RealDate.parse; + FakeDate.now = () => new RealDate(isoString).getTime(); + FakeDate.prototype = RealDate.prototype; + + return FakeDate; +}; + +const loadRepresentationWindowModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "representation-policy", + "resolveRepresentationWindow.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /export function\s+isRepresentationWindowOpen/, + "function isRepresentationWindowOpen" + ); + source = source.replace( + /export function\s+isRepresentationWindowClosed/, + "function isRepresentationWindowClosed" + ); + source = source.replace( + /export function\s+resolveRepresentationWindow/, + "function resolveRepresentationWindow" + ); + source += ` +module.exports = { + isRepresentationWindowOpen, + isRepresentationWindowClosed, + resolveRepresentationWindow +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + Date + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const { + isRepresentationWindowOpen, + isRepresentationWindowClosed, + resolveRepresentationWindow +} = loadRepresentationWindowModule(); + +test("general window is open when today falls inclusively between start and end", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const result = resolveRepresentationWindow( + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ); + + assert.strictEqual(result.hasStartDate, true); + assert.strictEqual(result.startDate, "2026-04-01T00:00:00.000Z"); + assert.strictEqual(result.endDate, "2026-04-30T00:00:00.000Z"); + assert.strictEqual(result.isOpen, true); + assert.strictEqual(result.isClosed, false); +}); + +test("general window is not open before the start date", () => { + const FakeDate = createFakeDate("2026-03-31T10:00:00.000Z"); + const result = resolveRepresentationWindow( + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ); + + assert.strictEqual(result.isOpen, false); + assert.strictEqual(result.isClosed, false); +}); + +test("general window is closed after the end date", () => { + const FakeDate = createFakeDate("2026-05-01T10:00:00.000Z"); + const result = resolveRepresentationWindow( + { + pinswg_startdate: "2026-04-01T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ); + + assert.strictEqual(result.isOpen, false); + assert.strictEqual(result.isClosed, true); +}); + +test("missing start-related properties preserve false open state", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const result = resolveRepresentationWindow( + { + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ); + + assert.strictEqual(result.hasStartDate, false); + assert.strictEqual(result.startDate, undefined); + assert.strictEqual(result.endDate, "2026-04-30T00:00:00.000Z"); + assert.strictEqual(result.isOpen, false); + assert.strictEqual(result.isClosed, false); +}); + +test("future window from fallback startdate field preserves open=false closed=false", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const result = resolveRepresentationWindow( + { + pinswg_startdates: "2026-04-20T00:00:00.000Z", + pinswg_finalcommentsduedate: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ); + + assert.strictEqual(result.startDate, "2026-04-20T00:00:00.000Z"); + assert.strictEqual(result.isOpen, false); + assert.strictEqual(result.isClosed, false); +}); + +test("application accepted as valid is used as fallback start date with representation-period end date", () => { + const FakeDate = createFakeDate("2026-04-15T10:00:00.000Z"); + const result = resolveRepresentationWindow( + { + pinswg_applicationacceptedasvalid: "2026-04-01T00:00:00.000Z", + pinswg_endofrepresentationperiod: "2026-04-30T00:00:00.000Z" + }, + FakeDate + ); + + assert.strictEqual(result.startDate, "2026-04-01T00:00:00.000Z"); + assert.strictEqual(result.endDate, "2026-04-30T00:00:00.000Z"); + assert.strictEqual(result.isOpen, true); + assert.strictEqual(result.isClosed, false); +}); + +test("consultation-like direct window helpers preserve inclusive same-day opening behaviour", () => { + const FakeDate = createFakeDate("2026-04-30T10:00:00.000Z"); + + assert.strictEqual( + isRepresentationWindowOpen( + "2026-04-01T00:00:00.000Z", + "2026-04-30T00:00:00.000Z", + FakeDate + ), + true + ); + assert.strictEqual( + isRepresentationWindowClosed( + "2026-04-01T00:00:00.000Z", + "2026-04-30T00:00:00.000Z", + FakeDate + ), + false + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 representation-window tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +}