diff --git a/jest.config.js b/jest.config.js index e0e7c7fea..1051baa34 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,7 +20,7 @@ module.exports = { '^jose$': '/src/test/unit/modules/s2s/__mocks__/jose.ts', '^uuid$': '/src/test/unit/__mocks__/uuid.ts', }, - testPathIgnorePatterns: ['/__mocks__/'], + testPathIgnorePatterns: ['/__mocks__/', '/mockTranslation.ts$'], coverageProvider: 'v8', transformIgnorePatterns: ['node_modules/(?!(jose|@panva|oidc-token-hash)/)'], }; diff --git a/src/main/assets/locales/cy/respondToClaim/rentArrearsDispute.json b/src/main/assets/locales/cy/respondToClaim/rentArrearsDispute.json index 1aac483ad..3281ed3f7 100644 --- a/src/main/assets/locales/cy/respondToClaim/rentArrearsDispute.json +++ b/src/main/assets/locales/cy/respondToClaim/rentArrearsDispute.json @@ -1,4 +1,32 @@ { - "caption": "cyRespond to a property possession claim", - "pageTitle": "cyRent Arrears Dispute(Placeholder)" + "title": "cyRent arrears", + "captionHeading": "cyRespond to a property possession claim", + "pageTitle": "cyRent arrears", + "insetIntroText": "cyRent arrears are money you owe in rent payments.", + "insetDetailsText": "cyWhen making their claim, {{claimantName}} had to provide a copy of the rent statement for your property, showing the total rent arrears you owe.", + "insetConditionalYesText": "cyThe rent statement will have been included in the pack you received in the post letting you know a claim had been made against you, and is also available to view from 'View the claim' on your case dashboard.", + "amountOwedHeading": "cyAmount you owe in rent arrears given by {{claimantName}}:", + "rentArrearsQuestion": "cyDo you owe this amount in rent arrears?", + "rentArrearsOptions": { + "yes": "cyYes", + "no": "cyNo", + "or": "cyor", + "notSure": "cyI'm not sure" + }, + "rentArrearsAmountCorrection": { + "label": "cyWhat amount do you believe you owe?", + "hint": "cyEnter the amount in pounds and pence, for example 123.45" + }, + "errors": { + "rentArrears": { + "required": "cySelect yes if you owe this amount in rent arrears" + }, + "rentArrearsAmountCorrection": { + "required": "cyEnter the amount you believe you owe in rent arrears", + "negativeAmount": "cyThe amount you believe you owe in rent arrears must be £0.00 or above", + "largeAmount": "cyEnter how much you believe you owe in rent arrears, in the correct format, up to £1,000,000,000.00", + "invalidFormat": "cyEnter how much you believe you owe in rent arrears, in the correct format (e.g. if you owe £148, please write £148.00)" + }, + "rentArrears.rentArrearsAmountCorrection": "cyEnter the amount you believe you owe in rent arrears" + } } diff --git a/src/main/assets/locales/en/respondToClaim/rentArrearsDispute.json b/src/main/assets/locales/en/respondToClaim/rentArrearsDispute.json index 8c48617e7..1d05860a2 100644 --- a/src/main/assets/locales/en/respondToClaim/rentArrearsDispute.json +++ b/src/main/assets/locales/en/respondToClaim/rentArrearsDispute.json @@ -1,4 +1,32 @@ { - "caption": "Respond to a property possession claim", - "pageTitle": "Rent Arrears Dispute(Placeholder)" + "title": "Rent arrears", + "captionHeading": "Respond to a property possession claim", + "pageTitle": "Rent arrears", + "insetIntroText": "Rent arrears are money you owe in rent payments.", + "insetDetailsText": "When making their claim, {{claimantName}} had to provide a copy of the rent statement for your property, showing the total rent arrears you owe.", + "insetConditionalYesText": "The rent statement will have been included in the pack you received in the post letting you know a claim had been made against you, and is also available to view from 'View the claim' on your case dashboard.", + "amountOwedHeading": "Amount you owe in rent arrears given by {{claimantName}}:", + "rentArrearsQuestion": "Do you owe this amount in rent arrears?", + "rentArrearsOptions": { + "yes": "Yes", + "no": "No", + "or": "or", + "notSure": "I'm not sure" + }, + "rentArrearsAmountCorrection": { + "label": "What amount do you believe you owe?", + "hint": "Enter the amount in pounds and pence, for example 123.45" + }, + "errors": { + "rentArrears": { + "required": "Select yes if you owe this amount in rent arrears" + }, + "rentArrearsAmountCorrection": { + "required": "Enter the amount you believe you owe in rent arrears", + "negativeAmount": "The amount you believe you owe in rent arrears must be £0.00 or above", + "largeAmount": "Enter how much you believe you owe in rent arrears, in the correct format, up to £1,000,000,000.00", + "invalidFormat": "Enter how much you believe you owe in rent arrears, in the correct format (e.g. if you owe £148, please write £148.00)" + }, + "rentArrears.rentArrearsAmountCorrection": "Enter the amount you believe you owe in rent arrears" + } } diff --git a/src/main/interfaces/formFieldConfig.interface.ts b/src/main/interfaces/formFieldConfig.interface.ts index 6975bf8c1..553f6c533 100644 --- a/src/main/interfaces/formFieldConfig.interface.ts +++ b/src/main/interfaces/formFieldConfig.interface.ts @@ -41,6 +41,10 @@ export interface FormFieldConfig { options?: FormFieldOption[]; classes?: string; attributes?: Record; + // Optional prefix for input fields (e.g. currency symbol) + prefix?: { + text: string; + }; legendClasses?: string; // Pre-built component config for Nunjucks template rendering component?: Record; diff --git a/src/main/middleware/autoSaveDraftToCCD.ts b/src/main/middleware/autoSaveDraftToCCD.ts index 82f2203c5..4429309e4 100644 --- a/src/main/middleware/autoSaveDraftToCCD.ts +++ b/src/main/middleware/autoSaveDraftToCCD.ts @@ -207,7 +207,7 @@ async function saveToCCD( } try { - logger.debug(`[${stepName}] Auto-saving to CCD draft`); + logger.debug(`[${stepName}] Starting auto-save with ${Object.keys(formData).length} fields`); let relevantData: string | string[] | Record; if (ccdMapping.frontendField) { @@ -246,6 +246,7 @@ async function saveToCCD( const ccdPayload = { ...nestedData, }; + logger.debug(`[${stepName}] Sending CCD payload for case ${validatedCase.id}`); await ccdCaseService.updateDraftRespondToClaim(accessToken, validatedCase.id, ccdPayload); diff --git a/src/main/modules/steps/formBuilder/helpers.ts b/src/main/modules/steps/formBuilder/helpers.ts index 895f2328e..2632bbca9 100644 --- a/src/main/modules/steps/formBuilder/helpers.ts +++ b/src/main/modules/steps/formBuilder/helpers.ts @@ -79,9 +79,10 @@ export function normalizeCheckboxFields(req: Request, fields: FormFieldConfig[]) /** * Processes all field data (checkbox normalization + date field consolidation) * This should run AFTER validation because date field validation expects individual day/month/year keys + * Now also handles subFields within radio/checkbox options */ export function processFieldData(req: Request, fields: FormFieldConfig[]): void { - for (const field of fields) { + const processField = (field: FormFieldConfig): void => { if (field.type === 'checkbox') { // Normalize checkbox values (in case they weren't normalized before validation) req.body[field.name] = normalizeCheckboxValue(req.body[field.name]); @@ -95,6 +96,22 @@ export function processFieldData(req: Request, fields: FormFieldConfig[]): void delete req.body[`${field.name}-month`]; delete req.body[`${field.name}-year`]; } + + // Process subFields if this is a radio or checkbox with options + if ((field.type === 'radio' || field.type === 'checkbox') && field.options) { + for (const option of field.options) { + if (option.subFields) { + for (const subField of Object.values(option.subFields)) { + // Recursively process subFields (they might be dates or checkboxes too) + processField(subField); + } + } + } + } + }; + + for (const field of fields) { + processField(field); } } @@ -173,7 +190,6 @@ export function getTranslationErrors( export function getCustomErrorTranslations(t: TFunction, fields: FormFieldConfig[]): Record { const stepSpecificErrors: Record = {}; - const nestedKeys = ['required', 'custom', 'missingOne', 'missingTwo', 'futureDate']; const commonErrorKeys = ['defaultRequired', 'defaultInvalid', 'defaultMaxLength']; for (const key of commonErrorKeys) { @@ -196,19 +212,26 @@ export function getCustomErrorTranslations(t: TFunction, fields: FormFieldConfig const visitField = (field: FormFieldConfig): void => { addMaxLengthTranslation(field.name); - // Keep existing nested error support for non-nested names (e.g., date fields) + // Auto-discover all error translations for this field from the step's translation file + // This eliminates the need for a hardcoded nestedKeys list if (!field.name.includes('.')) { - for (const nestedKey of nestedKeys) { - const nestedErrorKey = `errors.${field.name}.${nestedKey}`; - const nestedError = t(nestedErrorKey); - if (nestedError && nestedError !== nestedErrorKey) { - if (field.type === 'date') { - const dateKey = getDateTranslationKey(nestedKey); - if (dateKey) { - stepSpecificErrors[dateKey] = nestedError; + const errorNamespace = `errors.${field.name}`; + const allFieldErrors = t(errorNamespace, { returnObjects: true }); + + // If the translation exists and is an object (not a string), it contains error keys + if (allFieldErrors && typeof allFieldErrors === 'object' && !Array.isArray(allFieldErrors)) { + for (const [errorKey, errorMessage] of Object.entries(allFieldErrors)) { + if (typeof errorMessage === 'string') { + // Special handling for date fields - map to date-specific keys + if (field.type === 'date') { + const dateKey = getDateTranslationKey(errorKey); + if (dateKey) { + stepSpecificErrors[dateKey] = errorMessage; + } } + // Add the standard field.errorKey mapping + stepSpecificErrors[`${field.name}.${errorKey}`] = errorMessage; } - stepSpecificErrors[`${field.name}.${nestedKey}`] = nestedError; } } } diff --git a/src/main/services/ccdCaseService.ts b/src/main/services/ccdCaseService.ts index fef509ae7..092998ee1 100644 --- a/src/main/services/ccdCaseService.ts +++ b/src/main/services/ccdCaseService.ts @@ -47,7 +47,7 @@ interface EventTokenResponse { } function getBaseUrl(): string { - return config.get('ccd.url'); + return config.get('ccd.url'); } function getApiUrl(): string { @@ -163,21 +163,23 @@ async function submitEvent( export const ccdCaseService = { async getCaseById(accessToken: string, caseId: string, eventId: string = 'respondPossessionClaim'): Promise { - const eventUrl = `${getBaseUrl()}/cases/${caseId}/event-triggers/${eventId}?ignore-warning=false`; + const ccdUrl = getBaseUrl(); + const eventUrl = `${ccdUrl}/cases/${caseId}/event-triggers/${eventId}?ignore-warning=false`; try { - logger.info(`[ccdCaseService] Validating case access for caseId: ${caseId}, eventId: ${eventId}`); + logger.debug(`[ccdCaseService] Validating case ${caseId} for event ${eventId}`); + const response = await http.get<{ case_details?: { case_data?: Record } }>( eventUrl, getCaseHeaders(accessToken) ); - logger.info(`[ccdCaseService] Case access validated successfully for caseId: ${caseId}`); return { id: caseId, data: response.data.case_details?.case_data || {}, }; } catch (error) { + logger.error('[ccdCaseService] getCaseById failed:', error); throw convertAxiosErrorToHttpError(error, 'getCaseById'); } }, diff --git a/src/main/steps/respond-to-claim/flow.config.ts b/src/main/steps/respond-to-claim/flow.config.ts index 874d315b3..ac29a2a8b 100644 --- a/src/main/steps/respond-to-claim/flow.config.ts +++ b/src/main/steps/respond-to-claim/flow.config.ts @@ -2,11 +2,12 @@ import { type Request } from 'express'; import type { JourneyFlowConfig } from '../../interfaces/stepFlow.interface'; import { - getPreviousPageForArrears, + getPreviousNoticeStep, + hasAnyRentArrearsGround, + hasOnlyRentArrearsGrounds, isDefendantNameKnown, isNoticeDateProvided, isNoticeServed, - isRentArrearsClaim, isTenancyStartDateKnown, isWelshProperty, } from '../utils'; @@ -162,14 +163,14 @@ export const flowConfig: JourneyFlowConfig = { }, { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return rentArrears; }, nextStep: 'rent-arrears-dispute', }, { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return !rentArrears; }, nextStep: 'non-rent-arrears-dispute', @@ -185,14 +186,14 @@ export const flowConfig: JourneyFlowConfig = { }, { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return rentArrears; }, nextStep: 'rent-arrears-dispute', }, { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return !rentArrears; }, nextStep: 'non-rent-arrears-dispute', @@ -230,7 +231,7 @@ export const flowConfig: JourneyFlowConfig = { if (confirmNoticeGiven !== 'no' && confirmNoticeGiven !== 'imNotSure') { return false; } - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return rentArrears; }, nextStep: 'rent-arrears-dispute', @@ -241,7 +242,7 @@ export const flowConfig: JourneyFlowConfig = { if (confirmNoticeGiven !== 'no' && confirmNoticeGiven !== 'imNotSure') { return false; } - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return !rentArrears; }, nextStep: 'non-rent-arrears-dispute', @@ -257,14 +258,14 @@ export const flowConfig: JourneyFlowConfig = { routes: [ { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return rentArrears; }, nextStep: 'rent-arrears-dispute', }, { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return !rentArrears; }, nextStep: 'non-rent-arrears-dispute', @@ -276,14 +277,14 @@ export const flowConfig: JourneyFlowConfig = { routes: [ { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return rentArrears; }, nextStep: 'rent-arrears-dispute', }, { condition: async (req: Request): Promise => { - const rentArrears = await isRentArrearsClaim(req); + const rentArrears = await hasAnyRentArrearsGround(req); return !rentArrears; }, nextStep: 'non-rent-arrears-dispute', @@ -293,20 +294,33 @@ export const flowConfig: JourneyFlowConfig = { }, 'rent-arrears-dispute': { defaultNext: 'counter-claim', - previousStep: req => getPreviousPageForArrears(req), + previousStep: (req: Request, _formData: Record) => getPreviousNoticeStep(req), + routes: [ + { + condition: (req: Request): Promise => hasOnlyRentArrearsGrounds(req), + nextStep: 'counter-claim', + }, + { + condition: async (req: Request): Promise => !(await hasOnlyRentArrearsGrounds(req)), + nextStep: 'non-rent-arrears-dispute', + }, + ], }, 'non-rent-arrears-dispute': { defaultNext: 'counter-claim', - previousStep: req => getPreviousPageForArrears(req), - }, - 'counter-claim': { - defaultNext: 'payment-interstitial', previousStep: async (req: Request) => { - const rentArrearsClaim = await isRentArrearsClaim(req); + const rentArrearsClaim = await hasAnyRentArrearsGround(req); if (rentArrearsClaim) { return 'rent-arrears-dispute'; } - return 'non-rent-arrears-dispute'; + return getPreviousNoticeStep(req); + }, + }, + 'counter-claim': { + defaultNext: 'payment-interstitial', + previousStep: async (req: Request) => { + const onlyRentArrears = await hasOnlyRentArrearsGrounds(req); + return onlyRentArrears ? 'rent-arrears-dispute' : 'non-rent-arrears-dispute'; }, }, 'payment-interstitial': { diff --git a/src/main/steps/respond-to-claim/non-rent-arrears-dispute/nonRentArrearsDispute.njk b/src/main/steps/respond-to-claim/non-rent-arrears-dispute/nonRentArrearsDispute.njk index 469b68aea..763e11e87 100644 --- a/src/main/steps/respond-to-claim/non-rent-arrears-dispute/nonRentArrearsDispute.njk +++ b/src/main/steps/respond-to-claim/non-rent-arrears-dispute/nonRentArrearsDispute.njk @@ -15,11 +15,11 @@
- {{ govukButton({ + {{ govukButton({ text: continue, attributes: { type: 'submit', name: 'action', value: 'continue' } }) }} - {{ govukButton({ + {{ govukButton({ text: saveForLater, classes: 'govuk-button--secondary', attributes: { type: 'submit', name: 'action', value: 'saveForLater' } @@ -27,4 +27,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/main/steps/respond-to-claim/rent-arrears-dispute/index.ts b/src/main/steps/respond-to-claim/rent-arrears-dispute/index.ts index 2d71ff5be..d05c809aa 100644 --- a/src/main/steps/respond-to-claim/rent-arrears-dispute/index.ts +++ b/src/main/steps/respond-to-claim/rent-arrears-dispute/index.ts @@ -1,17 +1,189 @@ +import type { Request } from 'express'; + +import type { FormFieldConfig } from '../../../interfaces/formFieldConfig.interface'; import type { StepDefinition } from '../../../interfaces/stepFormData.interface'; +import { currency } from '../../../modules/nunjucks/filters/currency'; +import { createFormStep, getTranslationFunction } from '../../../modules/steps'; import { flowConfig } from '../flow.config'; -import { createFormStep } from '@modules/steps'; +// Define fields separately so we can dynamically inject validator +const fieldsConfig: FormFieldConfig[] = [ + { + name: 'rentArrears', + type: 'radio', + required: true, + translationKey: { + label: 'rentArrearsQuestion', + }, + legendClasses: 'govuk-fieldset__legend--m', + options: [ + { + value: 'yes', + translationKey: 'rentArrearsOptions.yes', + }, + { + value: 'no', + translationKey: 'rentArrearsOptions.no', + subFields: { + rentArrearsAmountCorrection: { + name: 'rentArrearsAmountCorrection', + type: 'text', + required: true, + translationKey: { + label: 'rentArrearsAmountCorrection.label', + hint: 'rentArrearsAmountCorrection.hint', + }, + classes: 'govuk-input--width-10', + prefix: { + text: '£', + }, + attributes: { + inputmode: 'decimal', + spellcheck: false, + }, + }, + }, + }, + { divider: 'rentArrearsOptions.or', translationKey: 'rentArrearsOptions.or' }, + { value: 'notSure', translationKey: 'rentArrearsOptions.notSure' }, + ], + }, +]; export const step: StepDefinition = createFormStep({ stepName: 'rent-arrears-dispute', journeyFolder: 'respondToClaim', stepDir: __dirname, flowConfig, - customTemplate: `${__dirname}/rentArrearsDispute.njk`, + customTemplate: `${__dirname}/rentArrears.njk`, translationKeys: { pageTitle: 'pageTitle', - caption: 'caption', + caption: 'captionHeading', + }, + ccdMapping: { + backendPath: 'possessionClaimResponse.defendantResponses', + frontendFields: ['rentArrears', 'rentArrears.rentArrearsAmountCorrection'], + valueMapper: (formData, _ctx) => { + // Handle single value case (shouldn't happen for this step, but for type safety) + if (typeof formData === 'string' || Array.isArray(formData)) { + return {}; + } + + const rentArrears = formData.rentArrears as 'yes' | 'no' | 'notSure' | undefined; + const amountRaw = formData['rentArrears.rentArrearsAmountCorrection'] as string | undefined; + + const result: Record = {}; + + // Map frontend radio value to backend enum (updated field name) + if (rentArrears === 'yes') { + result.rentArrearsAmountConfirmation = 'YES'; + } else if (rentArrears === 'no') { + result.rentArrearsAmountConfirmation = 'NO'; + // Convert pounds to pence (backend stores as pence string via MoneyGBP serializer) + if (amountRaw) { + const normalized = amountRaw.replace(/,/g, ''); // Remove comma separators + const amountInPounds = parseFloat(normalized); + if (!Number.isNaN(amountInPounds)) { + result.rentArrearsAmount = String(Math.round(amountInPounds * 100)); + } + } + } else if (rentArrears === 'notSure') { + result.rentArrearsAmountConfirmation = 'NOT_SURE'; + } + + return result; + }, + }, + getInitialFormData: (req: Request) => { + const caseData = req.res?.locals?.validatedCase?.data; + const response = caseData?.possessionClaimResponse?.defendantResponses; + + if (!response?.rentArrearsAmountConfirmation) { + return {}; + } + + const formData: Record = {}; + + // Map backend enum to frontend radio value (updated field name) + if (response.rentArrearsAmountConfirmation === 'YES') { + formData.rentArrears = 'yes'; + } else if (response.rentArrearsAmountConfirmation === 'NO') { + formData.rentArrears = 'no'; + // Prepopulate the amount if it exists (convert pence to pounds) + // Use dotted notation for subField, matching defendant-name-confirmation pattern + if (response.rentArrearsAmount) { + const amountInPence = parseFloat(response.rentArrearsAmount as string); + const amountInPounds = amountInPence / 100; + formData['rentArrears.rentArrearsAmountCorrection'] = amountInPounds.toFixed(2); + } + } else if (response.rentArrearsAmountConfirmation === 'NOT_SURE') { + formData.rentArrears = 'notSure'; + } + + return formData; + }, + extendGetContent: (req: Request) => { + const caseData = req.res?.locals.validatedCase?.data; + const claimantName = caseData?.possessionClaimResponse?.claimantOrganisations?.[0]?.value as string | undefined; + const amountInPence = (caseData?.rentArrears_Total as string | number) || 0; + const amountInPounds = typeof amountInPence === 'string' ? parseFloat(amountInPence) / 100 : amountInPence / 100; + const rentArrearsAmount = currency(amountInPounds); + + const t = getTranslationFunction(req, 'rent-arrears-dispute', ['common']); + + // Dynamically inject validator with translation function (following postcode pattern) + const rentArrearsField = fieldsConfig[0]; + const noOption = rentArrearsField.options?.find(opt => opt.value === 'no'); + const amountField = noOption?.subFields?.rentArrearsAmountCorrection; + + if (amountField) { + amountField.validator = (value: unknown): boolean | string => { + if (typeof value !== 'string') { + return true; + } + + const trimmed = value.trim(); + if (!trimmed) { + return true; + } // Let required validation handle empty values + + // Remove commas to handle user input like 1,234.56 + const normalized = trimmed.replace(/,/g, ''); + const numericValue = parseFloat(normalized); + + // Range validation + if (!Number.isNaN(numericValue)) { + if (numericValue < 0) { + return t('errors.rentArrearsAmountCorrection.negativeAmount'); + } + if (numericValue > 1000000000) { + return t('errors.rentArrearsAmountCorrection.largeAmount'); + } + } + + // Format validation: 1-10 digits, decimal point, exactly 2 decimal places + const formatRegex = /^\d{1,10}\.\d{2}$/; + if (!formatRegex.test(normalized)) { + return t('errors.rentArrearsAmountCorrection.invalidFormat'); + } + + return true; + }; + } + + const insetIntroText = t('insetIntroText'); + const insetDetailsText = t('insetDetailsText', { claimantName }); + const insetConditionalYesText = t('insetConditionalYesText'); + const amountOwedHeading = t('amountOwedHeading', { claimantName }); + const rentArrearsAmountCorrection = t('rentArrearsAmountCorrection'); + return { + insetIntroText, + insetDetailsText, + insetConditionalYesText, + amountOwedHeading, + rentArrearsAmount, + rentArrearsAmountCorrection, + }; }, - fields: [], + fields: fieldsConfig, }); diff --git a/src/main/steps/respond-to-claim/rent-arrears-dispute/rentArrears.njk b/src/main/steps/respond-to-claim/rent-arrears-dispute/rentArrears.njk new file mode 100644 index 000000000..a5d2c4bd3 --- /dev/null +++ b/src/main/steps/respond-to-claim/rent-arrears-dispute/rentArrears.njk @@ -0,0 +1,56 @@ +{% extends "stepsTemplate.njk" %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/character-count/macro.njk" import govukCharacterCount %} +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} + +{% block pageTitle %} + {{ pageTitle }} - HM Courts & Tribunals Service – GOV.UK +{% endblock %} + +{% block mainContent %} + {% if errorSummary %} + {{ govukErrorSummary(errorSummary) }} + {% endif %} + + {% if caption %} + {{ caption }} + {% endif %} + +

{{ pageTitle }}

+ +
+

{{ insetIntroText }}

+

{{ insetDetailsText }}

+

{{ insetConditionalYesText }}

+
+ +

{{ amountOwedHeading }}

+ +

{{ rentArrearsAmount }}

+ +
+ {% for field in fields %} + {% if field.component and field.componentType %} + {% if field.componentType == 'radios' %} + {{ govukRadios(field.component) }} + {% elif field.componentType == 'characterCount' %} + {{ govukCharacterCount(field.component) }} + {% endif %} + {% endif %} + {% endfor %} + +
+ {{ govukButton({ + text: saveAndContinue, + attributes: { type: 'submit', name: 'action', value: 'continue' } + }) }} + {{ govukButton({ + text: saveForLater, + classes: 'govuk-button--secondary', + attributes: { type: 'submit', name: 'action', value: 'saveForLater' } + }) }} +
+
+{% endblock %} diff --git a/src/main/steps/utils/isRentArrearsClaim.ts b/src/main/steps/utils/hasAnyRentArrearsGround.ts similarity index 78% rename from src/main/steps/utils/isRentArrearsClaim.ts rename to src/main/steps/utils/hasAnyRentArrearsGround.ts index 9a4520d22..0171cf93a 100644 --- a/src/main/steps/utils/isRentArrearsClaim.ts +++ b/src/main/steps/utils/hasAnyRentArrearsGround.ts @@ -1,12 +1,12 @@ import type { Request } from 'express'; /** - * Checks if the claim includes rent arrears from CCD case data. + * Checks if the claim includes any rent arrears ground from CCD case data. * * Checks claimGroundSummaries array from CCD case data. * Returns true if ANY claim ground has isRentArrears: "Yes" (case-insensitive), false otherwise. */ -export const isRentArrearsClaim = async (req: Request): Promise => { +export const hasAnyRentArrearsGround = async (req: Request): Promise => { const caseData = req.res?.locals?.validatedCase?.data; const claimGroundSummaries = caseData?.claimGroundSummaries; diff --git a/src/main/steps/utils/hasOnlyRentArrearsGrounds.ts b/src/main/steps/utils/hasOnlyRentArrearsGrounds.ts new file mode 100644 index 000000000..2ebc03d9b --- /dev/null +++ b/src/main/steps/utils/hasOnlyRentArrearsGrounds.ts @@ -0,0 +1,24 @@ +import type { Request } from 'express'; + +export const hasOnlyRentArrearsGrounds = async (req: Request): Promise => { + const caseData = req.res?.locals?.validatedCase?.data; + const claimGroundSummaries = caseData?.claimGroundSummaries; + + if (!Array.isArray(claimGroundSummaries) || claimGroundSummaries.length === 0) { + return false; + } + + const hasRentArrearsGround = claimGroundSummaries.some( + ground => ground?.value?.isRentArrears?.toUpperCase() === 'YES' + ); + + if (!hasRentArrearsGround) { + return false; + } + + const allGroundsAreRentArrears = claimGroundSummaries.every( + ground => ground?.value?.isRentArrears?.toUpperCase() === 'YES' + ); + + return allGroundsAreRentArrears; +}; diff --git a/src/main/steps/utils/index.ts b/src/main/steps/utils/index.ts index b9842c158..e99ebd398 100644 --- a/src/main/steps/utils/index.ts +++ b/src/main/steps/utils/index.ts @@ -1,8 +1,9 @@ export { isDefendantNameKnown } from './isDefendantNameKnown'; export { isWelshProperty } from './isWelshProperty'; export { isNoticeDateProvided } from './isNoticeDateProvided'; -export { isRentArrearsClaim } from './isRentArrearsClaim'; +export { hasAnyRentArrearsGround } from './hasAnyRentArrearsGround'; export { isNoticeServed } from './isNoticeServed'; +export { getPreviousNoticeStep } from './journeyHelpers'; +export { hasOnlyRentArrearsGrounds } from './hasOnlyRentArrearsGrounds'; export { isTenancyStartDateKnown } from './isTenancyStartDateKnown'; -export { getPreviousPageForArrears } from './journeyHelpers'; export { formatDatePartsToISODate } from './dateUtils'; diff --git a/src/main/steps/utils/journeyHelpers.ts b/src/main/steps/utils/journeyHelpers.ts index 61d19820a..4bf2a5a8c 100644 --- a/src/main/steps/utils/journeyHelpers.ts +++ b/src/main/steps/utils/journeyHelpers.ts @@ -4,7 +4,7 @@ import { isNoticeDateProvided } from './isNoticeDateProvided'; import { isNoticeServed } from './isNoticeServed'; import { isTenancyStartDateKnown } from './isTenancyStartDateKnown'; -export async function getPreviousPageForArrears(req: Request): Promise { +export async function getPreviousNoticeStep(req: Request): Promise { const noticeServed = await isNoticeServed(req); const noticeDateProvided = await isNoticeDateProvided(req); const tenancyStartDateKnown = await isTenancyStartDateKnown(req); diff --git a/src/test/ui/data/api-data/submitCase.api.data.ts b/src/test/ui/data/api-data/submitCase.api.data.ts index f64f05eb3..2e43ddea6 100644 --- a/src/test/ui/data/api-data/submitCase.api.data.ts +++ b/src/test/ui/data/api-data/submitCase.api.data.ts @@ -196,5 +196,222 @@ export const submitCaseApiData = { }, }; }, + get submitCasePayloadDemotedOld() { + return { + legislativeCountry: 'England', + claimantType: { + value: { + code: 'PROVIDER_OF_SOCIAL_HOUSING', + label: 'Registered provider of social housing', + }, + list_items: [ + { + code: 'PRIVATE_LANDLORD', + label: 'Private landlord', + }, + { + code: 'PROVIDER_OF_SOCIAL_HOUSING', + label: 'Registered provider of social housing', + }, + { + code: 'MORTGAGE_LENDER', + label: 'Mortgage lender', + }, + { + code: 'OTHER', + label: 'Other', + }, + ], + valueCode: 'PROVIDER_OF_SOCIAL_HOUSING', + }, + claimAgainstTrespassers: 'NO', + orgNameFound: 'Yes', + claimantName: 'Possession Claims Solicitor Org', + isClaimantNameCorrect: 'YES', + claimantContactEmail: 'pcs-solicitor-automation@test.com', + isCorrectClaimantContactEmail: 'YES', + orgAddressFound: 'Yes', + organisationAddress: { + AddressLine1: 'Ministry Of Justice', + AddressLine2: 'Seventh Floor 102 Petty France', + PostTown: 'London', + PostCode: 'SW1H 9AJ', + Country: 'United Kingdom', + }, + formattedClaimantContactAddress: 'Ministry Of Justice
Seventh Floor 102 Petty France
London
SW1H 9AJ', + isCorrectClaimantContactAddress: 'YES', + claimantProvidePhoneNumber: 'NO', + defendant1: { + nameKnown: 'YES', + firstName: 'Jeremiah', + lastName: 'Test', + addressKnown: 'YES', + addressSameAsPossession: 'YES', + }, + addAnotherDefendant: 'NO', + tenancy_TypeOfTenancyLicence: 'DEMOTED_TENANCY', + tenancy_TenancyLicenceDate: '2020-05-25', + tenancy_TenancyLicenceDocuments: [], + showIntroductoryDemotedOtherGroundReasonPage: 'No', + introGrounds_HasIntroductoryDemotedOtherGroundsForPossession: 'YES', + introGrounds_IntroductoryDemotedOrOtherGrounds: ['RENT_ARREARS'], + preActionProtocolCompleted: 'NO', + mediationAttempted: 'NO', + settlementAttempted: 'NO', + noticeServed: 'Yes', + rentDetails_CurrentRent: '250000', + rentDetails_Frequency: 'MONTHLY', + rentDetails_CalculatedDailyCharge: '8213', + notice_NoticeServiceMethod: 'FIRST_CLASS_POST', + notice_NoticePostedDate: '2022-05-20', + notice_NoticeDocuments: [], + rentSectionPaymentFrequency: 'MONTHLY', + rentDetails_FormattedCalculatedDailyCharge: '£82.13', + rentDetails_PerDayCorrect: 'YES', + showRentArrearsPage: 'Yes', + rentArrears_StatementDocuments: [ + { + id: '5b0334b3-2bdd-4ff5-a6bb-31a4850b61dc', + value: { + document_url: + 'http://dm-store-aat.service.core-compute-aat.internal/documents/a05c8b78-8c6e-43b8-b576-1254af8f51d8', + document_binary_url: + 'http://dm-store-aat.service.core-compute-aat.internal/documents/a05c8b78-8c6e-43b8-b576-1254af8f51d8/binary', + document_filename: 'Rent Arrears.docx', + document_hash: '62fcdba0614031ef277cc266972b5f03a2cb6f74589418d354c786f4e7ec6bbe', + }, + }, + ], + rentArrears_Total: '200000', + rentArrears_ThirdPartyPayments: 'NO', + arrearsJudgmentWanted: 'NO', + claimantNamePossessiveForm: 'Possession Claims Solicitor Org’s', + claimantCircumstancesSelect: 'NO', + hasDefendantCircumstancesInfo: 'NO', + suspensionOfRTB_ShowHousingActsPage: 'No', + demotionOfTenancy_ShowHousingActsPage: 'No', + suspensionToBuyDemotionOfTenancyPages: 'No', + alternativesToPossession: [], + claimingCostsWanted: 'NO', + additionalReasonsForPossession: { + hasReasons: 'NO', + }, + hasUnderlesseeOrMortgagee: 'NO', + wantToUploadDocuments: 'NO', + applicationWithClaim: 'NO', + languageUsed: 'ENGLISH_AND_WELSH', + completionNextStep: 'SUBMIT_AND_PAY_NOW', + statementOfTruth: { + completedBy: 'CLAIMANT', + fullNameClaimant: 'fg', + positionClaimant: 'fg', + agreementClaimant: ['BELIEVE_TRUE'], + }, + }; + }, + get submitCasePayloadDemoted() { + return { + legislativeCountry: 'England', + claimantType: { + value: { + code: 'PROVIDER_OF_SOCIAL_HOUSING', + label: 'Registered provider of social housing', + }, + list_items: [ + { + code: 'PRIVATE_LANDLORD', + label: 'Private landlord', + }, + { + code: 'PROVIDER_OF_SOCIAL_HOUSING', + label: 'Registered provider of social housing', + }, + { + code: 'MORTGAGE_LENDER', + label: 'Mortgage lender', + }, + { + code: 'OTHER', + label: 'Other', + }, + ], + valueCode: 'PROVIDER_OF_SOCIAL_HOUSING', + }, + claimAgainstTrespassers: 'NO', + orgNameFound: 'Yes', + claimantName: 'Possession Claims Solicitor Org', + isClaimantNameCorrect: 'YES', + claimantContactEmail: 'pcs-solicitor-automation@test.com', + isCorrectClaimantContactEmail: 'YES', + orgAddressFound: 'Yes', + organisationAddress: { + AddressLine1: 'Ministry Of Justice', + AddressLine2: 'Seventh Floor 102 Petty France', + PostTown: 'London', + PostCode: 'SW1H 9AJ', + Country: 'United Kingdom', + }, + formattedClaimantContactAddress: 'Ministry Of Justice
Seventh Floor 102 Petty France
London
SW1H 9AJ', + isCorrectClaimantContactAddress: 'YES', + claimantProvidePhoneNumber: 'NO', + addAnotherDefendant: 'NO', + tenancy_TypeOfTenancyLicence: 'DEMOTED_TENANCY', + tenancy_TenancyLicenceDate: '2020-05-20', + defendant1: { + nameKnown: 'YES', + firstName: 'Jeremiah', + lastName: 'Test', + addressKnown: 'YES', + addressSameAsPossession: 'YES', + }, + tenancy_TenancyLicenceDocuments: [], + showIntroductoryDemotedOtherGroundReasonPage: 'No', + introGrounds_HasIntroductoryDemotedOtherGroundsForPossession: 'YES', + introGrounds_IntroductoryDemotedOrOtherGrounds: ['RENT_ARREARS'], + preActionProtocolCompleted: 'NO', + mediationAttempted: 'NO', + settlementAttempted: 'NO', + noticeServed: 'No', + rentDetails_CurrentRent: '200000', + rentDetails_Frequency: 'MONTHLY', + rentDetails_CalculatedDailyCharge: '6570', + rentSectionPaymentFrequency: 'MONTHLY', + rentDetails_FormattedCalculatedDailyCharge: '£65.70', + rentDetails_PerDayCorrect: 'YES', + showRentArrearsPage: 'Yes', + rentArrears_StatementDocuments: [ + { + id: '7cd96ae9-5cb9-499c-a59c-4a8c8abf5cf6', + value: { + document_url: + 'http://dm-store-aat.service.core-compute-aat.internal/documents/78df59c5-df94-462c-9fe9-eaa0563a628f', + document_binary_url: + 'http://dm-store-aat.service.core-compute-aat.internal/documents/78df59c5-df94-462c-9fe9-eaa0563a628f/binary', + document_filename: 'Rent Arrears.docx', + document_hash: 'f80a62d4af70b26fdc97c2f65531191d1d664f9f5b6497e7fdcd21e7b646d774', + }, + }, + ], + rentArrears_Total: '500000', + rentArrears_ThirdPartyPayments: 'NO', + arrearsJudgmentWanted: 'NO', + claimantNamePossessiveForm: 'Possession Claims Solicitor Org’s', + claimantCircumstancesSelect: 'NO', + hasDefendantCircumstancesInfo: 'NO', + suspensionOfRTB_ShowHousingActsPage: 'No', + demotionOfTenancy_ShowHousingActsPage: 'No', + suspensionToBuyDemotionOfTenancyPages: 'No', + alternativesToPossession: [], + claimingCostsWanted: 'NO', + additionalReasonsForPossession: { + hasReasons: 'NO', + }, + hasUnderlesseeOrMortgagee: 'NO', + wantToUploadDocuments: 'NO', + applicationWithClaim: 'NO', + languageUsed: 'ENGLISH_AND_WELSH', + completionNextStep: 'SUBMIT_AND_PAY_NOW', + }; + }, submitCaseApiEndPoint: (): string => `/cases/${process.env.CASE_NUMBER}/events`, }; diff --git a/src/test/ui/data/page-data/index.ts b/src/test/ui/data/page-data/index.ts index e79489b49..4dca23f91 100644 --- a/src/test/ui/data/page-data/index.ts +++ b/src/test/ui/data/page-data/index.ts @@ -14,7 +14,7 @@ export * from './contactByTextMessage.page.data'; export * from './noticeDetails.page.data'; export * from './noticeDateKnown.page.data'; export * from './noticeDateUnknown.page.data'; -export * from './rentArrearsDispute.page.data'; +export * from './rentArrears.page.data'; export * from './nonRentArrearsDispute.page.data'; export * from './counterClaim.page.data'; export * from './repaymentsMade.page.data'; diff --git a/src/test/ui/data/page-data/rentArrears.page.data.ts b/src/test/ui/data/page-data/rentArrears.page.data.ts new file mode 100644 index 000000000..4c8967f0d --- /dev/null +++ b/src/test/ui/data/page-data/rentArrears.page.data.ts @@ -0,0 +1,22 @@ +export const rentArrears = { + mainHeader: `Rent arrears`, + saveAndContinueButton: `Save and continue`, + rentAmountTextInput: `1000.00`, + incorrectFormatTextInput: `1000`, + negativeTextInput: `-100.00`, + billionTextInput: `1000001.00`, + doYouOweThisQuestion: `Do you owe this amount in rent arrears?`, + howMuchDoYouBelieveHiddenTextLabel: `How much do you believe you owe in rent arrears?`, + yesRadioOption: `Yes`, + noRadioOption: `No`, + imNotSureRadioOption: `I’m not sure`, + saveForLaterButton: `Save for later`, + backLink: `Back`, + thereIsAProblemErrorMessageHeader: `There is a problem`, + errorValidationHeader: `There is a problem`, + enterHowMuchYouBelieveErrorMessage: `Enter the amount you believe you owe in rent arrears`, + theAmountYouBelieveErrorMessage: `The amount you believe you owe in rent arrears must be £0.00 or above`, + lessThanBillionErrorMessage: `The amount you believe you owe in rent arrears must be less than £1 billion`, + doYouOweThisAmountErrorMessage: `Do you owe this amount in rent arrears?`, + enterAmountInCorrectFormat: `Enter an amount in the correct format, for example 148.00 or 148.50`, +}; diff --git a/src/test/ui/data/page-data/rentArrearsDispute.page.data.ts b/src/test/ui/data/page-data/rentArrearsDispute.page.data.ts deleted file mode 100644 index 1ce132dcf..000000000 --- a/src/test/ui/data/page-data/rentArrearsDispute.page.data.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const rentArrearsDispute = { - mainHeader: `Rent Arrears Dispute(Placeholder)`, - continueButton: `Continue`, -}; diff --git a/src/test/ui/e2eTest/respondToAClaim.spec.ts b/src/test/ui/e2eTest/respondToAClaim.spec.ts index 31a5ded8a..23084e2be 100644 --- a/src/test/ui/e2eTest/respondToAClaim.spec.ts +++ b/src/test/ui/e2eTest/respondToAClaim.spec.ts @@ -14,7 +14,7 @@ import { freeLegalAdvice, nonRentArrearsDispute, noticeDetails, - rentArrearsDispute, + rentArrears, repaymentsAgreed, repaymentsMade, startNow, @@ -338,8 +338,9 @@ test.describe('Respond to a claim - e2e Journey @nightly', async () => { option: noticeDetails.yesRadioOption, }); await performAction('enterNoticeDateUnknown'); - await performValidation('mainHeader', rentArrearsDispute.mainHeader); - await performAction('clickButton', rentArrearsDispute.continueButton); + await performAction('rentArrears', { + option: rentArrears.yesRadioOption, + }); // placeholder page, so need to be replaced with custom action when actual page is implemented await performValidation('mainHeader', counterClaim.mainHeader); await performAction('clickButton', counterClaim.saveAndContinueButton); @@ -383,8 +384,10 @@ test.describe('Respond to a claim - e2e Journey @nightly', async () => { month: '2', year: '2020', }); - await performValidation('mainHeader', rentArrearsDispute.mainHeader); - await performAction('clickButton', rentArrearsDispute.continueButton); + await performAction('rentArrears', { + option: rentArrears.noRadioOption, + rentAmount: rentArrears.rentAmountTextInput, + }); }); test('RentArrears - NoticeServed - Yes NoticeDateProvided - No - NoticeDetails - No - RentArrearsDispute @regression', async () => { @@ -418,8 +421,9 @@ test.describe('Respond to a claim - e2e Journey @nightly', async () => { await performAction('selectNoticeDetails', { option: noticeDetails.noRadioOption, }); - await performValidation('mainHeader', rentArrearsDispute.mainHeader); - await performAction('clickButton', rentArrearsDispute.continueButton); + await performAction('rentArrears', { + option: rentArrears.yesRadioOption, + }); // placeholder page, so need to be replaced with custom action when actual page is implemented await performValidation('mainHeader', counterClaim.mainHeader); await performAction('clickButton', counterClaim.saveAndContinueButton); @@ -457,8 +461,9 @@ test.describe('Respond to a claim - e2e Journey @nightly', async () => { await performAction('selectNoticeDetails', { option: noticeDetails.imNotSureRadioOption, }); - await performValidation('mainHeader', rentArrearsDispute.mainHeader); - await performAction('clickButton', rentArrearsDispute.continueButton); + await performAction('rentArrears', { + option: rentArrears.imNotSureRadioOption, + }); // placeholder page, so need to be replaced with custom action when actual page is implemented await performValidation('mainHeader', counterClaim.mainHeader); await performAction('clickButton', counterClaim.saveAndContinueButton); @@ -497,8 +502,9 @@ test.describe('Respond to a claim - e2e Journey @nightly', async () => { month: '12', year: '2025', }); - await performValidation('mainHeader', rentArrearsDispute.mainHeader); - await performAction('clickButton', rentArrearsDispute.continueButton); + await performAction('rentArrears', { + option: rentArrears.yesRadioOption, + }); // placeholder page, so need to be replaced with custom action when actual page is implemented await performValidation('mainHeader', counterClaim.mainHeader); await performAction('clickButton', counterClaim.saveAndContinueButton); diff --git a/src/test/ui/functional/respondToAClaim.spec.ts b/src/test/ui/functional/respondToAClaim.spec.ts index 358328e41..49064f96e 100644 --- a/src/test/ui/functional/respondToAClaim.spec.ts +++ b/src/test/ui/functional/respondToAClaim.spec.ts @@ -18,7 +18,7 @@ import { noticeDateUnknown, noticeDetails, paymentInterstitial, - rentArrearsDispute, + rentArrears, repaymentsMade, startNow, tenancyDateDetails, @@ -435,8 +435,9 @@ test.describe('Respond to a claim - functional @nightly', async () => { await performAction('selectNoticeDetails', { option: noticeDetails.imNotSureRadioOption, }); - await performValidation('mainHeader', rentArrearsDispute.mainHeader); - await performAction('clickButton', rentArrearsDispute.continueButton); + await performAction('rentArrears', { + option: rentArrears.yesRadioOption, + }); await performValidation('mainHeader', counterClaim.mainHeader); await performAction('clickButton', counterClaim.saveAndContinueButton); await performAction('clickButton', paymentInterstitial.continueButton); diff --git a/src/test/ui/utils/actions/custom-actions/respondToClaim.action.ts b/src/test/ui/utils/actions/custom-actions/respondToClaim.action.ts index 97625477b..429a6326b 100644 --- a/src/test/ui/utils/actions/custom-actions/respondToClaim.action.ts +++ b/src/test/ui/utils/actions/custom-actions/respondToClaim.action.ts @@ -14,6 +14,7 @@ import { noticeDateUnknown, noticeDetails, paymentInterstitial, + rentArrears, repaymentsMade, tenancyDateDetails, tenancyDateUnknown, @@ -40,6 +41,7 @@ export class RespondToClaimAction implements IAction { ['readPaymentInterstitial', () => this.readPaymentInterstitial()], ['repaymentsMade', () => this.repaymentsMade(fieldName as actionRecord)], ['disputeClaimInterstitial', () => this.disputeClaimInterstitial(fieldName as actionData)], + ['rentArrears', () => this.rentArrears(fieldName as actionRecord)], ['enterTenancyStartDetailsUnKnown', () => this.enterTenancyStartDetailsUnKnown(fieldName as actionRecord)], ]); const actionToPerform = actionsMap.get(action); @@ -217,6 +219,39 @@ export class RespondToClaimAction implements IAction { await performAction('clickButton', noticeDateUnknown.saveAndContinueButton); } + private async rentArrears(rentArrearsInfo: actionRecord): Promise { + // let rentArrears_Total: string | null = null; + // let claimantName: string | null = null; + // if (rentArrearsInfo.tenancy === 'flexible') { + // rentArrears_Total = `${submitCaseApiData.submitCasePayloadFlexibleTenancyDate.rentArrears_Total}`; + // claimantName = `${submitCaseApiData.submitCasePayloadFlexibleTenancyDate.claimantName}`; + // } + // if (rentArrearsInfo.tenancy === 'introductory') { + // rentArrears_Total = `${submitCaseApiData.submitCasePayloadIntroductoryTenancy.rentArrears_Total}`; + // claimantName = `${submitCaseApiData.submitCasePayloadIntroductoryTenancy.claimantName}`; + // } + + await performValidation('text', { + elementType: 'paragraph', + text: `Amount you owe in rent arrears given by ${submitCaseApiData.submitCasePayload.claimantName}:`, + }); + await performValidation('text', { + elementType: 'paragraph', + text: `Rent arrears are money you owe in rent payments. +When making their claim, ${submitCaseApiData.submitCasePayload.claimantName} had to provide a copy of the rent statement for your property, showing the total rent arrears you owe. +The rent statement will have been included in the pack you received in the post letting you know a claim had been made against you, and is also available to view from ‘View the claim’ on your case overview.`, + }); + //await performValidation('text', { elementType: 'heading', text: `${submitCaseApiData.submitCasePayload.rentArrears_Total}` }); + await performAction('clickRadioButton', { + question: rentArrears.doYouOweThisQuestion, + option: rentArrearsInfo.option, + }); + if (rentArrearsInfo.option === rentArrears.noRadioOption) { + await performAction('inputText', rentArrears.howMuchDoYouBelieveHiddenTextLabel, rentArrearsInfo.rentAmount); + } + await performAction('clickButton', rentArrears.saveAndContinueButton); + } + private async enterTenancyStartDetailsUnKnown(tenancyStartData: actionRecord) { const getDidNotProvideParagraph = tenancyDateUnknown.getDidNotProvideParagraph(claimantsName); await performValidation('text', { elementType: 'paragraph', text: getDidNotProvideParagraph }); diff --git a/src/test/ui/utils/registry/action.registry.ts b/src/test/ui/utils/registry/action.registry.ts index 8549314d7..f4e83fb07 100644 --- a/src/test/ui/utils/registry/action.registry.ts +++ b/src/test/ui/utils/registry/action.registry.ts @@ -58,6 +58,7 @@ export class ActionRegistry { ['enterTenancyStartDetailsUnKnown', new RespondToClaimAction()], ['triggerFunctionalTests', new TriggerPageFunctionalTestsAction()], ['selectTenancyStartDateKnown', new RespondToClaimAction()], + ['rentArrears', new RespondToClaimAction()], ]); static getAction(actionName: string): IAction { diff --git a/src/test/unit/app/controller/formHelpers.test.ts b/src/test/unit/app/controller/formHelpers.test.ts index 599646a6a..896e0962e 100644 --- a/src/test/unit/app/controller/formHelpers.test.ts +++ b/src/test/unit/app/controller/formHelpers.test.ts @@ -11,6 +11,7 @@ import { validateForm, } from '../../../../main/modules/steps'; import { getErrorMessage } from '../../../../main/modules/steps/formBuilder/errorUtils'; +import { createMockT } from '../../helpers/mockTranslation'; describe('formHelpers', () => { describe('getFormData', () => { @@ -1510,15 +1511,13 @@ describe('formHelpers', () => { }, ]; - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.futureDate') { - return 'Your date of birth must be in the past'; - } - if (key === 'errors.date.futureDate') { - return 'Date must be in the past'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + futureDate: 'Your date of birth must be in the past', + }, + 'errors.dateOfBirth.futureDate': 'Your date of birth must be in the past', + 'errors.date.futureDate': 'Date must be in the past', + }); const stepSpecificErrors = getCustomErrorTranslations(mockT, fields); const translations = { ...stepSpecificErrors }; @@ -2084,15 +2083,14 @@ describe('formHelpers', () => { }); it('should return step-specific date error translations', () => { - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.required') { - return 'Enter your date of birth'; - } - if (key === 'errors.dateOfBirth.missingOne') { - return 'Your date of birth must include a {{missingField}}'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + required: 'Enter your date of birth', + missingOne: 'Your date of birth must include a {{missingField}}', + }, + 'errors.dateOfBirth.required': 'Enter your date of birth', + 'errors.dateOfBirth.missingOne': 'Your date of birth must include a {{missingField}}', + }); const fields = [ { @@ -2111,12 +2109,12 @@ describe('formHelpers', () => { }); it('should return custom error translations', () => { - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.custom') { - return 'Custom validation error'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + custom: 'Custom validation error', + }, + 'errors.dateOfBirth.custom': 'Custom validation error', + }); const fields = [ { @@ -2132,15 +2130,16 @@ describe('formHelpers', () => { }); it('should handle multiple fields with custom errors', () => { - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.required') { - return 'Enter your date of birth'; - } - if (key === 'errors.startDate.required') { - return 'Enter a start date'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + required: 'Enter your date of birth', + }, + 'errors.startDate': { + required: 'Enter a start date', + }, + 'errors.dateOfBirth.required': 'Enter your date of birth', + 'errors.startDate.required': 'Enter a start date', + }); const fields = [ { @@ -2162,12 +2161,12 @@ describe('formHelpers', () => { }); it('should handle missingTwo translation key', () => { - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.missingTwo') { - return 'Your date of birth must include two parts'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + missingTwo: 'Your date of birth must include two parts', + }, + 'errors.dateOfBirth.missingTwo': 'Your date of birth must include two parts', + }); const fields = [ { @@ -2184,12 +2183,12 @@ describe('formHelpers', () => { }); it('should handle futureDate translation key for date fields', () => { - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.futureDate') { - return 'Your date of birth must be in the past'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + futureDate: 'Your date of birth must be in the past', + }, + 'errors.dateOfBirth.futureDate': 'Your date of birth must be in the past', + }); const fields = [ { @@ -2206,12 +2205,12 @@ describe('formHelpers', () => { }); it('should not add date key for non-date fields', () => { - const mockT = ((key: string) => { - if (key === 'errors.textField.required') { - return 'Text field is required'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.textField': { + required: 'Text field is required', + }, + 'errors.textField.required': 'Text field is required', + }); const fields = [ { @@ -2227,12 +2226,12 @@ describe('formHelpers', () => { }); it('should handle nested keys that do not map to date keys', () => { - const mockT = ((key: string) => { - if (key === 'errors.dateOfBirth.unknownKey') { - return 'Unknown error'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.dateOfBirth': { + unknownKey: 'Unknown error', + }, + 'errors.dateOfBirth.unknownKey': 'Unknown error', + }); const fields = [ { @@ -2242,17 +2241,20 @@ describe('formHelpers', () => { ]; const result = getCustomErrorTranslations(mockT, fields); - // unknownKey doesn't map to a date key, so it won't be in result - expect(result).toEqual({}); + // unknownKey doesn't map to a date-specific key (like dateRequired), but it does get included + // as a field-specific error translation (dateOfBirth.unknownKey) + expect(result).toEqual({ + 'dateOfBirth.unknownKey': 'Unknown error', + }); }); it('should handle non-date field custom errors', () => { - const mockT = ((key: string) => { - if (key === 'errors.textField.required') { - return 'Text field is required'; - } - return key; - }) as TFunction; + const mockT = createMockT({ + 'errors.textField': { + required: 'Text field is required', + }, + 'errors.textField.required': 'Text field is required', + }); const fields = [ { diff --git a/src/test/unit/helpers/mockTranslation.ts b/src/test/unit/helpers/mockTranslation.ts new file mode 100644 index 000000000..4ec51878c --- /dev/null +++ b/src/test/unit/helpers/mockTranslation.ts @@ -0,0 +1,56 @@ +import type { TFunction } from 'i18next'; + +/** + * Creates a mock TFunction for testing that properly supports returnObjects option. + * + * This factory eliminates the need to duplicate mock logic across test files. + * It handles both string translations and object translations (via returnObjects: true). + * + * @param translations - A map of translation keys to their values (strings or nested objects) + * @returns A mock TFunction that can be used in tests + * + * @example + * ```typescript + * const mockT = createMockT({ + * 'errors.dateOfBirth': { + * required: 'Enter your date of birth', + * maxLength: 'Must be 60 characters or less', + * }, + * 'errors.dateOfBirth.required': 'Enter your date of birth', // Also support direct key access + * }); + * + * // When called with returnObjects: true + * mockT('errors.dateOfBirth', { returnObjects: true }) + * // Returns: { required: 'Enter your date of birth', maxLength: 'Must be 60 characters or less' } + * + * // When called without returnObjects + * mockT('errors.dateOfBirth.required') + * // Returns: 'Enter your date of birth' + * ``` + */ +export function createMockT(translations: Record): TFunction { + return ((key: string, options?: Record) => { + const value = translations[key]; + + // Handle returnObjects: true - return object if it exists + if (options?.returnObjects && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + + // Handle string translations + if (typeof value === 'string') { + // Support interpolation if provided + if (options && typeof options === 'object' && !options.returnObjects) { + let result = value; + for (const [placeholder, replacement] of Object.entries(options)) { + result = result.replace(new RegExp(`{{\\s*${placeholder}\\s*}}`, 'g'), String(replacement)); + } + return result; + } + return value; + } + + // Fallback: return the key itself (i18next behavior for missing translations) + return key; + }) as TFunction; +} diff --git a/src/test/unit/modules/steps/formBuilder/helpers.test.ts b/src/test/unit/modules/steps/formBuilder/helpers.test.ts index f7564caf4..804046a03 100644 --- a/src/test/unit/modules/steps/formBuilder/helpers.test.ts +++ b/src/test/unit/modules/steps/formBuilder/helpers.test.ts @@ -11,6 +11,7 @@ import { setFormData, validateForm, } from '../../../../../main/modules/steps/formBuilder/helpers'; +import { createMockT } from '../../../helpers/mockTranslation'; describe('formBuilder helpers', () => { describe('getTranslation', () => { @@ -206,12 +207,12 @@ describe('formBuilder helpers', () => { }); describe('getTranslationErrors', () => { - const createMockT = (translations: Record = {}) => { + const createSimpleMockT = (translations: Record = {}) => { return jest.fn((key: string) => translations[key] || key) as unknown as TFunction; }; it('should return empty object when no fields provided', () => { - const mockT = createMockT(); + const mockT = createSimpleMockT(); const result = getTranslationErrors(mockT, []); expect(result).toEqual({}); }); @@ -391,15 +392,14 @@ describe('formBuilder helpers', () => { }); it('should include both common errors and field-specific errors', () => { - const mockT = jest.fn((key: string) => { - const translations: Record = { - 'errors.defaultRequired': 'This field is required', - 'errors.defaultInvalid': 'Invalid format', - 'errors.defaultMaxLength': 'Must be {max} characters or fewer', - 'errors.dateOfBirth.required': 'Enter your date of birth', - }; - return translations[key] || key; - }) as unknown as TFunction; + const mockT = createMockT({ + 'errors.defaultRequired': 'This field is required', + 'errors.defaultInvalid': 'Invalid format', + 'errors.defaultMaxLength': 'Must be {max} characters or fewer', + 'errors.dateOfBirth': { + required: 'Enter your date of birth', + }, + }); const fields = [ { @@ -421,12 +421,12 @@ describe('formBuilder helpers', () => { }); describe('getCustomErrorTranslations - field-specific maxLength', () => { - const createMockT = (translations: Record = {}) => { + const createSimpleMockT2 = (translations: Record = {}) => { return jest.fn((key: string) => translations[key] || key) as unknown as TFunction; }; it('should map errors.MaxLength to .maxLength', () => { - const mockT = createMockT({ + const mockT = createSimpleMockT2({ 'errors.firstNameMaxLength': 'First name must be 60 characters or less', }); diff --git a/src/test/unit/steps/utils/isRentArrearsClaim.test.ts b/src/test/unit/steps/utils/hasAnyRentArrearsGround.test.ts similarity index 90% rename from src/test/unit/steps/utils/isRentArrearsClaim.test.ts rename to src/test/unit/steps/utils/hasAnyRentArrearsGround.test.ts index 2ef38504b..4acad905d 100644 --- a/src/test/unit/steps/utils/isRentArrearsClaim.test.ts +++ b/src/test/unit/steps/utils/hasAnyRentArrearsGround.test.ts @@ -1,8 +1,8 @@ import { Request } from 'express'; -import { isRentArrearsClaim } from '../../../../main/steps/utils/isRentArrearsClaim'; +import { hasAnyRentArrearsGround } from '../../../../main/steps/utils/hasAnyRentArrearsGround'; -describe('isRentArrearsClaim', () => { +describe('hasAnyRentArrearsGround', () => { describe('when claim includes rent arrears', () => { it('should return true when single ground has isRentArrears=Yes', async () => { const mockReq = { @@ -25,7 +25,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -50,7 +50,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -75,7 +75,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -100,7 +100,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -140,7 +140,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -173,7 +173,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -201,7 +201,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -241,7 +241,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -266,7 +266,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -292,7 +292,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -318,7 +318,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -344,7 +344,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -362,7 +362,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -380,7 +380,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -398,7 +398,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -416,7 +416,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -434,7 +434,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -450,7 +450,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -462,7 +462,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -472,7 +472,7 @@ describe('isRentArrearsClaim', () => { res: {}, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -480,7 +480,7 @@ describe('isRentArrearsClaim', () => { it('should return false when res is undefined', async () => { const mockReq = {} as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -504,7 +504,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -522,7 +522,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -540,7 +540,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -568,7 +568,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -597,7 +597,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -630,7 +630,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -680,7 +680,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); // Should return true because ground-1 has isRentArrears: 'Yes' expect(result).toBe(true); @@ -711,7 +711,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); @@ -746,7 +746,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(true); }); @@ -786,7 +786,7 @@ describe('isRentArrearsClaim', () => { }, } as unknown as Request; - const result = await isRentArrearsClaim(mockReq); + const result = await hasAnyRentArrearsGround(mockReq); expect(result).toBe(false); }); diff --git a/src/test/unit/steps/utils/hasOnlyRentArrearsGrounds.test.ts b/src/test/unit/steps/utils/hasOnlyRentArrearsGrounds.test.ts new file mode 100644 index 000000000..d04ba7088 --- /dev/null +++ b/src/test/unit/steps/utils/hasOnlyRentArrearsGrounds.test.ts @@ -0,0 +1,423 @@ +import { Request } from 'express'; + +import { hasOnlyRentArrearsGrounds } from '../../../../main/steps/utils/hasOnlyRentArrearsGrounds'; + +describe('hasOnlyRentArrearsGrounds', () => { + describe('when all grounds are rent arrears (rent-only)', () => { + it('should return true when single ground has isRentArrears=Yes (case-insensitive)', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + }, + id: 'ground-1', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(true); + }); + + it('should return true when multiple grounds all have isRentArrears=Yes', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + }, + id: 'ground-1', + }, + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + }, + id: 'ground-2', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(true); + }); + }); + + describe('when some grounds are NOT rent arrears (mixed)', () => { + it('should return false when one ground is rent arrears and another is not', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + }, + id: 'ground-1', + }, + { + value: { + isRentArrears: 'No', + code: 'NUISANCE_OR_IMMORAL_USE', + }, + id: 'ground-2', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false with multiple non-rent grounds', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + }, + id: 'ground-1', + }, + { + value: { + isRentArrears: 'No', + code: 'NUISANCE_OR_IMMORAL_USE', + }, + id: 'ground-2', + }, + { + value: { + isRentArrears: 'No', + code: 'CRIMINAL_CONVICTION', + }, + id: 'ground-3', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + }); + + describe('when NO grounds are rent arrears (non-rent only)', () => { + it('should return false when no grounds have isRentArrears=Yes', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'No', + code: 'NUISANCE_OR_IMMORAL_USE', + }, + id: 'ground-1', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false with multiple non-rent grounds only', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'No', + code: 'NUISANCE_OR_IMMORAL_USE', + }, + id: 'ground-1', + }, + { + value: { + isRentArrears: 'No', + code: 'CRIMINAL_CONVICTION', + }, + id: 'ground-2', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false when claimGroundSummaries is empty array', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false when claimGroundSummaries is undefined', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: {}, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false when claimGroundSummaries is not an array', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: 'not-an-array', + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false when validatedCase is undefined', async () => { + const mockReq = { + res: { + locals: {}, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false when res.locals is undefined', async () => { + const mockReq = { + res: {}, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false when ground.value is undefined', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + id: 'ground-1', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should handle mixed null/undefined grounds with valid rent arrears ground', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + }, + id: 'ground-1', + }, + { + value: null, + id: 'ground-2', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + }); + + describe('regression tests - verify against real CCD data', () => { + it('should return true for rent-only scenario (real CCD data)', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + description: 'Rent arrears or breach of other obligation of the tenancy', + }, + id: '4bd94bb9-e72f-473e-95ae-a6d2b3f8e8be', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(true); + }); + + it('should return false for mixed grounds scenario (real CCD data)', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'Yes', + code: 'RENT_ARREARS_OR_BREACH_OF_TENANCY', + description: 'Rent arrears or breach of other obligation of the tenancy', + }, + id: 'ground-1', + }, + { + value: { + isRentArrears: 'No', + code: 'NUISANCE_OR_IMMORAL_USE', + description: + 'Nuisance or annoyance to neighbours, illegal or immoral use of the property, or conviction for an arrestable offence in or near the property', + }, + id: 'ground-2', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + + it('should return false for non-rent-only scenario (real CCD data)', async () => { + const mockReq = { + res: { + locals: { + validatedCase: { + data: { + claimGroundSummaries: [ + { + value: { + isRentArrears: 'No', + code: 'NUISANCE_OR_IMMORAL_USE', + description: + 'Nuisance or annoyance to neighbours, illegal or immoral use of the property, or conviction for an arrestable offence in or near the property', + }, + id: 'ground-1', + }, + ], + }, + }, + }, + }, + } as unknown as Request; + + const result = await hasOnlyRentArrearsGrounds(mockReq); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/test/unit/steps/utils/journeyHelpers.test.ts b/src/test/unit/steps/utils/journeyHelpers.test.ts index 0ff50a512..1f09abb33 100644 --- a/src/test/unit/steps/utils/journeyHelpers.test.ts +++ b/src/test/unit/steps/utils/journeyHelpers.test.ts @@ -1,13 +1,13 @@ import { isNoticeDateProvided } from '../../../../main/steps/utils/isNoticeDateProvided'; import { isNoticeServed } from '../../../../main/steps/utils/isNoticeServed'; import { isTenancyStartDateKnown } from '../../../../main/steps/utils/isTenancyStartDateKnown'; -import { getPreviousPageForArrears } from '../../../../main/steps/utils/journeyHelpers'; +import { getPreviousNoticeStep } from '../../../../main/steps/utils/journeyHelpers'; jest.mock('../../../../main/steps/utils/isNoticeDateProvided'); jest.mock('../../../../main/steps/utils/isNoticeServed'); jest.mock('../../../../main/steps/utils/isTenancyStartDateKnown'); -describe('getPreviousPageForArrears', () => { +describe('getPreviousNoticeStep', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockReq: any; @@ -24,7 +24,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'no' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-given'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-given'); }); it('returns confirmation-of-notice-given when user said imNotSure and notice served', async () => { @@ -32,7 +32,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'imNotSure' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-given'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-given'); }); it('returns confirmation-of-notice-given when user said no, notice served, even if date was provided', async () => { @@ -40,7 +40,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); // Date provided, but user rejected mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'no' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-given'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-given'); }); it('returns confirmation-of-notice-given when user said imNotSure, notice served, even if date was provided', async () => { @@ -48,7 +48,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); // Date provided, but user rejected mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'imNotSure' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-given'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-given'); }); }); @@ -58,7 +58,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'yes' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-provided'); }); it('returns confirmation-of-notice-date-when-provided when date provided, notice served, no user answer', async () => { @@ -66,7 +66,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq.session.formData = {}; // No confirmation answer - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-provided'); }); it('returns confirmation-of-notice-date-when-provided when date provided, notice served, confirmNoticeGiven undefined', async () => { @@ -74,7 +74,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq.session.formData = { 'confirmation-of-notice-given': {} }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-provided'); }); }); @@ -84,7 +84,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'yes' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); }); it('returns confirmation-of-notice-date-when-not-provided when date not provided, notice served, no user answer', async () => { @@ -92,7 +92,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); mockReq.session.formData = {}; // No confirmation answer - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); }); it('returns confirmation-of-notice-date-when-not-provided when date not provided, notice served, confirmNoticeGiven undefined', async () => { @@ -100,47 +100,43 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': {} }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); }); }); describe('Priority 4: No notice served (fallback)', () => { - it('returns tenancy-date-details when notice not served and tenancy start date is known', async () => { + it('returns tenancy-date-unknown when notice not served and tenancy start date is unknown', async () => { (isNoticeServed as jest.Mock).mockResolvedValue(false); (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); - (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(true); + (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(false); - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); + expect(await getPreviousNoticeStep(mockReq)).toBe('tenancy-date-unknown'); }); - it('returns tenancy-date-details when notice not served and tenancy start date is unknown', async () => { - (isNoticeServed as jest.Mock).mockResolvedValue(false); - (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); - - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); - }); - - it('returns tenancy-date-details when notice not served, even if date provided', async () => { + it('returns tenancy-date-details when notice not served and tenancy start date is known', async () => { (isNoticeServed as jest.Mock).mockResolvedValue(false); (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); // Date provided but notice not served + (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(true); - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); + expect(await getPreviousNoticeStep(mockReq)).toBe('tenancy-date-details'); }); - it('returns tenancy-date-details when notice not served, user said no', async () => { + it('returns tenancy-date-unknown when notice not served, user said no', async () => { (isNoticeServed as jest.Mock).mockResolvedValue(false); (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); + (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'no' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); + expect(await getPreviousNoticeStep(mockReq)).toBe('tenancy-date-unknown'); }); - it('returns tenancy-date-details when notice not served, user said yes', async () => { + it('returns tenancy-date-unknown when notice not served, user said yes', async () => { (isNoticeServed as jest.Mock).mockResolvedValue(false); (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); + (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'yes' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); + expect(await getPreviousNoticeStep(mockReq)).toBe('tenancy-date-unknown'); }); }); @@ -150,7 +146,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq.session = {}; // No formData - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-provided'); }); it('returns correct page when session is undefined', async () => { @@ -158,15 +154,16 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq = {}; // No session - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-provided'); }); - it('returns tenancy-date-details when session is undefined and notice not served', async () => { + it('returns tenancy-date-unknown when session is undefined and notice not served', async () => { (isNoticeServed as jest.Mock).mockResolvedValue(false); (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); + (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(false); mockReq = {}; // No session - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); + expect(await getPreviousNoticeStep(mockReq)).toBe('tenancy-date-unknown'); }); }); @@ -176,7 +173,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'yes' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-provided'); }); it('handles non-rent arrears scenario without notice date', async () => { @@ -184,7 +181,7 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'yes' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-date-when-not-provided'); }); it('handles user rejection scenario', async () => { @@ -192,15 +189,16 @@ describe('getPreviousPageForArrears', () => { (isNoticeDateProvided as jest.Mock).mockResolvedValue(true); mockReq.session.formData = { 'confirmation-of-notice-given': { confirmNoticeGiven: 'no' } }; - expect(await getPreviousPageForArrears(mockReq)).toBe('confirmation-of-notice-given'); + expect(await getPreviousNoticeStep(mockReq)).toBe('confirmation-of-notice-given'); }); it('handles no notice served scenario', async () => { // Real scenario: CCD has notice=No, goes straight to rent-arrears-dispute from tenancy-date-details (isNoticeServed as jest.Mock).mockResolvedValue(false); (isNoticeDateProvided as jest.Mock).mockResolvedValue(false); + (isTenancyStartDateKnown as jest.Mock).mockResolvedValue(false); - expect(await getPreviousPageForArrears(mockReq)).toBe('tenancy-date-details'); + expect(await getPreviousNoticeStep(mockReq)).toBe('tenancy-date-unknown'); }); }); });