From 16b2282b133d8418ae988509df8f96b28b84d814 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Fri, 5 Jun 2026 17:13:58 +0100 Subject: [PATCH] CCM-18131: Add new mobile number --- .../__snapshots__/page.test.tsx.snap | 504 ++++++++++++++++++ .../app/add-new-mobile-number/page.test.tsx | 105 ++++ .../app/add-new-mobile-number/form-action.ts | 58 ++ .../src/app/add-new-mobile-number/page.tsx | 78 +++ frontend/src/content/content.ts | 38 ++ frontend/src/middleware.ts | 1 + .../test-team/pages/contact-details/index.ts | 1 + .../template-mgmt-add-mobile-number-page.ts | 9 + .../contact-details.accessibility.spec.ts | 18 +- .../route-coverage.accessibility.spec.ts | 6 +- ...bile-number.sms-template-component.spec.ts | 160 ++++++ ...rotected-routes.template-component.spec.ts | 2 + 12 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/app/add-new-mobile-number/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/add-new-mobile-number/page.test.tsx create mode 100644 frontend/src/app/add-new-mobile-number/form-action.ts create mode 100644 frontend/src/app/add-new-mobile-number/page.tsx create mode 100644 tests/test-team/pages/contact-details/template-mgmt-add-mobile-number-page.ts create mode 100644 tests/test-team/template-mgmt-component-tests/contact-details-component/template-mgmt-add-new-mobile-number.sms-template-component.spec.ts diff --git a/frontend/src/__tests__/app/add-new-mobile-number/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/add-new-mobile-number/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..83e92082e --- /dev/null +++ b/frontend/src/__tests__/app/add-new-mobile-number/__snapshots__/page.test.tsx.snap @@ -0,0 +1,504 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`form validation renders error summary when form is submitted with empty mobile field 1`] = ` + +
+ + Back + +
+ +
+
+

+ Add a new mobile number +

+

+ We'll send a security code to this mobile number to make sure you can access it. +

+

+ This mobile number will only be available for you to use. If someone else needs to send tests to it, they must add it to their own account. +

+
+
+ +
+ For example, 07700900123. We recommend using a work mobile number +
+ + + Error: + + Enter a mobile number,Enter a mobile number between 11 and 13 digits long + + +
+
+ +
+
+
+
+
+
+
+`; + +exports[`form validation renders error summary when form is submitted with invalid mobile field 1`] = ` + +
+ + Back + +
+ +
+
+

+ Add a new mobile number +

+

+ We'll send a security code to this mobile number to make sure you can access it. +

+

+ This mobile number will only be available for you to use. If someone else needs to send tests to it, they must add it to their own account. +

+
+
+ +
+ For example, 07700900123. We recommend using a work mobile number +
+ + + Error: + + Enter a mobile number between 11 and 13 digits long + + +
+
+ +
+
+
+
+
+
+
+`; + +exports[`form validation renders error summary when form is submitted with numberLength mobile field 1`] = ` + +
+ + Back + +
+ +
+
+

+ Add a new mobile number +

+

+ We'll send a security code to this mobile number to make sure you can access it. +

+

+ This mobile number will only be available for you to use. If someone else needs to send tests to it, they must add it to their own account. +

+
+
+ +
+ For example, 07700900123. We recommend using a work mobile number +
+ + + Error: + + Enter a mobile number between 11 and 13 digits long + + +
+
+ +
+
+
+
+
+
+
+`; + +exports[`search params - empty matches initial snapshot 1`] = ` + +
+ + Back + +
+
+
+

+ Add a new mobile number +

+

+ We'll send a security code to this mobile number to make sure you can access it. +

+

+ This mobile number will only be available for you to use. If someone else needs to send tests to it, they must add it to their own account. +

+
+
+ +
+ For example, 07700900123. We recommend using a work mobile number +
+ +
+
+ +
+
+
+
+
+
+
+`; + +exports[`search params - with templateId matches initial snapshot 1`] = ` + +
+ + Back + +
+
+
+

+ Add a new mobile number +

+

+ We'll send a security code to this mobile number to make sure you can access it. +

+

+ This mobile number will only be available for you to use. If someone else needs to send tests to it, they must add it to their own account. +

+
+ +
+ +
+ For example, 07700900123. We recommend using a work mobile number +
+ +
+
+ +
+
+
+
+
+
+
+`; diff --git a/frontend/src/__tests__/app/add-new-mobile-number/page.test.tsx b/frontend/src/__tests__/app/add-new-mobile-number/page.test.tsx new file mode 100644 index 000000000..5a9b8235c --- /dev/null +++ b/frontend/src/__tests__/app/add-new-mobile-number/page.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mock } from 'jest-mock-extended'; +import { useRouter } from 'next/navigation'; +import Page, { metadata } from '@app/add-new-mobile-number/page'; +import { createContactDetail } from '@utils/contact-details'; + +jest.mock('next/navigation'); +jest.mock('@utils/contact-details'); + +const mockRouter = mock>(); + +beforeEach(() => { + jest.resetAllMocks(); + + jest.mocked(createContactDetail).mockImplementation((input) => + Promise.resolve({ + ...input, + id: 'contact-detail-id', + status: 'PENDING_VERIFICATION', + rawValue: input.value, + }) + ); + + jest.mocked(useRouter).mockReturnValue(mockRouter); +}); + +test('metadata', () => { + expect(metadata).toEqual({ + title: 'Add a new mobile number - NHS Notify', + }); +}); + +describe.each([ + { + case: 'empty', + searchParams: {}, + expectedRedirect: '/enter-security-code/contact-detail-id', + }, + { + case: 'with templateId', + searchParams: { templateId: 'template-id' }, + expectedRedirect: + '/enter-security-code/contact-detail-id?templateId=template-id', + }, +])('search params - $case', ({ searchParams, expectedRedirect }) => { + it('matches initial snapshot', async () => { + expect( + render( + await Page({ searchParams: Promise.resolve(searchParams) }) + ).asFragment() + ).toMatchSnapshot(); + }); + + it('creates new contact detail and redirects to enter security code page', async () => { + const user = userEvent.setup(); + + render(await Page({ searchParams: Promise.resolve(searchParams) })); + + await user.type(screen.getByLabelText('Mobile number'), '07900123456'); + + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(createContactDetail).toHaveBeenCalledWith({ + type: 'SMS', + value: '07900123456', + }); + + expect(mockRouter.push).toHaveBeenCalledWith(expectedRedirect); + }); +}); + +describe('form validation', () => { + it.each([ + { case: 'empty', mobile: '' }, + { case: 'invalid', mobile: 'notamobile' }, + { case: 'numberLength', mobile: '123' }, + ])( + 'renders error summary when form is submitted with $case mobile field', + async ({ mobile }) => { + const user = userEvent.setup(); + + const container = render( + await Page({ + searchParams: Promise.resolve({}), + }) + ); + + if (mobile) { + await user.type(screen.getByLabelText('Mobile number'), mobile); + } + + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(async () => { + expect(screen.getByTestId('error-summary')).toBeVisible(); + }); + + expect(container.asFragment()).toMatchSnapshot(); + + expect(createContactDetail).not.toHaveBeenCalled(); + expect(mockRouter.push).not.toHaveBeenCalled(); + } + ); +}); diff --git a/frontend/src/app/add-new-mobile-number/form-action.ts b/frontend/src/app/add-new-mobile-number/form-action.ts new file mode 100644 index 000000000..63a19c71e --- /dev/null +++ b/frontend/src/app/add-new-mobile-number/form-action.ts @@ -0,0 +1,58 @@ +'use client'; + +import type { SubmitEvent } from 'react'; +import { z } from 'zod/v4'; +import { parsePhoneNumber } from 'nhs-notify-backend-client/schemas'; +import copy from '@content/content'; +import { createContactDetail } from '@utils/contact-details'; +import { NHSNotifyClientSideFormSubmitHandler } from '@atoms/NHSNotifyForm/Form'; + +const content = copy.pages.addMobileNumber; + +const $FormSchema = z.object({ + mobile: z + .string() + .nonempty(content.form.mobile.errors.empty) + .min(11, content.form.mobile.errors.numberLength) + .pipe( + z + .string() + .refine( + (value) => parsePhoneNumber(value) !== null, + content.form.mobile.errors.invalid + ) + ), + templateId: z.string().optional(), +}); + +export const addMobileNumber: NHSNotifyClientSideFormSubmitHandler = + (router, [, setState]) => + async (event: SubmitEvent) => { + event.preventDefault(); + + const parseResult = $FormSchema.safeParse( + Object.fromEntries(new FormData(event.target).entries()) + ); + + if (parseResult.error) { + setState({ + errorState: z.flattenError(parseResult.error), + }); + return; + } + + const { mobile, templateId } = parseResult.data; + + const { id } = await createContactDetail({ + type: 'SMS', + value: mobile, + }); + + let redirectUrl = `/enter-security-code/${id}`; + + if (templateId) { + redirectUrl += `?templateId=${encodeURIComponent(templateId)}`; + } + + router.push(redirectUrl); + }; diff --git a/frontend/src/app/add-new-mobile-number/page.tsx b/frontend/src/app/add-new-mobile-number/page.tsx new file mode 100644 index 000000000..2315c0b73 --- /dev/null +++ b/frontend/src/app/add-new-mobile-number/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from 'next'; +import type { NextJsPageProps } from 'nhs-notify-web-template-management-utils'; +import { HintText, Label } from '@atoms/nhsuk-components'; +import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import addMobileNumberContent from '@content/content'; +import { NHSNotifyContainer } from '@layouts/container/container'; +import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { NHSNotifyClientSideFormProvider } from '@providers/form-provider'; +import { $ContactDetailsSearchParams } from '@utils/schemas'; +import { addMobileNumber } from './form-action'; + +const content = addMobileNumberContent.pages.addMobileNumber; + +export const metadata: Metadata = { + title: content.pageTitle, +}; + +export default async function AddMobileNumberPage(props: NextJsPageProps) { + const { templateId } = $ContactDetailsSearchParams.parse( + await props.searchParams + ); + + return ( + + + {content.backLink.text} + + + + +
+
+

{content.pageHeading}

+ + + + + {templateId && ( + + )} + + + + {content.form.mobile.hint} + + + + + + + {content.form.submit.text} + + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 42aca9950..0eee760bf 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -2441,6 +2441,43 @@ const addEmailAddress = { }, }; +const addMobileNumber = { + pageTitle: generatePageTitle('Add a new mobile number'), + pageHeading: 'Add a new mobile number', + bodyText: [ + { + type: 'text', + text: "We'll send a security code to this mobile number to make sure you can access it.", + }, + { + type: 'text', + text: 'This mobile number will only be available for you to use. If someone else needs to send tests to it, they must add it to their own account.', + }, + ] satisfies ContentBlock[], + backLink: { + text: 'Back', + href: (templateId?: string) => + templateId + ? `/send-test-text-message/${templateId}` + : '/message-templates', + }, + form: { + mobile: { + label: 'Mobile number', + hint: 'For example, 07700900123. We recommend using a work mobile number', + errors: { + empty: 'Enter a mobile number', + invalid: + 'Enter a mobile number in the correct format, like 07700 900123, 07700 900 123 or +447700900123', + numberLength: 'Enter a mobile number between 11 and 13 digits long', + }, + }, + submit: { + text: 'Continue', + }, + }, +}; + const content = { global: { mainLayout }, components: { @@ -2488,6 +2525,7 @@ const content = { }, pages: { addEmailAddress, + addMobileNumber, chooseDigitalTemplatePage, chooseLetterTemplatePage, chooseOtherLanguageLetterTemplate, diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index fb6e3c791..7e7afb7df 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -5,6 +5,7 @@ import { getClientIdFromToken } from '@utils/token-utils'; const protectedPaths = [ /^\/add-new-email-address$/, + /^\/add-new-mobile-number$/, /^\/choose-a-template-type$/, /^\/choose-printing-and-postage\/[^/]+$/, /^\/copy-template\/[^/]+$/, diff --git a/tests/test-team/pages/contact-details/index.ts b/tests/test-team/pages/contact-details/index.ts index 945f8e116..20bc3c46a 100644 --- a/tests/test-team/pages/contact-details/index.ts +++ b/tests/test-team/pages/contact-details/index.ts @@ -1,2 +1,3 @@ export * from './template-mgmt-add-email-address-page'; +export * from './template-mgmt-add-mobile-number-page'; export * from './template-mgmt-enter-security-code-page'; diff --git a/tests/test-team/pages/contact-details/template-mgmt-add-mobile-number-page.ts b/tests/test-team/pages/contact-details/template-mgmt-add-mobile-number-page.ts new file mode 100644 index 000000000..ff80d275c --- /dev/null +++ b/tests/test-team/pages/contact-details/template-mgmt-add-mobile-number-page.ts @@ -0,0 +1,9 @@ +import { TemplateMgmtBasePage } from '../template-mgmt-base-page'; + +export class TemplateMgmtAddMobileNumberPage extends TemplateMgmtBasePage { + static readonly pathTemplate = '/add-new-mobile-number'; + + mobileField = this.page.getByLabel('Mobile number'); + + continueButton = this.page.getByRole('button', { name: 'Continue' }); +} diff --git a/tests/test-team/template-mgmt-accessibility/contact-details.accessibility.spec.ts b/tests/test-team/template-mgmt-accessibility/contact-details.accessibility.spec.ts index dae52c092..39327d48a 100644 --- a/tests/test-team/template-mgmt-accessibility/contact-details.accessibility.spec.ts +++ b/tests/test-team/template-mgmt-accessibility/contact-details.accessibility.spec.ts @@ -1,5 +1,8 @@ import { test } from 'fixtures/accessibility-analyze'; -import { TemplateMgmtAddEmailAddressPage } from 'pages/contact-details'; +import { + TemplateMgmtAddEmailAddressPage, + TemplateMgmtAddMobileNumberPage, +} from 'pages/contact-details'; test.describe('Contact details pages', () => { test('Add new email address page', async ({ page, analyze }) => @@ -12,4 +15,17 @@ test.describe('Contact details pages', () => { await p.errorSummary.isVisible(); }, })); + + test.describe('Add new mobile number page', () => { + test('Add new mobile number page', async ({ page, analyze }) => + analyze(new TemplateMgmtAddMobileNumberPage(page))); + + test('Add new mobile number error', async ({ page, analyze }) => + analyze(new TemplateMgmtAddMobileNumberPage(page), { + beforeAnalyze: async (p) => { + await p.continueButton.click(); + await p.errorSummary.isVisible(); + }, + })); + }); }); diff --git a/tests/test-team/template-mgmt-accessibility/route-coverage.accessibility.spec.ts b/tests/test-team/template-mgmt-accessibility/route-coverage.accessibility.spec.ts index c61b00274..e421051ec 100644 --- a/tests/test-team/template-mgmt-accessibility/route-coverage.accessibility.spec.ts +++ b/tests/test-team/template-mgmt-accessibility/route-coverage.accessibility.spec.ts @@ -99,7 +99,10 @@ import { } from 'pages/routing'; // Contact Details pages -import { TemplateMgmtAddEmailAddressPage } from 'pages/contact-details'; +import { + TemplateMgmtAddEmailAddressPage, + TemplateMgmtAddMobileNumberPage, +} from 'pages/contact-details'; /** * All page objects that must have accessibility test coverage. @@ -195,6 +198,7 @@ const allPages: (typeof TemplateMgmtBasePage)[] = [ // Contact Details TemplateMgmtAddEmailAddressPage, + TemplateMgmtAddMobileNumberPage, ]; test('all app routes have accessibility test coverage', async () => { diff --git a/tests/test-team/template-mgmt-component-tests/contact-details-component/template-mgmt-add-new-mobile-number.sms-template-component.spec.ts b/tests/test-team/template-mgmt-component-tests/contact-details-component/template-mgmt-add-new-mobile-number.sms-template-component.spec.ts new file mode 100644 index 000000000..deada8f59 --- /dev/null +++ b/tests/test-team/template-mgmt-component-tests/contact-details-component/template-mgmt-add-new-mobile-number.sms-template-component.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from '@playwright/test'; +import { TestUser, testUsers } from 'helpers/auth/cognito-auth-helper'; +import { getTestContext } from 'helpers/context/context'; +import { ContactDetailHelper } from 'helpers/db/contact-details-helper'; +import { generateMobileNumber } from 'helpers/factories/contact-details-factory'; +import { + assertAndClickBackLinkTop, + assertBackLinkBottomNotPresent, + assertFooterLinks, + assertHeaderLogoLink, + assertSignOutLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { TemplateMgmtAddMobileNumberPage } from 'pages/contact-details/template-mgmt-add-mobile-number-page'; +import { TemplateMgmtEnterSecurityCodePage } from 'pages/contact-details/template-mgmt-enter-security-code-page'; + +test.describe('Add mobile number page', () => { + let user: TestUser; + const storageHelper = new ContactDetailHelper(); + + test.beforeAll(async () => { + const context = getTestContext(); + + user = await context.auth.getTestUser(testUsers.User1.userId); + }); + + test.afterAll(async () => { + await storageHelper.cleanup(); + }); + + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtAddMobileNumberPage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + }); + + test.describe('without search parameters', () => { + test('back link goes to template list page', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtAddMobileNumberPage(page), + baseURL, + }; + + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/message-templates', + }); + }); + + test('user enters their mobile number and is redirected to enter security code page', async ({ + page, + }) => { + const addMobileNumberPage = new TemplateMgmtAddMobileNumberPage(page); + + await addMobileNumberPage.loadPage(); + + const mobileNumber = generateMobileNumber(); + + await addMobileNumberPage.mobileField.fill(mobileNumber); + + await addMobileNumberPage.continueButton.click(); + + await expect(page).toHaveURLMatchingPathTemplate( + TemplateMgmtEnterSecurityCodePage.pathTemplate + ); + + storageHelper.addAdHoc({ + type: 'SMS', + value: mobileNumber, + owner: user.internalUserId, + }); + + expect(new URL(page.url()).searchParams.get('templateId')).toBeNull(); + }); + }); + + test.describe('with templateId search parameter', () => { + test('back link goes to send test email page for the templateId', async ({ + page, + baseURL, + }) => { + const props = { + page: new TemplateMgmtAddMobileNumberPage(page).setSearchParam( + 'templateId', + 'example-template-id' + ), + baseURL, + }; + + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: `templates/send-test-text-message/example-template-id`, + }); + }); + + test('user enters their mobile number and is redirected to enter security code page with the templateId search parameter maintained', async ({ + page, + }) => { + const addMobileNumberPage = new TemplateMgmtAddMobileNumberPage( + page + ).setSearchParam('templateId', 'example-template-id'); + + await addMobileNumberPage.loadPage(); + + const mobileNumber = generateMobileNumber(); + + await addMobileNumberPage.mobileField.fill(mobileNumber); + + await addMobileNumberPage.continueButton.click(); + + await expect(page).toHaveURLMatchingPathTemplate( + TemplateMgmtEnterSecurityCodePage.pathTemplate + ); + + storageHelper.addAdHoc({ + type: 'SMS', + value: mobileNumber, + owner: user.internalUserId, + }); + + expect(new URL(page.url()).searchParams.get('templateId')).toBe( + 'example-template-id' + ); + }); + }); + + test.describe('validations', () => { + for (const mobileNumber of [ + '', + 'not-a-mobile-number', + '1234', // Too short + '1234567890123456789012345678901234567890', // Too long + ]) { + test(`displays validation error if mobile number is invalid - ${mobileNumber || 'empty'}`, async ({ + page, + }) => { + const addMobileNumberPage = new TemplateMgmtAddMobileNumberPage(page); + + await addMobileNumberPage.loadPage(); + + await addMobileNumberPage.mobileField.fill(mobileNumber); + + await addMobileNumberPage.continueButton.click(); + + await expect(page).toHaveURLMatchingPathTemplate( + TemplateMgmtAddMobileNumberPage.pathTemplate + ); + + await expect(addMobileNumberPage.errorSummary).toBeVisible(); + }); + } + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/template-component/template-protected-routes.template-component.spec.ts b/tests/test-team/template-mgmt-component-tests/template-component/template-protected-routes.template-component.spec.ts index 8a3ce65ab..5f9f3572f 100644 --- a/tests/test-team/template-mgmt-component-tests/template-component/template-protected-routes.template-component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/template-component/template-protected-routes.template-component.spec.ts @@ -74,6 +74,7 @@ import { TemplateMgmtUploadStandardEnglishLetterTemplatePage } from 'pages/lette import { TemplateMgmtPreviewApprovedLetterPage } from 'pages/letter/template-mgmt-preview-approved-letter-page'; import { TemplateMgmtLetterTemplateApprovedPage } from 'pages/letter'; import { TemplateMgmtAddEmailAddressPage } from 'pages/contact-details/template-mgmt-add-email-address-page'; +import { TemplateMgmtAddMobileNumberPage } from 'pages/contact-details/template-mgmt-add-mobile-number-page'; import { TemplateMgmtTestEmailMessageSentPage } from 'pages/email/template-mgmt-test-email-message-sent-page'; import { TemplateMgmtTestNhsAppMessageSentPage } from 'pages/nhs-app'; @@ -108,6 +109,7 @@ const protectedPages = [ RoutingReviewAndMoveToProductionPage, RoutingReviewAndMoveToProductionPreviewLetterTemplatePage, TemplateMgmtAddEmailAddressPage, + TemplateMgmtAddMobileNumberPage, TemplateMgmtChoosePage, TemplateMgmtChoosePrintingAndPostagePage, TemplateMgmtCopyPage,