diff --git a/.gitleaksignore b/.gitleaksignore index 196f6cb44..e52a7eb4b 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -10,3 +10,4 @@ bc79df4f82052918ae6bf69d36279e5dd391d61e:tests/test-team/auth/user.json:jwt:25 306d9ec55d3498b86d5506da9a90ac486fc66563:frontend/src/components/molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions.tsx:ipv4:92 a408293ecbc3a95684246fd16b111b6e09d6e194:lambdas/backend-client/src/__tests__/schemas/contact-details/email.test.ts:ipv4:80 a408293ecbc3a95684246fd16b111b6e09d6e194:lambdas/backend-client/src/__tests__/schemas/contact-details/email.test.ts:ipv4:81 +9ff9d02e5f132ff57aaee7e28b4002c17c3041a3:.tool-versions:ipv4:27 diff --git a/frontend/src/__tests__/utils/contact-details.test.ts b/frontend/src/__tests__/utils/contact-details.test.ts index 554a9bbd7..b6760b2e2 100644 --- a/frontend/src/__tests__/utils/contact-details.test.ts +++ b/frontend/src/__tests__/utils/contact-details.test.ts @@ -1,22 +1,179 @@ -import { contactDetailApiClient } from 'nhs-notify-backend-client'; -import { createContactDetail } from '@utils/contact-details'; +/** + * @jest-environment node + */ import { getSessionServer } from '@utils/amplify-utils'; +import { + createContactDetail, + getVerifiedContactDetails, +} from '@utils/contact-details'; +import { contactDetailApiClient } from 'nhs-notify-backend-client'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; -jest.mock('nhs-notify-backend-client'); -jest.mock('nhs-notify-web-template-management-utils/logger'); jest.mock('@utils/amplify-utils'); +jest.mock('nhs-notify-backend-client'); +jest.mock('nhs-notify-web-template-management-utils/logger', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const getSessionServerMock = jest.mocked(getSessionServer); +const contactDetailApiClientMock = jest.mocked(contactDetailApiClient); +const loggerMock = jest.mocked(logger); + +describe('getVerifiedContactDetails', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw when access token is missing', async () => { + getSessionServerMock.mockResolvedValueOnce({ + accessToken: undefined, + clientId: undefined, + }); + + await expect(getVerifiedContactDetails('EMAIL')).rejects.toThrow( + 'Failed to get access token' + ); + + expect(contactDetailApiClientMock.list).not.toHaveBeenCalled(); + }); + + it('should return id and rawValue for verified contact details', async () => { + getSessionServerMock.mockResolvedValueOnce({ + accessToken: 'token', + clientId: 'client-1', + }); + + const contactDetails = [ + { + id: 'contact-1', + type: 'EMAIL', + status: 'VERIFIED', + value: 'one@example.com', + rawValue: 'oNe@example.com', + } as ContactDetailDto, + { + id: 'contact-2', + type: 'EMAIL', + status: 'VERIFIED', + value: 'two@example.com', + rawValue: 'Two@example.com', + } as ContactDetailDto, + ]; -beforeEach(() => { - jest.resetAllMocks(); + contactDetailApiClientMock.list.mockResolvedValueOnce({ + data: contactDetails, + }); + + const result = await getVerifiedContactDetails('EMAIL'); + + expect(contactDetailApiClientMock.list).toHaveBeenCalledWith('token', { + type: 'EMAIL', + status: 'VERIFIED', + }); + + expect(result).toEqual([ + { + id: 'contact-1', + rawValue: 'oNe@example.com', + }, + { + id: 'contact-2', + rawValue: 'Two@example.com', + }, + ]); + }); + + it('should log and filter out invalid contact details from API response', async () => { + getSessionServerMock.mockResolvedValueOnce({ + accessToken: 'token', + clientId: 'client-1', + }); - jest - .mocked(getSessionServer) - .mockResolvedValue({ accessToken: 'access-token' }); + const contactDetails = [ + { + id: 'contact-1', + type: 'EMAIL', + status: 'VERIFIED', + value: 'one@example.com', + rawValue: 'One@example.com', + }, + { + id: 'contact-2', + type: 'EMAIL', + status: 'VERIFIED', + rawValue: 'Two@example.com', + }, + ] as ContactDetailDto[]; + + contactDetailApiClientMock.list.mockResolvedValueOnce({ + data: contactDetails, + }); + + const result = await getVerifiedContactDetails('EMAIL'); + + expect(result).toEqual([ + { + id: 'contact-1', + rawValue: 'One@example.com', + }, + ]); + + expect(loggerMock.error).toHaveBeenCalledWith( + 'Listed invalid contact detail', + expect.objectContaining({ + clientId: 'client-1', + type: 'EMAIL', + }) + ); + }); + + it('should return empty list and log when API call fails', async () => { + getSessionServerMock.mockResolvedValueOnce({ + accessToken: 'token', + clientId: 'client-1', + }); + + const error = { + errorMeta: { + code: 500, + description: 'Unexpected error', + }, + }; + + contactDetailApiClientMock.list.mockResolvedValueOnce({ + error, + }); + + const result = await getVerifiedContactDetails('EMAIL'); + + expect(result).toEqual([]); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Failed to get verified contact details', + { + clientId: 'client-1', + type: 'EMAIL', + error, + } + ); + }); }); describe('createContactDetail', () => { beforeEach(() => { - jest.mocked(contactDetailApiClient.create).mockImplementation((input) => + jest.resetAllMocks(); + + getSessionServerMock.mockResolvedValue({ + accessToken: 'access-token', + clientId: 'client-1', + }); + + contactDetailApiClientMock.create.mockImplementation((input) => Promise.resolve({ data: { ...input, @@ -53,7 +210,10 @@ describe('createContactDetail', () => { }); it('errors if unable to get an access token', async () => { - jest.mocked(getSessionServer).mockResolvedValueOnce({}); + getSessionServerMock.mockResolvedValueOnce({ + accessToken: undefined, + clientId: undefined, + }); await expect(() => createContactDetail({ @@ -66,7 +226,7 @@ describe('createContactDetail', () => { }); it('errors if api client call returns an error', async () => { - jest.mocked(contactDetailApiClient.create).mockResolvedValueOnce({ + contactDetailApiClientMock.create.mockResolvedValueOnce({ error: { errorMeta: { code: 500, diff --git a/frontend/src/app/send-test-email/[templateId]/__tests__/__snapshots__/page.test.tsx.snap b/frontend/src/app/send-test-email/[templateId]/__tests__/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..834166cf7 --- /dev/null +++ b/frontend/src/app/send-test-email/[templateId]/__tests__/__snapshots__/page.test.tsx.snap @@ -0,0 +1,776 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SendTestEmailMessagePage should match snapshot with a single verified email 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+

+ Your test email will be sent to + + user@example.nhs.uk + + . +

+ +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestEmailMessagePage should match snapshot with multiple verified emails 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+ + +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; diff --git a/frontend/src/app/send-test-email/[templateId]/__tests__/page.test.tsx b/frontend/src/app/send-test-email/[templateId]/__tests__/page.test.tsx new file mode 100644 index 000000000..cd9b6b4d1 --- /dev/null +++ b/frontend/src/app/send-test-email/[templateId]/__tests__/page.test.tsx @@ -0,0 +1,156 @@ +import SendTestEmailMessagePage, { + generateMetadata, +} from '@app/send-test-email/[templateId]/page'; +import { getTemplate } from '@utils/form-actions'; +import { serverIsFeatureEnabled } from '@utils/server-features'; +import { getVerifiedContactDetails } from '@utils/contact-details'; +import { redirect } from 'next/navigation'; +import { render } from '@testing-library/react'; +import { + EMAIL_TEMPLATE, + NHS_APP_TEMPLATE, + SMS_TEMPLATE, + LETTER_TEMPLATE, +} from '@testhelpers/helpers'; + +jest.mock('@utils/form-actions'); +jest.mock('@utils/server-features'); +jest.mock('@utils/contact-details'); +jest.mock('next/navigation'); + +const redirectMock = jest.mocked(redirect); +const getTemplateMock = jest.mocked(getTemplate); +const serverIsFeatureEnabledMock = jest.mocked(serverIsFeatureEnabled); +const getVerifiedContactDetailsMock = jest.mocked(getVerifiedContactDetails); + +const emailTemplateWithCustomPersonalisation = { + ...EMAIL_TEMPLATE, + customPersonalisation: ['appointmentDate', 'clinicName'], +}; + +describe('SendTestEmailMessagePage', () => { + beforeEach(() => { + jest.resetAllMocks(); + serverIsFeatureEnabledMock.mockResolvedValue(true); + getVerifiedContactDetailsMock.mockResolvedValue([ + { id: 'contact-1', rawValue: 'user@example.nhs.uk' }, + ]); + }); + + it('should generate correct metadata', async () => { + const metadata = await generateMetadata(); + + expect(metadata).toEqual({ + title: 'Send a test email - NHS Notify', + }); + }); + + it('should render page with valid email template', async () => { + getTemplateMock.mockResolvedValueOnce(EMAIL_TEMPLATE); + + const page = await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: EMAIL_TEMPLATE.id }), + }); + + const container = render(page); + + expect(getTemplateMock).toHaveBeenCalledWith(EMAIL_TEMPLATE.id); + expect(getVerifiedContactDetailsMock).toHaveBeenCalledWith('EMAIL'); + expect(redirectMock).not.toHaveBeenCalled(); + expect( + container.getByRole('heading', { name: 'Send a test email' }) + ).toBeInTheDocument(); + }); + + it('should match snapshot with a single verified email', async () => { + getTemplateMock.mockResolvedValueOnce( + emailTemplateWithCustomPersonalisation + ); + + const page = await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: EMAIL_TEMPLATE.id }), + }); + + const { container } = render(page); + + expect(container).toMatchSnapshot(); + }); + + it('should match snapshot with multiple verified emails', async () => { + getTemplateMock.mockResolvedValueOnce( + emailTemplateWithCustomPersonalisation + ); + getVerifiedContactDetailsMock.mockResolvedValueOnce([ + { id: 'contact-1', rawValue: 'user@example.nhs.uk' }, + { id: 'contact-2', rawValue: 'another@example.nhs.uk' }, + ]); + + const page = await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: EMAIL_TEMPLATE.id }), + }); + + const { container } = render(page); + + expect(container).toMatchSnapshot(); + }); + + it('should redirect to add-new-email-address when user has no verified email addresses', async () => { + getTemplateMock.mockResolvedValueOnce(EMAIL_TEMPLATE); + getVerifiedContactDetailsMock.mockResolvedValueOnce([]); + + await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: EMAIL_TEMPLATE.id }), + }); + + expect(redirectMock).toHaveBeenCalledWith( + `/add-new-email-address?templateId=${EMAIL_TEMPLATE.id}`, + 'replace' + ); + }); + + it('should redirect to preview page when digitalProofingEmail is disabled', async () => { + getTemplateMock.mockResolvedValueOnce(EMAIL_TEMPLATE); + serverIsFeatureEnabledMock.mockResolvedValueOnce(false); + + await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: EMAIL_TEMPLATE.id }), + }); + + expect(serverIsFeatureEnabledMock).toHaveBeenCalledWith( + 'digitalProofingEmail' + ); + expect(redirectMock).toHaveBeenCalledWith( + `/preview-email-template/${EMAIL_TEMPLATE.id}` + ); + }); + + it('should redirect to invalid-template when template is NHS_APP', async () => { + getTemplateMock.mockResolvedValueOnce(NHS_APP_TEMPLATE); + + await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: NHS_APP_TEMPLATE.id }), + }); + + expect(redirectMock).toHaveBeenCalledWith('/invalid-template', 'replace'); + }); + + it('should redirect to invalid-template when template is SMS', async () => { + getTemplateMock.mockResolvedValueOnce(SMS_TEMPLATE); + + await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: SMS_TEMPLATE.id }), + }); + + expect(redirectMock).toHaveBeenCalledWith('/invalid-template', 'replace'); + }); + + it('should redirect to invalid-template for letter template', async () => { + getTemplateMock.mockResolvedValueOnce(LETTER_TEMPLATE); + + await SendTestEmailMessagePage({ + params: Promise.resolve({ templateId: LETTER_TEMPLATE.id }), + }); + + expect(redirectMock).toHaveBeenCalledWith('/invalid-template', 'replace'); + }); +}); diff --git a/frontend/src/app/send-test-email/[templateId]/page.tsx b/frontend/src/app/send-test-email/[templateId]/page.tsx new file mode 100644 index 000000000..cf7cb8761 --- /dev/null +++ b/frontend/src/app/send-test-email/[templateId]/page.tsx @@ -0,0 +1,60 @@ +'use server'; + +import { Metadata } from 'next'; +import { + TemplatePageProps, + validateEmailTemplate, +} from 'nhs-notify-web-template-management-utils'; +import { redirect, RedirectType } from 'next/navigation'; +import content from '@content/content'; +import { getTemplate } from '@utils/form-actions'; +import { serverIsFeatureEnabled } from '@utils/server-features'; +import { getVerifiedContactDetails } from '@utils/contact-details'; +import { SendTestMessageToContactDetail } from '@forms/SendTestMessage/SendTestMessage'; + +const { pageTitle, pageHeading } = content.pages.sendTestEmailMessagePage; + +export async function generateMetadata(): Promise { + return { + title: pageTitle, + }; +} + +const SendTestEmailMessagePage = async (props: TemplatePageProps) => { + const { templateId } = await props.params; + + const isDigitalProofingEmailEnabled = await serverIsFeatureEnabled( + 'digitalProofingEmail' + ); + + if (!isDigitalProofingEmailEnabled) { + return redirect(`/preview-email-template/${templateId}`); + } + + const template = await getTemplate(templateId); + const validatedTemplate = validateEmailTemplate(template); + + if (!validatedTemplate) { + return redirect('/invalid-template', RedirectType.replace); + } + + const verifiedContactDetails = await getVerifiedContactDetails('EMAIL'); + + if (verifiedContactDetails.length === 0) { + return redirect( + `/add-new-email-address?templateId=${templateId}`, + RedirectType.replace + ); + } + + return ( + + ); +}; + +export default SendTestEmailMessagePage; diff --git a/frontend/src/app/send-test-nhs-app-message/[templateId]/__tests__/__snapshots__/page.test.tsx.snap b/frontend/src/app/send-test-nhs-app-message/[templateId]/__tests__/__snapshots__/page.test.tsx.snap index 717a70f60..2b43c054b 100644 --- a/frontend/src/app/send-test-nhs-app-message/[templateId]/__tests__/__snapshots__/page.test.tsx.snap +++ b/frontend/src/app/send-test-nhs-app-message/[templateId]/__tests__/__snapshots__/page.test.tsx.snap @@ -26,17 +26,12 @@ exports[`SendTestNhsAppMessagePage should handle template with SUBMITTED status

Send a test NHS App message

-

+

Preview how your message will be displayed by sending it to your test patient NHS App account.

-

+

If you do not have a test patient account, Send a test NHS App message -

+

Preview how your message will be displayed by sending it to your test patient NHS App account.

-

+

If you do not have a test patient account, Back to template diff --git a/frontend/src/app/test-email-message-sent/[proofingRequestId]/__tests__/page.test.tsx b/frontend/src/app/test-email-message-sent/[proofingRequestId]/__tests__/page.test.tsx index bc932ff31..9542d343a 100644 --- a/frontend/src/app/test-email-message-sent/[proofingRequestId]/__tests__/page.test.tsx +++ b/frontend/src/app/test-email-message-sent/[proofingRequestId]/__tests__/page.test.tsx @@ -46,13 +46,11 @@ describe('TestEmailMessageSentPage', () => { expect(getByTestId('banner-heading')).toHaveTextContent(bannerHeading); expect(getByText('test@example.com')).toBeInTheDocument(); expect(getByText(/NHS Notify - test/)).toBeInTheDocument(); - expect(getByTestId('back-to-template-link')).toHaveAttribute( + expect(getByTestId('back-link-bottom')).toHaveAttribute( 'href', '/preview-email-template/template-id' ); - expect(getByTestId('back-to-template-link')).toHaveTextContent( - backLink.text - ); + expect(getByTestId('back-link-bottom')).toHaveTextContent(backLink.text); }); it('should redirect to message-templates when digitalProofingEmail is disabled', async () => { diff --git a/frontend/src/app/test-email-message-sent/[proofingRequestId]/page.tsx b/frontend/src/app/test-email-message-sent/[proofingRequestId]/page.tsx index f30fa6cba..250bc1545 100644 --- a/frontend/src/app/test-email-message-sent/[proofingRequestId]/page.tsx +++ b/frontend/src/app/test-email-message-sent/[proofingRequestId]/page.tsx @@ -58,7 +58,7 @@ const TestEmailMessageSentPage = async (props: ProofingRequestPageProps) => { href={interpolate(backLink.href, { templateId: proofingRequest.templateId, })} - data-testid='back-to-template-link' + data-testid='back-link-bottom' className='nhsuk-link' > {backLink.text} diff --git a/frontend/src/components/forms/SendTestMessage/SendTestMessage.tsx b/frontend/src/components/forms/SendTestMessage/SendTestMessage.tsx index f0b4f9641..c13281305 100644 --- a/frontend/src/components/forms/SendTestMessage/SendTestMessage.tsx +++ b/frontend/src/components/forms/SendTestMessage/SendTestMessage.tsx @@ -10,7 +10,7 @@ import { import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; -import { HintText } from 'nhsuk-react-components'; +import { Details, HintText, Label } from 'nhsuk-react-components'; import { SHORT_EXAMPLE_RECIPIENTS, LONG_EXAMPLE_RECIPIENTS, @@ -18,12 +18,35 @@ import { import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { TestPersonalisationForm } from '@forms/TestPersonalisationForm/TestPersonalisationForm'; import { NHSNotifyFormProvider } from '@providers/form-provider'; -import { sendTestNhsAppMessageAction } from './server-action'; +import { + sendTestEmailAction, + sendTestNhsAppMessageAction, + sendTestSmsAction, +} from './server-action'; import type { FormState } from '@utils/types'; - -import copy from '@content/content'; +import type { VerifiedContactDetail } from '@utils/contact-details'; import { NHSNotifyContainer } from '@layouts/container/container'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import type { ContactDetailType } from 'nhs-notify-web-template-management-types'; + +import copy from '@content/content'; + +const CONTACT_DETAIL_CONFIG: Record< + ContactDetailType, + { + fieldName: 'testEmail' | 'testMobileNumber'; + serverAction: (state: FormState, data: FormData) => Promise; + } +> = { + EMAIL: { + fieldName: 'testEmail', + serverAction: sendTestEmailAction, + }, + SMS: { + fieldName: 'testMobileNumber', + serverAction: sendTestSmsAction, + }, +}; export function SendTestMessage({ template, @@ -149,3 +172,89 @@ export function SendTestNhsAppMessage({ ); } + +export function SendTestMessageToContactDetail({ + template, + pageHeading, + verifiedContactDetails, + contactDetailType, +}: { + template: Exclude; + pageHeading: string; + verifiedContactDetails: VerifiedContactDetail[]; + contactDetailType: ContactDetailType; +}) { + const { fieldName, serverAction } = CONTACT_DETAIL_CONFIG[contactDetailType]; + + const content = copy.components.sendTestMessage; + const contactDetailField = content.fields[fieldName]; + + const [singleContactDetail] = verifiedContactDetails; + + return ( + +

+ {contactDetailField.heading} +

+ + + {verifiedContactDetails.length > 1 ? ( + <> + + + + + {verifiedContactDetails.map((contactDetail) => ( + + ))} + + + ) : ( + <> + + + + )} + + + + +
+ + {contactDetailField.removeContactDetails.summary} + + + + +
+ + ); +} diff --git a/frontend/src/components/forms/SendTestMessage/__tests__/SendTestMessage.test.tsx b/frontend/src/components/forms/SendTestMessage/__tests__/SendTestMessage.test.tsx index f29ef7f9e..66f3e7116 100644 --- a/frontend/src/components/forms/SendTestMessage/__tests__/SendTestMessage.test.tsx +++ b/frontend/src/components/forms/SendTestMessage/__tests__/SendTestMessage.test.tsx @@ -1,8 +1,12 @@ 'use client'; import { useActionState } from 'react'; -import { render, screen, within } from '@testing-library/react'; -import { SendTestMessage, SendTestNhsAppMessage } from '../SendTestMessage'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { + SendTestMessageToContactDetail, + SendTestMessage, + SendTestNhsAppMessage, +} from '../SendTestMessage'; import type { FormState } from '@utils/types'; import { NHS_APP_TEMPLATE, @@ -483,3 +487,660 @@ describe('SendTestNhsAppMessage', () => { expect(container).toMatchSnapshot(); }); }); + +describe('SendTestMessageToContactDetail', () => { + describe('when the contact detail type is EMAIL', () => { + const pageHeading = 'Send a test email'; + const addNewEmailHref = `/templates/add-new-email-address?templateId=${EMAIL_TEMPLATE.id}`; + + const singleVerifiedContactDetails = [ + { id: 'contact-1', rawValue: 'one@example.nhs.uk' }, + ] as [{ id: string; rawValue: string }]; + + const multipleVerifiedContactDetails = [ + { id: 'contact-1', rawValue: 'one@example.nhs.uk' }, + { id: 'contact-2', rawValue: 'two@example.nhs.uk' }, + ] as [ + { id: string; rawValue: string }, + ...{ id: string; rawValue: string }[], + ]; + + const renderSendTestMessageToContactDetail = ( + verifiedContactDetails = multipleVerifiedContactDetails + ) => + render( + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the email-specific heading', () => { + renderSendTestMessageToContactDetail(); + + expect( + screen.getByRole('heading', { + level: 2, + name: 'Choose where to send your test email', + }) + ).toBeInTheDocument(); + }); + + describe('with a single verified contact detail', () => { + beforeEach(() => { + renderSendTestMessageToContactDetail(singleVerifiedContactDetails); + }); + + it('should display the selected email address', () => { + expect(screen.getByTestId('testEmail-form-group')).toHaveTextContent( + 'Your test email will be sent to' + ); + expect(screen.getByText('one@example.nhs.uk')).toBeInTheDocument(); + }); + + it('should submit a hidden contact detail input', () => { + const hiddenInput = document.querySelector( + 'input[name="contactDetailId"]' + ) as HTMLInputElement; + + expect(hiddenInput).toHaveValue('contact-1'); + }); + + it('should render a link to add a new email address', () => { + expect( + screen.getByRole('link', { name: 'add a new email address' }) + ).toHaveAttribute('href', addNewEmailHref); + }); + + it('should render expandable content on removing an email address, closed by default', () => { + const summary = screen.getByText('How to remove an email address'); + + expect(summary).toBeInTheDocument(); + expect(summary.closest('details')).not.toHaveAttribute('open'); + }); + + it('should open details on removing an email address when summary is clicked', () => { + const summary = screen.getByText('How to remove an email address'); + const details = summary.closest('details'); + + fireEvent.click(summary); + + expect(details).toHaveAttribute('open'); + }); + }); + + describe('with multiple verified contact details', () => { + it('should render a contact details dropdown with available verified email addresses', () => { + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + + expect( + document.querySelector('select#contactDetailId') + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: 'one@example.nhs.uk' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: 'two@example.nhs.uk' }) + ).toBeInTheDocument(); + }); + + it('should render a link to add a new email address', () => { + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + + expect( + screen.getByRole('link', { name: 'add a new email address' }) + ).toHaveAttribute('href', addNewEmailHref); + }); + + it('should render expandable content on removing an email address, closed by default', () => { + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + + expect( + screen.getByText('How to remove an email address') + ).toBeInTheDocument(); + expect( + screen.getByText('How to remove an email address').closest('details') + ).not.toHaveAttribute('open'); + }); + + describe('when validation fails', () => { + const errorMessage = + 'Choose an email address or add a new email address'; + + beforeEach(() => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [errorMessage], + }, + }, + fields: { + contactDetailId: '', + }, + }, + '/action', + ]); + + jest + .mocked(useActionState) + .mockImplementationOnce(mockUseActionState); + + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + }); + + it('shows error summary and inline validation for contact details field', () => { + const errorSummary = document.querySelector('.nhsuk-error-summary'); + expect(errorSummary).toBeInTheDocument(); + + const errorLink = within(errorSummary as HTMLElement).getByRole( + 'link', + { + name: errorMessage, + } + ); + expect(errorLink).toBeInTheDocument(); + expect(errorLink).toHaveAttribute('href', '#contactDetailId'); + + const emailFormGroup = screen.getByTestId('testEmail-form-group'); + expect(emailFormGroup).toHaveClass('nhsuk-form-group--error'); + expect( + within(emailFormGroup).getByText(/Error:/).parentElement + ).toHaveTextContent(errorMessage); + + const select = within(emailFormGroup).getByRole('combobox'); + expect(select).toHaveClass('nhsuk-select--error'); + }); + }); + + describe('when re-rendered after validation error', () => { + beforeEach(() => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [ + 'Choose an email address or add a new email address', + ], + }, + }, + fields: { + contactDetailId: 'contact-2', + }, + }, + '/action', + ]); + + jest + .mocked(useActionState) + .mockImplementationOnce(mockUseActionState); + + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + }); + + it('preserves selected contact detail in the dropdown', () => { + const select = within( + screen.getByTestId('testEmail-form-group') + ).getByRole('combobox') as HTMLSelectElement; + + expect(select.value).toBe('contact-2'); + }); + }); + }); + + describe('snapshots', () => { + it('matches snapshot when there is a single email address available', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot when there are multiple email addresses available', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot when there are multiple email addresses with a validation error', () => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [ + 'Choose an email address or add a new email address', + ], + }, + }, + fields: { + contactDetailId: 'contact-2', + }, + }, + '/action', + ]); + + jest.mocked(useActionState).mockImplementationOnce(mockUseActionState); + + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot when all fields have validation errors', () => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [ + 'Choose an email address or add a new email address', + ], + exampleRecipient: ['Choose an example recipient'], + 'personalisation|appointmentDate': [ + 'Enter example data for appointmentDate', + ], + 'personalisation|clinicName': [ + 'Enter example data for clinicName', + ], + }, + }, + fields: { + contactDetailId: '', + exampleRecipient: '', + 'personalisation|appointmentDate': '', + 'personalisation|clinicName': '', + }, + }, + '/action', + ]); + + jest.mocked(useActionState).mockImplementationOnce(mockUseActionState); + + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with expanded details showing how to remove contact details', () => { + const { container } = render( + + ); + + const detailsElement = container.querySelector('details'); + if (detailsElement) { + detailsElement.setAttribute('open', ''); + } + + expect(container).toMatchSnapshot(); + }); + }); + }); + + describe('when the contact detail type is SMS', () => { + const pageHeading = 'Send a test text message'; + const addNewMobileNumberHref = `/templates/add-new-mobile-number?templateId=${SMS_TEMPLATE.id}`; + + const singleVerifiedContactDetails = [ + { id: 'contact-1', rawValue: '07123456789' }, + ] as [{ id: string; rawValue: string }]; + + const multipleVerifiedContactDetails = [ + { id: 'contact-1', rawValue: '07123456789' }, + { id: 'contact-2', rawValue: '07234567890' }, + ] as [ + { id: string; rawValue: string }, + ...{ id: string; rawValue: string }[], + ]; + + const renderSendTestMessageToContactDetail = ( + verifiedContactDetails = multipleVerifiedContactDetails + ) => + render( + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the SMS-specific heading', () => { + renderSendTestMessageToContactDetail(); + + expect( + screen.getByRole('heading', { + level: 2, + name: 'Choose where to send your test text message', + }) + ).toBeInTheDocument(); + }); + + describe('with a single verified contact detail', () => { + beforeEach(() => { + renderSendTestMessageToContactDetail(singleVerifiedContactDetails); + }); + + it('should display the selected mobile number', () => { + expect( + screen.getByTestId('testMobileNumber-form-group') + ).toHaveTextContent('Your test text message will be sent to'); + expect(screen.getByText('07123456789')).toBeInTheDocument(); + }); + + it('should submit a hidden contact detail input', () => { + const hiddenInput = document.querySelector( + 'input[name="contactDetailId"]' + ) as HTMLInputElement; + + expect(hiddenInput).toHaveValue('contact-1'); + }); + + it('should render a link to add a new mobile number', () => { + expect( + screen.getByRole('link', { name: 'add a new mobile number' }) + ).toHaveAttribute('href', addNewMobileNumberHref); + }); + + it('should render expandable content on removing a mobile number, closed by default', () => { + const summary = screen.getByText('How to remove a mobile number'); + + expect(summary).toBeInTheDocument(); + expect(summary.closest('details')).not.toHaveAttribute('open'); + }); + + it('should open details on removing a mobile number when summary is clicked', () => { + const summary = screen.getByText('How to remove a mobile number'); + const details = summary.closest('details'); + + fireEvent.click(summary); + + expect(details).toHaveAttribute('open'); + }); + }); + + describe('with multiple verified contact details', () => { + it('should render a contact details dropdown with available verified mobile numbers', () => { + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + + expect( + document.querySelector('select#contactDetailId') + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: '07123456789' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: '07234567890' }) + ).toBeInTheDocument(); + }); + + it('should render a link to add a new mobile number', () => { + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + + expect( + screen.getByRole('link', { name: 'add a new mobile number' }) + ).toHaveAttribute('href', addNewMobileNumberHref); + }); + + it('should render expandable content on removing a mobile number, closed by default', () => { + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + + expect( + screen.getByText('How to remove a mobile number').closest('details') + ).not.toHaveAttribute('open'); + }); + + describe('when validation fails', () => { + const errorMessage = + 'Choose a mobile number or add a new mobile number'; + + beforeEach(() => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [errorMessage], + }, + }, + fields: { + contactDetailId: '', + }, + }, + '/action', + ]); + + jest + .mocked(useActionState) + .mockImplementationOnce(mockUseActionState); + + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + }); + + it('shows error summary and inline validation for contact details field', () => { + const errorSummary = document.querySelector('.nhsuk-error-summary'); + expect(errorSummary).toBeInTheDocument(); + + const errorLink = within(errorSummary as HTMLElement).getByRole( + 'link', + { + name: errorMessage, + } + ); + expect(errorLink).toBeInTheDocument(); + expect(errorLink).toHaveAttribute('href', '#contactDetailId'); + + const mobileNumberFormGroup = screen.getByTestId( + 'testMobileNumber-form-group' + ); + expect(mobileNumberFormGroup).toHaveClass('nhsuk-form-group--error'); + expect( + within(mobileNumberFormGroup).getByText(/Error:/).parentElement + ).toHaveTextContent(errorMessage); + + const select = within(mobileNumberFormGroup).getByRole('combobox'); + expect(select).toHaveClass('nhsuk-select--error'); + }); + }); + + describe('when re-rendered after validation error', () => { + beforeEach(() => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [ + 'Choose a mobile number or add a new mobile number', + ], + }, + }, + fields: { + contactDetailId: 'contact-2', + }, + }, + '/action', + ]); + + jest + .mocked(useActionState) + .mockImplementationOnce(mockUseActionState); + + renderSendTestMessageToContactDetail(multipleVerifiedContactDetails); + }); + + it('preserves selected contact detail in the dropdown', () => { + const select = within( + screen.getByTestId('testMobileNumber-form-group') + ).getByRole('combobox') as HTMLSelectElement; + + expect(select.value).toBe('contact-2'); + }); + }); + }); + + describe('snapshots', () => { + it('matches snapshot when there is a single mobile number available', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot when there are multiple mobile numbers available', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot when there are multiple mobile numbers with a validation error', () => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [ + 'Choose a mobile number or add a new mobile number', + ], + }, + }, + fields: { + contactDetailId: 'contact-2', + }, + }, + '/action', + ]); + + jest.mocked(useActionState).mockImplementationOnce(mockUseActionState); + + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot when all fields have validation errors', () => { + const mockUseActionState = jest.fn().mockReturnValue([ + { + errorState: { + formErrors: [], + fieldErrors: { + contactDetailId: [ + 'Choose a mobile number or add a new mobile number', + ], + exampleRecipient: ['Choose an example recipient'], + 'personalisation|appointmentDate': [ + 'Enter example data for appointmentDate', + ], + 'personalisation|clinicName': [ + 'Enter example data for clinicName', + ], + }, + }, + fields: { + contactDetailId: '', + exampleRecipient: '', + 'personalisation|appointmentDate': '', + 'personalisation|clinicName': '', + }, + }, + '/action', + ]); + + jest.mocked(useActionState).mockImplementationOnce(mockUseActionState); + + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with expanded details showing how to remove contact details', () => { + const { container } = render( + + ); + + const detailsElement = container.querySelector('details'); + if (detailsElement) { + detailsElement.setAttribute('open', ''); + } + + expect(container).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/frontend/src/components/forms/SendTestMessage/__tests__/__snapshots__/SendTestMessage.test.tsx.snap b/frontend/src/components/forms/SendTestMessage/__tests__/__snapshots__/SendTestMessage.test.tsx.snap index 661b339ce..1d780bfd0 100644 --- a/frontend/src/components/forms/SendTestMessage/__tests__/__snapshots__/SendTestMessage.test.tsx.snap +++ b/frontend/src/components/forms/SendTestMessage/__tests__/__snapshots__/SendTestMessage.test.tsx.snap @@ -1,5 +1,4135 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`SendTestMessageToContactDetail when the contact detail type is EMAIL snapshots matches snapshot when all fields have validation errors 1`] = ` +
+
+ + Back to template + +
+
+
+ +

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+ + + + Error: + + Choose an email address or add a new email address + + +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + + + Error: + + Choose an example recipient + + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is EMAIL snapshots matches snapshot when there are multiple email addresses available 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+ + +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is EMAIL snapshots matches snapshot when there are multiple email addresses with a validation error 1`] = ` +
+
+ + Back to template + +
+
+
+ +

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+ + + + Error: + + Choose an email address or add a new email address + + +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is EMAIL snapshots matches snapshot when there is a single email address available 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+

+ Your test email will be sent to + + one@example.nhs.uk + + . +

+ +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is EMAIL snapshots matches snapshot with expanded details showing how to remove contact details 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test email +

+

+ Preview how your email will be displayed by sending a test to your email address. +

+
+ + + +

+ Choose where to send your test email +

+
+ + +
+

+ Or + + add a new email address + + . +

+
+ + + How to remove an email address + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove email address from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the email address you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - email + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your email by entering example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is SMS snapshots matches snapshot when all fields have validation errors 1`] = ` +
+
+ + Back to template + +
+
+
+ +

+ Send a test text message +

+

+ Preview how your message will be displayed by sending a test text message to your mobile phone. +

+
+ + + +

+ Choose where to send your test text message +

+
+ + + + Error: + + Choose a mobile number or add a new mobile number + + +
+

+ Or + + add a new mobile number + + . +

+
+ + + How to remove a mobile number + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove mobile number from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the mobile number you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - SMS + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your text message by entering some example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + + + Error: + + Choose an example recipient + + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is SMS snapshots matches snapshot when there are multiple mobile numbers available 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test text message +

+

+ Preview how your message will be displayed by sending a test text message to your mobile phone. +

+
+ + + +

+ Choose where to send your test text message +

+
+ + +
+

+ Or + + add a new mobile number + + . +

+
+ + + How to remove a mobile number + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove mobile number from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the mobile number you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - SMS + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your text message by entering some example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is SMS snapshots matches snapshot when there are multiple mobile numbers with a validation error 1`] = ` +
+
+ + Back to template + +
+
+
+ +

+ Send a test text message +

+

+ Preview how your message will be displayed by sending a test text message to your mobile phone. +

+
+ + + +

+ Choose where to send your test text message +

+
+ + + + Error: + + Choose a mobile number or add a new mobile number + + +
+

+ Or + + add a new mobile number + + . +

+
+ + + How to remove a mobile number + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove mobile number from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the mobile number you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - SMS + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your text message by entering some example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is SMS snapshots matches snapshot when there is a single mobile number available 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test text message +

+

+ Preview how your message will be displayed by sending a test text message to your mobile phone. +

+
+ + + +

+ Choose where to send your test text message +

+
+

+ Your test text message will be sent to + + 07123456789 + + . +

+ +
+

+ Or + + add a new mobile number + + . +

+
+ + + How to remove a mobile number + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove mobile number from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the mobile number you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - SMS + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your text message by entering some example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`SendTestMessageToContactDetail when the contact detail type is SMS snapshots matches snapshot with expanded details showing how to remove contact details 1`] = ` +
+
+ + Back to template + +
+
+
+

+ Send a test text message +

+

+ Preview how your message will be displayed by sending a test text message to your mobile phone. +

+
+ + + +

+ Choose where to send your test text message +

+
+ + +
+

+ Or + + add a new mobile number + + . +

+
+ + + How to remove a mobile number + + +
+
    +
  1. + Go to + + ServiceNow (opens in a new tab) + + . +
  2. +
  3. + Sign in with your NHS.net account, or register for a Portal account. +
  4. +
  5. + In the + + subject + + field, add 'Remove mobile number from list for sending test messages'. +
  6. +
  7. + In the + + description + + field, include: + +
      +
    • + your service name +
    • +
    • + campaign +
    • +
    • + the mobile number you want to remove +
    • +
    +
  8. +
  9. + For the + + service offering + + , select + + NHS Notify - SMS + + from the drop-down list. +
  10. +
+
+
+
+

+ Personalisation +

+

+ Check how your personalisation fields will be displayed in your text message by entering some example data. +

+
+

+ PDS personalisation fields +

+

+ Your Personal Demographics Service (PDS) personalisation fields will be pre-filled with example data when you choose an example recipient. +

+
+ + +
+
+
+

+ Custom personalisation fields +

+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+`; + exports[`SendTestNhsAppMessage matches snapshot 1`] = `
Send a test NHS App message -

+

Preview how your message will be displayed by sending it to your test patient NHS App account.

-

+

If you do not have a test patient account, Send a test NHS App message -

+

Preview how your message will be displayed by sending it to your test patient NHS App account.

-

+

If you do not have a test patient account, { }); }); }); + +describe('$SendTestEmailSchema', () => { + test('passes validation with a valid payload', () => { + const result = $SendTestEmailSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(true); + }); + + test('fails validation when templateId is invalid', () => { + const result = $SendTestEmailSchema.safeParse({ + templateId: 'invalid-template-id', + contactDetailId: validContactDetailId, + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('templateId'); + }); + + test('fails validation when contactDetailId is missing', () => { + const result = $SendTestEmailSchema.safeParse({ + templateId: validTemplateId, + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('contactDetailId'); + expect(result.error?.issues[0].message).toBe( + 'Choose an email address or add a new email address' + ); + }); + + test('fails validation when contactDetailId is invalid', () => { + const result = $SendTestEmailSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: 'invalid-contact-detail-id', + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('contactDetailId'); + expect(result.error?.issues[0].message).toBe( + 'Choose an email address or add a new email address' + ); + }); + + test('fails validation when exampleRecipient is missing', () => { + const result = $SendTestEmailSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('exampleRecipient'); + expect(result.error?.issues[0].message).toBe('Choose an example recipient'); + }); + + test('fails validation when exampleRecipient is empty', () => { + const result = $SendTestEmailSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: '', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('exampleRecipient'); + expect(result.error?.issues[0].message).toBe('Choose an example recipient'); + }); +}); + +describe('$SendTestSmsSchema', () => { + test('passes validation with a valid payload', () => { + const result = $SendTestSmsSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(true); + }); + + test('fails validation when contactDetailId is missing', () => { + const result = $SendTestSmsSchema.safeParse({ + templateId: validTemplateId, + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('contactDetailId'); + expect(result.error?.issues[0].message).toBe( + 'Choose a mobile number or add a new mobile number' + ); + }); + + test('fails validation when templateId is invalid', () => { + const result = $SendTestSmsSchema.safeParse({ + templateId: 'invalid-template-id', + contactDetailId: validContactDetailId, + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('templateId'); + }); + + test('fails validation when contactDetailId is invalid', () => { + const result = $SendTestSmsSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: 'invalid-contact-detail-id', + exampleRecipient: 'patient-a', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('contactDetailId'); + expect(result.error?.issues[0].message).toBe( + 'Choose a mobile number or add a new mobile number' + ); + }); + + test('fails validation when exampleRecipient is missing', () => { + const result = $SendTestSmsSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('exampleRecipient'); + expect(result.error?.issues[0].message).toBe('Choose an example recipient'); + }); + + test('fails validation when exampleRecipient is empty', () => { + const result = $SendTestSmsSchema.safeParse({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: '', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toContain('exampleRecipient'); + expect(result.error?.issues[0].message).toBe('Choose an example recipient'); + }); +}); + +describe('sendTestEmailAction', () => { + const mockFormState: FormState = { + errorState: undefined, + fields: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('redirects to confirmation screen on successful validation', async () => { + const formData = getMockFormData({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: 'example-recipient', + }); + + await sendTestEmailAction(mockFormState, formData); + + // TODO: CCM-8407 - Update URL to contain proofing request ID instead of template ID + expect(redirect).toHaveBeenCalledWith( + `/test-email-sent/${validTemplateId}`, + 'push' + ); + }); + + test('returns error when contact detail is missing', async () => { + const formData = getMockFormData({ + templateId: validTemplateId, + contactDetailId: '', + exampleRecipient: 'example-recipient', + }); + + const result = await sendTestEmailAction(mockFormState, formData); + + expect(redirect).not.toHaveBeenCalled(); + expect(result.errorState?.fieldErrors?.contactDetailId).toContain( + 'Choose an email address or add a new email address' + ); + expect(result.errorState?.formErrors).toEqual([]); + }); + + test('returns error when example recipient is missing', async () => { + const formData = getMockFormData({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: '', + }); + + const result = await sendTestEmailAction(mockFormState, formData); + + expect(redirect).not.toHaveBeenCalled(); + expect(result.errorState?.fieldErrors?.exampleRecipient).toContain( + 'Choose an example recipient' + ); + }); + + test('redirects to invalid template when templateId is invalid', async () => { + const formData = getMockFormData({ + templateId: 'invalid-template-id', + contactDetailId: validContactDetailId, + exampleRecipient: 'example-recipient', + }); + + await sendTestEmailAction(mockFormState, formData); + + expect(redirect).toHaveBeenCalledWith('/invalid-template', 'replace'); + }); +}); + +describe('sendTestSmsAction', () => { + const mockFormState: FormState = { + errorState: undefined, + fields: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('redirects to confirmation screen on successful validation', async () => { + const formData = getMockFormData({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: 'example-recipient', + }); + + await sendTestSmsAction(mockFormState, formData); + + expect(redirect).toHaveBeenCalledWith( + `/test-text-message-sent/${validTemplateId}`, + 'push' + ); + }); + + test('returns error when contact detail is missing', async () => { + const formData = getMockFormData({ + templateId: validTemplateId, + contactDetailId: '', + exampleRecipient: 'example-recipient', + }); + + const result = await sendTestSmsAction(mockFormState, formData); + + expect(redirect).not.toHaveBeenCalled(); + expect(result.errorState?.fieldErrors?.contactDetailId).toContain( + 'Choose a mobile number or add a new mobile number' + ); + }); + + test('returns error when example recipient is missing', async () => { + const formData = getMockFormData({ + templateId: validTemplateId, + contactDetailId: validContactDetailId, + exampleRecipient: '', + }); + + const result = await sendTestSmsAction(mockFormState, formData); + + expect(redirect).not.toHaveBeenCalled(); + expect(result.errorState?.fieldErrors?.exampleRecipient).toContain( + 'Choose an example recipient' + ); + }); + + test('redirects to invalid template when templateId is invalid', async () => { + const formData = getMockFormData({ + templateId: 'invalid-template-id', + contactDetailId: validContactDetailId, + exampleRecipient: 'example-recipient', + }); + + await sendTestSmsAction(mockFormState, formData); + + expect(redirect).toHaveBeenCalledWith('/invalid-template', 'replace'); + }); +}); diff --git a/frontend/src/components/forms/SendTestMessage/server-action.ts b/frontend/src/components/forms/SendTestMessage/server-action.ts index 614a5aa78..8ef6af821 100644 --- a/frontend/src/components/forms/SendTestMessage/server-action.ts +++ b/frontend/src/components/forms/SendTestMessage/server-action.ts @@ -14,10 +14,17 @@ import { z } from 'zod'; const { fields: { testNhsNumber: { errors: testNhsNumberFieldErrors }, + testEmail: { errors: testEmailFieldErrors }, + testMobileNumber: { errors: testMobileNumberFieldErrors }, }, } = content.components.sendTestMessage; const { + pdsSection: { + exampleRecipient: { + errors: { invalid: exampleRecipientFieldInvalidError }, + }, + }, customPersonalisationSection: { errors: { required: customPersonalisationFieldRequiredError }, }, @@ -81,22 +88,29 @@ export const $SendTestNhsAppMessageSchema = z.object({ templateId: z.uuidv4(), }); -export async function sendTestNhsAppMessageAction( - _formState: FormState, - formData: FormData -): Promise { - const result = $SendTestNhsAppMessageSchema.safeParse( - Object.fromEntries(formData.entries()) - ); +const createSendTestContactDetailSchema = (invalidContactDetailError: string) => + z.object({ + templateId: z.uuidv4(), + contactDetailId: z.uuidv4(invalidContactDetailError), + exampleRecipient: z + .string(exampleRecipientFieldInvalidError) + .min(1, exampleRecipientFieldInvalidError), + }); - if (!result.success && z.flattenError(result.error).fieldErrors.templateId) { - return redirect('/invalid-template', RedirectType.replace); - } +export const $SendTestEmailSchema = createSendTestContactDetailSchema( + testEmailFieldErrors.empty +); - const fields = formDataToFormStateFields(formData); +export const $SendTestSmsSchema = createSendTestContactDetailSchema( + testMobileNumberFieldErrors.empty +); - const personalisationFieldErrors: Record = {}; +function collectCustomPersonalisation(fields: Record): { + personalisation: Record; + personalisationFieldErrors: Record; +} { const personalisation: Record = {}; + const personalisationFieldErrors: Record = {}; for (const [key, value] of Object.entries(fields)) { if (!key.startsWith(PERSONALISATION_FORMDATA_PREFIX)) { @@ -117,6 +131,29 @@ export async function sendTestNhsAppMessageAction( } } + return { + personalisation, + personalisationFieldErrors, + }; +} + +export async function sendTestNhsAppMessageAction( + _formState: FormState, + formData: FormData +): Promise { + const result = $SendTestNhsAppMessageSchema.safeParse( + Object.fromEntries(formData.entries()) + ); + + if (!result.success && z.flattenError(result.error).fieldErrors.templateId) { + return redirect('/invalid-template', RedirectType.replace); + } + + const fields = formDataToFormStateFields(formData); + + const { personalisation, personalisationFieldErrors } = + collectCustomPersonalisation(fields); + if (!result.success || Object.keys(personalisationFieldErrors).length > 0) { const nhsNumberFieldErrors = result.success ? {} @@ -146,3 +183,77 @@ export async function sendTestNhsAppMessageAction( RedirectType.push ); } + +async function sendTestMessageToContactDetailAction( + _formState: FormState, + formData: FormData, + options: { + schema: ReturnType; + successRedirectPath: (templateId: string) => string; + } +): Promise { + const result = options.schema.safeParse( + Object.fromEntries(formData.entries()) + ); + + if (!result.success && z.flattenError(result.error).fieldErrors.templateId) { + return redirect('/invalid-template', RedirectType.replace); + } + + const fields = formDataToFormStateFields(formData); + const { + personalisation: customPersonalisation, + personalisationFieldErrors: customPersonalisationFieldErrors, + } = collectCustomPersonalisation(fields); + + const fieldErrors: Record = { + ...customPersonalisationFieldErrors, + }; + + if (!result.success) { + Object.assign(fieldErrors, z.flattenError(result.error).fieldErrors); + } + + if (Object.keys(fieldErrors).length > 0 || !result.success) { + return { + errorState: { + formErrors: [], + fieldErrors, + }, + fields, + }; + } + + const { templateId, contactDetailId, exampleRecipient } = result.data; + + const _proofRequest = { + templateId, + contactDetailId, + exampleRecipient, + customPersonalisation, + }; + + // TODO: CCM-8407 - Implement actual call to send test message & Update URL to contain proofing request ID instead of template ID + return redirect(options.successRedirectPath(templateId), RedirectType.push); +} + +export async function sendTestEmailAction( + formState: FormState, + formData: FormData +): Promise { + return sendTestMessageToContactDetailAction(formState, formData, { + schema: $SendTestEmailSchema, + successRedirectPath: (templateId) => `/test-email-sent/${templateId}`, + }); +} + +export async function sendTestSmsAction( + formState: FormState, + formData: FormData +): Promise { + return sendTestMessageToContactDetailAction(formState, formData, { + schema: $SendTestSmsSchema, + successRedirectPath: (templateId) => + `/test-text-message-sent/${templateId}`, + }); +} diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 167cd9761..f97375a51 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1549,17 +1549,10 @@ const sendTestMessageIntroParagraphs: Record< { type: 'text', text: 'Preview how your message will be displayed by sending it to your test patient NHS App account.', - overrides: { - p: { props: { className: 'nhsuk-body' } }, - }, }, { type: 'text', text: 'If you do not have a test patient account, [set up a test patient in the NHS App (opens in a new tab)](https://digital.nhs.uk/services/nhs-app/resources/set-up-a-test-patient).', - overrides: { - p: { props: { className: 'nhsuk-body' } }, - a: { props: { className: 'nhsuk-link' } }, - }, }, ], SMS: [ @@ -1599,19 +1592,127 @@ const sendTestMessage = { testEmail: { heading: 'Choose where to send your test email', label: 'Email address', - footer: 'Or [add a new email address](https://example.com).', errors: { - empty: '', - invalid: '', + empty: 'Choose an email address or add a new email address', + }, + footer: [ + { + type: 'text', + text: 'Or [add a new email address](/templates/add-new-email-address?templateId={{templateId}}).', + overrides: { + p: { + props: { + 'data-testid': 'test-email-footer', + }, + }, + ...sameTabLink({}), + }, + }, + ] satisfies ContentBlock[], + placeholder: 'Choose an email address', + singleContactDetailMessage: [ + { + type: 'text', + text: 'Your test email will be sent to **{{contactDetail}}**.', + overrides: { + p: { + props: { + 'data-testid': 'single-contact-detail-message', + }, + }, + }, + }, + ] satisfies ContentBlock[], + removeContactDetails: { + summary: 'How to remove an email address', + content: [ + { + type: 'text', + text: markdownList('ol', [ + 'Go to [ServiceNow (opens in a new tab)](https://nhsdigitallive.service-now.com/csm?id=sc_cat_item&sys_id=6cc625151b9fbad083b0a7d0b24bcb11&sysparm_category=a37b549b971b3a10dd80f2df9153aff6).', + 'Sign in with your NHS.net account, or register for a Portal account.', + "In the **subject** field, add 'Remove email address from list for sending test messages'.", + 'In the **description** field, include:\n - your service name\n - campaign\n - the email address you want to remove', + 'For the **service offering**, select **NHS Notify - email** from the drop-down list.', + ]), + overrides: { + ol: { + props: { + className: 'nhsuk-list nhsuk-list--number', + }, + }, + ul: { + props: { + className: + 'nhsuk-list nhsuk-list--bullet nhsuk-u-margin-bottom-3', + }, + }, + }, + }, + ] satisfies ContentBlock[], }, }, testMobileNumber: { heading: 'Choose where to send your test text message', label: 'Mobile number', - footer: 'Or [add a new mobile number](https://example.com).', errors: { - empty: '', - invalid: '', + empty: 'Choose a mobile number or add a new mobile number', + }, + footer: [ + { + type: 'text', + text: 'Or [add a new mobile number](/templates/add-new-mobile-number?templateId={{templateId}}).', + overrides: { + p: { + props: { + 'data-testid': 'test-mobile-footer', + }, + }, + ...sameTabLink({}), + }, + }, + ] satisfies ContentBlock[], + placeholder: 'Choose a mobile number', + singleContactDetailMessage: [ + { + type: 'text', + text: 'Your test text message will be sent to **{{contactDetail}}**.', + overrides: { + p: { + props: { + 'data-testid': 'single-contact-detail-message', + }, + }, + }, + }, + ] satisfies ContentBlock[], + removeContactDetails: { + summary: 'How to remove a mobile number', + content: [ + { + type: 'text', + text: markdownList('ol', [ + 'Go to [ServiceNow (opens in a new tab)](https://nhsdigitallive.service-now.com/csm?id=sc_cat_item&sys_id=6cc625151b9fbad083b0a7d0b24bcb11&sysparm_category=a37b549b971b3a10dd80f2df9153aff6).', + 'Sign in with your NHS.net account, or register for a Portal account.', + "In the **subject** field, add 'Remove mobile number from list for sending test messages'.", + 'In the **description** field, include:\n - your service name\n - campaign\n - the mobile number you want to remove', + 'For the **service offering**, select **NHS Notify - SMS** from the drop-down list.', + ]), + overrides: { + ol: { + props: { + className: 'nhsuk-list nhsuk-list--number', + }, + }, + ul: { + props: { + className: + 'nhsuk-list nhsuk-list--bullet nhsuk-u-margin-bottom-3', + }, + }, + }, + }, + ] satisfies ContentBlock[], }, }, }, diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 2786a6634..23ca58cd1 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -65,6 +65,7 @@ const protectedPaths = [ /^\/request-a-proof\/[^/]+$/, /^\/review-and-approve-letter-template\/[^/]+$/, /^\/send-test-nhs-app-message\/[^/]+$/, + /^\/send-test-email\/[^/]+$/, /^\/submit-email-template\/[^/]+$/, /^\/submit-letter-template\/[^/]+$/, /^\/submit-nhs-app-template\/[^/]+$/, diff --git a/frontend/src/utils/contact-details.ts b/frontend/src/utils/contact-details.ts index 472de3a8c..8e0ca7f58 100644 --- a/frontend/src/utils/contact-details.ts +++ b/frontend/src/utils/contact-details.ts @@ -1,9 +1,59 @@ 'use server'; +import { getSessionServer } from '@utils/amplify-utils'; import { contactDetailApiClient } from 'nhs-notify-backend-client'; -import type { ContactDetailInput } from 'nhs-notify-web-template-management-types'; +import { $ContactDetailDto } from 'nhs-notify-backend-client/schemas'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; -import { getSessionServer } from '@utils//amplify-utils'; +import type { + ContactDetailDto, + ContactDetailInput, + ContactDetailType, +} from 'nhs-notify-web-template-management-types'; + +export type VerifiedContactDetail = Pick; + +export async function getVerifiedContactDetails( + type: ContactDetailType +): Promise { + const { accessToken, clientId } = await getSessionServer(); + + if (!accessToken) { + throw new Error('Failed to get access token'); + } + + const { data, error } = await contactDetailApiClient.list(accessToken, { + type, + status: 'VERIFIED', + }); + + if (error) { + logger.error('Failed to get verified contact details', { + clientId, + type, + error, + }); + return []; + } + + const valid = data.filter((d) => { + const { error: validationError, success } = $ContactDetailDto.safeParse(d); + + if (!success) { + logger.error('Listed invalid contact detail', { + clientId, + type, + validationError, + }); + } + + return success; + }); + + return valid.map(({ id, rawValue }: ContactDetailDto) => ({ + id, + rawValue, + })); +} export async function createContactDetail(contactDetail: ContactDetailInput) { const { accessToken } = await getSessionServer(); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index e033a3591..0a04c03f2 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -511,6 +511,49 @@ describe('ProofingRequestClient', () => { false ); }); + + it('allows proofing requests for submitted templates', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { + ...template, + templateStatus: 'SUBMITTED', + }, + }); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: contactDetail, + }); + + const expectedProof: ProofRequest = { + ...proofRequest, + templateType: template.templateType, + contactDetailValue: contactDetail.rawValue, + }; + + mocks.proofRequestRepository.put.mockResolvedValueOnce({ + data: expectedProof, + }); + + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); + + expect(result).toEqual({ data: expectedProof }); + expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( + { + templateId: templateId, + templateType: template.templateType, + contactDetailValue: contactDetail.rawValue, + testPatientNhsNumber: nhsNumber, + personalisation: undefined, + }, + user + ); + }); } ); }); diff --git a/lambdas/backend-client/src/__tests__/contact-detail-api-client.test.ts b/lambdas/backend-client/src/__tests__/contact-detail-api-client.test.ts index 5d6739f04..c77c52c55 100644 --- a/lambdas/backend-client/src/__tests__/contact-detail-api-client.test.ts +++ b/lambdas/backend-client/src/__tests__/contact-detail-api-client.test.ts @@ -11,6 +11,71 @@ beforeEach(() => { axiosMock = new MockAdapter(httpClient); }); +describe('list', () => { + it('should return error when failing to fetch from API', async () => { + axiosMock.onGet('/v1/contact-details').reply(400, { + statusCode: 400, + technicalMessage: 'Bad request', + details: { + message: 'Invalid filter', + }, + }); + + const response = await contactDetailApiClient.list('token', { + type: 'EMAIL', + status: 'VERIFIED', + }); + + expect(response.error).toEqual({ + errorMeta: { + code: 400, + description: 'Bad request', + details: { + message: 'Invalid filter', + }, + }, + }); + expect(response.data).toBeUndefined(); + expect(axiosMock.history.get).toHaveLength(1); + }); + + it('should return contact details and send auth headers and filters', async () => { + const data = [ + { + id: 'contact-1', + type: 'EMAIL', + rawValue: 'one@example.com', + status: 'VERIFIED', + }, + { + id: 'contact-2', + type: 'EMAIL', + rawValue: 'two@example.com', + status: 'VERIFIED', + }, + ]; + + axiosMock.onGet('/v1/contact-details').reply(200, { + data, + }); + + const response = await contactDetailApiClient.list('test-token', { + type: 'EMAIL', + status: 'VERIFIED', + }); + + expect(response.data).toEqual(data); + expect(response.error).toBeUndefined(); + + expect(axiosMock.history.get).toHaveLength(1); + expect(axiosMock.history.get[0].headers?.Authorization).toBe('test-token'); + expect(axiosMock.history.get[0].params).toEqual({ + type: 'EMAIL', + status: 'VERIFIED', + }); + }); +}); + describe('create', () => { it('creates contact detail and returns response data', async () => { const input: ContactDetailInput = { diff --git a/lambdas/backend-client/src/contact-detail-api-client.ts b/lambdas/backend-client/src/contact-detail-api-client.ts index 879d93de4..e07da4f73 100644 --- a/lambdas/backend-client/src/contact-detail-api-client.ts +++ b/lambdas/backend-client/src/contact-detail-api-client.ts @@ -1,13 +1,37 @@ import type { ContactDetailDto, ContactDetailInput, + ContactDetailSuccessList, ContactDetailSuccess, PostV1ContactDetailsData, } from 'nhs-notify-web-template-management-types'; import { catchAxiosError, httpClient } from './axios-client'; +import type { ContactDetailFilters } from './schemas'; import type { ApiResult } from './types/result'; export const contactDetailApiClient = { + async list( + token: string, + filters?: ContactDetailFilters + ): Promise>> { + const response = await catchAxiosError( + httpClient.get('/v1/contact-details', { + headers: { Authorization: token }, + params: filters, + }) + ); + + if (response.error) { + return { + error: response.error, + }; + } + + return { + data: response.data.data, + }; + }, + async create( contactDetail: ContactDetailInput, token: string diff --git a/tests/test-team/pages/email/index.ts b/tests/test-team/pages/email/index.ts index 6bf2f7970..350b6d2f4 100644 --- a/tests/test-team/pages/email/index.ts +++ b/tests/test-team/pages/email/index.ts @@ -2,6 +2,7 @@ export * from './template-mgmt-create-email-page'; export * from './template-mgmt-edit-email-page'; export * from './template-mgmt-preview-email-page'; export * from './template-mgmt-preview-submitted-email-page'; +export * from './template-mgmt-send-test-email-message-page'; export * from './template-mgmt-submit-email-page'; export * from './template-mgmt-template-submitted-email-page'; export * from './template-mgmt-test-email-message-sent-page'; diff --git a/tests/test-team/pages/email/template-mgmt-send-test-email-message-page.ts b/tests/test-team/pages/email/template-mgmt-send-test-email-message-page.ts new file mode 100644 index 000000000..03d60bb5b --- /dev/null +++ b/tests/test-team/pages/email/template-mgmt-send-test-email-message-page.ts @@ -0,0 +1,76 @@ +import { Locator, Page } from '@playwright/test'; +import { TemplateMgmtSendTestMessagePage } from '../template-mgmt-send-test-message-page'; + +export class TemplateMgmtSendTestEmailMessagePage extends TemplateMgmtSendTestMessagePage { + static readonly pathTemplate = '/send-test-email/:templateId'; + + public static readonly urlRegexp = new RegExp( + /\/templates\/send-test-email\/([\dA-Fa-f-]+)$/ + ); + + readonly testEmailForm: { + formGroup: Locator; + heading: Locator; + label: Locator; + select: Locator; + selectOptions: Locator; + inlineError: Locator; + hiddenInput: Locator; + addNewEmailAddressLink: Locator; + footer: Locator; + singleEmailMessage: Locator; + removeEmailAddressDetails: { + container: Locator; + summary: Locator; + steps: Locator; + serviceNowLink: Locator; + expand: () => Promise; + }; + }; + + constructor(page: Page) { + super(page); + + const testEmailFormGroup = page.getByTestId('testEmail-form-group'); + const emailSelect = testEmailFormGroup.locator( + 'select[id="contactDetailId"]' + ); + const removeEmailAddressDetails = page.locator('details.nhsuk-details'); + const removeEmailAddressSummary = + removeEmailAddressDetails.locator('summary'); + + this.testEmailForm = { + formGroup: testEmailFormGroup, + heading: page.locator('h2[id="contactDetailId-heading"]'), + label: testEmailFormGroup.locator('label[for="contactDetailId"]'), + select: emailSelect, + selectOptions: emailSelect.locator('option'), + inlineError: testEmailFormGroup.locator('.nhsuk-error-message'), + hiddenInput: testEmailFormGroup.locator( + 'input[type="hidden"][id="contactDetailId"]' + ), + addNewEmailAddressLink: page + .locator('[data-testid="test-email-footer"]') + .locator('a'), + footer: page.getByTestId('test-email-footer'), + singleEmailMessage: testEmailFormGroup.getByTestId( + 'single-contact-detail-message' + ), + removeEmailAddressDetails: { + container: removeEmailAddressDetails, + summary: removeEmailAddressSummary, + steps: removeEmailAddressDetails.locator('ol > li'), + serviceNowLink: removeEmailAddressDetails.getByRole('link', { + name: 'ServiceNow (opens in a new tab)', + }), + expand: async () => { + await removeEmailAddressSummary.click(); + }, + }, + }; + } + + async selectContactDetail(id: string) { + await this.testEmailForm.select.selectOption(id); + } +} diff --git a/tests/test-team/pages/email/template-mgmt-test-email-message-sent-page.ts b/tests/test-team/pages/email/template-mgmt-test-email-message-sent-page.ts index a57b8916f..2e5527baf 100644 --- a/tests/test-team/pages/email/template-mgmt-test-email-message-sent-page.ts +++ b/tests/test-team/pages/email/template-mgmt-test-email-message-sent-page.ts @@ -13,11 +13,11 @@ export class TemplateMgmtTestEmailMessageSentPage extends TemplateMgmtBasePage { .first() .locator('strong'); - public readonly backToTemplateLink = this.page.locator( - '[data-testid="back-to-template-link"]' - ); - constructor(page: Page) { super(page); } + + get proofRequestId() { + return this.getPathParametersFromCurrentPageUrl()['proofRequestId']; + } } diff --git a/tests/test-team/template-mgmt-accessibility/email-templates.accessibility.spec.ts b/tests/test-team/template-mgmt-accessibility/email-templates.accessibility.spec.ts index 3b0493f3a..c22cea163 100644 --- a/tests/test-team/template-mgmt-accessibility/email-templates.accessibility.spec.ts +++ b/tests/test-team/template-mgmt-accessibility/email-templates.accessibility.spec.ts @@ -1,14 +1,17 @@ import { randomUUID } from 'node:crypto'; import { test } from 'fixtures/accessibility-analyze'; import { TestUser, testUsers } from 'helpers/auth/cognito-auth-helper'; +import { ContactDetailHelper } from 'helpers/db/contact-details-helper'; import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; import { ProofRequestsStorageHelper } from 'helpers/db/proof-requests-storage-helper'; +import { makeVerifiedEmailContactDetail } from 'helpers/factories/contact-details-factory'; import { TemplateFactory } from 'helpers/factories/template-factory'; import { TemplateMgmtCreateEmailPage, TemplateMgmtEditEmailPage, TemplateMgmtPreviewEmailPage, TemplateMgmtPreviewSubmittedEmailPage, + TemplateMgmtSendTestEmailMessagePage, TemplateMgmtSubmitEmailPage, TemplateMgmtTemplateSubmittedEmailPage, TemplateMgmtTestEmailMessageSentPage, @@ -26,10 +29,23 @@ const templateIds = { const proofingRequestId = randomUUID(); const templateStorageHelper = new TemplateStorageHelper(); const proofRequestsStorageHelper = new ProofRequestsStorageHelper(); +const contactDetailHelper = new ContactDetailHelper(); + let userWithTemplateData: TestUser; let userWithRoutingDisabled: TestUser; let userWithDigitalProofingEnabled: TestUser; +const seedVerifiedEmailsForDigitalProofingUser = async (values: string[]) => { + await contactDetailHelper.seed( + values.map((value) => + makeVerifiedEmailContactDetail({ + owner: userWithDigitalProofingEnabled.internalUserId, + value, + }) + ) + ); +}; + test.beforeAll(async () => { const context = getTestContext(); @@ -88,6 +104,7 @@ test.afterAll(async () => { await Promise.all([ templateStorageHelper.deleteSeededTemplates(), proofRequestsStorageHelper.deleteSeeded(), + contactDetailHelper.cleanup(), ]); }); @@ -173,6 +190,7 @@ test.describe('Email templates - digital proofing', () => { test.use({ storageState: { cookies: [], origins: [] } }); test.beforeEach(async ({ page }) => { + await contactDetailHelper.cleanup(); await loginAsUser(userWithDigitalProofingEnabled, page); }); @@ -183,4 +201,58 @@ test.describe('Email templates - digital proofing', () => { proofingRequestId ) )); + + test('send test email page with single contact detail', async ({ + page, + analyze, + }) => { + await seedVerifiedEmailsForDigitalProofingUser(['single@example.nhs.uk']); + + await analyze( + new TemplateMgmtSendTestEmailMessagePage(page).setPathParam( + 'templateId', + templateIds.DRAFT_DIGITAL_PROOFING + ) + ); + }); + + test('send test email page with multiple contact details', async ({ + page, + analyze, + }) => { + await seedVerifiedEmailsForDigitalProofingUser([ + 'first@example.nhs.uk', + 'second@example.nhs.uk', + ]); + + await analyze( + new TemplateMgmtSendTestEmailMessagePage(page).setPathParam( + 'templateId', + templateIds.DRAFT_DIGITAL_PROOFING + ) + ); + }); + + test('send test email page with validation errors', async ({ + page, + analyze, + }) => { + await seedVerifiedEmailsForDigitalProofingUser([ + 'first@example.nhs.uk', + 'second@example.nhs.uk', + ]); + + await analyze( + new TemplateMgmtSendTestEmailMessagePage(page).setPathParam( + 'templateId', + templateIds.DRAFT_DIGITAL_PROOFING + ), + { + beforeAnalyze: async (p) => { + await p.clickSendTestMessageButton(); + 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..14898fd82 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 @@ -20,6 +20,7 @@ import { TemplateMgmtEditEmailPage, TemplateMgmtPreviewEmailPage, TemplateMgmtPreviewSubmittedEmailPage, + TemplateMgmtSendTestEmailMessagePage, TemplateMgmtSubmitEmailPage, TemplateMgmtTemplateSubmittedEmailPage, TemplateMgmtTestEmailMessageSentPage, @@ -124,6 +125,7 @@ const allPages: (typeof TemplateMgmtBasePage)[] = [ TemplateMgmtEditEmailPage, TemplateMgmtPreviewEmailPage, TemplateMgmtPreviewSubmittedEmailPage, + TemplateMgmtSendTestEmailMessagePage, TemplateMgmtSubmitEmailPage, TemplateMgmtTemplateSubmittedEmailPage, TemplateMgmtTestEmailMessageSentPage, diff --git a/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-preview-email-page.email-template-component.spec.ts b/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-preview-email-page.email-template-component.spec.ts index bea62dd98..644ca2668 100644 --- a/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-preview-email-page.email-template-component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-preview-email-page.email-template-component.spec.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto'; import { test, expect } from '@playwright/test'; +import { ContactDetailHelper } from '../../helpers/db/contact-details-helper'; import { TemplateStorageHelper } from '../../helpers/db/template-storage-helper'; +import { makeVerifiedEmailContactDetail } from '../../helpers/factories/contact-details-factory'; import { TemplateMgmtPreviewEmailPage } from '../../pages/email/template-mgmt-preview-email-page'; import { TemplateFactory } from '../../helpers/factories/template-factory'; import { @@ -23,6 +25,8 @@ import { loginAsUser } from 'helpers/auth/login-as-user'; let routingDisabledUser: TestUser; let digitalProofingUser: TestUser; +const contactDetailHelper = new ContactDetailHelper(); + async function createTemplates() { const context = getTestContext(); const user = await context.auth.getTestUser(testUsers.User1.userId); @@ -93,6 +97,7 @@ test.describe('Preview Email message template Page', () => { test.afterAll(async () => { await templateStorageHelper.deleteSeededTemplates(); + await contactDetailHelper.cleanup(); }); test('when user visits page, then page is loaded', async ({ @@ -348,6 +353,13 @@ test.describe('Preview Email message template Page', () => { }) => { await loginAsUser(digitalProofingUser, page); + await contactDetailHelper.seed([ + makeVerifiedEmailContactDetail({ + owner: digitalProofingUser.internalUserId, + value: 'test@example.nhs.uk', + }), + ]); + const previewPage = new TemplateMgmtPreviewEmailPage(page).setPathParam( 'templateId', templates.digitalProofing.id diff --git a/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-send-test-email-message-page.email-template-component.spec.ts b/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-send-test-email-message-page.email-template-component.spec.ts new file mode 100644 index 000000000..d0fa99c54 --- /dev/null +++ b/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-send-test-email-message-page.email-template-component.spec.ts @@ -0,0 +1,771 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { TestUser, testUsers } from 'helpers/auth/cognito-auth-helper'; +import { getTestContext } from 'helpers/context/context'; +import { loginAsUser } from 'helpers/auth/login-as-user'; +import { ContactDetailHelper } from 'helpers/db/contact-details-helper'; +import { ProofRequestsStorageHelper } from 'helpers/db/proof-requests-storage-helper'; +import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; +import { + makeVerifiedEmailContactDetail, + type FactoryContactDetail, +} from 'helpers/factories/contact-details-factory'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { + assertAndClickBackLinkTop, + assertBackLinkBottomNotPresent, + assertFooterLinks, + assertHeaderLogoLink, + assertSignOutLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { Template } from 'helpers/types'; +import { TemplateMgmtPreviewEmailPage } from 'pages/email/template-mgmt-preview-email-page'; +import { TemplateMgmtPreviewSubmittedEmailPage } from 'pages/email/template-mgmt-preview-submitted-email-page'; +import { TemplateMgmtSendTestEmailMessagePage } from 'pages/email/template-mgmt-send-test-email-message-page'; +import { TemplateMgmtTestEmailMessageSentPage } from 'pages/email/template-mgmt-test-email-message-sent-page'; + +function createTemplates( + digitalProofingUser: TestUser, + digitalProofingDisabledUser: TestUser +) { + return { + validEmailTemplate: TemplateFactory.createEmailTemplate( + digitalProofingUser, + { + id: randomUUID(), + name: 'Send test email template', + templateStatus: 'NOT_YET_SUBMITTED', + } + ), + + validEmailTemplateWithCustomFields: TemplateFactory.createEmailTemplate( + digitalProofingUser, + { + id: randomUUID(), + name: 'Send test email template with custom fields', + templateStatus: 'NOT_YET_SUBMITTED', + customPersonalisation: ['appointmentDate', 'clinicName'], + message: + 'Your appointment is on ((appointmentDate)) at ((clinicName)).', + } + ), + + submittedEmailTemplate: TemplateFactory.createEmailTemplate( + digitalProofingUser, + { + id: randomUUID(), + name: 'Submitted email template', + templateStatus: 'SUBMITTED', + } + ), + + digitalProofingDisabledEmailTemplate: TemplateFactory.createEmailTemplate( + digitalProofingDisabledUser, + { + id: randomUUID(), + name: 'Digital proofing disabled email template', + templateStatus: 'NOT_YET_SUBMITTED', + } + ), + + smsTemplate: TemplateFactory.createSmsTemplate(digitalProofingUser, { + id: randomUUID(), + }), + }; +} + +test.describe('Send test email page', () => { + let digitalProofingUser: TestUser; + let digitalProofingDisabledUser: TestUser; + + const proofRequestStorageHelper = new ProofRequestsStorageHelper(); + const templateStorageHelper = new TemplateStorageHelper(); + const contactDetailHelper = new ContactDetailHelper(); + + let templates: { + validEmailTemplate: Template; + validEmailTemplateWithCustomFields: Template; + submittedEmailTemplate: Template; + digitalProofingDisabledEmailTemplate: Template; + smsTemplate: Template; + }; + + const seedVerifiedEmails = async ( + values: string[] + ): Promise => { + const contactDetails = values.map((value) => + makeVerifiedEmailContactDetail({ + owner: digitalProofingUser.internalUserId, + value, + }) + ); + + await contactDetailHelper.seed(contactDetails); + + return contactDetails; + }; + + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeAll(async () => { + const context = getTestContext(); + + digitalProofingUser = await context.auth.getTestUser( + testUsers.UserDigitalProofingEnabled.userId + ); + digitalProofingDisabledUser = await context.auth.getTestUser( + testUsers.UserRoutingEnabled.userId + ); + + templates = createTemplates( + digitalProofingUser, + digitalProofingDisabledUser + ); + + await templateStorageHelper.seedTemplateData(Object.values(templates)); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteSeededTemplates(); + await proofRequestStorageHelper.deleteAdHoc(); + await contactDetailHelper.cleanup(); + }); + + test.describe('when digital proofing feature flag is enabled', () => { + test.beforeEach(async ({ page }) => { + await contactDetailHelper.cleanup(); + await loginAsUser(digitalProofingUser, page); + }); + + test('common page tests', async ({ page, baseURL }) => { + await seedVerifiedEmails(['test@example.nhs.uk']); + + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam('templateId', templates.validEmailTemplate.id); + + await sendTestEmailMessagePage.loadPage(); + await expect(page).toHaveURL(sendTestEmailMessagePage.getUrl()); + await expect(sendTestEmailMessagePage.pageHeading).toHaveText( + 'Send a test email' + ); + + const props = { + page: sendTestEmailMessagePage, + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: `templates/preview-email-template/${templates.validEmailTemplate.id}`, + }); + }); + + test('user is redirected to add new email address page when they have no verified email addresses', async ({ + page, + }) => { + const previewPage = new TemplateMgmtPreviewEmailPage(page).setPathParam( + 'templateId', + templates.validEmailTemplate.id + ); + + await previewPage.loadPage(); + + await previewPage.sendTestMessageButton.click(); + + await expect(page).toHaveURL( + `/templates/add-new-email-address?templateId=${templates.validEmailTemplate.id}` + ); + }); + + test('user can send a test email using a single verified email for a template without custom personalisation', async ({ + page, + baseURL, + }) => { + const singleEmailAddress = 'single@example.com'; + const [singleEmail] = await seedVerifiedEmails([singleEmailAddress]); + + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam('templateId', templates.validEmailTemplate.id); + + await sendTestEmailMessagePage.loadPage(); + + await expect(sendTestEmailMessagePage.pageHeading).toHaveText( + 'Send a test email' + ); + + await expect(sendTestEmailMessagePage.testEmailForm.heading).toHaveText( + 'Choose where to send your test email' + ); + + await test.step('shows the email address and no email dropdown', async () => { + await expect( + sendTestEmailMessagePage.testEmailForm.singleEmailMessage + ).toContainText( + `Your test email will be sent to ${singleEmailAddress}` + ); + await expect( + sendTestEmailMessagePage.testEmailForm.select + ).toBeHidden(); + await expect( + sendTestEmailMessagePage.testEmailForm.hiddenInput + ).toHaveValue(singleEmail.id); + + await expect( + sendTestEmailMessagePage.testEmailForm.addNewEmailAddressLink + ).toHaveText('add a new email address'); + await expect( + sendTestEmailMessagePage.testEmailForm.addNewEmailAddressLink + ).toHaveAttribute( + 'href', + `/templates/add-new-email-address?templateId=${templates.validEmailTemplate.id}` + ); + + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails + .container + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails + .summary + ).toContainText('How to remove an email address'); + + await sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails.expand(); + + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails + .container + ).toHaveAttribute('open', ''); + + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails.steps + ).toHaveCount(5); + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails + .serviceNowLink + ).toHaveAttribute( + 'href', + 'https://nhsdigitallive.service-now.com/csm?id=sc_cat_item&sys_id=6cc625151b9fbad083b0a7d0b24bcb11&sysparm_category=a37b549b971b3a10dd80f2df9153aff6' + ); + }); + + await test.step('shows personalisation section without custom fields', async () => { + await expect( + sendTestEmailMessagePage.personalisationHeading + ).toHaveText('Personalisation'); + await expect( + sendTestEmailMessagePage.personalisationDescription + ).toBeHidden(); + await expect(sendTestEmailMessagePage.pdsSection.heading).toHaveText( + 'PDS personalisation fields' + ); + + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.label + ).toHaveText('Example recipient'); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.input + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.input + ).toHaveValue(''); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.options.first() + ).toHaveText('Choose an example recipient'); + expect( + await sendTestEmailMessagePage.pdsSection.exampleRecipient.options.count() + ).toBeGreaterThan(1); + + await expect( + sendTestEmailMessagePage.customFieldsSection.container + ).toBeHidden(); + }); + + await test.step('select an example recipient and submit the form', async () => { + const firstExampleRecipientValue = + await sendTestEmailMessagePage.pdsSection.exampleRecipient.options + .nth(1) + .getAttribute('value'); + + await sendTestEmailMessagePage.pdsSection.exampleRecipient.select( + firstExampleRecipientValue ?? '' + ); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + }); + + await test.step('redirects to test email sent page', async () => { + await expect(page).toHaveURL( + `${baseURL}/templates/test-email-sent/${templates.validEmailTemplate.id}` + ); + }); + + const _testEmailSentPage = new TemplateMgmtTestEmailMessageSentPage(page); + + // TODO: CCM-8407 - add assertions for proof request persistence and sent-page URL when backend support is available. + }); + + test('user can choose from their list of verified emails to send a test email with custom personalisation fields', async ({ + page, + baseURL, + }) => { + const [firstEmail] = await seedVerifiedEmails([ + 'first@example.com', + 'second@example.com', + ]); + + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam( + 'templateId', + templates.validEmailTemplateWithCustomFields.id + ); + + await sendTestEmailMessagePage.loadPage(); + + await expect(sendTestEmailMessagePage.pageHeading).toHaveText( + 'Send a test email' + ); + + await expect(sendTestEmailMessagePage.testEmailForm.heading).toHaveText( + 'Choose where to send your test email' + ); + + await test.step('shows the email address dropdown and select an email', async () => { + await expect(sendTestEmailMessagePage.testEmailForm.label).toHaveText( + 'Email address' + ); + await expect( + sendTestEmailMessagePage.testEmailForm.select + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.testEmailForm.selectOptions + ).toHaveCount(3); + await expect( + sendTestEmailMessagePage.testEmailForm.selectOptions.nth(0) + ).toHaveText('Choose an email address'); + await expect( + sendTestEmailMessagePage.testEmailForm.selectOptions.nth(1) + ).toHaveText('first@example.com'); + await expect( + sendTestEmailMessagePage.testEmailForm.selectOptions.nth(2) + ).toHaveText('second@example.com'); + + await expect( + sendTestEmailMessagePage.testEmailForm.addNewEmailAddressLink + ).toHaveText('add a new email address'); + await expect( + sendTestEmailMessagePage.testEmailForm.addNewEmailAddressLink + ).toHaveAttribute( + 'href', + `/templates/add-new-email-address?templateId=${templates.validEmailTemplateWithCustomFields.id}` + ); + + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails + .container + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.testEmailForm.removeEmailAddressDetails + .summary + ).toContainText('How to remove an email address'); + + await sendTestEmailMessagePage.selectContactDetail(firstEmail.id); + await expect(sendTestEmailMessagePage.testEmailForm.select).toHaveValue( + firstEmail.id + ); + }); + + await test.step('shows the personalisation section with example recipient and custom fields', async () => { + await expect( + sendTestEmailMessagePage.personalisationHeading + ).toHaveText('Personalisation'); + await expect( + sendTestEmailMessagePage.personalisationDescription + ).toContainText( + 'Check how your personalisation fields will be displayed in your email by entering example data.' + ); + await expect(sendTestEmailMessagePage.pdsSection.heading).toHaveText( + 'PDS personalisation fields' + ); + + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.label + ).toHaveText('Example recipient'); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.input + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.input + ).toHaveValue(''); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.options.first() + ).toHaveText('Choose an example recipient'); + expect( + await sendTestEmailMessagePage.pdsSection.exampleRecipient.options.count() + ).toBeGreaterThan(1); + + await expect( + sendTestEmailMessagePage.customFieldsSection.container + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('appointmentDate') + .input + ).toBeVisible(); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('clinicName').input + ).toBeVisible(); + }); + + await test.step('select an example recipient and enter values for custom fields', async () => { + const firstExampleRecipientValue = + await sendTestEmailMessagePage.pdsSection.exampleRecipient.options + .nth(1) + .getAttribute('value'); + + await sendTestEmailMessagePage.pdsSection.exampleRecipient.select( + firstExampleRecipientValue ?? '' + ); + + await sendTestEmailMessagePage.customFieldsSection + .field('appointmentDate') + .fill('01/01/2026'); + + await sendTestEmailMessagePage.customFieldsSection + .field('clinicName') + .fill('Test Clinic'); + }); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await expect(page).toHaveURL( + `${baseURL}/templates/test-email-sent/${templates.validEmailTemplateWithCustomFields.id}` + ); + + const _testEmailSentPage = new TemplateMgmtTestEmailMessageSentPage(page); + + // TODO: CCM-8407 - add proof request persistence assertions when backend support is available. + }); + + test('user must choose an email address, an example recipient and enter values for the custom fields before sending a test message', async ({ + page, + baseURL, + }) => { + const [firstEmail] = await seedVerifiedEmails([ + 'first@example.com', + 'second@example.com', + ]); + + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam( + 'templateId', + templates.validEmailTemplateWithCustomFields.id + ); + + await sendTestEmailMessagePage.loadPage(); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await test.step('verify error summary and inline errors are displayed', async () => { + await expect(sendTestEmailMessagePage.errorSummary).toBeVisible(); + await expect(sendTestEmailMessagePage.errorSummaryLinks).toHaveCount(4); + await expect(sendTestEmailMessagePage.errorSummary).toContainText( + 'Choose an email address or add a new email address' + ); + await expect(sendTestEmailMessagePage.errorSummary).toContainText( + 'Choose an example recipient' + ); + await expect(sendTestEmailMessagePage.errorSummary).toContainText( + 'Enter example data for appointmentDate' + ); + await expect(sendTestEmailMessagePage.errorSummary).toContainText( + 'Enter example data for clinicName' + ); + await expect( + sendTestEmailMessagePage.testEmailForm.inlineError + ).toContainText('Choose an email address or add a new email address'); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.error + ).toContainText('Choose an example recipient'); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('appointmentDate') + .inlineError + ).toContainText('Enter example data for appointmentDate'); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('clinicName') + .inlineError + ).toContainText('Enter example data for clinicName'); + }); + + await test.step('resolve email address error', async () => { + const emailAddressErrorLink = + sendTestEmailMessagePage.errorSummaryLinks.filter({ + hasText: 'Choose an email address or add a new email address', + }); + + await expect(emailAddressErrorLink).toBeVisible(); + await expect(emailAddressErrorLink).toHaveAttribute( + 'href', + '#contactDetailId' + ); + + await emailAddressErrorLink.click(); + await expect( + sendTestEmailMessagePage.testEmailForm.select + ).toBeFocused(); + + await sendTestEmailMessagePage.selectContactDetail(firstEmail.id); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await expect(sendTestEmailMessagePage.errorSummary).toBeVisible(); + await expect(sendTestEmailMessagePage.errorSummaryLinks).toHaveCount(3); + await expect(sendTestEmailMessagePage.errorSummary).not.toContainText( + 'Choose an email address or add a new email address' + ); + await expect( + sendTestEmailMessagePage.testEmailForm.inlineError + ).toBeHidden(); + }); + + await test.step('resolve example recipient error', async () => { + const exampleRecipientErrorLink = + sendTestEmailMessagePage.errorSummaryLinks.filter({ + hasText: 'Choose an example recipient', + }); + + await expect(exampleRecipientErrorLink).toBeVisible(); + await expect(exampleRecipientErrorLink).toHaveAttribute( + 'href', + '#exampleRecipient' + ); + + await exampleRecipientErrorLink.click(); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.input + ).toBeFocused(); + + const firstExampleRecipientValue = + await sendTestEmailMessagePage.pdsSection.exampleRecipient.options + .nth(1) + .getAttribute('value'); + + await sendTestEmailMessagePage.pdsSection.exampleRecipient.select( + firstExampleRecipientValue ?? '' + ); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await expect(sendTestEmailMessagePage.errorSummary).toBeVisible(); + await expect(sendTestEmailMessagePage.errorSummaryLinks).toHaveCount(2); + await expect(sendTestEmailMessagePage.errorSummary).not.toContainText( + 'Choose an example recipient' + ); + await expect( + sendTestEmailMessagePage.pdsSection.exampleRecipient.error + ).toBeHidden(); + }); + + await test.step('resolve custom field errors', async () => { + const appointmentDateErrorLink = + sendTestEmailMessagePage.errorSummaryLinks.filter({ + hasText: 'Enter example data for appointmentDate', + }); + + await expect(appointmentDateErrorLink).toBeVisible(); + await expect(appointmentDateErrorLink).toHaveAttribute( + 'href', + '#custom-appointmentDate' + ); + + await appointmentDateErrorLink.click(); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('appointmentDate') + .input + ).toBeFocused(); + + await sendTestEmailMessagePage.customFieldsSection + .field('appointmentDate') + .fill('01/01/2026'); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await expect(sendTestEmailMessagePage.errorSummary).toBeVisible(); + await expect(sendTestEmailMessagePage.errorSummaryLinks).toHaveCount(1); + await expect(sendTestEmailMessagePage.errorSummary).toContainText( + 'Enter example data for clinicName' + ); + await expect(sendTestEmailMessagePage.errorSummary).not.toContainText( + 'Enter example data for appointmentDate' + ); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('appointmentDate') + .inlineError + ).toBeHidden(); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('clinicName') + .inlineError + ).toContainText('Enter example data for clinicName'); + + const clinicNameErrorLink = + sendTestEmailMessagePage.errorSummaryLinks.filter({ + hasText: 'Enter example data for clinicName', + }); + + await expect(clinicNameErrorLink).toBeVisible(); + await expect(clinicNameErrorLink).toHaveAttribute( + 'href', + '#custom-clinicName' + ); + + await clinicNameErrorLink.click(); + await expect( + sendTestEmailMessagePage.customFieldsSection.field('clinicName').input + ).toBeFocused(); + + await sendTestEmailMessagePage.customFieldsSection + .field('clinicName') + .fill('Test Clinic'); + }); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await expect(page).toHaveURL( + `${baseURL}/templates/test-email-sent/${templates.validEmailTemplateWithCustomFields.id}` + ); + }); + + test('user can request a test message for a submitted email template', async ({ + page, + baseURL, + }) => { + const submittedEmailAddress = 'submitted@example.com'; + const [singleEmail] = await seedVerifiedEmails([submittedEmailAddress]); + + const previewPage = new TemplateMgmtPreviewSubmittedEmailPage( + page + ).setPathParam('templateId', templates.submittedEmailTemplate.id); + + await previewPage.loadPage(); + + await expect(page).toHaveURL( + `${baseURL}/templates/preview-submitted-email-template/${templates.submittedEmailTemplate.id}` + ); + + await expect(previewPage.testMessageBannerLink).toBeVisible(); + await previewPage.testMessageBannerLink.click(); + + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam('templateId', templates.submittedEmailTemplate.id); + + await expect(page).toHaveURL(sendTestEmailMessagePage.getUrl()); + + await expect( + sendTestEmailMessagePage.testEmailForm.singleEmailMessage + ).toContainText( + `Your test email will be sent to ${submittedEmailAddress}` + ); + await expect( + sendTestEmailMessagePage.testEmailForm.hiddenInput + ).toHaveValue(singleEmail.id); + + const firstExampleRecipientValue = + await sendTestEmailMessagePage.pdsSection.exampleRecipient.options + .nth(1) + .getAttribute('value'); + + await sendTestEmailMessagePage.pdsSection.exampleRecipient.select( + firstExampleRecipientValue ?? '' + ); + await sendTestEmailMessagePage.clickSendTestMessageButton(); + + await expect(page).toHaveURL( + `${baseURL}/templates/test-email-sent/${templates.submittedEmailTemplate.id}` + ); + + const _emailSentPage = new TemplateMgmtTestEmailMessageSentPage(page); + + // TODO: CCM-8407 - add proof request persistence assertions when backend support is available. + }); + + test('redirects to invalid template page when template is not an email template', async ({ + page, + baseURL, + }) => { + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam('templateId', templates.smsTemplate.id); + + await sendTestEmailMessagePage.loadPage(); + + await expect(page).toHaveURL(`${baseURL}/templates/invalid-template`); + }); + + test('redirects to invalid template page when template does not exist', async ({ + page, + baseURL, + }) => { + const sendTestEmailPage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam('templateId', 'fake-template-id'); + + await sendTestEmailPage.loadPage(); + + await expect(page).toHaveURL(`${baseURL}/templates/invalid-template`); + }); + }); + + test.describe('when digital proofing feature flag is disabled', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await contactDetailHelper.cleanup(); + await loginAsUser(digitalProofingDisabledUser, page); + }); + + test('redirects to template preview page', async ({ page, baseURL }) => { + const sendTestEmailMessagePage = new TemplateMgmtSendTestEmailMessagePage( + page + ).setPathParam( + 'templateId', + templates.digitalProofingDisabledEmailTemplate.id + ); + + await sendTestEmailMessagePage.loadPage(); + + await expect(page).toHaveURL( + `${baseURL}/templates/preview-email-template/${templates.digitalProofingDisabledEmailTemplate.id}` + ); + + const previewPage = new TemplateMgmtPreviewEmailPage(page).setPathParam( + 'templateId', + templates.digitalProofingDisabledEmailTemplate.id + ); + await expect(previewPage.sendTestMessageButton).toBeHidden(); + }); + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-test-email-message-sent-page.component.spec.ts b/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-test-email-message-sent-page.component.spec.ts index 2afb75fcb..33c810bb1 100644 --- a/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-test-email-message-sent-page.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/email-template-component/template-mgmt-test-email-message-sent-page.component.spec.ts @@ -62,9 +62,9 @@ test.describe('Test Email Message Sent Page', () => { proofRequest.contactDetailValue ); await expect(page.getByText('NHS Notify - test')).toBeVisible(); - await expect(sentPage.backToTemplateLink).toBeVisible(); - await expect(sentPage.backToTemplateLink).toHaveText('Back to template'); - await expect(sentPage.backToTemplateLink).toHaveAttribute( + await expect(sentPage.backLinkBottom).toBeVisible(); + await expect(sentPage.backLinkBottom).toHaveText('Back to template'); + await expect(sentPage.backLinkBottom).toHaveAttribute( 'href', `/preview-email-template/${templateId}` ); diff --git a/tests/test-team/template-mgmt-component-tests/routing-choose-template-component/preview-email-template.routing-choose-template-component.spec.ts b/tests/test-team/template-mgmt-component-tests/routing-choose-template-component/preview-email-template.routing-choose-template-component.spec.ts index 1bf2d824b..5db832358 100644 --- a/tests/test-team/template-mgmt-component-tests/routing-choose-template-component/preview-email-template.routing-choose-template-component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/routing-choose-template-component/preview-email-template.routing-choose-template-component.spec.ts @@ -9,7 +9,9 @@ import { assertRequestProofBannerVisible, assertTestMessageBannerVisible, } from '../../helpers/template-mgmt-common.steps'; +import { ContactDetailHelper } from 'helpers/db/contact-details-helper'; import { TestUser, testUsers } from 'helpers/auth/cognito-auth-helper'; +import { makeVerifiedEmailContactDetail } from 'helpers/factories/contact-details-factory'; import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; import { randomUUID } from 'node:crypto'; import { TemplateFactory } from 'helpers/factories/template-factory'; @@ -21,6 +23,7 @@ import { loginAsUser } from 'helpers/auth/login-as-user'; const routingConfigStorageHelper = new RoutingConfigStorageHelper(); const templateStorageHelper = new TemplateStorageHelper(); +const contactDetailHelper = new ContactDetailHelper(); const invalidTemplateId = 'invalid-id'; const notFoundTemplateId = '7842c202-a31a-49d8-bdaf-276d64aec4a4'; @@ -53,6 +56,8 @@ test.describe('Routing - Preview email template page', () => { let messagePlans: ReturnType; let templates: ReturnType; + let digitalProofingEnabledUser: TestUser; + test.beforeAll(async () => { const user = await context.auth.getTestUser(testUsers.User1.userId); @@ -66,6 +71,7 @@ test.describe('Routing - Preview email template page', () => { test.afterAll(async () => { await routingConfigStorageHelper.deleteSeeded(); await templateStorageHelper.deleteSeededTemplates(); + await contactDetailHelper.cleanup(); }); test('common page tests', async ({ page, baseURL }) => { @@ -174,7 +180,7 @@ test.describe('Routing - Preview email template page', () => { test.use({ storageState: { cookies: [], origins: [] } }); test.beforeEach(async ({ page }) => { - const digitalProofingEnabledUser = await context.auth.getTestUser( + digitalProofingEnabledUser = await context.auth.getTestUser( testUsers.UserDigitalProofingEnabled.userId ); @@ -198,6 +204,13 @@ test.describe('Routing - Preview email template page', () => { page, baseURL, }) => { + await contactDetailHelper.seed([ + makeVerifiedEmailContactDetail({ + owner: digitalProofingEnabledUser.internalUserId, + value: 'test@example.nhs.uk', + }), + ]); + const previewTemplatePage = new RoutingPreviewEmailTemplatePage(page) .setPathParam('messagePlanId', digitalProofingEnabledMessagePlanId) .setPathParam('templateId', digitalProofingEnabledTemplateId) 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..2982b30be 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 @@ -55,6 +55,7 @@ import { TemplateMgmtPreviewSubmittedNhsAppPage } from '../../pages/nhs-app/temp import { TemplateMgmtPreviewSubmittedSmsPage } from '../../pages/sms/template-mgmt-preview-submitted-sms-page'; import { TemplateMgmtRequestADigitalProofPage } from 'pages/template-mgmt-request-a-digital-proof-page'; import { TemplateMgmtReviewAndApproveLetterTemplatePage } from 'pages/letter/template-mgmt-review-and-approve-letter-template-page'; +import { TemplateMgmtSendTestEmailMessagePage } from 'pages/email/template-mgmt-send-test-email-message-page'; import { TemplateMgmtSendTestNhsAppMessagePage } from 'pages/nhs-app/template-mgmt-send-test-nhs-app-message-page'; import { TemplateMgmtStartPage } from '../../pages/template-mgmt-start-page'; import { TemplateMgmtSubmitEmailPage } from '../../pages/email/template-mgmt-submit-email-page'; @@ -136,6 +137,7 @@ const protectedPages = [ TemplateMgmtPreviewSubmittedSmsPage, TemplateMgmtRequestADigitalProofPage, TemplateMgmtReviewAndApproveLetterTemplatePage, + TemplateMgmtSendTestEmailMessagePage, TemplateMgmtSendTestNhsAppMessagePage, TemplateMgmtSubmitEmailPage, TemplateMgmtSubmitLetterPage,