diff --git a/cypress/component/fines/consolidation/AccountResult.cy.ts b/cypress/component/fines/consolidation/AccountResult.cy.ts new file mode 100644 index 0000000000..ff59bfe565 --- /dev/null +++ b/cypress/component/fines/consolidation/AccountResult.cy.ts @@ -0,0 +1,777 @@ +import { AccountSearchLocators } from '../../../shared/selectors/consolidation/AccountSearch.locators'; +import { AccountResultsLocators } from '../../../shared/selectors/consolidation/AccountResults.locators'; +import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-company-formatting.mock'; +import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-formatting.mock'; +import { IFinesConSearchResultDefendantAccount } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/interfaces/fines-con-search-result-defendant-account.interface'; +import { setupConsolidationComponent as mountConsolidationComponent } from './setup/SetupComponent'; +import { IComponentProperties } from './setup/setupComponent.interface'; +import { + createCompanyFalseyResult, + createCompanyMaxResultsMock, + createCompanyTooManyResultsMock, + createCompanyMultipleErrorsAndWarningsResult, + createCompanyMultipleWarningsResult, + createFalseyResult, + createMaxResultsMock, + createTooManyResultsMock, + createMultipleErrorsAndWarningsResult, + createMultipleWarningsResult, +} from './mocks/account_results_mock'; + +const CONSOLIDATION_JIRA_LABEL = '@JIRA-LABEL:consolidation'; +const CONSOLIDATION_EPIC_TAG = '@JIRA-STORY:PO-2294'; +const INDIVIDUAL_STORY_TAG = '@JIRA-STORY:PO-2415'; +const COMPANY_STORY_TAG = '@JIRA-STORY:PO-2421'; +const RESULTS_TAB_FUNCTIONALITY_STORY_TAG = '@JIRA-STORY:PO-2416'; +const INVALID_RESULTS_STORY_TAG = '@JIRA-STORY:PO-2420'; +const EM_DASH = '—'; +const individualResultsTableHeaders = [ + 'Account', + 'Name', + 'Aliases', + 'Date of birth', + 'Address line 1', + 'Postcode', + 'CO', + 'ENF', + 'Balance', + 'P/G', + 'NI number', + 'Ref', +]; +const companyResultsTableHeaders = [ + 'Account', + 'Name', + 'Aliases', + 'Address line 1', + 'Postcode', + 'CO', + 'ENF', + 'Balance', + 'Ref', +]; + +const buildTags = (...tags: string[]): string[] => [...tags, CONSOLIDATION_JIRA_LABEL]; +const buildIndividualTags = (...tags: string[]): string[] => + buildTags(CONSOLIDATION_EPIC_TAG, INDIVIDUAL_STORY_TAG, ...tags); +const buildCompanyTags = (...tags: string[]): string[] => buildTags(CONSOLIDATION_EPIC_TAG, COMPANY_STORY_TAG, ...tags); +const buildResultsTabFunctionalityTags = (...tags: string[]): string[] => + buildTags(CONSOLIDATION_EPIC_TAG, RESULTS_TAB_FUNCTIONALITY_STORY_TAG, ...tags); +const buildInvalidResultsTags = (...tags: string[]): string[] => + buildTags(CONSOLIDATION_EPIC_TAG, INVALID_RESULTS_STORY_TAG, ...tags); +const normaliseText = (value: string): string => value.replace(/\s+/g, ' ').trim(); +type ExpectedResultsOrderRow = { + account: string; + name: string; + dateOfBirth?: string; +}; + +describe('FinesConConsolidateAccComponent - Account Results', () => { + let defendantAccountResults: IFinesConSearchResultDefendantAccount[] = structuredClone( + FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK, + ); + + const defaultComponentProperties: IComponentProperties = { + defendantType: 'individual', + fragments: 'results', + }; + + const setupComponent = (componentProperties: IComponentProperties = {}) => { + return mountConsolidationComponent({ + ...defaultComponentProperties, + ...componentProperties, + initialResults: defendantAccountResults, + }); + }; + + const assertResultsTabSummary = (defendantType: 'Individual' | 'Company' = 'Individual') => { + cy.get(AccountSearchLocators.heading).should('contain', 'Consolidate accounts'); + cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business unit'); + cy.get(AccountSearchLocators.businessUnitValue).should('contain', 'Historical Debt'); + cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant type'); + cy.get(AccountSearchLocators.defendantTypeValue).should('contain', defendantType); + cy.get(AccountSearchLocators.resultsTab).should('have.attr', 'aria-current', 'page'); + }; + + const assertResultsSummary = (defendantType: 'Individual' | 'Company' = 'Individual') => { + assertResultsTabSummary(defendantType); + cy.get(AccountResultsLocators.resultsTable).should('be.visible'); + }; + + const assertNoMatchingResultsState = (defendantType: 'Individual' | 'Company' = 'Individual') => { + assertResultsTabSummary(defendantType); + cy.get(AccountResultsLocators.resultsTable).should('not.exist'); + cy.get(AccountResultsLocators.invalidResultsHeading).should('contain', 'There are no matching results.'); + cy.get(AccountResultsLocators.invalidResultsBody) + .invoke('text') + .then((text) => { + expect(normaliseText(text)).to.equal('Check your search and try again.'); + }); + cy.get(AccountResultsLocators.invalidResultsLink).should('contain', 'Check your search'); + }; + + const assertTooManyResultsState = (defendantType: 'Individual' | 'Company' = 'Individual') => { + assertResultsTabSummary(defendantType); + cy.get(AccountResultsLocators.resultsTable).should('not.exist'); + cy.get(AccountResultsLocators.invalidResultsHeading).should('contain', 'There are more than 100 results.'); + cy.get(AccountResultsLocators.invalidResultsBody) + .invoke('text') + .then((text) => { + expect(normaliseText(text)).to.equal('Try adding more information to your search.'); + }); + cy.get(AccountResultsLocators.invalidResultsLink).should('contain', 'Try adding more information'); + }; + + const assertRowCellText = (accountNumber: string, cellSelector: string, expectedText: string) => { + cy.get(AccountResultsLocators.resultRowWithAccount(accountNumber)) + .find(cellSelector) + .should(($cell) => { + expect(normaliseText($cell.text())).to.equal(expectedText); + }); + }; + + const assertDisplayedResultsOrder = (expectedRows: ExpectedResultsOrderRow[]) => { + cy.get(AccountResultsLocators.resultAccountLink) + .should('have.length', expectedRows.length) + .then(($accountLinks) => { + const actualRows = [...$accountLinks].map((accountLink) => { + const row = Cypress.$(accountLink).closest('tr'); + const actualRow: ExpectedResultsOrderRow = { + account: normaliseText(accountLink.textContent ?? ''), + name: normaliseText(row.find(AccountResultsLocators.resultNameCell).text()), + }; + + if ('dateOfBirth' in expectedRows[0]) { + actualRow.dateOfBirth = normaliseText(row.find(AccountResultsLocators.resultDateOfBirthCell).text()); + } + + return actualRow; + }); + + expect(actualRows).to.deep.equal(expectedRows); + }); + }; + + const buildIndividualResult = ( + overrides: Partial, + ): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK[0]), + aliases: null, + checks: { + errors: [], + warnings: [], + }, + ...overrides, + }); + + const buildCompanyResult = ( + overrides: Partial, + ): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK[0]), + aliases: null, + checks: { + errors: [], + warnings: [], + }, + ...overrides, + }); + + describe('Individual tests', () => { + beforeEach(() => { + defendantAccountResults = structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK); + }); + + it( + 'AC1, AC1a, AC1b. should render the individual account results tab with populated mock data', + { tags: buildIndividualTags() }, + () => { + setupComponent(); + + // AC1, AC1a, AC1b. Results tab renders with the selected business unit and defendant type. + cy.get(AccountSearchLocators.heading).should('contain', 'Consolidate accounts'); + cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business unit'); + cy.get(AccountSearchLocators.businessUnitValue).should('contain', 'Historical Debt'); + cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant type'); + cy.get(AccountSearchLocators.defendantTypeValue).should('contain', 'Individual'); + cy.get(AccountSearchLocators.resultsTab).should('have.attr', 'aria-current', 'page'); + + // AC1. Results tab content renders with the results table and actions. + cy.get(AccountResultsLocators.resultsHeading).should('contain', 'Select accounts to consolidate'); + cy.get(AccountResultsLocators.addToListButton).should('contain', 'Add to list'); + cy.get(AccountResultsLocators.selectedAccountsHint).should('be.visible'); + cy.get(AccountResultsLocators.resultsTable).should('be.visible'); + cy.get(AccountResultsLocators.resultAccountLinkByNumber('ACC001')).should('be.visible'); + cy.get(AccountResultsLocators.resultRowWithAccount('ACC001')) + .find(AccountResultsLocators.resultNameCell) + .should('contain', 'SMITH, John James'); + }, + ); + + it( + 'AC2, AC2a, AC5a, AC5b, AC5c, AC5d, AC5e, AC5f, AC5g, AC5h, AC5i. should display the individual results columns in the AC order and format populated data', + { tags: buildIndividualTags() }, + () => { + defendantAccountResults[0].has_paying_parent_guardian = true; // Set to true to confirm Y is displayed in the relevant cell + defendantAccountResults[0].checks = { errors: [], warnings: [] }; //checks should be empty for check boxes to appear + setupComponent(); + + assertResultsSummary(); + cy.get(AccountResultsLocators.resultSelectAllCheckbox).should('exist'); + // AC2a. Results table displays the named columns in the required order. + cy.get(AccountResultsLocators.resultsTableNamedHeaders).then(($headers) => { + const headers = [...$headers].map((header) => normaliseText(header.textContent ?? '')); + expect(headers).to.deep.equal(individualResultsTableHeaders); + }); + + cy.get(AccountResultsLocators.resultAccountLinkByNumber('ACC001')).should('be.visible'); + // AC5a. Name displays SURNAME, Forename. + assertRowCellText('ACC001', AccountResultsLocators.resultNameCell, 'SMITH, John James'); + // AC5b. Aliases display in ascending alias order when one or more aliases exist. + assertRowCellText('ACC001', AccountResultsLocators.resultAliasesCell, 'ADAMS, Amy BAKER, Ben'); + // AC5c. Date of birth displays as DD Mon YYYY. + assertRowCellText('ACC001', AccountResultsLocators.resultDateOfBirthCell, '03 Jan 1990'); + assertRowCellText('ACC001', AccountResultsLocators.resultAddressLine1Cell, '1 Main Street'); + assertRowCellText('ACC001', AccountResultsLocators.resultPostcodeCell, 'AB1 2CD'); + // AC5d. CO displays Y when collection order is true. + assertRowCellText('ACC001', AccountResultsLocators.resultCollectionOrderCell, 'Y'); + // AC5e. ENF displays the most recent enforcement action code. + assertRowCellText('ACC001', AccountResultsLocators.resultEnforcementCell, 'DISTRESS'); + // AC5f. Balance displays with a pound sign and currency formatting. + assertRowCellText('ACC001', AccountResultsLocators.resultBalanceCell, '£120.50'); + // AC5g. P/G displays Y when a paying parent or guardian exists. + assertRowCellText('ACC001', AccountResultsLocators.resultPayingParentGuardianCell, 'Y'); + // AC5h. NI number displays in the standard formatted layout. + assertRowCellText('ACC001', AccountResultsLocators.resultNationalInsuranceNumberCell, 'QQ 12 34 56 C'); + // AC5i. Ref displays the prosecutor case reference when present. + assertRowCellText('ACC001', AccountResultsLocators.resultRefCell, 'REF-1'); + }, + ); + + it( + 'AC2b, AC2c, AC5b, AC5d, AC5fi, AC5g. should display an em dash for optional or unavailable account data', + { tags: buildIndividualTags() }, + () => { + defendantAccountResults.push(createFalseyResult()); + + setupComponent(); + + assertResultsSummary(); + cy.get(AccountResultsLocators.resultAccountLinkByNumber('ACC002')).should('be.visible'); + cy.get(AccountResultsLocators.resultRowWithAccount('ACC002')) + .find(AccountResultsLocators.resultNameCell) + .should('contain', EM_DASH); + // AC5b. Aliases display only when aliases exist; otherwise the no-data marker is shown. + assertRowCellText('ACC002', AccountResultsLocators.resultAliasesCell, EM_DASH); + assertRowCellText('ACC002', AccountResultsLocators.resultDateOfBirthCell, EM_DASH); + assertRowCellText('ACC002', AccountResultsLocators.resultAddressLine1Cell, EM_DASH); + assertRowCellText('ACC002', AccountResultsLocators.resultPostcodeCell, EM_DASH); + // AC5d. CO displays an '-' when collection order is false. + assertRowCellText('ACC002', AccountResultsLocators.resultCollectionOrderCell, '-'); + assertRowCellText('ACC002', AccountResultsLocators.resultEnforcementCell, EM_DASH); + assertRowCellText('ACC002', AccountResultsLocators.resultBalanceCell, EM_DASH); + // AC5g. P/G displays an '-' when there is no paying parent or guardian. + assertRowCellText('ACC002', AccountResultsLocators.resultPayingParentGuardianCell, '-'); + assertRowCellText('ACC002', AccountResultsLocators.resultNationalInsuranceNumberCell, EM_DASH); + assertRowCellText('ACC002', AccountResultsLocators.resultRefCell, EM_DASH); + }, + ); + + it( + 'AC2d, AC2e. should display a maximum of 100 accounts on a single scrollable page with no pagination', + { tags: buildIndividualTags() }, + () => { + defendantAccountResults = createMaxResultsMock(); + + setupComponent(); + + assertResultsSummary(); + // AC2e. Results are displayed on a single scrollable page. + cy.get(AccountResultsLocators.resultsScrollPane).should('exist'); + // AC2e. No pagination is displayed. + cy.get(AccountResultsLocators.resultsPagination).should('not.exist'); + // AC2d. A maximum of 100 accounts are displayed per search. + cy.get(AccountResultsLocators.resultAccountLink).should('have.length', 100); + cy.get(AccountResultsLocators.resultAccountLinkByNumber('ACC100')).should('be.visible'); + }, + ); + + it( + 'AC3. should display individual results in Name, Date of birth, then Account number ascending order', + { tags: buildIndividualTags() }, + () => { + defendantAccountResults = [ + buildIndividualResult({ + defendant_account_id: 14, + account_number: 'ACC003', + defendant_surname: 'Resultlink', + defendant_firstnames: 'Aaron', + birth_date: '2003-05-15', + }), + buildIndividualResult({ + defendant_account_id: 11, + account_number: 'ACC001', + defendant_surname: 'Resultlink', + defendant_firstnames: 'Consolidation', + birth_date: '2001-05-15', + }), + buildIndividualResult({ + defendant_account_id: 12, + account_number: 'ACC002', + defendant_surname: 'Resultlink', + defendant_firstnames: 'Consolidation', + birth_date: '2001-05-15', + }), + buildIndividualResult({ + defendant_account_id: 13, + account_number: 'ACC004', + defendant_surname: 'Resultlink', + defendant_firstnames: 'Consolidation', + birth_date: '2002-05-15', + }), + ]; + + setupComponent(); + + assertResultsSummary(); + assertDisplayedResultsOrder([ + { account: 'ACC003', name: 'RESULTLINK, Aaron', dateOfBirth: '15 May 2003' }, + { account: 'ACC001', name: 'RESULTLINK, Consolidation', dateOfBirth: '15 May 2001' }, + { account: 'ACC002', name: 'RESULTLINK, Consolidation', dateOfBirth: '15 May 2001' }, + { account: 'ACC004', name: 'RESULTLINK, Consolidation', dateOfBirth: '15 May 2002' }, + ]); + }, + ); + + it( + 'AC1a, AC1b, AC3, AC3a, AC3b, AC3c. should display the individual over-100 results state with the try adding more information link', + { tags: buildInvalidResultsTags() }, + () => { + defendantAccountResults = createTooManyResultsMock(); + + setupComponent(); + // AC1a, AC1b. The Business unit row displays the Business Unit used in the search The Defendant type row displays the defendant type used in the search. + assertResultsTabSummary(); + + assertTooManyResultsState(); + }, + ); + + it( + 'AC1a, AC1b, AC2, AC2a, AC2b, AC2c. should display the individual no-results state with the check your search link', + { tags: buildInvalidResultsTags() }, + () => { + defendantAccountResults = []; + + setupComponent(); + // AC1a, AC1b. The Business unit row displays the Business Unit used in the search The Defendant type row displays the defendant type used in the search. + assertResultsTabSummary(); + + assertNoMatchingResultsState(); + }, + ); + + it( + 'AC7. should display warning and error checks beneath the relevant account row', + { tags: buildIndividualTags() }, + () => { + setupComponent(); + + assertResultsSummary(); + // AC7. Checks are displayed beneath the relevant account row. + cy.get(AccountResultsLocators.resultRowWithAccount('ACC001')) + .next(AccountResultsLocators.resultTableRow) + .find(AccountResultsLocators.resultChecksCellByAccountId(11)) + .should('be.visible') + .and('contain', 'Account has days in default'); + }, + ); + + it( + 'AC7a, AC7b. should show only errors when both errors and warnings exist, listing multiple errors as bullets', + { tags: buildIndividualTags() }, + () => { + defendantAccountResults = [createMultipleErrorsAndWarningsResult()]; + + setupComponent(); + + assertResultsSummary(); + // AC7a. Only errors are displayed when both errors and warnings exist. + cy.get(AccountResultsLocators.resultRowWithAccount('ACC005')) + .next(AccountResultsLocators.resultTableRow) + .find(AccountResultsLocators.resultChecksCellByAccountId(15)) + .should('contain', 'Account status is CS') + .and('contain', 'Account is blocked for consolidation') + .and('not.contain', 'Account has uncleared cheque payments') + .and('not.contain', 'Account has linked cases'); + // AC7b. Multiple errors are displayed as bullet points. + cy.get(AccountResultsLocators.resultChecksCellByAccountId(15)) + .find(AccountResultsLocators.resultChecksBulletItems) + .should('have.length', 2); + }, + ); + + it( + 'AC7c. should display all warnings when multiple warnings apply and no errors exist', + { tags: buildIndividualTags() }, + () => { + defendantAccountResults = [createMultipleWarningsResult()]; + + setupComponent(); + + assertResultsSummary(); + // AC7c. Multiple warnings are displayed when no errors apply. + cy.get(AccountResultsLocators.resultRowWithAccount('ACC006')) + .next(AccountResultsLocators.resultTableRow) + .find(AccountResultsLocators.resultChecksCellByAccountId(16)) + .should('contain', 'Account has uncleared cheque payments') + .and('contain', 'Account has linked cases'); + cy.get(AccountResultsLocators.resultChecksCellByAccountId(16)) + .find(AccountResultsLocators.resultChecksBulletItems) + .should('have.length', 2); + }, + ); + }); + + describe('Company tests', () => { + beforeEach(() => { + defendantAccountResults = structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK); + }); + + it( + 'AC1, AC1a, AC1b. should render the company account results tab with populated mock data', + { tags: buildCompanyTags() }, + () => { + setupComponent({ defendantType: 'company' }); + + cy.get(AccountSearchLocators.heading).should('contain', 'Consolidate accounts'); + cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business unit'); + cy.get(AccountSearchLocators.businessUnitValue).should('contain', 'Historical Debt'); + cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant type'); + cy.get(AccountSearchLocators.defendantTypeValue).should('contain', 'Company'); + cy.get(AccountSearchLocators.resultsTab).should('have.attr', 'aria-current', 'page'); + + cy.get(AccountResultsLocators.resultsHeading).should('contain', 'Select accounts to consolidate'); + cy.get(AccountResultsLocators.addToListButton).should('contain', 'Add to list'); + cy.get(AccountResultsLocators.selectedAccountsHint).should('be.visible'); + cy.get(AccountResultsLocators.resultsTable).should('be.visible'); + cy.get(AccountResultsLocators.resultAccountLinkByNumber('COMP001')).should('be.visible'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP001')) + .find(AccountResultsLocators.resultNameCell) + .should('contain', 'Acme Corporation'); + }, + ); + + it( + 'AC2, AC2a, AC5a, AC5b, AC5d, AC5e, AC5f, AC5i. should display the company results columns in the AC order and format populated data', + { tags: buildCompanyTags() }, + () => { + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + cy.get(AccountResultsLocators.resultSelectAllCheckbox).should('exist'); + cy.get(AccountResultsLocators.resultsTableNamedHeaders).then(($headers) => { + const headers = [...$headers].map((header) => normaliseText(header.textContent ?? '')); + expect(headers).to.deep.equal(companyResultsTableHeaders); + }); + + cy.get(AccountResultsLocators.resultAccountLinkByNumber('COMP001')).should('be.visible'); + assertRowCellText('COMP001', AccountResultsLocators.resultNameCell, 'Acme Corporation'); + assertRowCellText('COMP001', AccountResultsLocators.resultAliasesCell, 'Alpha Ltd Bravo Ltd'); + assertRowCellText('COMP001', AccountResultsLocators.resultAddressLine1Cell, '21 Company Street'); + assertRowCellText('COMP001', AccountResultsLocators.resultPostcodeCell, 'CO1 2MP'); + assertRowCellText('COMP001', AccountResultsLocators.resultCollectionOrderCell, 'Y'); + assertRowCellText('COMP001', AccountResultsLocators.resultEnforcementCell, 'DISTRESS'); + assertRowCellText('COMP001', AccountResultsLocators.resultBalanceCell, '£520.50'); + assertRowCellText('COMP001', AccountResultsLocators.resultRefCell, 'COMP-REF-1'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP001')) + .find(AccountResultsLocators.resultDateOfBirthCell) + .should('not.exist'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP001')) + .find(AccountResultsLocators.resultPayingParentGuardianCell) + .should('not.exist'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP001')) + .find(AccountResultsLocators.resultNationalInsuranceNumberCell) + .should('not.exist'); + }, + ); + + it( + 'AC2b, AC2c, AC5b, AC5d, AC5fi. should display an em dash for unavailable company account data', + { tags: buildCompanyTags() }, + () => { + defendantAccountResults.push(createCompanyFalseyResult()); + + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + cy.get(AccountResultsLocators.resultAccountLinkByNumber('COMP002')).should('be.visible'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP002')) + .find(AccountResultsLocators.resultNameCell) + .should('contain', EM_DASH); + assertRowCellText('COMP002', AccountResultsLocators.resultAliasesCell, EM_DASH); + assertRowCellText('COMP002', AccountResultsLocators.resultAddressLine1Cell, EM_DASH); + assertRowCellText('COMP002', AccountResultsLocators.resultPostcodeCell, EM_DASH); + assertRowCellText('COMP002', AccountResultsLocators.resultCollectionOrderCell, '-'); + assertRowCellText('COMP002', AccountResultsLocators.resultEnforcementCell, EM_DASH); + assertRowCellText('COMP002', AccountResultsLocators.resultBalanceCell, EM_DASH); + assertRowCellText('COMP002', AccountResultsLocators.resultRefCell, EM_DASH); + }, + ); + + it( + 'AC2d, AC2e. should display a maximum of 100 company accounts on a single scrollable page with no pagination', + { tags: buildCompanyTags() }, + () => { + defendantAccountResults = createCompanyMaxResultsMock(); + + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + cy.get(AccountResultsLocators.resultsScrollPane).should('exist'); + cy.get(AccountResultsLocators.resultsPagination).should('not.exist'); + cy.get(AccountResultsLocators.resultAccountLink).should('have.length', 100); + cy.get(AccountResultsLocators.resultAccountLinkByNumber('COMP100')).should('be.visible'); + }, + ); + + it( + 'AC3. should display company results in Name, then Account number ascending order', + { tags: buildCompanyTags() }, + () => { + defendantAccountResults = [ + buildCompanyResult({ + defendant_account_id: 24, + account_number: 'COMP003', + organisation_name: 'Alpha Holdings', + }), + buildCompanyResult({ + defendant_account_id: 21, + account_number: 'COMP001', + organisation_name: 'Beta Holdings', + }), + buildCompanyResult({ + defendant_account_id: 22, + account_number: 'COMP002', + organisation_name: 'Beta Holdings', + }), + buildCompanyResult({ + defendant_account_id: 23, + account_number: 'COMP004', + organisation_name: 'Gamma Holdings', + }), + ]; + + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + assertDisplayedResultsOrder([ + { account: 'COMP003', name: 'Alpha Holdings' }, + { account: 'COMP001', name: 'Beta Holdings' }, + { account: 'COMP002', name: 'Beta Holdings' }, + { account: 'COMP004', name: 'Gamma Holdings' }, + ]); + }, + ); + + it( + 'AC1a, AC1b, AC3, AC3a, AC3b, AC3c. should display the company over-100 results state with the try adding more information link', + { tags: buildInvalidResultsTags() }, + () => { + defendantAccountResults = createCompanyTooManyResultsMock(); + + setupComponent({ defendantType: 'company' }); + + // AC1a, AC1b. The Business unit row displays the Business Unit used in the search The Defendant type row displays the defendant type used in the search. + assertResultsTabSummary('Company'); + assertTooManyResultsState('Company'); + }, + ); + + it( + 'AC1a, AC1b, AC2, AC2a, AC2b, AC2c. should display the company no-results state with the check your search link', + { tags: buildInvalidResultsTags() }, + () => { + defendantAccountResults = []; + + setupComponent({ defendantType: 'company' }); + + // AC1a, AC1b. The Business unit row displays the Business Unit used in the search The Defendant type row displays the defendant type used in the search. + assertResultsTabSummary('Company'); + assertNoMatchingResultsState('Company'); + }, + ); + + it( + 'AC7. should display warning and error checks beneath the relevant company account row', + { tags: buildCompanyTags() }, + () => { + defendantAccountResults[0].checks = { + errors: [{ reference: 'CON.ER.4', message: 'Account has days in default' }], + warnings: [], + }; + + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP001')) + .next(AccountResultsLocators.resultTableRow) + .find(AccountResultsLocators.resultChecksCellByAccountId(21)) + .should('be.visible') + .and('contain', 'Account has days in default'); + }, + ); + + it( + 'AC7a, AC7b. should show only errors for company results when both errors and warnings exist, listing multiple errors as bullets', + { tags: buildCompanyTags() }, + () => { + defendantAccountResults = [createCompanyMultipleErrorsAndWarningsResult()]; + + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP005')) + .next(AccountResultsLocators.resultTableRow) + .find(AccountResultsLocators.resultChecksCellByAccountId(25)) + .should('contain', 'Account status is CS') + .and('contain', 'Account is blocked for consolidation') + .and('not.contain', 'Account has uncleared cheque payments') + .and('not.contain', 'Account has linked cases'); + cy.get(AccountResultsLocators.resultChecksCellByAccountId(25)) + .find(AccountResultsLocators.resultChecksBulletItems) + .should('have.length', 2); + }, + ); + + it( + 'AC7c. should display all warnings for company results when multiple warnings apply and no errors exist', + { tags: buildCompanyTags() }, + () => { + defendantAccountResults = [createCompanyMultipleWarningsResult()]; + + setupComponent({ defendantType: 'company' }); + + assertResultsSummary('Company'); + cy.get(AccountResultsLocators.resultRowWithAccount('COMP006')) + .next(AccountResultsLocators.resultTableRow) + .find(AccountResultsLocators.resultChecksCellByAccountId(26)) + .should('contain', 'Account has uncleared cheque payments') + .and('contain', 'Account has linked cases'); + cy.get(AccountResultsLocators.resultChecksCellByAccountId(26)) + .find(AccountResultsLocators.resultChecksBulletItems) + .should('have.length', 2); + }, + ); + }); + + describe('Results tab functionality tests', () => { + beforeEach(() => { + defendantAccountResults = structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK); + }); + + it( + 'AC3, AC3a, AC3b. should show row checkboxes for selectable accounts, hide them for errors, and keep warning rows enabled', + { tags: buildResultsTabFunctionalityTags() }, + () => { + defendantAccountResults[0].checks = { errors: [], warnings: [] }; + defendantAccountResults.push(createMultipleErrorsAndWarningsResult(), createMultipleWarningsResult()); + + setupComponent(); + + assertResultsSummary(); + + // AC3. Each row includes a checkbox for selecting or unselecting the account. + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(11)) + .should('exist') + .and('be.enabled') + .and('not.be.checked') + .check({ force: true }) + .should('be.checked'); + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(11)) + .uncheck({ force: true }) + .should('not.be.checked'); + + // AC3a. Accounts that contain one or more errors have their checkbox hidden. + cy.get(AccountResultsLocators.resultRowWithAccount('ACC005')) + .find(AccountResultsLocators.resultRowCheckboxByAccountId(15)) + .should('not.exist'); + + // AC3b. Accounts that contain warnings keep their checkbox enabled. + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(16)).should('exist').and('be.enabled'); + }, + ); + + it( + 'AC4, AC4a, AC4b, AC4c, AC5a, AC5b, AC5c. should bulk select and deselect all enabled accounts while excluding accounts with errors', + { tags: buildResultsTabFunctionalityTags() }, + () => { + defendantAccountResults[0].checks = { errors: [], warnings: [] }; + defendantAccountResults.push(createMultipleErrorsAndWarningsResult(), createMultipleWarningsResult()); + + setupComponent(); + + assertResultsSummary(); + + // AC4. A top-level checkbox is displayed above the table to allow bulk selection. + // AC5a, AC5b. The dynamic counter shows selected accounts against the total returned results. + cy.get(AccountResultsLocators.selectedAccountsHint).should('contain', '0 of 3 accounts selected'); + + // AC4a. Selecting the top-level checkbox selects all enabled accounts in the results. + cy.get(AccountResultsLocators.resultSelectAllCheckbox) + .should('exist') + .and('not.be.checked') + .check({ force: true }) + .should('be.checked'); + + // AC4b. Accounts with one or more errors are not selected. + // AC5c. The counter updates automatically as accounts are selected. + cy.get(AccountResultsLocators.selectedAccountsHint).should('contain', '2 of 3 accounts selected'); + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(11)).should('be.checked'); + cy.get(AccountResultsLocators.resultRowWithAccount('ACC005')) + .find(AccountResultsLocators.resultRowCheckboxByAccountId(15)) + .should('not.exist'); + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(16)).should('be.checked'); + + // AC4c. Deselecting the top-level checkbox deselects all currently selected accounts. + // AC5c. The counter updates automatically as accounts are deselected. + cy.get(AccountResultsLocators.resultSelectAllCheckbox).uncheck({ force: true }).should('not.be.checked'); + + cy.get(AccountResultsLocators.selectedAccountsHint).should('contain', '0 of 3 accounts selected'); + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(11)).should('not.be.checked'); + cy.get(AccountResultsLocators.resultRowCheckboxByAccountId(16)).should('not.be.checked'); + }, + ); + + it( + 'AC6, AC6a, AC6b. should display Add to list above the counter and show a validation error when no accounts are selected', + { tags: buildResultsTabFunctionalityTags() }, + () => { + defendantAccountResults[0].checks = { errors: [], warnings: [] }; + + setupComponent(); + + assertResultsSummary(); + + // AC6. An Add to list button is displayed above the counter. + cy.get(AccountResultsLocators.addToListButton) + .should('be.visible') + .and('contain', 'Add to list') + .then(($button) => { + cy.get(AccountResultsLocators.selectedAccountsHint).then(($hint) => { + expect($button[0].compareDocumentPosition($hint[0]) & Node.DOCUMENT_POSITION_FOLLOWING).to.not.equal(0); + }); + }); + + // AC6a, AC6b. Selecting Add to list validates the selected accounts and shows an error when none are selected. + cy.get(AccountResultsLocators.addToListButton).click(); + + cy.get(AccountSearchLocators.errorSummary) + .should('be.visible') + .and('contain', 'Select 1 or more accounts to consolidate.'); + cy.get(AccountResultsLocators.addToListErrorMessage) + .should('be.visible') + .and('contain', 'Select 1 or more accounts to consolidate.'); + }, + ); + }); +}); diff --git a/cypress/component/fines/consolidation/AccountSearch.cy.ts b/cypress/component/fines/consolidation/AccountSearch.cy.ts index 206f21ed3d..76d204c307 100644 --- a/cypress/component/fines/consolidation/AccountSearch.cy.ts +++ b/cypress/component/fines/consolidation/AccountSearch.cy.ts @@ -1,6 +1,7 @@ import { AccountSearchLocators } from '../../../shared/selectors/consolidation/AccountSearch.locators'; import { FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/mocks/fines-con-search-account-form-empty.mock'; import { IFinesConSearchAccountState } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/interfaces/fines-con-search-account-state.interface'; +import { FINES_CON_ROUTING_PATHS } from 'src/app/flows/fines/fines-con/routing/constants/fines-con-routing-paths.constant'; import { setupConsolidationComponent as mountConsolidationComponent } from './setup/SetupComponent'; import { ConsolidationTabFragment, IComponentProperties } from './setup/setupComponent.interface'; @@ -35,6 +36,36 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(inlineSelector).should('be.visible').and('contain', message); }; + const assertSearchErrorRedirect = (expectedFormData: Partial) => { + cy.get('@routerNavigate').should('have.been.calledWithMatch', [FINES_CON_ROUTING_PATHS.children.searchError], { + relativeTo: {}, + }); + + cy.get('@finesConStore').then((store: any) => { + const searchAccountForm = store.searchAccountForm(); + expect(searchAccountForm).to.deep.include(expectedFormData); + }); + }; + + const assertNoSearchUpdate = (updateSearchSpy: sinon.SinonSpy) => { + cy.then(() => { + expect(updateSearchSpy).to.not.have.been.called; + }); + }; + + const findSubmittedFormData = ( + updateSearchSpy: sinon.SinonSpy, + predicate: (formData: IFinesConSearchAccountState) => boolean, + ): IFinesConSearchAccountState => { + const matchingCall = updateSearchSpy + .getCalls() + .map((call) => call.args[0] as IFinesConSearchAccountState) + .find(predicate); + + expect(matchingCall, 'matching search payload').to.exist; + return matchingCall!; + }; + const switchToTab = (tab: ConsolidationTabFragment) => { cy.get('@finesConStore').then((store: any) => { store.setActiveTab(tab); @@ -69,18 +100,18 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { //AC1a. Business unit displays the selected BU and is read-only' - cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business Unit'); + cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business unit'); cy.get(AccountSearchLocators.businessUnitValue).should('contain', 'Historical Debt'); //AC1b. Defendant type displays 'Individual' - cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant Type'); + cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant type'); cy.get(AccountSearchLocators.defendantTypeValue).should('contain', 'Individual'); //AC1c. Search screen mirrors expected field types, headings and actions cy.get(AccountSearchLocators.tabsNav).should('be.visible'); cy.get(AccountSearchLocators.searchTab).should('contain', 'Search'); cy.get(AccountSearchLocators.resultsTab).should('contain', 'Results'); - cy.get(AccountSearchLocators.forConsolidationTab).should('contain', 'For Consolidation'); + cy.get(AccountSearchLocators.forConsolidationTab).should('contain', 'For consolidation'); cy.get(AccountSearchLocators.quickSearchHeading).should('contain', 'Quick search'); cy.contains(AccountSearchLocators.advancedSearchHeading, 'Advanced Search').should('be.visible'); @@ -127,14 +158,63 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { ); it( - 'AC3. Invalid search criteria display the expected errors and no search update occurs', + 'AC3a. Invalid account number format displays the expected error and no search update occurs', + { tags: buildTags('@JIRA-KEY:POT-3869') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData.fcon_search_account_number = '1234567'; + + setupConsolidationComponent({ updateSearchSpy }); + cy.get(AccountSearchLocators.searchButton).click(); + + assertValidationError( + 'Enter account number in the correct format such as 12345678 or 12345678A', + AccountSearchLocators.accountNumberError, + ); + assertNoSearchUpdate(updateSearchSpy); + }, + ); + + it( + 'AC3b. Invalid National Insurance number format displays the expected error and no search update occurs', + { tags: buildTags('@JIRA-KEY:POT-3869') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData.fcon_search_account_national_insurance_number = 'AB12345$C'; + + setupConsolidationComponent({ updateSearchSpy }); + cy.get(AccountSearchLocators.searchButton).click(); + + assertValidationError( + 'Enter a National Insurance number in the format AANNNNNNA', + AccountSearchLocators.nationalInsuranceNumberError, + ); + assertNoSearchUpdate(updateSearchSpy); + }, + ); + + it( + 'AC4a. Account number max length displays the expected error and no search update occurs', + { tags: buildTags('@JIRA-KEY:POT-3870') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData.fcon_search_account_number = '123456789A'; + + setupConsolidationComponent({ updateSearchSpy }); + cy.get(AccountSearchLocators.searchButton).click(); + + assertValidationError('Account number must be 9 characters or fewer', AccountSearchLocators.accountNumberError); + assertNoSearchUpdate(updateSearchSpy); + }, + ); + + it( + 'AC3. Invalid advanced search criteria display the expected errors and no search update occurs', { tags: buildTags('@JIRA-KEY:POT-3869') }, () => { const updateSearchSpy = Cypress.sinon.spy(); finesConSearchAccountFormData = { ...structuredClone(FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK.formData), - fcon_search_account_number: '1234567', - fcon_search_account_national_insurance_number: 'AB12345$C', fcon_search_account_individuals_search_criteria: { fcon_search_account_individuals_last_name: 'Smith', fcon_search_account_individuals_last_name_exact_match: false, @@ -150,14 +230,6 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); const expectedValidationErrors = [ - { - message: 'Enter account number in the correct format such as 12345678 or 12345678A', - selector: AccountSearchLocators.accountNumberError, - }, - { - message: 'Enter a National Insurance number in the format AANNNNNNA', - selector: AccountSearchLocators.nationalInsuranceNumberError, - }, { message: 'Date of birth must be in the format DD/MM/YYYY', selector: AccountSearchLocators.dateOfBirthError, @@ -176,21 +248,17 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { assertValidationError(message, selector); }); - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); it( - 'AC4. Max length validation errors display expected messages and no search update occurs', + 'AC4. Advanced search max length validation errors display expected messages and no search update occurs', { tags: buildTags('@JIRA-KEY:POT-3870') }, () => { const updateSearchSpy = Cypress.sinon.spy(); finesConSearchAccountFormData = { ...structuredClone(FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK.formData), - fcon_search_account_number: '123456789A', - fcon_search_account_national_insurance_number: 'AB1234567C', fcon_search_account_individuals_search_criteria: { fcon_search_account_individuals_last_name: 'A'.repeat(31), fcon_search_account_individuals_last_name_exact_match: false, @@ -206,10 +274,6 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); const expectedValidationErrors = [ - { - message: 'Account number must be 9 characters or fewer', - selector: AccountSearchLocators.accountNumberError, - }, { message: 'Last name must be 30 characters or fewer', selector: AccountSearchLocators.lastNameError, @@ -218,10 +282,6 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { message: 'First names must be 20 characters or fewer', selector: AccountSearchLocators.firstNamesError, }, - { - message: 'Enter a National Insurance number in the format AANNNNNNA', - selector: AccountSearchLocators.nationalInsuranceNumberError, - }, { message: 'Address line 1 must be 30 characters or fewer', selector: AccountSearchLocators.addressLine1Error, @@ -236,9 +296,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { assertValidationError(message, selector); }); - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -254,9 +312,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); assertValidationError('Enter last name', AccountSearchLocators.lastNameError); - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -271,9 +327,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); assertValidationError('Enter last name', AccountSearchLocators.lastNameError); - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -287,9 +341,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); assertValidationError('Enter last name', AccountSearchLocators.lastNameError); - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -304,9 +356,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); assertValidationError('Enter last name', AccountSearchLocators.lastNameError); - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -321,8 +371,10 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); cy.then(() => { - expect(updateSearchSpy).to.have.been.calledOnce; - const submittedFormData = updateSearchSpy.firstCall.args[0] as IFinesConSearchAccountState; + const submittedFormData = findSubmittedFormData( + updateSearchSpy, + (formData) => formData.fcon_search_account_number === '12345678', + ); expect(submittedFormData.fcon_search_account_number).to.equal('12345678'); expect(submittedFormData.fcon_search_account_national_insurance_number).to.be.null; @@ -351,8 +403,10 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); cy.then(() => { - expect(updateSearchSpy).to.have.been.calledOnce; - const submittedFormData = updateSearchSpy.firstCall.args[0] as IFinesConSearchAccountState; + const submittedFormData = findSubmittedFormData( + updateSearchSpy, + (formData) => formData.fcon_search_account_national_insurance_number === 'AB123456C', + ); expect(submittedFormData.fcon_search_account_national_insurance_number).to.equal('AB123456C'); expect(submittedFormData.fcon_search_account_number).to.be.null; @@ -449,14 +503,14 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { //AC1a. Business unit displays the selected BU and is read-only' - cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business Unit'); + cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business unit'); cy.get(AccountSearchLocators.businessUnitValue) .should('contain', 'Historical Debt') .find('input, select, textarea') .should('not.exist'); //AC1b. Defendant type displays 'Company' - cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant Type'); + cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant type'); cy.get(AccountSearchLocators.defendantTypeValue) .should('contain', 'Company') .find('input, select, textarea') @@ -466,7 +520,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.tabsNav).should('be.visible'); cy.get(AccountSearchLocators.searchTab).should('contain', 'Search'); cy.get(AccountSearchLocators.resultsTab).should('contain', 'Results'); - cy.get(AccountSearchLocators.forConsolidationTab).should('contain', 'For Consolidation'); + cy.get(AccountSearchLocators.forConsolidationTab).should('contain', 'For consolidation'); cy.get(AccountSearchLocators.quickSearchHeading).should('contain', 'Quick search'); cy.contains(AccountSearchLocators.advancedSearchHeading, 'Advanced Search').should('be.visible'); @@ -509,13 +563,45 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { ); it( - 'AC3. Invalid search criteria display the expected errors and no search update occurs', + 'AC3a. Invalid company account number format displays the expected error and no search update occurs', + { tags: buildTags('@JIRA-KEY:POT-3869') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData.fcon_search_account_number = '1234567'; + + setupConsolidationComponent({ updateSearchSpy, defendantType: 'company' }); + cy.get(AccountSearchLocators.searchButton).click(); + + assertValidationError( + 'Enter account number in the correct format such as 12345678 or 12345678A', + AccountSearchLocators.accountNumberError, + ); + assertNoSearchUpdate(updateSearchSpy); + }, + ); + + it( + 'AC4a. Company account number max length displays the expected error and no search update occurs', + { tags: buildTags('@JIRA-KEY:POT-3879') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData.fcon_search_account_number = '123456789A'; + + setupConsolidationComponent({ updateSearchSpy, defendantType: 'company' }); + cy.get(AccountSearchLocators.searchButton).click(); + + assertValidationError('Account number must be 9 characters or fewer', AccountSearchLocators.accountNumberError); + assertNoSearchUpdate(updateSearchSpy); + }, + ); + + it( + 'AC3. Invalid advanced search criteria display the expected errors and no search update occurs', { tags: buildTags('@JIRA-KEY:POT-3869') }, () => { const updateSearchSpy = Cypress.sinon.spy(); finesConSearchAccountFormData = { ...structuredClone(FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK.formData), - fcon_search_account_number: '1234567', fcon_search_account_companies_search_criteria: { fcon_search_account_companies_company_name: 'Testing!!!', fcon_search_account_companies_company_name_exact_match: false, @@ -528,11 +614,6 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); const expectedValidationErrors = [ - { - //AC3a. User enters value that is not in correct format and the following error is produced - message: 'Enter account number in the correct format such as 12345678 or 12345678A', - selector: AccountSearchLocators.accountNumberError, - }, { //AC3b. User enters non-alphabetical or special characters and the following error is produced message: 'Company name must only include letters a to z, hyphens, spaces and apostrophes', @@ -555,20 +636,17 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { }); //AC3 Following selecting 'search' the system remains on the same screen - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); it( - 'AC4. Max length search validation displays the expected errors and no search update occurs', + 'AC4. Advanced search max length validation displays the expected errors and no search update occurs', { tags: buildTags('@JIRA-KEY:POT-3879') }, () => { const updateSearchSpy = Cypress.sinon.spy(); finesConSearchAccountFormData = { ...structuredClone(FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK.formData), - fcon_search_account_number: '1234567890', fcon_search_account_companies_search_criteria: { fcon_search_account_companies_company_name: 'QwertyuiopQwertyuiopQwertyuiopQwertyuiopQwertyuiopQ', fcon_search_account_companies_company_name_exact_match: false, @@ -581,11 +659,6 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); const expectedValidationErrors = [ - { - //AC4a. User enters value exceeding the max characters. Error isnt in line with others/conflicts this one first. Confirmed in ..field-errors.constant that it should be 'Account number must be 9 characters or fewer',. Pri 3. How to reach? - message: 'Account number must be 9 characters or fewer', - selector: AccountSearchLocators.accountNumberError, - }, { //AC4b. User enters value exceeding the max characters. message: 'Company name must be 50 characters or fewer', @@ -608,9 +681,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { }); //AC4 Following selecting 'search' the system remains on the same screen - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -646,9 +717,7 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { }); //AC5 Following selecting 'search' the system remains on the same screen - cy.then(() => { - expect(updateSearchSpy).to.not.have.been.called; - }); + assertNoSearchUpdate(updateSearchSpy); }, ); @@ -663,8 +732,10 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { cy.get(AccountSearchLocators.searchButton).click(); cy.then(() => { - expect(updateSearchSpy).to.have.been.calledOnce; - const submittedFormData = updateSearchSpy.firstCall.args[0] as IFinesConSearchAccountState; + const submittedFormData = findSubmittedFormData( + updateSearchSpy, + (formData) => formData.fcon_search_account_number === '12345678', + ); expect(submittedFormData.fcon_search_account_number).to.equal('12345678'); expect(submittedFormData.fcon_search_account_companies_search_criteria).to.deep.equal({ @@ -724,4 +795,94 @@ describe('FinesConConsolidateAccComponent - Account & Company Search', () => { // cy.get(AccountSearchLocators.forConsolidationTab).should('have.attr', 'aria-current', 'page'); }, ); + + it( + 'AC1a. Individual searches route to Search error when quick search and other account details are combined', + { tags: buildTags('@JIRA-KEY:POT-3882') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData = { + ...structuredClone(FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK.formData), + fcon_search_account_number: '12345678', + fcon_search_account_individuals_search_criteria: { + fcon_search_account_individuals_last_name: 'Smith', + fcon_search_account_individuals_last_name_exact_match: false, + fcon_search_account_individuals_first_names: null, + fcon_search_account_individuals_first_names_exact_match: false, + fcon_search_account_individuals_include_aliases: false, + fcon_search_account_individuals_date_of_birth: null, + fcon_search_account_individuals_address_line_1: null, + fcon_search_account_individuals_post_code: null, + }, + }; + + setupConsolidationComponent({ updateSearchSpy, setupRouterSpy: true }); + cy.get(AccountSearchLocators.searchButton).click(); + + cy.then(() => { + findSubmittedFormData( + updateSearchSpy, + (formData) => + formData.fcon_search_account_number === '12345678' && + formData.fcon_search_account_individuals_search_criteria?.fcon_search_account_individuals_last_name === + 'Smith', + ); + }); + assertSearchErrorRedirect({ + fcon_search_account_number: '12345678', + fcon_search_account_individuals_search_criteria: { + fcon_search_account_individuals_last_name: 'Smith', + fcon_search_account_individuals_last_name_exact_match: false, + fcon_search_account_individuals_first_names: null, + fcon_search_account_individuals_first_names_exact_match: false, + fcon_search_account_individuals_include_aliases: false, + fcon_search_account_individuals_date_of_birth: null, + fcon_search_account_individuals_address_line_1: null, + fcon_search_account_individuals_post_code: null, + }, + }); + }, + ); + + it( + 'AC1b. Company searches route to Search error when account number and other account details are combined', + { tags: buildTags('@JIRA-KEY:POT-3883') }, + () => { + const updateSearchSpy = Cypress.sinon.spy(); + finesConSearchAccountFormData = { + ...structuredClone(FINES_CON_SEARCH_ACCOUNT_FORM_EMPTY_MOCK.formData), + fcon_search_account_number: '12345678', + fcon_search_account_companies_search_criteria: { + fcon_search_account_companies_company_name: 'Test Company', + fcon_search_account_companies_company_name_exact_match: false, + fcon_search_account_companies_include_aliases: false, + fcon_search_account_companies_address_line_1: null, + fcon_search_account_companies_post_code: null, + }, + }; + + setupConsolidationComponent({ updateSearchSpy, defendantType: 'company', setupRouterSpy: true }); + cy.get(AccountSearchLocators.searchButton).click(); + + cy.then(() => { + findSubmittedFormData( + updateSearchSpy, + (formData) => + formData.fcon_search_account_number === '12345678' && + formData.fcon_search_account_companies_search_criteria?.fcon_search_account_companies_company_name === + 'Test Company', + ); + }); + assertSearchErrorRedirect({ + fcon_search_account_number: '12345678', + fcon_search_account_companies_search_criteria: { + fcon_search_account_companies_company_name: 'Test Company', + fcon_search_account_companies_company_name_exact_match: false, + fcon_search_account_companies_include_aliases: false, + fcon_search_account_companies_address_line_1: null, + fcon_search_account_companies_post_code: null, + }, + }); + }, + ); }); diff --git a/cypress/component/fines/consolidation/ErrorPage.cy.ts b/cypress/component/fines/consolidation/ErrorPage.cy.ts new file mode 100644 index 0000000000..f33b3cd290 --- /dev/null +++ b/cypress/component/fines/consolidation/ErrorPage.cy.ts @@ -0,0 +1,64 @@ +import { mount } from 'cypress/angular'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { FinesConSearchErrorComponent } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component'; +import { FinesConStore } from 'src/app/flows/fines/fines-con/stores/fines-con.store'; +import { ErrorPageLocators } from 'cypress/shared/selectors/consolidation/ErrorPage.locators'; + +const CONSOLIDATION_JIRA_LABEL = '@JIRA-LABEL:consolidation'; +const CONSOLIDATION_STORY_LABEL = '@JIRA-STORY:PO-2417'; + +const buildTags = (...tags: string[]): string[] => [...tags, CONSOLIDATION_JIRA_LABEL, CONSOLIDATION_STORY_LABEL]; + +describe('FinesConSearchErrorComponent', () => { + const setupComponent = (defendantType: 'individual' | 'company') => { + return mount(FinesConSearchErrorComponent, { + providers: [ + provideRouter([]), + FinesConStore, + { + provide: ActivatedRoute, + useValue: { + parent: {}, + }, + }, + ], + }).then(({ fixture }) => { + const store = fixture.componentRef.injector.get(FinesConStore); + cy.stub(store, 'getDefendantType').returns(defendantType); + expect(store.getDefendantType()).to.equal(defendantType); + fixture.detectChanges(); + }); + }; + + const assertHeadingAndIntro = () => { + cy.get(ErrorPageLocators.heading).should('contain', 'There is a problem'); + cy.get(ErrorPageLocators.message) + .invoke('text') + .then((text) => { + const normalisedText = text.replace(/\s+/g, ' ').trim(); + expect(normalisedText).to.equal( + 'Reference data and account information cannot be entered together when searching for an account. Search using either:', + ); + }); + }; + + it('AC2a. displays the individual search error heading and message text', { tags: buildTags() }, () => { + setupComponent('individual'); + + assertHeadingAndIntro(); + cy.get(ErrorPageLocators.bulletItems).then(($items) => { + const items = [...$items].map((item) => item.textContent?.replace(/\s+/g, ' ').trim()); + expect(items).to.deep.equal(['account number, or', 'National Insurance number, or', 'advanced search']); + }); + }); + + it('AC2b. displays the company search error heading and message text', { tags: buildTags() }, () => { + setupComponent('company'); + + assertHeadingAndIntro(); + cy.get(ErrorPageLocators.bulletItems).then(($items) => { + const items = [...$items].map((item) => item.textContent?.replace(/\s+/g, ' ').trim()); + expect(items).to.deep.equal(['account number, or', 'advanced search']); + }); + }); +}); diff --git a/cypress/component/fines/consolidation/mocks/account_results_mock.ts b/cypress/component/fines/consolidation/mocks/account_results_mock.ts new file mode 100644 index 0000000000..d91ebf2ac6 --- /dev/null +++ b/cypress/component/fines/consolidation/mocks/account_results_mock.ts @@ -0,0 +1,183 @@ +import { IFinesConSearchResultDefendantAccount } from '@app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/interfaces/fines-con-search-result-defendant-account.interface'; +import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK } from '@app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-company-formatting.mock'; +import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK } from '@app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-formatting.mock'; + +export const createFalseyResult = (): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK[0]), + defendant_account_id: 12, + account_number: 'ACC002', + aliases: null, + address_line_1: null, + postcode: null, + prosecutor_case_reference: null, + last_enforcement_action: null, + account_balance: null, + defendant_firstnames: null, + defendant_surname: null, + birth_date: null, + national_insurance_number: null, + collection_order: false, + last_enforcement: null, + has_paying_parent_guardian: false, + checks: { + errors: [], + warnings: [], + }, +}); + +export const createCompanyFalseyResult = (): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK[0]), + defendant_account_id: 22, + account_number: 'COMP002', + aliases: null, + address_line_1: null, + postcode: null, + prosecutor_case_reference: null, + last_enforcement_action: null, + account_balance: null, + organisation_name: null, + collection_order: false, + last_enforcement: null, + checks: { + errors: [], + warnings: [], + }, +}); + +export const createMaxResultsMock = (): IFinesConSearchResultDefendantAccount[] => + Array.from({ length: 100 }, (_, index) => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK[0]), + defendant_account_id: index + 1, + account_number: `ACC${String(index + 1).padStart(3, '0')}`, + aliases: null, + prosecutor_case_reference: `REF-${index + 1}`, + defendant_firstnames: `First${index + 1}`, + defendant_surname: `Surname${index + 1}`, + birth_date: '1990-01-03', + account_balance: 50 + index, + has_paying_parent_guardian: false, + checks: { + errors: [], + warnings: [], + }, + })); + +export const createTooManyResultsMock = (): IFinesConSearchResultDefendantAccount[] => [ + ...createMaxResultsMock(), + { + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK[0]), + defendant_account_id: 101, + account_number: 'ACC101', + aliases: null, + prosecutor_case_reference: 'REF-101', + defendant_firstnames: 'First101', + defendant_surname: 'Surname101', + birth_date: '1990-01-03', + account_balance: 150, + has_paying_parent_guardian: false, + checks: { + errors: [], + warnings: [], + }, + }, +]; + +export const createCompanyMaxResultsMock = (): IFinesConSearchResultDefendantAccount[] => + Array.from({ length: 100 }, (_, index) => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK[0]), + defendant_account_id: index + 1, + account_number: `COMP${String(index + 1).padStart(3, '0')}`, + aliases: null, + prosecutor_case_reference: `COMP-REF-${index + 1}`, + organisation_name: `Company ${index + 1}`, + account_balance: 500 + index, + checks: { + errors: [], + warnings: [], + }, + })); + +export const createCompanyTooManyResultsMock = (): IFinesConSearchResultDefendantAccount[] => [ + ...createCompanyMaxResultsMock(), + { + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK[0]), + defendant_account_id: 101, + account_number: 'COMP101', + aliases: null, + prosecutor_case_reference: 'COMP-REF-101', + organisation_name: 'Company 101', + account_balance: 600, + checks: { + errors: [], + warnings: [], + }, + }, +]; + +export const createMultipleErrorsAndWarningsResult = (): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK[0]), + defendant_account_id: 15, + account_number: 'ACC005', + prosecutor_case_reference: 'REF-5', + defendant_firstnames: 'Erin', + defendant_surname: 'Example', + checks: { + errors: [ + { reference: 'CON.ER.1', message: 'Account status is CS' }, + { reference: 'CON.ER.2', message: 'Account is blocked for consolidation' }, + ], + warnings: [ + { reference: 'CON.WN.1', message: 'Account has uncleared cheque payments' }, + { reference: 'CON.WN.2', message: 'Account has linked cases' }, + ], + }, +}); + +export const createMultipleWarningsResult = (): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK[0]), + defendant_account_id: 16, + account_number: 'ACC006', + prosecutor_case_reference: 'REF-6', + defendant_firstnames: 'Wendy', + defendant_surname: 'Warning', + checks: { + errors: [], + warnings: [ + { reference: 'CON.WN.1', message: 'Account has uncleared cheque payments' }, + { reference: 'CON.WN.2', message: 'Account has linked cases' }, + ], + }, +}); + +export const createCompanyMultipleErrorsAndWarningsResult = (): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK[0]), + defendant_account_id: 25, + account_number: 'COMP005', + prosecutor_case_reference: 'COMP-REF-5', + organisation_name: 'Errors & Warnings Ltd', + checks: { + errors: [ + { reference: 'CON.ER.1', message: 'Account status is CS' }, + { reference: 'CON.ER.2', message: 'Account is blocked for consolidation' }, + ], + warnings: [ + { reference: 'CON.WN.1', message: 'Account has uncleared cheque payments' }, + { reference: 'CON.WN.2', message: 'Account has linked cases' }, + ], + }, +}); + +export const createCompanyMultipleWarningsResult = (): IFinesConSearchResultDefendantAccount => ({ + ...structuredClone(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK[0]), + defendant_account_id: 26, + account_number: 'COMP006', + prosecutor_case_reference: 'COMP-REF-6', + organisation_name: 'Warnings Only Ltd', + checks: { + errors: [], + warnings: [ + { reference: 'CON.WN.1', message: 'Account has uncleared cheque payments' }, + { reference: 'CON.WN.2', message: 'Account has linked cases' }, + ], + }, +}); diff --git a/cypress/component/fines/consolidation/setup/SetupComponent.ts b/cypress/component/fines/consolidation/setup/SetupComponent.ts index 242a3d23c7..79f3ebe5ad 100644 --- a/cypress/component/fines/consolidation/setup/SetupComponent.ts +++ b/cypress/component/fines/consolidation/setup/SetupComponent.ts @@ -13,6 +13,7 @@ import { IComponentProperties } from './setupComponent.interface'; export const setupConsolidationComponent = (componentProperties: IComponentProperties = {}) => { const fragment = componentProperties.fragments ?? 'search'; const defendantType = componentProperties.defendantType ?? 'individual'; + const initialResults = structuredClone(componentProperties.initialResults ?? []); const finesConSelectBuFormData = defendantType === 'company' @@ -32,6 +33,11 @@ export const setupConsolidationComponent = (componentProperties: IComponentPrope const store = new FinesConStore(); store.updateSelectBuForm(finesConSelectBuFormData); store.updateSearchAccountFormTemporary(searchAccountFormData); + if (defendantType === 'company') { + store.updateDefendantResults([], initialResults); + } else { + store.updateDefendantResults(initialResults, []); + } store.setActiveTab(fragment); if (componentProperties.updateSearchSpy) { diff --git a/cypress/component/fines/consolidation/setup/setupComponent.interface.ts b/cypress/component/fines/consolidation/setup/setupComponent.interface.ts index 0a9fdc87ea..f8a4975ed2 100644 --- a/cypress/component/fines/consolidation/setup/setupComponent.interface.ts +++ b/cypress/component/fines/consolidation/setup/setupComponent.interface.ts @@ -1,4 +1,5 @@ import { IFinesConSearchAccountState } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/interfaces/fines-con-search-account-state.interface'; +import { IFinesConSearchResultDefendantAccount } from 'src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/interfaces/fines-con-search-result-defendant-account.interface'; export type ConsolidationTabFragment = 'search' | 'results' | 'for-consolidation'; export type DefendantType = 'individual' | 'company'; @@ -7,6 +8,7 @@ export interface IComponentProperties { defendantType?: DefendantType; fragments?: ConsolidationTabFragment; searchAccountFormData?: IFinesConSearchAccountState; + initialResults?: IFinesConSearchResultDefendantAccount[]; setupRouterSpy?: boolean; updateSearchSpy?: (formData: IFinesConSearchAccountState) => void; } diff --git a/cypress/e2e/functional/opal/actions/consolidation/consolidation.actions.ts b/cypress/e2e/functional/opal/actions/consolidation/consolidation.actions.ts index 784a94ca0a..4d72407939 100644 --- a/cypress/e2e/functional/opal/actions/consolidation/consolidation.actions.ts +++ b/cypress/e2e/functional/opal/actions/consolidation/consolidation.actions.ts @@ -5,13 +5,22 @@ import { SelectBusinessUnitLocators } from '../../../../../shared/selectors/consolidation/SelectBusinessUnit.locators'; import { AccountSearchLocators } from '../../../../../shared/selectors/consolidation/AccountSearch.locators'; +import { AccountResultsLocators } from '../../../../../shared/selectors/consolidation/AccountResults.locators'; +import { ErrorPageLocators } from '../../../../../shared/selectors/consolidation/ErrorPage.locators'; import { createScopedLogger } from '../../../../../support/utils/log.helper'; +import { applyUniqPlaceholder } from '../../../../../support/utils/stringUtils'; const log = createScopedLogger('ConsolidationActions'); +const SINGLE_BUSINESS_UNIT_MESSAGE_PREFIX = 'The consolidation will be processed in'; const CONSOLIDATION_LINK = '#finesConsolidationLink'; export type ConsolidationDefendantType = 'Individual' | 'Company'; type SearchDetails = Record; +type CreatedAccountAlias = { + accountId?: number | string | null; + accountNumber?: string | null; +}; +const SELECTED_BUSINESS_UNIT_ALIAS = 'selectedConsolidationBusinessUnit'; /** Actions and assertions for the Consolidation flow screens. */ export class ConsolidationActions { @@ -65,15 +74,98 @@ export class ConsolidationActions { throw new Error(`Unsupported checkbox value "${value}". Use true/false (or yes/no).`); } + /** + * Resolves the last created account id/number stored on the shared @etagUpdate alias. + * This alias is set by the draft account creation helpers. + * @returns Chainable yielding the created account id and account number. + */ + private getCreatedAccountAlias(): Cypress.Chainable<{ accountId: number; accountNumber: string }> { + return cy.get('@etagUpdate').then((etagUpdate) => { + const accountId = Number(etagUpdate?.accountId); + const accountNumber = String(etagUpdate?.accountNumber ?? '').trim(); + + if (!Number.isFinite(accountId) || accountId <= 0) { + throw new Error('Expected @etagUpdate to contain a valid accountId for consolidation result assertions.'); + } + + if (!accountNumber) { + throw new Error('Expected @etagUpdate to contain a valid accountNumber for consolidation result assertions.'); + } + + return { accountId, accountNumber }; + }); + } + + /** + * Stores the selected consolidation business unit so later assertions can verify the actual chosen value. + * @param businessUnitName - Business unit label captured from the UI. + */ + private setSelectedBusinessUnitAlias(businessUnitName: string): void { + cy.wrap(String(businessUnitName).trim(), { log: false }).as(SELECTED_BUSINESS_UNIT_ALIAS); + } + + /** + * Resolves the selected consolidation business unit captured during the select BU step. + * @returns Chainable yielding the trimmed business unit name. + */ + private getSelectedBusinessUnitAlias(): Cypress.Chainable { + return cy + .get(`@${SELECTED_BUSINESS_UNIT_ALIAS}`) + .then((businessUnitName) => String(businessUnitName).trim()); + } + + /** + * Waits until the select business unit screen has rendered either the autocomplete + * input or the single-business-unit informational message. + * @returns Chainable yielding the rendered business unit selection mode. + */ + private waitForBusinessUnitSelectionMode(): Cypress.Chainable<'single' | 'multiple'> { + return cy + .get('body', { timeout: 10_000 }) + .should(($body) => { + const hasBusinessUnitInput = $body.find(SelectBusinessUnitLocators.businessUnitInput).length > 0; + const hasSingleBusinessUnitMessage = $body + .find(SelectBusinessUnitLocators.singleBusinessUnitMessage) + .toArray() + .some((element) => Cypress.$(element).text().includes(SINGLE_BUSINESS_UNIT_MESSAGE_PREFIX)); + + expect( + hasBusinessUnitInput || hasSingleBusinessUnitMessage, + 'business unit autocomplete or single business unit message', + ).to.be.true; + }) + .then(($body) => { + const hasBusinessUnitInput = $body.find(SelectBusinessUnitLocators.businessUnitInput).length > 0; + return hasBusinessUnitInput ? 'multiple' : 'single'; + }); + } + /** * Selects a business unit when the selector is present. * If a single business unit is auto-selected, verifies the informational message instead. */ public selectBusinessUnitIfRequired(): void { - cy.get('body').then(($body) => { - if ($body.find(SelectBusinessUnitLocators.businessUnitInput).length === 0) { + // Wait for the select business unit form and its business unit branch to finish rendering + // before deciding whether we are in the single-BU or autocomplete path. + cy.get(SelectBusinessUnitLocators.heading, { timeout: 10_000 }).should('contain.text', 'Consolidate accounts'); + cy.get(SelectBusinessUnitLocators.defendantTypeHeading, { timeout: 10_000 }).should( + 'contain.text', + 'Defendant type', + ); + cy.get(SelectBusinessUnitLocators.continueButton, { timeout: 10_000 }).should('be.visible'); + + this.waitForBusinessUnitSelectionMode().then((mode) => { + if (mode === 'single') { log('info', 'Business unit input not shown; using auto-selected single business unit'); - cy.get(SelectBusinessUnitLocators.singleBusinessUnitMessage, { timeout: 10_000 }).should('be.visible'); + cy.contains(SelectBusinessUnitLocators.singleBusinessUnitMessage, SINGLE_BUSINESS_UNIT_MESSAGE_PREFIX, { + timeout: 10_000, + }) + .should('be.visible') + .invoke('text') + .then((text) => { + const businessUnitName = text.replace(SINGLE_BUSINESS_UNIT_MESSAGE_PREFIX, '').trim(); + this.setSelectedBusinessUnitAlias(businessUnitName); + }); return; } @@ -83,7 +175,11 @@ export class ConsolidationActions { .should('be.visible') .find('li') .first() - .click(); + .then(($item) => { + const businessUnitName = $item.text().trim(); + this.setSelectedBusinessUnitAlias(businessUnitName); + cy.wrap($item).click(); + }); }); } @@ -95,17 +191,53 @@ export class ConsolidationActions { log('select', `Selecting defendant type: ${defendantType}`); if (defendantType === 'Individual') { - cy.get(SelectBusinessUnitLocators.individualInput).check({ force: true }); + cy.get(SelectBusinessUnitLocators.individualInput, { timeout: 10_000 }) + .should('exist') + .and('not.be.disabled') + .check({ force: true }); return; } - cy.get(SelectBusinessUnitLocators.companyInput).check({ force: true }); + cy.get(SelectBusinessUnitLocators.companyInput, { timeout: 10_000 }) + .should('exist') + .and('not.be.disabled') + .check({ force: true }); } /** Clicks Continue on the Select Business Unit screen. */ public continueFromSelectBusinessUnit(): void { log('click', 'Clicking Continue on consolidation select business unit page'); - cy.get(SelectBusinessUnitLocators.continueButton, { timeout: 10_000 }).should('be.visible').click(); + cy.get(SelectBusinessUnitLocators.continueButton, { timeout: 10_000 }) + .should('be.visible') + .and('not.be.disabled') + .click(); + } + + /** + * Waits for the consolidation account search screen to finish rendering after continuing + * from the select business unit page. + * @param defendantType - Expected defendant type shown on the search summary. + */ + public waitForAccountSearchScreen(defendantType: ConsolidationDefendantType): void { + cy.location('pathname', { timeout: 10_000 }).should('include', '/fines/consolidation/consolidate-accounts'); + cy.get(AccountSearchLocators.heading, { timeout: 10_000 }).should('contain.text', 'Consolidate accounts'); + cy.get(AccountSearchLocators.summaryList, { timeout: 10_000 }).should('be.visible'); + cy.get(AccountSearchLocators.searchTabLink, { timeout: 10_000 }).should('have.attr', 'aria-current', 'page'); + cy.get(AccountSearchLocators.defendantTypeValue, { timeout: 10_000 }).should('contain', defendantType); + cy.get(AccountSearchLocators.accountNumberInput, { timeout: 10_000 }).should('be.visible'); + + if (defendantType === 'Company') { + cy.get(AccountSearchLocators.companyNameInput, { timeout: 10_000 }).should('be.visible'); + } + } + + /** Asserts the user is on the consolidation business unit and defendant type selection screen. */ + public assertOnSelectBusinessUnitScreen(): void { + log('assert', 'Verifying user is on consolidation select business unit screen'); + + cy.location('pathname', { timeout: 10_000 }).should('include', '/fines/consolidation/select-business-unit'); + cy.get(SelectBusinessUnitLocators.heading, { timeout: 10_000 }).should('contain.text', 'Consolidate accounts'); + cy.get(SelectBusinessUnitLocators.defendantTypeHeading).should('contain.text', 'Defendant type'); } /** Clicks Search on the consolidation Search tab. */ @@ -114,6 +246,14 @@ export class ConsolidationActions { cy.get(AccountSearchLocators.searchButton, { timeout: 10_000 }).should('be.visible').click(); } + /** Clicks the Clear search link on the consolidation Search tab. */ + public clearSearch(): void { + log('click', 'Clearing consolidation account search form'); + cy.contains(AccountSearchLocators.clearSearchLink, 'Clear search', { timeout: 10_000 }) + .should('be.visible') + .click(); + } + /** Asserts the user is on Consolidation account search for Individuals, Search tab active. */ public assertOnSearchTabForIndividuals(): void { log('assert', 'Verifying user is on consolidation Search tab for Individuals'); @@ -135,6 +275,178 @@ export class ConsolidationActions { cy.get(AccountSearchLocators.companyNameInput).should('be.visible'); } + /** Asserts the page-header back link is displayed on the consolidation shell. */ + public assertBackLinkIsDisplayed(): void { + log('assert', 'Verifying consolidation page-header back link is displayed'); + cy.get(AccountSearchLocators.backLink, { timeout: 10_000 }).should('be.visible').and('contain.text', 'Back'); + } + + /** Clicks the page-header back link on the consolidation shell. */ + public clickBackLink(): void { + log('click', 'Clicking consolidation page-header back link'); + cy.get(AccountSearchLocators.backLink, { timeout: 10_000 }).should('be.visible').click({ force: true }); + } + + /** Clicks the Results tab from the consolidation flow. */ + public openResultsTab(): void { + log('navigate', 'Opening consolidation Results tab'); + cy.get(AccountSearchLocators.resultsTab, { timeout: 10_000 }).should('be.visible').click(); + } + + /** Asserts the user is on the consolidation Results tab. */ + public assertOnResultsTab(): void { + log('assert', 'Verifying user is on consolidation Results tab'); + + cy.location('pathname', { timeout: 10_000 }).should('include', '/fines/consolidation/consolidate-accounts'); + cy.get(AccountSearchLocators.resultsTab, { timeout: 10_000 }).should('have.attr', 'aria-current', 'page'); + cy.get(AccountSearchLocators.searchButton).should('not.exist'); + cy.get(AccountResultsLocators.resultsTable, { timeout: 10_000 }).should('be.visible'); + } + + /** + * Asserts the user is on the consolidation Results tab with the expected summary values. + * Covers the active tab plus the displayed business unit and defendant type rows. + * @param defendantType - Expected defendant type shown in the summary. + */ + public assertOnResultsTabForDefendantType(defendantType: ConsolidationDefendantType): void { + this.assertOnResultsTab(); + this.getSelectedBusinessUnitAlias().then((businessUnitName) => { + cy.get(AccountSearchLocators.businessUnitKey).should('contain', 'Business unit'); + cy.get(AccountSearchLocators.businessUnitValue).should('contain', businessUnitName); + cy.get(AccountSearchLocators.defendantTypeKey).should('contain', 'Defendant type'); + cy.get(AccountSearchLocators.defendantTypeValue).should('contain', defendantType); + }); + } + + /** Asserts the no matching results state is shown with the Check your search hyperlink. */ + public assertNoMatchingResultsState(): void { + log('assert', 'Verifying consolidation no matching results state'); + + cy.get(AccountResultsLocators.resultsTable).should('not.exist'); + cy.get(AccountResultsLocators.invalidResultsHeading, { timeout: 10_000 }).should( + 'contain', + 'There are no matching results.', + ); + cy.get(AccountResultsLocators.invalidResultsBody) + .invoke('text') + .then((text) => { + const normalisedText = text.replace(/\s+/g, ' ').trim(); + expect(normalisedText).to.equal('Check your search and try again.'); + }); + cy.contains(AccountResultsLocators.invalidResultsLink, 'Check your search', { timeout: 10_000 }).should( + 'be.visible', + ); + } + + /** + * Asserts no consolidation result row displays the supplied balance. + * @param balance - Forbidden rendered balance value, e.g. "£0.00" + */ + public assertResultsExcludeBalance(balance: string): void { + const forbiddenBalance = balance.trim(); + + log('assert', 'Verifying consolidation results exclude balance', { forbiddenBalance }); + + cy.get(AccountResultsLocators.resultsRows, { timeout: 10_000 }).its('length').should('be.gte', 1); + cy.get(AccountResultsLocators.resultBalanceCell, { timeout: 10_000 }).each(($cell) => { + const renderedBalance = $cell.text().replace(/\s+/g, ' ').trim(); + expect(renderedBalance).to.not.equal(forbiddenBalance); + }); + } + + /** Clicks the Check your search hyperlink from the no matching results state. */ + public clickCheckYourSearchFromNoMatchingResults(): void { + log('click', 'Clicking Check your search from consolidation no matching results state'); + cy.contains(AccountResultsLocators.invalidResultsLink, 'Check your search', { timeout: 10_000 }) + .should('be.visible') + .click(); + } + + /** Asserts the newly created account number is displayed as a hyperlink in the results table. */ + public assertCreatedAccountLinkIsDisplayed(): void { + this.getCreatedAccountAlias().then(({ accountNumber }) => { + log('assert', 'Verifying created consolidation result account is displayed as a hyperlink', { accountNumber }); + + cy.get(AccountResultsLocators.resultAccountLinkByNumber(accountNumber), { timeout: 10_000 }) + .should('be.visible') + .and('have.class', 'govuk-link') + .then(($link) => { + expect($link.prop('tagName')).to.equal('A'); + }); + }); + } + + /** Opens the created consolidation result account and asserts it is opened in a new tab to the FAE details route. */ + public openCreatedAccountFromResultsInNewTab(): void { + cy.intercept('GET', '**/defendant-accounts/**/header-summary').as('consolidationHeaderSummary'); + + this.getCreatedAccountAlias().then(({ accountNumber }) => { + log('open', 'Opening created consolidation result account from results', { accountNumber }); + + cy.window().then((win) => { + cy.stub(win, 'open') + .callsFake((url?: string | URL, target?: string) => { + expect(target).to.equal('_blank'); + win.location.href = String(url); + }) + .as('consolidationWindowOpen'); + }); + + cy.get(AccountResultsLocators.resultAccountLinkByNumber(accountNumber), { timeout: 10_000 }) + .should('be.visible') + .click(); + + cy.get('@consolidationWindowOpen').then((windowOpenStub) => { + const stub = windowOpenStub as any; + expect(stub.calledOnce).to.equal(true); + + const [openedUrl, target] = stub.getCall(0).args as [string, string]; + expect(target).to.equal('_blank'); + expect(String(openedUrl)).to.match(/\/fines\/account\/defendant\/\d+\/details$/); + }); + + cy.wait('@consolidationHeaderSummary', { timeout: 15_000 }).its('response.statusCode').should('eq', 200); + }); + } + + /** + * Asserts the consolidation search error page content for the given defendant type. + * @param defendantType - "Individual" or "Company" + */ + public assertSearchErrorPage(defendantType: ConsolidationDefendantType): void { + const expectedBulletItems = + defendantType === 'Individual' + ? ['account number, or', 'national insurance number, or', 'advanced search'] + : ['account number, or', 'advanced search']; + + log('assert', 'Verifying consolidation search error page', { defendantType, expectedBulletItems }); + + cy.get(ErrorPageLocators.root, { timeout: 10_000 }).should('be.visible'); + cy.get(`${ErrorPageLocators.root} ${ErrorPageLocators.heading}`).should('have.text', 'There is a problem'); + cy.get(`${ErrorPageLocators.root} ${ErrorPageLocators.message}`) + .should('be.visible') + .invoke('text') + .then((text) => { + const normalisedText = text.replace(/\s+/g, ' ').trim(); + expect(normalisedText).to.equal( + 'Reference data and account information cannot be entered together when searching for an account. Search using either:', + ); + }); + + cy.get(`${ErrorPageLocators.root} ${ErrorPageLocators.bulletItems}`).then(($items) => { + const items = [...$items].map((item) => item.textContent?.replace(/\s+/g, ' ').trim().toLowerCase()); + expect(items).to.deep.equal(expectedBulletItems); + }); + } + + /** Clicks the Go back link on the consolidation search error page. */ + public goBackFromSearchError(): void { + log('click', 'Going back from consolidation search error page'); + cy.contains(`${ErrorPageLocators.root} ${ErrorPageLocators.backLink}`, 'Go back', { timeout: 10_000 }) + .should('be.visible') + .click(); + } + /** * Populates fields on the consolidation Search tab from key/value details. * @param details - Search details keyed by user-facing field labels. @@ -147,16 +459,17 @@ export class ConsolidationActions { Object.entries(details).forEach(([rawKey, value]) => { const key = rawKey.trim().toLowerCase(); + const resolvedValue = applyUniqPlaceholder(value); if (activeTextMap[key]) { const selector = activeTextMap[key]; - cy.get(selector).clear().type(value); + cy.get(selector).clear().type(resolvedValue); return; } if (activeCheckboxMap[key]) { const selector = activeCheckboxMap[key]; - const shouldCheck = this.parseCheckboxValue(value); + const shouldCheck = this.parseCheckboxValue(resolvedValue); if (shouldCheck) { cy.get(selector).check({ force: true }); } else { @@ -211,16 +524,17 @@ export class ConsolidationActions { Object.entries(details).forEach(([rawKey, value]) => { const key = rawKey.trim().toLowerCase(); + const resolvedValue = applyUniqPlaceholder(value); if (activeTextMap[key]) { const selector = activeTextMap[key]; - cy.get(selector).should('have.value', value); + cy.get(selector).should('have.value', resolvedValue); return; } if (activeCheckboxMap[key]) { const selector = activeCheckboxMap[key]; - const shouldCheck = this.parseCheckboxValue(value); + const shouldCheck = this.parseCheckboxValue(resolvedValue); if (shouldCheck) { cy.get(selector).should('be.checked'); } else { diff --git a/cypress/e2e/functional/opal/features/consolidation/FineAccountConsolidation.feature b/cypress/e2e/functional/opal/features/consolidation/FineAccountConsolidation.feature index 136266539f..c6b09ff2ef 100644 --- a/cypress/e2e/functional/opal/features/consolidation/FineAccountConsolidation.feature +++ b/cypress/e2e/functional/opal/features/consolidation/FineAccountConsolidation.feature @@ -39,7 +39,11 @@ Feature: Fines Account Consolidation | first names exact match | true | | include aliases | true | Then I click Search on consolidation account search - Then the consolidation search details are retained: + Then I see the consolidation search error page for "Individual" + # AC3b All data previously entered on the Search page is retained when a user clicks back from the consolidation search error page + When I go back from the consolidation search error page + Then I am on the consolidation Search tab for Individuals + And the consolidation search details are retained: | account number | 12345678 | | national insurance number | AB123456C | | last name | Smith | @@ -50,6 +54,94 @@ Feature: Fines Account Consolidation | last name exact match | true | | first names exact match | true | | include aliases | true | + When I clear the consolidation search + And I enter the following consolidation search details: + | last name | NoMatch | + | last name exact match | true | + And I click Search on consolidation account search + # AC2d - If no matching results are returned, Check your search is a hyperlink that returns the user to Search with all previously entered data retained + Then I see the consolidation no matching results state + When I click Check your search on consolidation no matching results + Then I am on the consolidation Search tab for Individuals + And the consolidation search details are retained: + | last name | NoMatch | + | last name exact match | true | + + And I click Search on consolidation account search + Then I see the consolidation no matching results state + # AC4 - A Back button will be displayed in the page header + Then the consolidation page header back link is displayed + # AC4 - Selecting Back will return the user to the BU and defendant type selection screen + When I click the consolidation page header back link + Then I am on the consolidation business unit and defendant type selection screen + + @JIRA-STORY:PO-2413 @JIRA-KEY:POT-3328 + Scenario: Consolidation Successful account search for Individuals + Given I create a "adultOrYouthOnly" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.forenames | Consolidation | + | account.defendant.surname | ResultLink{uniq} | + | account.defendant.email_address_1 | consolidation.result{uniq}@test.com | + | account.defendant.telephone_number_home | 02078259314 | + | account.account_type | Fine | + | account.prosecutor_case_reference | CONS-RESULT-IND-{uniq} | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | + | account.defendant.dob | 2002-05-15 | + When I open Consolidate accounts + When I continue to the consolidation account search as an "Individual" defendant + Then I am on the consolidation Search tab for Individuals + And I enter the following consolidation search details: + | last name | ResultLink{uniq} | + | last name exact match | true | + And I click Search on consolidation account search + # AC1 - A user is navigated to the Results tab for Individuals when a valid search has been performed from the Search tab within the Consolidate accounts journey + # AC1a - The Business unit row displays the Business Unit used in the search + # AC1b - The Defendant type row displays Individual + Then I am on the consolidation Results tab for Individuals + # AC4 - The Account column displays the account number as a hyperlink. Selecting it will open the relevant FAE – At a glance page in a new tab + And the created consolidation result account number is displayed as a hyperlink + When I open the created consolidation result account in a new tab + Then I should see the account header contains "Mr Consolidation RESULTLINK{uniqUpper}" + + @JIRA-STORY:PO-2413 @JIRA-KEY:POT-3328 + Scenario: Consolidation search excludes zero balance accounts for Individuals + Given I create a "adultOrYouthOnly" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.forenames | Zero | + | account.defendant.surname | ConsolidationZeroBalance{uniq} | + | account.defendant.email_address_1 | consolidation.zero.balance{uniq}@test.com | + | account.defendant.telephone_number_home | 02078259310 | + | account.account_type | Fine | + | account.prosecutor_case_reference | CONS-ZERO-BAL-IND-A-{uniq} | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | + | account.defendant.dob | 2001-05-15 | + | account.offences.0.impositions.0.amount_paid | 125 | + And I create a "adultOrYouthOnly" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.forenames | Visible | + | account.defendant.surname | ConsolidationZeroBalance{uniq} | + | account.defendant.email_address_1 | consolidation.visible.balance{uniq}@test.com | + | account.defendant.telephone_number_home | 02078259311 | + | account.account_type | Fine | + | account.prosecutor_case_reference | CONS-ZERO-BAL-IND-B-{uniq} | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | + | account.defendant.dob | 2002-05-15 | + When I open Consolidate accounts + And I continue to the consolidation account search as an "Individual" defendant + Then I am on the consolidation Search tab for Individuals + And I enter the following consolidation search details: + | last name | ConsolidationZero | + + And I click Search on consolidation account search + Then I am on the consolidation Results tab for Individuals + And the created consolidation result account number is displayed as a hyperlink + And the consolidation results exclude accounts with a balance of "£0.00" @JIRA-STORY:PO-2414 @JIRA-KEY:POT-3329 Scenario: Consolidation account search for Companies @@ -74,10 +166,93 @@ Feature: Fines Account Consolidation | Search exact match | true | | include aliases | true | Then I click Search on consolidation account search - Then the consolidation search details are retained: + Then I see the consolidation search error page for "Company" + # AC3b All data previously entered on the Search page is retained when a user clicks back from the consolidation search error page + When I go back from the consolidation search error page + Then I am on the consolidation Search tab for Companies + And the consolidation search details are retained: | account number | 12345678 | | company name | Company Name | | address line 1 | 1 High St | | postcode | SW1A 1AA | | Search exact match | true | | include aliases | true | + When I clear the consolidation search + And I enter the following consolidation search details: + | company name | No Match Co | + | Search exact match | true | + And I click Search on consolidation account search + # AC2d - If no matching results are returned, Check your search is a hyperlink that returns the user to Search with all previously entered data retained + Then I see the consolidation no matching results state + When I click Check your search on consolidation no matching results + Then I am on the consolidation Search tab for Companies + And the consolidation search details are retained: + | company name | No Match Co | + | Search exact match | true | + And I click Search on consolidation account search + Then I see the consolidation no matching results state + # AC4 - A Back button will be displayed in the page header + Then the consolidation page header back link is displayed + # AC4 - Selecting Back will return the user to the BU and defendant type selection screen + When I click the consolidation page header back link + Then I am on the consolidation business unit and defendant type selection screen + + + @JIRA-STORY:PO-2413 @JIRA-KEY:POT-3328 + Scenario: Consolidation Successful account search for Company + Given I create a "company" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.company_name | Consolidation Result Co {uniq} | + | account.defendant.email_address | consolidation.result.co{uniq}@test.com | + | account.defendant.post_code | AB23 4RN | + | account.prosecutor_case_reference | CONS-RESULT-COMP-{uniq} | + | account.account_type | Fine | + When I open Consolidate accounts + When I continue to the consolidation account search as an "Company" defendant + Then I am on the consolidation Search tab for Companies + And I enter the following consolidation search details: + | company name | Consolidation Result Co {uniq} | + | Search exact match | true | + And I click Search on consolidation account search + # AC1 - A user is navigated to the Results tab for Companies when a valid search has been performed from the Search tab within the Consolidate accounts journey + # AC1a - The Business unit row displays the Business Unit used in the search + # AC1b - The Defendant type row displays Company + Then I am on the consolidation Results tab for Companies + # AC4 - The Account column displays the account number as a hyperlink. Selecting it will open the relevant FAE – At a glance page in a new tab + And the created consolidation result account number is displayed as a hyperlink + When I open the created consolidation result account in a new tab + Then I should see the account header contains "Consolidation Result Co {uniqUpper}" + + @JIRA-STORY:PO-2414 @JIRA-KEY:POT-3329 + Scenario: Consolidation search excludes zero balance accounts for Company + Given I create a "company" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.company_name | Consolidation Zero Balance Co {uniq} | + | account.defendant.email_address_1 | consolidation.zero.balance.co{uniq}@test.com | + | account.defendant.post_code | AB23 4RN | + | account.prosecutor_case_reference | CONS-ZERO-BAL-COMP-A-{uniq} | + | account.account_type | Fine | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | + | account.offences.0.impositions.0.amount_paid | 125 | + And I create a "company" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.company_name | Consolidation Zero Balance Co {uniq} | + | account.defendant.email_address_1 | consolidation.visible.balance.co{uniq}@test.com | + | account.defendant.post_code | AB23 4RN | + | account.prosecutor_case_reference | CONS-ZERO-BAL-COMP-B-{uniq} | + | account.account_type | Fine | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | + When I open Consolidate accounts + And I continue to the consolidation account search as an "Company" defendant + Then I am on the consolidation Search tab for Companies + And I enter the following consolidation search details: + | company name | Consolidation Zero Balance Co {uniq} | + | Search exact match | true | + And I click Search on consolidation account search + Then I am on the consolidation Results tab for Companies + And the created consolidation result account number is displayed as a hyperlink + And the consolidation results exclude accounts with a balance of "£0.00" diff --git a/cypress/e2e/functional/opal/features/consolidation/FinesAccountConsolidationAccessibility.feature b/cypress/e2e/functional/opal/features/consolidation/FinesAccountConsolidationAccessibility.feature index 697966cace..e4beab898d 100644 --- a/cypress/e2e/functional/opal/features/consolidation/FinesAccountConsolidationAccessibility.feature +++ b/cypress/e2e/functional/opal/features/consolidation/FinesAccountConsolidationAccessibility.feature @@ -6,18 +6,87 @@ Feature: Accessibility Tests for Fines Consolidation Given I am logged in with email "opal-test@dev.platform.hmcts.net" Then I should be on the dashboard - @JIRA-STORY:PO-2413 @JIRA-KEY:POT-3210 + @JIRA-STORY:PO-2413 @JIRA-STORY:PO-2415 @JIRA-STORY:PO-2417 @JIRA-KEY:POT-3210 Scenario: Consolidate Accessibility for Individuals + Given I create a "adultOrYouthOnly" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.forenames | Consolidation | + | account.defendant.surname | Accessibility{uniq} | + | account.defendant.email_address_1 | consolidation.accessibility{uniq}@test.com | + | account.defendant.telephone_number_home | 02078259314 | + | account.account_type | Fine | + | account.prosecutor_case_reference | CONS-A11Y-IND-{uniq} | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | + | account.defendant.dob | 2002-05-15 | When I open Consolidate accounts Then I check the page for accessibility And I continue to the consolidation account search as an "Individual" defendant Then I am on the consolidation Search tab for Individuals And I check the page for accessibility + And I enter the following consolidation search details: + | account number | 12345678 | + | last name | Accessibility{uniq} | + When I click Search on consolidation account search + Then I see the consolidation search error page for "Individual" + And I check the page for accessibility + When I go back from the consolidation search error page + Then I am on the consolidation Search tab for Individuals + When I clear the consolidation search + And I enter the following consolidation search details: + | last name | Nobody | + | last name exact match | true | + When I click Search on consolidation account search + Then I see the consolidation no matching results state + Then I check the page for accessibility + When I click Check your search on consolidation no matching results + Then I am on the consolidation Search tab for Individuals + And I enter the following consolidation search details: + | last name | Accessibility{uniq} | + | last name exact match | true | + When I click Search on consolidation account search + Then I am on the consolidation Results tab + And I check the page for accessibility - @JIRA-STORY:PO-2414 @JIRA-KEY:POT-3211 + @JIRA-STORY:PO-2414 @JIRA-STORY:PO-2421 @JIRA-STORY:PO-2417 @JIRA-KEY:POT-3211 Scenario: Consolidate Accessibility for Companies + Given I create a "company" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.company_name | Consolidation Access Comp {uniq} | + | account.defendant.email_address_1 | consolidation.access.comp{uniq}@test.com | + | account.defendant.post_code | AB23 4RN | + | account.account_type | Fine | + | account.prosecutor_case_reference | CONS-A11Y-COMP-{uniq} | + | account.collection_order_made | false | + | account.collection_order_made_today | false | + | account.payment_card_request | false | When I open Consolidate accounts Then I check the page for accessibility And I continue to the consolidation account search as an "Company" defendant Then I am on the consolidation Search tab for Companies And I check the page for accessibility + And I enter the following consolidation search details: + | account number | 12345678 | + | company name | Consolidation Access Comp {uniq} | + When I click Search on consolidation account search + Then I see the consolidation search error page for "Company" + And I check the page for accessibility + When I go back from the consolidation search error page + Then I am on the consolidation Search tab for Companies + When I clear the consolidation search + And I enter the following consolidation search details: + | company name | Nobody | + | Search exact match | true | + When I click Search on consolidation account search + Then I see the consolidation no matching results state + Then I check the page for accessibility + When I click Check your search on consolidation no matching results + Then I am on the consolidation Search tab for Companies + When I clear the consolidation search + And I enter the following consolidation search details: + | company name | Consolidation Access Comp {uniq} | + | Search exact match | true | + When I click Search on consolidation account search + Then I am on the consolidation Results tab + And I check the page for accessibility diff --git a/cypress/e2e/functional/opal/flows/consolidation.flow.ts b/cypress/e2e/functional/opal/flows/consolidation.flow.ts index 4ab89ee09f..6411bec796 100644 --- a/cypress/e2e/functional/opal/flows/consolidation.flow.ts +++ b/cypress/e2e/functional/opal/flows/consolidation.flow.ts @@ -31,6 +31,7 @@ export class ConsolidationFlow { this.consolidation.selectBusinessUnitIfRequired(); this.consolidation.selectDefendantType(defendantType); this.consolidation.continueFromSelectBusinessUnit(); + this.consolidation.waitForAccountSearchScreen(defendantType); } /** @@ -41,18 +42,114 @@ export class ConsolidationFlow { this.consolidation.clickSearch(); } + /** Clears the consolidation account-search form. */ + public clearConsolidationSearch(): void { + log('flow', 'Clearing consolidation account-search form'); + this.consolidation.clearSearch(); + } + /** Asserts consolidation account search lands on Search tab for Individuals. */ public assertSearchTabForIndividuals(): void { log('flow', 'Asserting consolidation account search is on Search tab for Individuals'); this.consolidation.assertOnSearchTabForIndividuals(); } + /** Asserts the user is on the consolidation business unit and defendant type selection screen. */ + public assertSelectBusinessUnitScreen(): void { + log('flow', 'Asserting consolidation select business unit screen is displayed'); + this.consolidation.assertOnSelectBusinessUnitScreen(); + } + /** Asserts consolidation account search lands on Search tab for Companies. */ public assertSearchTabForCompanies(): void { log('flow', 'Asserting consolidation account search is on Search tab for Companies'); this.consolidation.assertOnSearchTabForCompanies(); } + /** Asserts the page-header back link is displayed on the consolidation shell. */ + public assertBackLinkIsDisplayed(): void { + log('flow', 'Asserting consolidation page-header back link is displayed'); + this.consolidation.assertBackLinkIsDisplayed(); + } + + /** Clicks the page-header back link on the consolidation shell. */ + public clickBackLink(): void { + log('flow', 'Clicking consolidation page-header back link'); + this.consolidation.clickBackLink(); + } + + /** Opens the consolidation Results tab. */ + public openResultsTab(): void { + log('flow', 'Opening consolidation Results tab'); + this.consolidation.openResultsTab(); + } + + /** Asserts consolidation account search lands on the Results tab. */ + public assertResultsTab(): void { + log('flow', 'Asserting consolidation account search is on the Results tab'); + this.consolidation.assertOnResultsTab(); + } + + /** Asserts consolidation account search lands on the Results tab for Individuals with the correct summary values. */ + public assertResultsTabForIndividuals(): void { + log('flow', 'Asserting consolidation account search is on the Results tab for Individuals'); + this.consolidation.assertOnResultsTabForDefendantType('Individual'); + } + + /** Asserts consolidation account search lands on the Results tab for Companies with the correct summary values. */ + public assertResultsTabForCompanies(): void { + log('flow', 'Asserting consolidation account search is on the Results tab for Companies'); + this.consolidation.assertOnResultsTabForDefendantType('Company'); + } + + /** Asserts the created account number is rendered as a hyperlink in consolidation results. */ + public assertCreatedAccountLinkIsDisplayed(): void { + log('flow', 'Asserting created consolidation result account is displayed as a hyperlink'); + this.consolidation.assertCreatedAccountLinkIsDisplayed(); + } + + /** Asserts the consolidation no matching results state is displayed. */ + public assertNoMatchingResultsState(): void { + log('flow', 'Asserting consolidation no matching results state'); + this.consolidation.assertNoMatchingResultsState(); + } + + /** + * Asserts the consolidation results do not contain the supplied balance. + * @param balance - Forbidden rendered balance value. + */ + public assertResultsExcludeBalance(balance: string): void { + log('flow', 'Asserting consolidation results exclude balance', { balance }); + this.consolidation.assertResultsExcludeBalance(balance); + } + + /** Clicks the Check your search hyperlink from the consolidation no matching results state. */ + public clickCheckYourSearchFromNoMatchingResults(): void { + log('flow', 'Clicking Check your search from consolidation no matching results state'); + this.consolidation.clickCheckYourSearchFromNoMatchingResults(); + } + + /** Opens the created consolidation result account and verifies the new-tab FAE details navigation. */ + public openCreatedAccountFromResultsInNewTab(): void { + log('flow', 'Opening created consolidation result account in a new tab'); + this.consolidation.openCreatedAccountFromResultsInNewTab(); + } + + /** + * Asserts the consolidation search error page for the given defendant type. + * @param defendantType - "Individual" or "Company" + */ + public assertSearchErrorPage(defendantType: ConsolidationDefendantType): void { + log('flow', 'Asserting consolidation search error page', { defendantType }); + this.consolidation.assertSearchErrorPage(defendantType); + } + + /** Clicks the back link on the consolidation search error page. */ + public goBackFromSearchError(): void { + log('flow', 'Going back from consolidation search error page'); + this.consolidation.goBackFromSearchError(); + } + /** * Enters consolidation account-search details from a two-column data table. * @param table - Data table in key/value form. diff --git a/cypress/shared/selectors/consolidation/AccountResults.locators.ts b/cypress/shared/selectors/consolidation/AccountResults.locators.ts new file mode 100644 index 0000000000..7bef78a735 --- /dev/null +++ b/cypress/shared/selectors/consolidation/AccountResults.locators.ts @@ -0,0 +1,43 @@ +export const AccountResultsLocators = { + // Results headings and actions + resultsHeading: 'h2.govuk-heading-m', + addToListButton: 'button.govuk-button[type="button"]', + selectedAccountsHint: 'p.govuk-hint', + invalidResultsHeading: 'h2.govuk-heading-m', + invalidResultsBody: 'p.govuk-body-m', + invalidResultsLink: 'p.govuk-body-m a.govuk-link', + + // Results table + resultsTable: 'table.govuk-table', + resultsTableHeaders: 'table.govuk-table thead th', + resultsTableNamedHeaders: 'table.govuk-table thead th[opal-lib-moj-sortable-table-header]', + resultsScrollPane: 'opal-lib-custom-horizontal-scroll-pane', + resultsPagination: 'opal-lib-moj-pagination, .govuk-pagination, nav.govuk-pagination', + resultsTableBody: 'table.govuk-table tbody', + resultsRows: 'table.govuk-table tbody > tr.govuk-table__row', + resultSelectionCheckboxes: 'table.govuk-table input[type="checkbox"]', + resultSelectAllCheckbox: '#defendants-select-all-checkbox', + resultAccountLink: 'td#defendantAccountNumber a.govuk-link', + resultNameCell: 'td#defendantName', + resultAliasesCell: 'td#defendantAliases', + resultDateOfBirthCell: 'td#defendantDateOfBirth', + resultAddressLine1Cell: 'td#defendantAddressLine1', + resultPostcodeCell: 'td#defendantPostcode', + resultCollectionOrderCell: 'td#defendantCollectionOrder', + resultEnforcementCell: 'td#defendantEnforcement', + resultBalanceCell: 'td#defendantBalance', + resultPayingParentGuardianCell: 'td#defendantPayingParentGuardian', + resultNationalInsuranceNumberCell: 'td#defendantNationalInsuranceNumber', + resultRefCell: 'td#defendantRef', + resultChecksBulletItems: 'ul.defendant-check-message-list > li', + resultTableRow: 'tr.govuk-table__row', + addToListErrorMessage: '#defendants-select-all-error-message', + + // Results row helpers + resultRowWithAccount: (accountNumber: string) => + `tr.govuk-table__row:has(td#defendantAccountNumber a:contains("${accountNumber}"))`, + resultAccountLinkByNumber: (accountNumber: string) => + `td#defendantAccountNumber a.govuk-link:contains("${accountNumber}")`, + resultChecksCellByAccountId: (accountId: number | string) => `#defendant-checks-${accountId}`, + resultRowCheckboxByAccountId: (accountId: number | string) => `#defendant-select-${accountId}`, +}; diff --git a/cypress/shared/selectors/consolidation/AccountSearch.locators.ts b/cypress/shared/selectors/consolidation/AccountSearch.locators.ts index 344ee2a9dd..05e5d2ea81 100644 --- a/cypress/shared/selectors/consolidation/AccountSearch.locators.ts +++ b/cypress/shared/selectors/consolidation/AccountSearch.locators.ts @@ -1,5 +1,6 @@ export const AccountSearchLocators = { heading: 'h1.govuk-heading-l', + backLink: 'a.govuk-back-link', // Summary rows summaryList: '#consolidateAccSummary', diff --git a/cypress/shared/selectors/consolidation/ErrorPage.locators.ts b/cypress/shared/selectors/consolidation/ErrorPage.locators.ts new file mode 100644 index 0000000000..f525e0442f --- /dev/null +++ b/cypress/shared/selectors/consolidation/ErrorPage.locators.ts @@ -0,0 +1,8 @@ +export const ErrorPageLocators = { + root: 'app-fines-con-search-error', + heading: 'h1.govuk-heading-l', + message: 'p.govuk-body', + bulletList: 'ul.govuk-list.govuk-list--bullet', + bulletItems: 'ul.govuk-list.govuk-list--bullet li', + backLink: 'a.govuk-link', +}; diff --git a/cypress/support/step_definitions/consolidation/consolidation.steps.ts b/cypress/support/step_definitions/consolidation/consolidation.steps.ts index 684548df21..4b3cca9b27 100644 --- a/cypress/support/step_definitions/consolidation/consolidation.steps.ts +++ b/cypress/support/step_definitions/consolidation/consolidation.steps.ts @@ -24,16 +24,91 @@ When('I click Search on consolidation account search', () => { consolidationFlow().clickConsolidationSearch(); }); +When('I clear the consolidation search', () => { + log('step', 'Clearing the consolidation search form'); + consolidationFlow().clearConsolidationSearch(); +}); + Then('I am on the consolidation Search tab for Individuals', () => { log('step', 'Verifying consolidation account search defaults for Individuals'); consolidationFlow().assertSearchTabForIndividuals(); }); +Then('I am on the consolidation business unit and defendant type selection screen', () => { + log('step', 'Verifying consolidation business unit and defendant type selection screen'); + consolidationFlow().assertSelectBusinessUnitScreen(); +}); + Then('I am on the consolidation Search tab for Companies', () => { log('step', 'Verifying consolidation account search defaults for Companies'); consolidationFlow().assertSearchTabForCompanies(); }); +Then('the consolidation page header back link is displayed', () => { + log('step', 'Verifying consolidation page header back link is displayed'); + consolidationFlow().assertBackLinkIsDisplayed(); +}); + +When('I click the consolidation page header back link', () => { + log('step', 'Clicking the consolidation page header back link'); + consolidationFlow().clickBackLink(); +}); + +When('I open the consolidation Results tab', () => { + log('step', 'Opening the consolidation Results tab'); + consolidationFlow().openResultsTab(); +}); + +Then('I am on the consolidation Results tab', () => { + log('step', 'Verifying consolidation Results tab is active'); + consolidationFlow().assertResultsTab(); +}); + +Then('I am on the consolidation Results tab for Individuals', () => { + log('step', 'Verifying consolidation Results tab summary for Individuals'); + consolidationFlow().assertResultsTabForIndividuals(); +}); + +Then('I am on the consolidation Results tab for Companies', () => { + log('step', 'Verifying consolidation Results tab summary for Companies'); + consolidationFlow().assertResultsTabForCompanies(); +}); + +Then('the created consolidation result account number is displayed as a hyperlink', () => { + log('step', 'Verifying created consolidation result account number is displayed as a hyperlink'); + consolidationFlow().assertCreatedAccountLinkIsDisplayed(); +}); + +Then('I see the consolidation no matching results state', () => { + log('step', 'Verifying consolidation no matching results state'); + consolidationFlow().assertNoMatchingResultsState(); +}); + +Then('the consolidation results exclude accounts with a balance of {string}', (balance: string) => { + log('step', 'Verifying consolidation results exclude accounts with balance', { balance }); + consolidationFlow().assertResultsExcludeBalance(balance); +}); + +When('I click Check your search on consolidation no matching results', () => { + log('step', 'Clicking Check your search on consolidation no matching results'); + consolidationFlow().clickCheckYourSearchFromNoMatchingResults(); +}); + +When('I open the created consolidation result account in a new tab', () => { + log('step', 'Opening created consolidation result account in a new tab'); + consolidationFlow().openCreatedAccountFromResultsInNewTab(); +}); + +Then('I see the consolidation search error page for {string}', (defendantType: ConsolidationDefendantType) => { + log('step', 'Verifying consolidation search error page', { defendantType }); + consolidationFlow().assertSearchErrorPage(defendantType); +}); + +When('I go back from the consolidation search error page', () => { + log('step', 'Going back from the consolidation search error page'); + consolidationFlow().goBackFromSearchError(); +}); + When('I enter the following consolidation search details:', (table: DataTable) => { log('step', 'Entering consolidation search details'); consolidationFlow().enterConsolidationSearchDetails(table); diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.html b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.html index 6c52658ca5..1c79ed1bf8 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.html +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.html @@ -65,6 +65,8 @@

Consolidate accounts

} @case ('for-consolidation') { diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.spec.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.spec.ts index 3c81044af0..849bdd8cdf 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.spec.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-consolidate-acc/fines-con-consolidate-acc.component.spec.ts @@ -6,6 +6,7 @@ import { FinesConStore } from '../../stores/fines-con.store'; import { FinesConStoreType } from '../../stores/types/fines-con-store.type'; import { OPAL_FINES_BUSINESS_UNIT_REF_DATA_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-business-unit-ref-data.mock'; import { OPAL_FINES_DEFENDANT_ACCOUNT_SEARCH_PARAMS_DEFAULTS } from '@services/fines/opal-fines-service/constants/opal-fines-defendant-account-search-params-defaults.constant'; +import { Observable, of } from 'rxjs'; import { FINES_ROUTING_PATHS } from '@app/flows/fines/routing/constants/fines-routing-paths.constant'; import { FINES_DASHBOARD_ROUTING_PATHS } from '@app/flows/fines/constants/fines-dashboard-routing-paths.constant'; @@ -15,7 +16,8 @@ describe('FinesConConsolidateAccComponent', () => { let mockRouter: { navigate: ReturnType }; let mockActivatedRoute: { parent: Record; - snapshot: { data: Record }; + fragment: Observable; + snapshot: { data: Record; fragment?: string | null }; }; let finesConStore: InstanceType; @@ -25,7 +27,9 @@ describe('FinesConConsolidateAccComponent', () => { const parentActivatedRoute = {}; mockActivatedRoute = { parent: parentActivatedRoute, + fragment: of('search'), snapshot: { + fragment: null, data: { businessUnits: OPAL_FINES_BUSINESS_UNIT_REF_DATA_MOCK, }, @@ -58,6 +62,20 @@ describe('FinesConConsolidateAccComponent', () => { expect(finesConStore.activeTab()).toBe('results'); }); + it('should not navigate to update fragment when selected tab matches current fragment', () => { + mockActivatedRoute.snapshot.fragment = 'results'; + + component.handleTabSwitch('results'); + + expect(finesConStore.activeTab()).toBe('results'); + expect(mockRouter.navigate).not.toHaveBeenCalledWith([], { + relativeTo: mockActivatedRoute, + fragment: 'results', + queryParamsHandling: 'preserve', + replaceUrl: true, + }); + }); + it('should store transformed search payload for results tab', () => { const activeTabSpy = vi.spyOn(finesConStore, 'setActiveTab'); const payload = { diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.spec.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.spec.ts index 0a59855450..b912dc2bf2 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.spec.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.spec.ts @@ -2,10 +2,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; +import { Router } from '@angular/router'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { FinesConSearchAccountFormComponent } from './fines-con-search-account-form.component'; import { FinesConStore } from '../../../stores/fines-con.store'; import { FinesConStoreType } from '../../../stores/types/fines-con-store.type'; +import { FINES_CON_ROUTING_PATHS } from '../../../routing/constants/fines-con-routing-paths.constant'; +import { AbstractFormBaseComponent } from '@hmcts/opal-frontend-common/components/abstract/abstract-form-base'; describe('FinesConSearchAccountFormComponent', () => { let component: FinesConSearchAccountFormComponent; @@ -16,11 +19,18 @@ describe('FinesConSearchAccountFormComponent', () => { const activatedRouteSpy = { params: { subscribe: () => {} }, queryParams: { subscribe: () => {} }, + parent: {}, + }; + const routerSpy = { + navigate: vi.fn().mockName('Router.navigate'), }; await TestBed.configureTestingModule({ imports: [ReactiveFormsModule, FinesConSearchAccountFormComponent], - providers: [{ provide: ActivatedRoute, useValue: activatedRouteSpy }], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteSpy }, + { provide: Router, useValue: routerSpy }, + ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -87,4 +97,54 @@ describe('FinesConSearchAccountFormComponent', () => { expect(component.form.get('fcon_search_account_number')?.value).toBeNull(); }); + + it('should persist form and navigate to search error page when conflicting criteria are submitted', () => { + const router = TestBed.inject(Router); + const updateTemporarySpy = vi.spyOn(finesConStore, 'updateSearchAccountFormTemporary'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const submitEmitSpy = vi.spyOn(component['formSubmit'], 'emit'); + + component.form.patchValue({ + fcon_search_account_number: '12345678', + fcon_search_account_national_insurance_number: 'AB123456C', + }); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(updateTemporarySpy).toHaveBeenCalledWith(component.form.value); + expect(router.navigate).toHaveBeenCalledWith([FINES_CON_ROUTING_PATHS.children.searchError], { + relativeTo: component['activatedRoute'].parent, + }); + expect(submitEmitSpy).not.toHaveBeenCalled(); + }); + + it('should not call super.handleFormSubmit when form is empty (formEmpty)', () => { + const superSubmitSpy = vi.spyOn(AbstractFormBaseComponent.prototype, 'handleFormSubmit'); + + component.form.reset(); + component.form.updateValueAndValidity({ emitEvent: false }); + expect(component.form.errors?.['formEmpty']).toBe(true); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(superSubmitSpy).not.toHaveBeenCalled(); + }); + + it('should call super.handleFormSubmit when form submission is valid', () => { + const superSubmitSpy = vi.spyOn(AbstractFormBaseComponent.prototype, 'handleFormSubmit'); + const router = TestBed.inject(Router); + + component.form.patchValue({ + fcon_search_account_number: '12345678', + }); + component.form.updateValueAndValidity({ emitEvent: false }); + expect(component.form.errors).toBeNull(); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(superSubmitSpy).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalledWith([FINES_CON_ROUTING_PATHS.children.searchError], { + relativeTo: component['activatedRoute'].parent, + }); + }); }); diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.ts index 9c8643ff30..8cb07a59e3 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-account/fines-con-search-account-form/fines-con-search-account-form.component.ts @@ -16,6 +16,7 @@ import { consolidateSearchAccountFormValidator } from './validators/fines-con-se import { FinesConStore } from '../../../stores/fines-con.store'; import { FINES_CON_SEARCH_ACCOUNT_FORM_INDIVIDUALS_FIELD_ERRORS } from './fines-con-search-account-form-individuals/constants/fines-con-search-account-form-individuals-field-errors.constant'; import { FINES_CON_SEARCH_ACCOUNT_FORM_COMPANIES_FIELD_ERRORS } from './fines-con-search-account-form-companies/constants/fines-con-search-account-form-companies-field-errors.constant'; +import { FINES_CON_ROUTING_PATHS } from '../../../routing/constants/fines-con-routing-paths.constant'; import { ALPHANUMERIC_WITH_HYPHENS_SPACES_APOSTROPHES_DOT_PATTERN, ACCOUNT_NUMBER_PATTERN, @@ -57,6 +58,7 @@ const ALPHANUMERIC_WITH_HYPHENS_SPACES_APOSTROPHES_DOT_VALIDATOR = patternValida }) export class FinesConSearchAccountFormComponent extends AbstractFormBaseComponent { private readonly finesConStore = inject(FinesConStore); + private readonly finesConRoutingPaths = FINES_CON_ROUTING_PATHS; @Output() protected override formSubmit = new EventEmitter(); override fieldErrors: IFinesConSearchAccountFieldErrors = { @@ -66,6 +68,30 @@ export class FinesConSearchAccountFormComponent extends AbstractFormBaseComponen }; @Input({ required: true }) defendantType: FinesConDefendant = 'individual'; + /** + * Pre-submit guard for mixed quick + advanced criteria. + * Navigates to the search error screen when mutually exclusive criteria are combined. + * + * @returns `true` when submission should continue, otherwise `false`. + */ + private handleFormSubmission(): boolean { + this.form.updateValueAndValidity({ emitEvent: false }); + + if (this.form.errors?.['atLeastOneCriteriaRequired']) { + this.setSearchAccountTemporary(); + this['router'].navigate([this.finesConRoutingPaths.children.searchError], { + relativeTo: this['activatedRoute'].parent, + }); + return false; + } + + if (this.form.errors?.['formEmpty']) { + return false; + } + + return true; + } + /** * Creates the form with quick search fields and detail search fields. */ @@ -136,6 +162,19 @@ export class FinesConSearchAccountFormComponent extends AbstractFormBaseComponen super.ngOnInit(); } + /** + * Runs pre-submit guards, then delegates to the base submit flow. + * + * @param event - Native submit event. + */ + public override handleFormSubmit(event: SubmitEvent): void { + if (!this.handleFormSubmission()) { + return; + } + + super.handleFormSubmit(event); + } + /** * Persists in-progress search criteria when the search tab is unmounted * (for example when switching to another tab). diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.html b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.html new file mode 100644 index 0000000000..14970a4aaa --- /dev/null +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.html @@ -0,0 +1,15 @@ +
+

There is a problem

+

+ Reference data and account information cannot be entered together when searching for an account. Search using + either: +

+
    +
  • account number, or
  • + @if (defendantType === 'individual') { +
  • National Insurance number, or
  • + } +
  • advanced search
  • +
+ Go back +
diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.spec.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.spec.ts new file mode 100644 index 0000000000..8d1b1dd4ae --- /dev/null +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.spec.ts @@ -0,0 +1,59 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FinesConSearchErrorComponent } from './fines-con-search-error.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FinesConStore } from '../../stores/fines-con.store'; +import { FINES_CON_ROUTING_PATHS } from '../../routing/constants/fines-con-routing-paths.constant'; + +describe('FinesConSearchErrorComponent', () => { + let component: FinesConSearchErrorComponent; + let fixture: ComponentFixture; + let routerSpy: { navigate: ReturnType }; + let finesConStore: InstanceType; + + beforeEach(async () => { + routerSpy = { + navigate: vi.fn().mockName('Router.navigate'), + }; + + await TestBed.configureTestingModule({ + imports: [FinesConSearchErrorComponent], + providers: [ + { provide: Router, useValue: routerSpy }, + { provide: ActivatedRoute, useValue: { parent: {} } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FinesConSearchErrorComponent); + component = fixture.componentInstance; + finesConStore = TestBed.inject(FinesConStore); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show National Insurance number guidance for individual defendant type', () => { + vi.spyOn(finesConStore, 'getDefendantType').mockReturnValue('individual'); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('National Insurance number, or'); + }); + + it('should not show National Insurance number guidance for company defendant type', () => { + vi.spyOn(finesConStore, 'getDefendantType').mockReturnValue('company'); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain('National Insurance number, or'); + }); + + it('should navigate back to consolidate accounts search when goBack is called', () => { + const event = new Event('click'); + component.goBack(event); + + expect(routerSpy.navigate).toHaveBeenCalledWith([FINES_CON_ROUTING_PATHS.children.consolidateAcc], { + relativeTo: component['activatedRoute'].parent, + fragment: 'search', + }); + }); +}); diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.ts new file mode 100644 index 0000000000..75089cfd88 --- /dev/null +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-error/fines-con-search-error.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FinesConDefendant } from '../../types/fines-con-defendant.type'; +import { FinesConStore } from '../../stores/fines-con.store'; +import { Router, ActivatedRoute } from '@angular/router'; +import { FINES_CON_ROUTING_PATHS } from '../../routing/constants/fines-con-routing-paths.constant'; + +@Component({ + selector: 'app-fines-con-search-error', + standalone: true, + imports: [CommonModule], + templateUrl: './fines-con-search-error.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FinesConSearchErrorComponent { + private readonly router = inject(Router); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly finesConStore = inject(FinesConStore); + + /** + * Gets the current defendant type from the consolidation store. + */ + public get defendantType(): FinesConDefendant { + return this.finesConStore.getDefendantType() as FinesConDefendant; + } + + /** + * Navigates back to the consolidate accounts page (Search tab). + */ + public goBack(event: Event): void { + event.preventDefault(); + this.router.navigate([FINES_CON_ROUTING_PATHS.children.consolidateAcc], { + relativeTo: this.activatedRoute.parent, + fragment: 'search', + }); + } +} diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.html b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.html index d0876d759c..289afa5c5e 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.html +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.html @@ -1,308 +1,351 @@ + +

Select accounts to consolidate

- +

{{ selectedAccountsHintComputed() }}

- - - - - @if (hasSelectableRowsComputed()) { - -
-
+
+ @if (addToListValidationMessageSignal()) { +

+ Error: {{ addToListValidationMessageSignal() }} +

+ } + + + + + Select accounts to consolidate + @if (hasSelectableRowsComputed()) { + +
+
+ } + + + Account + + + Name + + + Aliases + + @if (defendantType === 'individual') { + + Date of birth + + } + + Address line 1 + + + Postcode + + + CO + + + ENF + + + Balance + + @if (defendantType === 'individual') { + + P/G + + + NI number + } - - - Account - - - Name - - - Aliases - - - Date of birth - - - Address line 1 - - - Postcode - - - CO - - - ENF - - - Balance - - - P/G - - - NI number - - - Ref - -
+ + Ref + + - - @for (row of sortedTableDataComputed(); track row['Account ID'] ?? row['Account'] ?? $index) { - - - @if (isRowSelectable(row)) { - -
-
- } - - - @if (row['Account'] === null) { - - } @else { - @if (row['Account ID']) { - {{ row['Account']! }} + @for (row of sortedTableDataComputed(); track row['Account ID'] ?? row['Account'] ?? $index) { + + + @if (isRowSelectable(row)) { + +
+
+ } + + + @if (row['Account'] === null) { + } @else { - {{ row['Account'] }} + @if (row['Account ID']) { + {{ row['Account']! }} + } @else { + {{ row['Account'] }} + } } + + + @if (row['Name'] === null) { + + } @else { + {{ row['Name'] }} + } + + + @if (row['Aliases'] === null || row['Aliases'].length === 0) { + + } @else { + {{ row['Aliases'] }} + } + + @if (defendantType === 'individual') { + + @if (row['Date of birth'] === null) { + + } @else { + {{ row['Date of birth'] | dateFormat: DATE_INPUT_FORMAT : DATE_OUTPUT_FORMAT }} + } + } - - - @if (row['Name'] === null) { - - } @else { - {{ row['Name'] }} - } - - - @if (row['Aliases'] === null || row['Aliases'].length === 0) { - - } @else { - {{ row['Aliases'] }} - } - - - @if (row['Date of birth'] === null) { - - } @else { - {{ row['Date of birth'] | dateFormat: DATE_INPUT_FORMAT : DATE_OUTPUT_FORMAT }} - } - - - @if (row['Address line 1'] === null) { - - } @else { - {{ row['Address line 1'] }} - } - - - @if (row['Postcode'] === null) { - - } @else { - {{ row['Postcode'] }} - } - - - @if (row['CO'] === null) { - - } @else { - {{ row['CO'] }} - } - - - @if (row['ENF'] === null) { - - } @else { - {{ row['ENF'] | uppercase }} - } - - - @if (row['Balance'] === null) { - - } @else { - {{ row['Balance'] | currency: 'GBP' }} - } - - - @if (row['P/G'] === null) { - - } @else { - {{ row['P/G'] }} - } - - - @if (row['NI number'] === null) { - - } @else { - {{ row['NI number'] | nationalInsurance }} - } - - - @if (row['Ref'] === null) { - - } @else { - {{ row['Ref'] }} - } - - - - @if (hasRowChecks(row)) { - - - - @for (severity of checkSeverities; track severity) { - @let severityChecks = getChecksBySeverity(row, severity); - @if (severityChecks.length > 0) { -
-
- - @if (severityChecks.length === 1) { - {{ severityChecks[0].message }} - } @else { -
    - @for (check of severityChecks; track check.reference + '-' + $index) { -
  • {{ check.message }}
  • - } -
- } -
-
+ + @if (row['Address line 1'] === null) { + + } @else { + {{ row['Address line 1'] }} + } + + + @if (row['Postcode'] === null) { + + } @else { + {{ row['Postcode'] }} + } + + + @if (row['CO'] === null) { + + } @else { + {{ row['CO'] }} + } + + + @if (row['ENF'] === null) { + + } @else { + {{ row['ENF'] | uppercase }} + } + + + @if (row['Balance'] === null) { + + } @else { + {{ row['Balance'] | currency: 'GBP' }} + } + + @if (defendantType === 'individual') { + + @if (row['P/G'] === null) { + + } @else { + {{ row['P/G'] }} + } + + + @if (row['NI number'] === null) { + + } @else { + {{ row['NI number'] | nationalInsurance }} } + + } + + @if (row['Ref'] === null) { + + } @else { + {{ row['Ref'] }} } + + @if (hasRowChecks(row)) { + + + + @for (severity of checkSeverities; track severity) { + @let severityChecks = getChecksBySeverity(row, severity); + @if (severityChecks.length > 0) { +
+
+ + @if (severityChecks.length === 1) { + + @for ( + part of getFormattedCheckMessageParts(severityChecks[0].message); + track part.text + '-' + $index + ) { + @if (part.emphasized) { + {{ part.text }} + } @else { + {{ part.text }} + } + } + + } @else { +
    + @for (check of severityChecks; track check.reference + '-' + $index) { +
  • + @for ( + part of getFormattedCheckMessageParts(check.message); + track part.text + '-' + $index + ) { + @if (part.emphasized) { + {{ part.text }} + } @else { + {{ part.text }} + } + } +
  • + } +
+ } +
+
+ } + } + + + } } - } -
+ - - - -
-
+ + + + + +
diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.spec.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.spec.ts index d8d9e93db3..774c86a95b 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.spec.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.spec.ts @@ -68,6 +68,7 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { component.onAddToList(); expect(emitSpy).toHaveBeenCalledWith([visibleRows[0]['Account ID']!]); + expect(component.addToListValidationMessageSignal()).toBeNull(); }); it('should emit selected account IDs when Add to list button is clicked', () => { @@ -85,6 +86,7 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { addToListButton.click(); expect(emitSpy).toHaveBeenCalledWith([visibleRows[0]['Account ID']!]); + expect(component.addToListValidationMessageSignal()).toBeNull(); }); it('should render checks under account number when checks are provided', () => { @@ -97,20 +99,23 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { expect(checkMessage).toContain('Account status is Consolidated'); }); - it('should link account to checks row via aria-describedby when checks are present', () => { + it('should bold delimited text in rendered check messages', () => { component.tableData = GENERATE_FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_TABLE_DATA_MOCKS(1); - component.checksByAccountId = FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_CHECKS_BY_ACCOUNT_ID_MOCK; - fixture.detectChanges(); + component.checksByAccountId = { + 1: [ + { + reference: 'CON.WN.1', + severity: 'warning', + message: 'Last enforcement action on the account is `Application made for Benefit Deductions(ABDC)`', + }, + ], + }; - const accountLink: HTMLAnchorElement | null = fixture.nativeElement.querySelector( - '#defendantAccountNumber .govuk-link', - ); - const checksCell: HTMLTableCellElement | null = fixture.nativeElement.querySelector('td[colspan="12"]'); + fixture.detectChanges(); - expect(accountLink).toBeTruthy(); - expect(checksCell).toBeTruthy(); - expect(checksCell?.getAttribute('id')).toBe('defendant-checks-1'); - expect(accountLink?.getAttribute('aria-describedby')).toBe('defendant-checks-1'); + const boldText: HTMLElement | null = fixture.nativeElement.querySelector('.defendant-check-message__text strong'); + expect(boldText?.textContent).toBe('Application made for Benefit Deductions(ABDC)'); + expect(fixture.nativeElement.textContent).toContain('Last enforcement action on the account is'); }); it('should only return error checks when both warnings and errors exist', () => { @@ -128,7 +133,14 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { expect(component.getChecksBySeverity(row, 'warning')).toEqual([]); }); - it('should not select a row when account has an error check', () => { + it('should split delimited check messages into emphasised parts', () => { + expect(component.getFormattedCheckMessageParts('Account status is `CS`')).toEqual([ + { text: 'Account status is ', emphasized: false }, + { text: 'CS', emphasized: true }, + ]); + }); + + it('should show validation error and not emit when no selectable account is selected', () => { const emitSpy = vi.spyOn(component.addToList, 'emit'); component.tableData = GENERATE_FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_TABLE_DATA_MOCKS(1); @@ -139,7 +151,18 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { component.onRowSelectionChange({ rowId: 1, checked: true }); component.onAddToList(); - expect(emitSpy).toHaveBeenCalledWith([]); + expect(emitSpy).not.toHaveBeenCalled(); + expect(component.addToListValidationMessageSignal()).toBe('Select 1 or more accounts to consolidate.'); + }); + + it('should show validation error message in template when Add to list is clicked with no selected accounts', () => { + const emitSpy = vi.spyOn(component.addToList, 'emit'); + + component.onAddToList(); + fixture.detectChanges(); + + expect(emitSpy).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain('Select 1 or more accounts to consolidate.'); }); it('should not render select all checkbox when there are no selectable rows', () => { @@ -154,6 +177,12 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { expect(selectAllCheckbox).toBeNull(); }); + it('should render accessible text for the select all header', () => { + const selectAllHeader: HTMLElement | null = fixture.nativeElement.querySelector('#defendants-select-all'); + + expect(selectAllHeader?.textContent).toContain('Select accounts to consolidate'); + }); + it('should remove stale row controls when table data shrinks', () => { component.tableData = GENERATE_FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_TABLE_DATA_MOCKS(1); fixture.detectChanges(); @@ -245,4 +274,62 @@ describe('FinesConSearchResultDefendantTableWrapperComponent', () => { expect(component.selectedAccountsCountComputed()).toBe(0); expect(component['selectedRowIdsSignal']().has(999)).toBe(false); }); + + it('should keep selected row when selectable and remove it when it becomes unselectable during prune', () => { + component.tableData = GENERATE_FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_TABLE_DATA_MOCKS(1); + fixture.detectChanges(); + + const row = component.sortedTableDataComputed()[0]; + const rowId = component.getRowIdentifier(row, 0); + const accountId = row['Account ID']; + + component['selectedRowIdsSignal'].set(new Set([rowId])); + component.pruneUnselectableSelections(); + expect(component['selectedRowIdsSignal']().has(rowId)).toBe(true); + + component.checksByAccountId = + accountId === null + ? {} + : { + [accountId]: [{ reference: 'CON.ER.1', severity: 'error', message: 'Account is blocked' }], + }; + expect(component['selectedRowIdsSignal']().has(rowId)).toBe(false); + }); + + it('should hide individual-only columns and reduce checks colspan for company defendant type', () => { + component.defendantType = 'company'; + component.tableData = GENERATE_FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_TABLE_DATA_MOCKS(1); + component.checksByAccountId = FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_CHECKS_BY_ACCOUNT_ID_MOCK; + fixture.detectChanges(); + + const tableText = fixture.nativeElement.textContent; + expect(tableText).not.toContain('Date of birth'); + expect(tableText).not.toContain('P/G'); + expect(tableText).not.toContain('NI number'); + expect(component.getChecksColspan()).toBe(9); + }); + + it('should return early in scrollTo when target element is not found', () => { + const getElementByIdSpy = vi.spyOn(document, 'getElementById').mockReturnValueOnce(null); + + component.scrollTo('missing-id'); + + expect(getElementByIdSpy).toHaveBeenCalledWith('missing-id'); + }); + + it('should scroll and focus target element in scrollTo when target exists', () => { + const scrollIntoViewSpy = vi.fn(); + const focusSpy = vi.fn(); + const targetElement = { + scrollIntoView: scrollIntoViewSpy, + focus: focusSpy, + } as unknown as HTMLElement; + const getElementByIdSpy = vi.spyOn(document, 'getElementById').mockReturnValueOnce(targetElement); + + component.scrollTo('defendants-select-all-checkbox'); + + expect(getElementByIdSpy).toHaveBeenCalledWith('defendants-select-all-checkbox'); + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: 'center' }); + expect(focusSpy).toHaveBeenCalled(); + }); }); diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.ts index 5b070ee3be..dc1b4ac1c2 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result-defendant-table-wrapper/fines-con-search-result-defendant-table-wrapper.component.ts @@ -28,9 +28,12 @@ import { import { DateFormatPipe } from '@hmcts/opal-frontend-common/pipes/date-format'; import { NationalInsurancePipe } from '@hmcts/opal-frontend-common/pipes/national-insurance'; import { FinesNotProvidedComponent } from '@app/flows/fines/components/fines-not-provided/fines-not-provided.component'; +import { IAbstractFormBaseFormErrorSummaryMessage } from '@hmcts/opal-frontend-common/components/abstract/interfaces'; +import { GovukErrorSummaryComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-error-summary'; import { IFinesConSearchResultAccountCheck } from '../interfaces/fines-con-search-result-account-check.interface'; import { IFinesConSearchResultDefendantTableWrapperTableData } from './interfaces/fines-con-search-result-defendant-table-wrapper-table-data.interface'; import { IFinesConSearchResultDefendantTableWrapperTableSort } from './interfaces/fines-con-search-result-defendant-table-wrapper-table-sort.interface'; +import { FinesConDefendant } from '../../../types/fines-con-defendant.type'; @Component({ selector: 'app-fines-con-search-result-defendant-table-wrapper', @@ -50,12 +53,14 @@ import { IFinesConSearchResultDefendantTableWrapperTableSort } from './interface CustomHorizontalScrollPaneComponent, FinesNotProvidedComponent, MojAlertIconComponent, + GovukErrorSummaryComponent, ], templateUrl: './fines-con-search-result-defendant-table-wrapper.component.html', styleUrls: ['./fines-con-search-result-defendant-table-wrapper.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class FinesConSearchResultDefendantTableWrapperComponent extends AbstractSortableTableComponent { + private readonly ADD_TO_LIST_VALIDATION_MESSAGE = 'Select 1 or more accounts to consolidate.'; private readonly selectedRowIdsSignal = signal>(new Set()); private readonly checksByAccountIdSignal = signal>({}); private readonly rowControls = new Map>(); @@ -70,8 +75,13 @@ export class FinesConSearchResultDefendantTableWrapperComponent extends Abstract ); public readonly selectedAccountsCountComputed = computed(() => this.selectedRowIdsSignal().size); public readonly totalAccountsCountComputed = computed(() => this.sortedTableDataComputed().length); + public readonly addToListValidationMessageSignal = signal(null); + public readonly formErrorSummaryMessageComputed = computed(() => { + const message = this.addToListValidationMessageSignal(); + return message ? [{ fieldId: 'defendants-select-all-checkbox', message }] : []; + }); public readonly selectedAccountsHintComputed = computed(() => { - return `${this.selectedAccountsCountComputed()} to ${this.totalAccountsCountComputed()} accounts selected`; + return `${this.selectedAccountsCountComputed()} of ${this.totalAccountsCountComputed()} accounts selected`; }); /** @@ -104,6 +114,7 @@ export class FinesConSearchResultDefendantTableWrapperComponent extends Abstract ) { this.abstractExistingSortState = existingSortState; } + @Input({ required: true }) public defendantType: FinesConDefendant = 'individual'; @Output() public accountIdClicked = new EventEmitter(); @Output() public addToList = new EventEmitter(); @@ -139,6 +150,27 @@ export class FinesConSearchResultDefendantTableWrapperComponent extends Abstract } } + /** + * Splits check messages into plain and emphasised parts when the API wraps values in backticks. + * + * @param message - Raw check message. + * @returns Message parts with emphasis metadata for template rendering. + */ + public getFormattedCheckMessageParts(message: string): { text: string; emphasized: boolean }[] { + const parts = message.split('`'); + + if (parts.length < 3 || parts.length % 2 === 0) { + return [{ text: message, emphasized: false }]; + } + + return parts + .filter((part) => part.length > 0) + .map((text, index) => ({ + text, + emphasized: index % 2 === 1, + })); + } + /** * Emits selected account id for parent-level navigation handling. * @@ -334,9 +366,30 @@ export class FinesConSearchResultDefendantTableWrapperComponent extends Abstract .map((row) => row['Account ID']) .filter((accountId): accountId is number => accountId !== null); + if (selectedAccountIds.length === 0) { + this.addToListValidationMessageSignal.set(this.ADD_TO_LIST_VALIDATION_MESSAGE); + return; + } + + this.addToListValidationMessageSignal.set(null); this.addToList.emit(Array.from(new Set(selectedAccountIds))); } + /** + * Scrolls/focuses the requested target from the error summary. + * + * @param fieldId - DOM id to scroll to. + */ + public scrollTo(fieldId: string): void { + const targetElement = document.getElementById(fieldId); + if (!targetElement) { + return; + } + + targetElement.scrollIntoView({ block: 'center' }); + targetElement.focus?.(); + } + /** * Resolves whether a row id maps to a selectable row. * @@ -401,4 +454,13 @@ export class FinesConSearchResultDefendantTableWrapperComponent extends Abstract return classes.join(' '); } + + /** + * Returns the dynamic colspan for row checks cell, excluding the leading checkbox spacer column. + * + * @returns Column span for checks cell. + */ + public getChecksColspan(): number { + return this.defendantType === 'company' ? 9 : 12; + } } diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.html b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.html index b52d561b81..ad87386092 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.html +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.html @@ -1,14 +1,26 @@
@if (searchResults$ | async; as searchResults) { - @if (searchResults.tableData.length > 0) { + @if (invalidResultsState === 'none') { + } @else if (invalidResultsState === 'tooManyResults') { +

There are more than 100 results.

+

+ Try adding more information + to your search. +

} @else { -

There are no matching results

+

There are no matching results.

+

+ Check your search + and try again. +

} }
diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.spec.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.spec.ts index 2b31c22219..9d69b53ffb 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.spec.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.spec.ts @@ -12,6 +12,7 @@ import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK } from './mo import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FALSEY_VALUES_MOCK } from './mocks/fines-con-search-result-defendant-accounts-falsey-values.mock'; import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_WITH_CHECKS_MOCK } from './mocks/fines-con-search-result-defendant-accounts-with-checks.mock'; import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_RESPONSE_MOCK } from './mocks/fines-con-search-result-defendant-accounts-response.mock'; +import { FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK } from './mocks/fines-con-search-result-defendant-accounts-company-formatting.mock'; describe('FinesConSearchResultComponent', () => { let component: FinesConSearchResultComponent; @@ -102,6 +103,26 @@ describe('FinesConSearchResultComponent', () => { expect(component.tableData[0]['P/G']).toBe('-'); }); + it('should map company defendant accounts with organisation fields', () => { + component.defendantType = 'company'; + component.defendantAccounts = FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK; + + expect(component.tableData).toEqual([ + expect.objectContaining({ + 'Account ID': 21, + Account: 'COMP001', + Name: 'Acme Corporation', + Aliases: 'Alpha Ltd\nBravo Ltd', + 'Address line 1': '21 Company Street', + Postcode: 'CO1 2MP', + CO: 'Y', + ENF: 'distress', + Balance: 520.5, + Ref: 'COMP-REF-1', + }), + ]); + }); + it('should open account details in a new tab', () => { const mockUrl = '/fines/account/11/details'; router.serializeUrl.mockReturnValue(mockUrl); @@ -213,8 +234,7 @@ describe('FinesConSearchResultComponent', () => { ]); }); - it('should log and not display results when more than 100 results are provided', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('should set tooManyResults state and not display table when more than 100 results are provided', () => { const defendantAccounts = Array.from({ length: 101 }, (_, index) => ({ defendant_account_id: index + 1, account_number: `ACC-${index + 1}`, @@ -225,10 +245,34 @@ describe('FinesConSearchResultComponent', () => { component.defendantAccounts = defendantAccounts; fixture.detectChanges(); + expect(component.tableData).toHaveLength(0); + expect(component.defendantAccountsData).toHaveLength(101); + expect(component.checksByAccountId).toEqual({}); + expect(component.invalidResultsState).toBe('tooManyResults'); + }); + + it('should set noResults state when no accounts are provided', () => { + component.defendantAccounts = []; + expect(component.tableData).toHaveLength(0); expect(component.defendantAccountsData).toHaveLength(0); expect(component.checksByAccountId).toEqual({}); - expect(logSpy).toHaveBeenCalledWith('more than 100 results'); + expect(component.invalidResultsState).toBe('noResults'); + }); + + it('should set table state when result set is valid', () => { + component.defendantAccounts = FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK; + + expect(component.tableData.length).toBeGreaterThan(0); + expect(component.invalidResultsState).toBe('none'); + }); + + it('should emit navigateToSearch when navigateBackToSearch is called', () => { + const navigateToSearchSpy = vi.spyOn(component.navigateToSearch, 'emit'); + + component.navigateBackToSearch(); + + expect(navigateToSearchSpy).toHaveBeenCalledTimes(1); }); it('should ignore stale in-flight response when a newer search is triggered', () => { @@ -273,6 +317,25 @@ describe('FinesConSearchResultComponent', () => { ]); }); + it('should log selection alert navigation when at least one selected account is already in list', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const addSelectedAccountIdsSpy = vi.spyOn(finesConStore, 'addSelectedAccountIds'); + + component.alreadyAddedAccountIds = [11]; + component.onAddToList([11, 12]); + + expect(logSpy).toHaveBeenCalledWith('PO-2422: navigate to Selection alert screen'); + expect(addSelectedAccountIdsSpy).not.toHaveBeenCalled(); + }); + + it('should patch selected account ids to store when all selected ids are new', () => { + const addSelectedAccountIdsSpy = vi.spyOn(finesConStore, 'addSelectedAccountIds'); + + component.alreadyAddedAccountIds = [99]; + component.onAddToList([11, 12]); + + expect(addSelectedAccountIdsSpy).toHaveBeenCalledWith([11, 12]); + }); it('should emit empty results when defendant account search fails', () => { finesConStore.updateDefendantResults(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK, []); opalFines.getDefendantAccounts.mockReturnValue(throwError(() => new Error('Request failed'))); diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.ts index 2675139be4..3c6bbae233 100644 --- a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.ts +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/fines-con-search-result.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, inject, Input, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, Output } from '@angular/core'; import { AsyncPipe, CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { BehaviorSubject, catchError, map, Observable, of, switchMap, tap } from 'rxjs'; @@ -49,6 +49,9 @@ export class FinesConSearchResultComponent implements OnDestroy { @Input({ required: true }) public defendantType: FinesConDefendant = 'individual'; + @Input({ required: false }) public alreadyAddedAccountIds: number[] = []; + @Output() public navigateToSearch = new EventEmitter(); + @Input({ required: false }) public set searchPayload(searchPayload: IOpalFinesDefendantAccountSearchParams | null) { if (!searchPayload) { @@ -72,6 +75,18 @@ export class FinesConSearchResultComponent implements OnDestroy { return this.latestSearchResults.checksByAccountId; } + public get invalidResultsState(): 'none' | 'noResults' | 'tooManyResults' { + if (this.defendantAccountsData.length > this.MAX_RESULTS_WARNING_THRESHOLD) { + return 'tooManyResults'; + } + + if (this.tableData.length === 0) { + return 'noResults'; + } + + return 'none'; + } + /** * Resolves the latest result source into mapped table data. */ @@ -99,8 +114,6 @@ export class FinesConSearchResultComponent implements OnDestroy { /** * Persists the latest raw results in the correct store bucket for the current defendant type. - * - * @param defendantAccounts - Raw defendant accounts to store. */ private syncStoreResults(defendantAccounts: IFinesConSearchResultDefendantAccount[]): void { if (this.defendantType === 'company') { @@ -137,16 +150,12 @@ export class FinesConSearchResultComponent implements OnDestroy { * Maps raw accounts into the table result view model. */ private mapResults(defendantAccounts: IFinesConSearchResultDefendantAccount[]): IFinesConSearchResultData { - if (defendantAccounts.length > this.MAX_RESULTS_WARNING_THRESHOLD) { - // eslint-disable-next-line no-console - console.log('more than 100 results'); + this.defendantAccountsData = defendantAccounts; - this.defendantAccountsData = []; - this.syncStoreResults([]); + if (defendantAccounts.length === 0 || defendantAccounts.length > this.MAX_RESULTS_WARNING_THRESHOLD) { return this.EMPTY_RESULTS; } - this.defendantAccountsData = defendantAccounts; return { tableData: this.finesConPayloadService.mapDefendantAccounts(defendantAccounts), checksByAccountId: this.finesConPayloadService.buildChecksByAccountId(defendantAccounts), @@ -170,6 +179,31 @@ export class FinesConSearchResultComponent implements OnDestroy { window.open(url, '_blank'); } + /** + * Handles Add to list selections. + */ + public onAddToList(selectedAccountIds: number[]): void { + const hasAlreadyAddedAccount = selectedAccountIds.some((accountId) => + this.alreadyAddedAccountIds.includes(accountId), + ); + + if (hasAlreadyAddedAccount) { + // eslint-disable-next-line no-console + console.log('PO-2422: navigate to Selection alert screen'); + return; + } + + this.finesConStore.addSelectedAccountIds(selectedAccountIds); + } + + /** + * Navigates user back to Search tab in the consolidation flow. + */ + public navigateBackToSearch(event?: Event): void { + event?.preventDefault(); + this.navigateToSearch.emit(); + } + public ngOnDestroy(): void { this.resultsSourceSubject.complete(); } diff --git a/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-company-formatting.mock.ts b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-company-formatting.mock.ts new file mode 100644 index 0000000000..91fbbd9b74 --- /dev/null +++ b/src/app/flows/fines/fines-con/consolidate-acc/fines-con-search-result/mocks/fines-con-search-result-defendant-accounts-company-formatting.mock.ts @@ -0,0 +1,36 @@ +import { IFinesConSearchResultDefendantAccount } from '../interfaces/fines-con-search-result-defendant-account.interface'; + +export const FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_COMPANY_FORMATTING_MOCK: IFinesConSearchResultDefendantAccount[] = + [ + { + defendant_account_id: 21, + account_number: 'COMP001', + organisation_flag: true, + aliases: [ + { alias_number: 2, organisation_name: 'Bravo Ltd', surname: null, forenames: null }, + { alias_number: 1, organisation_name: 'Alpha Ltd', surname: null, forenames: null }, + ], + address_line_1: '21 Company Street', + postcode: 'CO1 2MP', + business_unit_name: null, + business_unit_id: null, + prosecutor_case_reference: 'COMP-REF-1', + last_enforcement_action: 'warrant', + account_balance: 520.5, + organisation_name: 'Acme Corporation', + defendant_title: null, + defendant_firstnames: null, + defendant_surname: null, + birth_date: null, + national_insurance_number: null, + parent_guardian_surname: null, + parent_guardian_firstnames: null, + collection_order: true, + last_enforcement: 'distress', + has_paying_parent_guardian: false, + checks: { + errors: [], + warnings: [], + }, + }, + ]; diff --git a/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-paths.constant.ts b/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-paths.constant.ts index c5ccec84e8..15ba8a46fc 100644 --- a/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-paths.constant.ts +++ b/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-paths.constant.ts @@ -5,5 +5,6 @@ export const FINES_CON_ROUTING_PATHS: IFinesConRoutingPaths = { children: { selectBusinessUnit: 'select-business-unit', consolidateAcc: 'consolidate-accounts', + searchError: 'search-error', }, }; diff --git a/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-titles.constant.ts b/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-titles.constant.ts index c3de840625..2d12f29411 100644 --- a/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-titles.constant.ts +++ b/src/app/flows/fines/fines-con/routing/constants/fines-con-routing-titles.constant.ts @@ -2,5 +2,6 @@ export const FINES_CON_ROUTING_TITLES = { children: { selectBusinessUnit: 'Select business unit', consolidateAcc: 'Consolidated accounts', + searchError: 'There is a problem', }, }; diff --git a/src/app/flows/fines/fines-con/routing/fines-con.routes.ts b/src/app/flows/fines/fines-con/routing/fines-con.routes.ts index 5e3d59800e..193deb3374 100644 --- a/src/app/flows/fines/fines-con/routing/fines-con.routes.ts +++ b/src/app/flows/fines/fines-con/routing/fines-con.routes.ts @@ -51,4 +51,18 @@ export const routing: Routes = [ }, resolve: { title: TitleResolver, businessUnits: fetchBusinessUnitsResolver }, }, + { + path: FINES_CON_ROUTING_PATHS.children.searchError, + loadComponent: () => + import('../consolidate-acc/fines-con-search-error/fines-con-search-error.component').then( + (c) => c.FinesConSearchErrorComponent, + ), + canActivate: [authGuard, routePermissionsGuard, finesConFlowStateGuard], + data: { + routePermissionId: [consolidationRootPermissionIds['consolidate']], + permission: 'CONSOLIDATE', + title: FINES_CON_ROUTING_TITLES.children.searchError, + }, + resolve: { title: TitleResolver }, + }, ]; diff --git a/src/app/flows/fines/fines-con/routing/interfaces/fines-con-routing-paths.interface.ts b/src/app/flows/fines/fines-con/routing/interfaces/fines-con-routing-paths.interface.ts index 0b6e0d73a9..23bdba6917 100644 --- a/src/app/flows/fines/fines-con/routing/interfaces/fines-con-routing-paths.interface.ts +++ b/src/app/flows/fines/fines-con/routing/interfaces/fines-con-routing-paths.interface.ts @@ -4,5 +4,6 @@ export interface IFinesConRoutingPaths extends IChildRoutingPaths { children: { selectBusinessUnit: string; consolidateAcc: string; + searchError: string; }; } diff --git a/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.spec.ts b/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.spec.ts index d1666c57da..6b4fcd1833 100644 --- a/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.spec.ts +++ b/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.spec.ts @@ -91,6 +91,75 @@ describe('fines-con-payload-map-defendant-accounts utils', () => { expect(result[0]).toBeUndefined(); }); + it('should map company name and aliases from organisation fields', () => { + const mapped = mapDefendantAccounts([ + { + defendant_account_id: 201, + account_number: 'COMP201', + organisation_flag: true, + aliases: [ + { alias_number: 2, organisation_name: 'Bravo Ltd', surname: null, forenames: null }, + { alias_number: 1, organisation_name: 'Alpha Ltd', surname: null, forenames: null }, + ], + address_line_1: null, + postcode: null, + business_unit_name: null, + business_unit_id: null, + prosecutor_case_reference: null, + last_enforcement_action: null, + account_balance: null, + organisation_name: 'Acme Corp', + defendant_title: null, + defendant_firstnames: null, + defendant_surname: null, + birth_date: null, + national_insurance_number: null, + parent_guardian_surname: null, + parent_guardian_firstnames: null, + }, + ]); + + expect(mapped[0]).toEqual( + expect.objectContaining({ + Name: 'Acme Corp', + Aliases: 'Alpha Ltd\nBravo Ltd', + }), + ); + }); + + it('should prefer organisation name when present even if organisation flag is false', () => { + const mapped = mapDefendantAccounts([ + { + defendant_account_id: 301, + account_number: 'COMP301', + organisation_flag: false, + aliases: [{ alias_number: 1, organisation_name: 'Org Alias', surname: 'Ignored', forenames: 'Name' }], + address_line_1: null, + postcode: null, + business_unit_name: null, + business_unit_id: null, + prosecutor_case_reference: null, + last_enforcement_action: null, + account_balance: null, + organisation_name: 'Preferred Org Name', + defendant_title: null, + defendant_firstnames: 'John', + defendant_surname: 'Doe', + birth_date: null, + national_insurance_number: null, + parent_guardian_surname: null, + parent_guardian_firstnames: null, + }, + ]); + + expect(mapped[0]).toEqual( + expect.objectContaining({ + Name: 'Preferred Org Name', + Aliases: 'Org Alias', + }), + ); + }); + it('should drop checks where reference or message is missing', () => { const result = buildChecksByAccountId(FINES_CON_PAYLOAD_MAP_DEFENDANT_ACCOUNTS_CHECKS_MISSING_FIELDS_MOCK); diff --git a/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.ts b/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.ts index 998651aa22..218e8b6563 100644 --- a/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.ts +++ b/src/app/flows/fines/fines-con/services/utils/fines-con-payload-map-defendant-accounts.utils.ts @@ -39,7 +39,7 @@ const formatName = (surname: string | null, forenames: string | null): string | /** * Sorts aliases by alias number and formats each alias on a new line. */ -const formatAliases = (aliases: IOpalFinesDefendantAccountAlias[] | null): string | null => { +const formatAliases = (aliases: IOpalFinesDefendantAccountAlias[] | null, isOrganisation: boolean): string | null => { if (!aliases?.length) { return null; } @@ -49,7 +49,13 @@ const formatAliases = (aliases: IOpalFinesDefendantAccountAlias[] | null): strin ); const aliasText = orderedAliases - .map((alias) => formatName(alias.surname, alias.forenames)) + .map((alias) => { + if (isOrganisation) { + return alias.organisation_name?.trim() || null; + } + + return formatName(alias.surname, alias.forenames); + }) .filter((alias): alias is string => alias !== null); return aliasText.length > 0 ? aliasText.join('\n') : null; @@ -160,16 +166,20 @@ export const mapDefendantAccounts = ( ): IFinesConSearchResultDefendantTableWrapperTableData[] => { return defendantAccounts.map((defendantAccount) => { const account = defendantAccount as IFinesConPayloadRawDefendantAccount; + const organisationName = account.organisation_name ?? account.organisationName ?? null; + const isOrganisation = (account.organisation_flag ?? account.organisationFlag) === true || !!organisationName; return { ...FINES_CON_SEARCH_RESULT_DEFENDANT_TABLE_WRAPPER_TABLE_DATA_EMPTY, 'Account ID': normaliseAccountId(account.defendant_account_id ?? account.defendantAccountId ?? null), Account: account.account_number ?? account.accountNumber ?? null, - Name: formatName( - account.defendant_surname ?? account.defendantSurname ?? null, - account.defendant_firstnames ?? account.defendantFirstnames ?? null, - ), - Aliases: formatAliases(account.aliases), + Name: isOrganisation + ? organisationName + : formatName( + account.defendant_surname ?? account.defendantSurname ?? null, + account.defendant_firstnames ?? account.defendantFirstnames ?? null, + ), + Aliases: formatAliases(account.aliases, isOrganisation), 'Date of birth': account.birth_date ?? account.birthDate ?? null, 'Address line 1': account.address_line_1 ?? account.addressLine1 ?? null, Postcode: account.postcode ?? account.postCode ?? null, diff --git a/src/app/flows/fines/fines-con/services/utils/interfaces/fines-con-payload-raw-defendant-account.interface.ts b/src/app/flows/fines/fines-con/services/utils/interfaces/fines-con-payload-raw-defendant-account.interface.ts index 87b198fb6e..bbcc0a124c 100644 --- a/src/app/flows/fines/fines-con/services/utils/interfaces/fines-con-payload-raw-defendant-account.interface.ts +++ b/src/app/flows/fines/fines-con/services/utils/interfaces/fines-con-payload-raw-defendant-account.interface.ts @@ -17,4 +17,6 @@ export interface IFinesConPayloadRawDefendantAccount extends IFinesConSearchResu hasPayingParentGuardian: boolean | null; nationalInsuranceNumber: string | null; prosecutorCaseReference: string | null; + organisationFlag?: boolean | null; + organisationName?: string | null; } diff --git a/src/app/flows/fines/fines-con/stores/fines-con.store.spec.ts b/src/app/flows/fines/fines-con/stores/fines-con.store.spec.ts index 84993a36e9..f5b0d8f3a6 100644 --- a/src/app/flows/fines/fines-con/stores/fines-con.store.spec.ts +++ b/src/app/flows/fines/fines-con/stores/fines-con.store.spec.ts @@ -26,6 +26,7 @@ describe('FinesConStore', () => { expect(store.selectBuForm().formData.fcon_select_bu_business_unit_id).toBeNull(); expect(store.selectBuForm().formData.fcon_select_bu_defendant_type).toBe('individual'); expect(store.selectBuForm().nestedFlow).toBe(false); + expect(store.selectedAccountIds()).toEqual([]); }); it('should update business unit and defendant type', () => { @@ -59,6 +60,7 @@ describe('FinesConStore', () => { it('should reset entire consolidation state', () => { store.updateSelectBuForm(FINES_CON_SELECT_BU_FORM_INDIVIDUAL_MOCK.formData); + store.addSelectedAccountIds([11, 12]); store.resetConsolidationState(); expect(store.selectBuForm().formData.fcon_select_bu_business_unit_id).toBe( @@ -68,6 +70,7 @@ describe('FinesConStore', () => { FINES_CON_SELECT_BU_FORM.formData.fcon_select_bu_defendant_type, ); expect(store.selectBuForm().nestedFlow).toBe(FINES_CON_SELECT_BU_FORM.nestedFlow); + expect(store.selectedAccountIds()).toEqual([]); }); it('should compute business unit id correctly', () => { @@ -123,6 +126,7 @@ describe('FinesConStore', () => { const testData = FINES_CON_SEARCH_ACCOUNT_FORM_ACCOUNT_NUMBER_MOCK.formData; store.updateSearchAccountFormTemporary(testData); + store.addSelectedAccountIds([11]); expect(store.searchAccountForm().fcon_search_account_number).toBe( FINES_CON_SEARCH_ACCOUNT_FORM_ACCOUNT_NUMBER_MOCK.formData.fcon_search_account_number, ); @@ -133,6 +137,13 @@ describe('FinesConStore', () => { expect(store.searchAccountForm().fcon_search_account_number).toBeNull(); }); + it('should add selected account ids uniquely', () => { + store.addSelectedAccountIds([11, 12, 12]); + store.addSelectedAccountIds([12, 13]); + + expect(store.selectedAccountIds()).toEqual([11, 12, 13]); + }); + it('should preserve cached results when resetting search account form', () => { store.updateDefendantResults(FINES_CON_SEARCH_RESULT_DEFENDANT_ACCOUNTS_FORMATTING_MOCK, []); diff --git a/src/app/flows/fines/fines-con/stores/fines-con.store.ts b/src/app/flows/fines/fines-con/stores/fines-con.store.ts index 7d83166998..2a7a636083 100644 --- a/src/app/flows/fines/fines-con/stores/fines-con.store.ts +++ b/src/app/flows/fines/fines-con/stores/fines-con.store.ts @@ -14,6 +14,7 @@ export const FinesConStore = signalStore( searchAccountForm: FINES_CON_SEARCH_ACCOUNT_STATE, individualResults: [] as IFinesConSearchResultDefendantAccount[], companyResults: [] as IFinesConSearchResultDefendantAccount[], + selectedAccountIds: [] as number[], activeTab: 'search', stateChanges: false, unsavedChanges: false, @@ -26,6 +27,7 @@ export const FinesConStore = signalStore( searchAccountForm: FINES_CON_SEARCH_ACCOUNT_STATE, individualResults: [], companyResults: [], + selectedAccountIds: [], activeTab: 'search', stateChanges: false, unsavedChanges: false, @@ -63,6 +65,7 @@ export const FinesConStore = signalStore( searchAccountForm: FINES_CON_SEARCH_ACCOUNT_STATE, individualResults: [], companyResults: [], + selectedAccountIds: [], stateChanges: true, }); }, @@ -110,6 +113,15 @@ export const FinesConStore = signalStore( }); }, + /** + * Adds selected account ids for consolidation, ensuring uniqueness. + */ + addSelectedAccountIds(selectedAccountIds: number[]): void { + patchState(store, { + selectedAccountIds: Array.from(new Set([...store.selectedAccountIds(), ...selectedAccountIds])), + }); + }, + /** * Resets the entire consolidation form state */ @@ -119,6 +131,7 @@ export const FinesConStore = signalStore( searchAccountForm: FINES_CON_SEARCH_ACCOUNT_STATE, individualResults: [], companyResults: [], + selectedAccountIds: [], stateChanges: false, activeTab: 'search', unsavedChanges: false,