From 1baf6ba5005f44871f286b5a001233d157a192af Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Fri, 6 Feb 2026 17:47:24 +0000 Subject: [PATCH 1/6] CCM-14169: edit template name page --- .../__snapshots__/page.test.tsx.snap | 291 ++++++++++++ .../[templateId]/page.test.tsx | 244 ++++++++++ .../[templateId]/server-action.test.ts | 96 ++++ .../edit-message-plan-settings/page.test.tsx | 2 +- .../SmsTemplateForm/SmsTemplateForm.test.tsx | 5 +- .../__snapshots__/form-provider.test.tsx.snap | 5 +- .../providers/form-provider.test.tsx | 35 +- .../src/__tests__/utils/form-actions.test.ts | 74 +++ .../edit-template-name/[templateId]/page.tsx | 112 +++++ .../[templateId]/server-action.ts | 41 ++ .../[routingConfigId]/page.tsx | 7 +- .../create-message-plan/page.tsx | 2 + .../[routingConfigId]/page.tsx | 2 + .../page.tsx | 12 +- .../page.tsx | 12 +- .../page.tsx | 12 +- .../page.tsx | 12 +- .../atoms/NHSNotifyForm/ErrorMessage.tsx | 21 + .../atoms/NHSNotifyForm/ErrorSummary.tsx | 15 + .../components/atoms/NHSNotifyForm/Form.tsx | 19 + .../atoms/NHSNotifyForm/FormGroup.tsx | 29 ++ .../components/atoms/NHSNotifyForm/Input.tsx | 22 + .../components/atoms/NHSNotifyForm/Select.tsx | 34 ++ .../components/atoms/NHSNotifyForm/index.ts | 6 + .../NHSNotifyFormGroup/NHSNotifyFormGroup.tsx | 25 -- .../atoms/nhsuk-components/index.ts | 8 +- .../{form.tsx => index.tsx} | 91 ++-- .../TemplateNameGuidance.tsx | 2 + .../components/providers/form-provider.tsx | 7 - frontend/src/content/content.ts | 27 +- frontend/src/middleware.ts | 1 + frontend/src/utils/form-actions.ts | 27 ++ .../terraform/modules/backend-api/README.md | 1 + .../iam_role_api_gateway_execution_role.tf | 1 + .../terraform/modules/backend-api/locals.tf | 1 + .../module_patch_template_lambda.tf | 67 +++ .../modules/backend-api/spec.tmpl.json | 94 +++- lambdas/backend-api/build.sh | 1 + .../src/__tests__/api/patch-template.test.ts | 294 ++++++++++++ .../src/__tests__/app/template-client.test.ts | 266 +++++++++++ .../template-repository/repository.test.ts | 179 ++++++++ lambdas/backend-api/src/api/patch-template.ts | 39 ++ .../backend-api/src/app/template-client.ts | 62 +++ .../infra/template-repository/repository.ts | 49 +- lambdas/backend-api/src/patch-template.ts | 4 + .../src/__tests__/schemas/template.test.ts | 52 +++ .../src/__tests__/template-api-client.test.ts | 68 +++ .../backend-client/src/schemas/template.ts | 54 ++- .../backend-client/src/template-api-client.ts | 31 ++ .../src/types/generated/index.ts | 6 + .../src/types/generated/types.gen.ts | 45 ++ .../template-mgmt-edit-template-name-page.ts | 18 + .../patch-template.api.spec.ts | 425 ++++++++++++++++++ ...-mgmt-edit-template-name.component.spec.ts | 275 ++++++++++++ ...emplate-protected-routes.component.spec.ts | 2 + ...choose-templates.routing-component.spec.ts | 14 +- 56 files changed, 3162 insertions(+), 184 deletions(-) create mode 100644 frontend/src/__tests__/app/edit-template-name/[templateId]/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/edit-template-name/[templateId]/page.test.tsx create mode 100644 frontend/src/__tests__/app/edit-template-name/[templateId]/server-action.test.ts create mode 100644 frontend/src/app/edit-template-name/[templateId]/page.tsx create mode 100644 frontend/src/app/edit-template-name/[templateId]/server-action.ts create mode 100644 frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx create mode 100644 frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx create mode 100644 frontend/src/components/atoms/NHSNotifyForm/Form.tsx create mode 100644 frontend/src/components/atoms/NHSNotifyForm/FormGroup.tsx create mode 100644 frontend/src/components/atoms/NHSNotifyForm/Input.tsx create mode 100644 frontend/src/components/atoms/NHSNotifyForm/Select.tsx create mode 100644 frontend/src/components/atoms/NHSNotifyForm/index.ts delete mode 100644 frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx rename frontend/src/components/forms/UploadDocxLetterTemplateForm/{form.tsx => index.tsx} (60%) create mode 100644 infrastructure/terraform/modules/backend-api/module_patch_template_lambda.tf create mode 100644 lambdas/backend-api/src/__tests__/api/patch-template.test.ts create mode 100644 lambdas/backend-api/src/api/patch-template.ts create mode 100644 lambdas/backend-api/src/patch-template.ts create mode 100644 tests/test-team/pages/letter/template-mgmt-edit-template-name-page.ts create mode 100644 tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts create mode 100644 tests/test-team/template-mgmt-component-tests/letter/template-mgmt-edit-template-name.component.spec.ts diff --git a/frontend/src/__tests__/app/edit-template-name/[templateId]/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/edit-template-name/[templateId]/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..59d9d00a5 --- /dev/null +++ b/frontend/src/__tests__/app/edit-template-name/[templateId]/__snapshots__/page.test.tsx.snap @@ -0,0 +1,291 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`valid template matches snapshot on initial render 1`] = ` + +
+
+
+
+ + + + +
+

+ +

+
+ This will not be visible to recipients +
+
+ + + Naming your templates + + +
+

+ You should name your templates in a way that works best for your service or organisation. +

+

+ Common template names include the: +

+
    +
  • + subject or reason for the message +
  • +
  • + intended audience for the template +
  • +
  • + version number of the template +
  • +
+

+ For example, 'Covid19 2025 - over 65s - version 3' +

+
+
+ +
+
+ + + Go back + +
+
+
+
+
+
+`; + +exports[`valid template renders errors when blank form is submitted and error state is returned 1`] = ` + +
+ +
+
+
+ + + + +
+

+ +

+
+ This will not be visible to recipients +
+
+ + + Naming your templates + + +
+

+ You should name your templates in a way that works best for your service or organisation. +

+

+ Common template names include the: +

+
    +
  • + subject or reason for the message +
  • +
  • + intended audience for the template +
  • +
  • + version number of the template +
  • +
+

+ For example, 'Covid19 2025 - over 65s - version 3' +

+
+
+ + + Error: + + Enter a template name + + +
+
+ + + Go back + +
+
+
+
+
+
+`; diff --git a/frontend/src/__tests__/app/edit-template-name/[templateId]/page.test.tsx b/frontend/src/__tests__/app/edit-template-name/[templateId]/page.test.tsx new file mode 100644 index 000000000..f9ae37d8d --- /dev/null +++ b/frontend/src/__tests__/app/edit-template-name/[templateId]/page.test.tsx @@ -0,0 +1,244 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { TemplateDto } from 'nhs-notify-backend-client'; +import { fetchClient } from '@utils/server-features'; +import { getTemplate } from '@utils/form-actions'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { editTemplateName } from '@app/edit-template-name/[templateId]/server-action'; +import Page, { metadata } from '@app/edit-template-name/[templateId]/page'; + +jest.mock('next/navigation'); +jest.mock('@utils/server-features'); +jest.mock('@utils/form-actions'); +jest.mock('@app/edit-template-name/[templateId]/server-action'); +jest.mock('@utils/csrf-utils'); + +const mockTemplate: TemplateDto = { + id: 'template-123', + name: 'Original Template Name', + templateType: 'LETTER', + letterVersion: 'AUTHORING', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 5, + language: 'en', + letterType: 'x0', + sidesCount: 2, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + clientId: 'client-123', +}; + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(editTemplateName).mockResolvedValue({}); + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1'], + features: { + letterAuthoring: true, + }, + }); + jest.mocked(getTemplate).mockResolvedValue(mockTemplate); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); +}); + +test('metadata', () => { + expect(metadata).toEqual({ + title: 'Edit template name - NHS Notify', + }); +}); + +describe('template does not exist', () => { + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue(undefined); + }); + + it('redirects to invalid template page', async () => { + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + + expect(redirect).toHaveBeenCalledWith( + '/invalid-template', + RedirectType.replace + ); + }); +}); + +describe('template is not a letter', () => { + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue({ + id: 'template-123', + name: 'Email Template', + templateType: 'EMAIL', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 5, + message: 'Test message', + subject: 'Test subject', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + clientId: 'client-123', + }); + }); + + it('redirects to message templates page', async () => { + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + + expect(redirect).toHaveBeenCalledWith( + '/message-templates', + RedirectType.replace + ); + }); +}); + +describe('letter version is not AUTHORING', () => { + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue({ + id: 'template-123', + name: 'PDF Letter Template', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 5, + language: 'en', + letterType: 'x0', + letterVersion: 'PDF', + files: { + pdfTemplate: { + fileName: 'test.pdf', + currentVersion: 'v1', + virusScanStatus: 'PASSED', + }, + }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + clientId: 'client-123', + }); + }); + + it('redirects to preview letter template page', async () => { + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('client has letter authoring feature flag disabled', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1'], + features: { + letterAuthoring: false, + }, + }); + }); + + it('redirects to message templates page', async () => { + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + + expect(redirect).toHaveBeenCalledWith( + '/message-templates', + RedirectType.replace + ); + }); +}); + +describe('template has been submitted', () => { + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue({ + ...mockTemplate, + templateStatus: 'SUBMITTED', + }); + }); + + it('redirects to preview submitted letter template page', async () => { + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-submitted-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('valid template', () => { + it('matches snapshot on initial render', async () => { + expect( + render( + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + ).asFragment() + ).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render( + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + ); + + const nameInput = screen.getByLabelText('Edit template name'); + + expect(nameInput).toHaveValue('Original Template Name'); + + await user.clear(nameInput); + await user.click(nameInput); + await user.keyboard('Updated Template Name'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(editTemplateName).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(editTemplateName).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('Updated Template Name'); + expect(formData.get('templateId')).toBe('template-123'); + expect(formData.get('lockNumber')).toBe('5'); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(editTemplateName).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render( + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + ); + + const nameInput = screen.getByLabelText('Edit template name'); + await user.clear(nameInput); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(editTemplateName).toHaveBeenCalledTimes(1); + + expect( + await screen.findByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); + + it('displays the cancel link with correct href', async () => { + render( + await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + ); + + const cancelLink = screen.getByRole('link', { name: 'Go back' }); + expect(cancelLink).toHaveAttribute( + 'href', + '/preview-letter-template/template-123' + ); + }); +}); diff --git a/frontend/src/__tests__/app/edit-template-name/[templateId]/server-action.test.ts b/frontend/src/__tests__/app/edit-template-name/[templateId]/server-action.test.ts new file mode 100644 index 000000000..3f3bdac32 --- /dev/null +++ b/frontend/src/__tests__/app/edit-template-name/[templateId]/server-action.test.ts @@ -0,0 +1,96 @@ +import { redirect } from 'next/navigation'; +import { NextRedirectError } from '@testhelpers/next-redirect'; +import { patchTemplate } from '@utils/form-actions'; +import { editTemplateName } from '@app/edit-template-name/[templateId]/server-action'; + +jest.mock('next/navigation'); +jest.mocked(redirect).mockImplementation((url) => { + throw new NextRedirectError(url); +}); + +jest.mock('@utils/form-actions'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('editTemplateName', () => { + it('patches the template and redirects when all fields are valid', async () => { + const formData = new FormData(); + formData.append('name', 'Updated Template Name'); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '5'); + + await expect(editTemplateName({}, formData)).rejects.toMatchObject({ + message: 'NEXT_REDIRECT', + url: '/preview-letter-template/template-123', + }); + + expect(patchTemplate).toHaveBeenCalledWith( + 'template-123', + { name: 'Updated Template Name' }, + 5 + ); + }); + + it('returns validation error when name is empty', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '5'); + + const result = await editTemplateName({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when name is missing', async () => { + const formData = new FormData(); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '5'); + + const result = await editTemplateName({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when templateId is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Updated Template Name'); + formData.append('lockNumber', '5'); + + const result = await editTemplateName({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + templateId: [expect.any(String)], + }, + }); + }); + + it('returns validation error when lockNumber is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Updated Template Name'); + formData.append('templateId', 'template-123'); + + const result = await editTemplateName({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + lockNumber: [expect.any(String)], + }, + }); + }); +}); diff --git a/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/page.test.tsx b/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/page.test.tsx index c31bdb439..68fae2c9b 100644 --- a/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/page.test.tsx +++ b/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/page.test.tsx @@ -220,7 +220,7 @@ describe('multiple campaigns', () => { await user.click(await screen.findByTestId('name-field')); - await user.keyboard('x'.repeat(201)); + await user.paste('x'.repeat(201)); await user.selectOptions( await screen.findByTestId('campaign-id-field'), diff --git a/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx b/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx index 63d2f95ab..700eab448 100644 --- a/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx +++ b/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx @@ -125,14 +125,15 @@ describe('CreateSmsTemplate component', () => { const longMessage = 'x'.repeat(300); - await user.type(templateMessageBox, longMessage); + await user.click(templateMessageBox); + await user.paste(longMessage); const characterCount = await screen.findByTestId('character-message-count'); expect(characterCount.textContent).toContain( `${longMessage.length} characters` ); - }, 15_000); + }); test('Client-side validation triggers - valid form - no errors', () => { const container = render( diff --git a/frontend/src/__tests__/components/providers/__snapshots__/form-provider.test.tsx.snap b/frontend/src/__tests__/components/providers/__snapshots__/form-provider.test.tsx.snap index 6bd241f25..1989b89a6 100644 --- a/frontend/src/__tests__/components/providers/__snapshots__/form-provider.test.tsx.snap +++ b/frontend/src/__tests__/components/providers/__snapshots__/form-provider.test.tsx.snap @@ -1,10 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NHSNotifyFormProvider renders the child form with the error summary before children 1`] = ` +exports[`NHSNotifyFormProvider renders the child form 1`] = ` -

Page Heading

diff --git a/frontend/src/__tests__/components/providers/form-provider.test.tsx b/frontend/src/__tests__/components/providers/form-provider.test.tsx index 6ab5ba43d..2967811ec 100644 --- a/frontend/src/__tests__/components/providers/form-provider.test.tsx +++ b/frontend/src/__tests__/components/providers/form-provider.test.tsx @@ -10,23 +10,12 @@ import type { ErrorState, FormState, } from 'nhs-notify-web-template-management-utils'; -import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; import { NHSNotifyFormProvider, useNHSNotifyForm, } from '@providers/form-provider'; import { startTransition } from 'react'; -jest.mock('@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'); - -jest - .mocked(NhsNotifyErrorSummary) - .mockImplementation(() =>
); - -beforeEach(() => { - jest.mocked(NhsNotifyErrorSummary).mockClear(); -}); - function TestForm() { const [, action] = useNHSNotifyForm(); @@ -39,7 +28,7 @@ function TestForm() { } describe('NHSNotifyFormProvider', () => { - it('renders the child form with the error summary before children', async () => { + it('renders the child form', async () => { const container = render(

Page Heading

@@ -75,28 +64,6 @@ describe('NHSNotifyFormProvider', () => { expect(formData.get('name')).toEqual('Foo'); }); - it('renders the error summary with the errors returned from the server action', async () => { - const user = await userEvent.setup(); - - const errorState: ErrorState = { fieldErrors: { name: ['Name is empty'] } }; - - const serverAction = jest.fn().mockResolvedValue({ errorState }); - render( - -

Page Heading

- -
- ); - - await user.click(await screen.findByTestId('submit')); - - const lastErrorSummaryProps = jest - .mocked(NhsNotifyErrorSummary) - .mock.lastCall?.at(0); - - expect(lastErrorSummaryProps).toEqual({ errorState }); - }); - it('calls the server action with the given initial state when the form is submitted', async () => { const user = await userEvent.setup(); diff --git a/frontend/src/__tests__/utils/form-actions.test.ts b/frontend/src/__tests__/utils/form-actions.test.ts index 6d4253b1f..17f696733 100644 --- a/frontend/src/__tests__/utils/form-actions.test.ts +++ b/frontend/src/__tests__/utils/form-actions.test.ts @@ -9,6 +9,7 @@ import { import { createTemplate, saveTemplate, + patchTemplate, getTemplate, getTemplates, getForeignLanguageLetterTemplates, @@ -395,6 +396,79 @@ describe('form-actions', () => { ).rejects.toThrow('Failed to get access token'); }); + test('patchTemplate', async () => { + const responseData: TemplateDto = { + id: 'template-123', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + name: 'Updated Template Name', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + createdAt: '2025-01-13T10:19:25.579Z', + updatedAt: '2025-01-13T10:19:25.579Z', + lockNumber: 6, + sidesCount: 2, + }; + + mockedTemplateClient.patchTemplate.mockResolvedValueOnce({ + data: responseData, + }); + + const patchData = { + name: 'Updated Template Name', + }; + + const response = await patchTemplate('template-123', patchData, 5); + + expect(mockedTemplateClient.patchTemplate).toHaveBeenCalledWith( + 'template-123', + patchData, + 'token', + 5 + ); + + expect(response).toEqual(responseData); + }); + + test('patchTemplate - should throw error when saving unexpectedly fails', async () => { + mockedTemplateClient.patchTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Bad request', + }, + }, + }); + + const patchData = { + name: 'Updated Template Name', + }; + + await expect(patchTemplate('template-123', patchData, 5)).rejects.toThrow( + 'Failed to save template data' + ); + + expect(mockedTemplateClient.patchTemplate).toHaveBeenCalledWith( + 'template-123', + patchData, + 'token', + 5 + ); + }); + + test('patchTemplate - should throw error when no token', async () => { + authIdTokenServerMock.mockResolvedValueOnce({}); + + const patchData = { + name: 'Updated Template Name', + }; + + await expect(patchTemplate('template-123', patchData, 5)).rejects.toThrow( + 'Failed to get access token' + ); + }); + test('getTemplate', async () => { const responseData = { id: 'id', diff --git a/frontend/src/app/edit-template-name/[templateId]/page.tsx b/frontend/src/app/edit-template-name/[templateId]/page.tsx new file mode 100644 index 000000000..2b558f7cd --- /dev/null +++ b/frontend/src/app/edit-template-name/[templateId]/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect, RedirectType } from 'next/navigation'; +import type { TemplatePageProps } from 'nhs-notify-web-template-management-utils'; +import { HintText, Label } from '@atoms/nhsuk-components'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; +import copy from '@content/content'; +import { TemplateNameGuidance } from '@molecules/TemplateNameGuidance'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; +import { getTemplate } from '@utils/form-actions'; +import { fetchClient } from '@utils/server-features'; +import { editTemplateName } from './server-action'; + +const content = copy.pages.editTemplateNamePage; + +export const metadata: Metadata = { + title: content.pageTitle, +}; + +export default async function EditTemplateNamePage({ + params, +}: TemplatePageProps) { + const { templateId } = await params; + + const template = await getTemplate(templateId); + + const client = await fetchClient(); + + if (!template) { + return redirect('/invalid-template', RedirectType.replace); + } + + if (template.templateType !== 'LETTER') { + return redirect('/message-templates', RedirectType.replace); + } + + if (template.letterVersion !== 'AUTHORING') { + return redirect( + `/preview-letter-template/${templateId}`, + RedirectType.replace + ); + } + + if (!client?.features.letterAuthoring) { + return redirect('/message-templates', RedirectType.replace); + } + + if (template.templateStatus === 'SUBMITTED') { + return redirect( + `/preview-submitted-letter-template/${templateId}`, + RedirectType.replace + ); + } + + return ( + + + +
+
+ + + + + + {content.form.name.hint} + + + + + + + + {content.form.submit.text} + + + {content.backLink.text} + + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/edit-template-name/[templateId]/server-action.ts b/frontend/src/app/edit-template-name/[templateId]/server-action.ts new file mode 100644 index 000000000..ca26c0958 --- /dev/null +++ b/frontend/src/app/edit-template-name/[templateId]/server-action.ts @@ -0,0 +1,41 @@ +'use server'; + +import { z } from 'zod/v4'; +import { $LockNumber } from 'nhs-notify-backend-client'; +import type { FormState } from 'nhs-notify-web-template-management-utils'; +import copy from '@content/content'; +import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; +import { redirect } from 'next/navigation'; +import { patchTemplate } from '@utils/form-actions'; + +const content = copy.pages.editTemplateNamePage; + +const $FormSchema = z.object({ + name: z + .string(content.form.name.errors.empty) + .nonempty(content.form.name.errors.empty), + templateId: z.string().nonempty(), + lockNumber: $LockNumber, +}); + +export async function editTemplateName( + _: FormState, + form: FormData +): Promise { + const result = $FormSchema.safeParse(Object.fromEntries(form.entries())); + + const fields = formDataToFormStateFields(form); + + if (result.error) { + return { + errorState: z.flattenError(result.error), + fields, + }; + } + + const { name, lockNumber, templateId } = result.data; + + await patchTemplate(templateId, { name }, lockNumber); + + redirect(`/preview-letter-template/${result.data.templateId}`); +} diff --git a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx index 2f6b15550..7e6c3456b 100644 --- a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx @@ -19,6 +19,7 @@ import { SummaryListValue, Tag, } from '@atoms/nhsuk-components'; +import { NHSNotifyErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; import { MessagePlanChooseTemplatesMoveToProductionForm } from '@forms/ChooseTemplates/MovetoProduction'; @@ -77,10 +78,8 @@ export default async function ChooseTemplatesPage(props: MessagePlanPageProps) {
- + + {content.headerCaption}

{messagePlan.name} diff --git a/frontend/src/app/message-plans/create-message-plan/page.tsx b/frontend/src/app/message-plans/create-message-plan/page.tsx index 1d10be201..39d2f5a92 100644 --- a/frontend/src/app/message-plans/create-message-plan/page.tsx +++ b/frontend/src/app/message-plans/create-message-plan/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { z } from 'zod/v4'; import { MESSAGE_ORDER_OPTIONS_LIST } from 'nhs-notify-web-template-management-utils'; +import { NHSNotifyErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; @@ -48,6 +49,7 @@ export default async function CreateMessagePlanPage({ return ( +

{pageContent.pageHeading}

diff --git a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx index 1c2785e02..7629ca6e1 100644 --- a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import type { MessagePlanPageProps } from 'nhs-notify-web-template-management-utils'; +import { NHSNotifyErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; @@ -39,6 +40,7 @@ export default async function EditMessagePlanSettingsPage({ return ( +

{pageContent.pageHeading}

diff --git a/frontend/src/app/upload-british-sign-language-letter-template/page.tsx b/frontend/src/app/upload-british-sign-language-letter-template/page.tsx index ab909f5e6..8d09bce51 100644 --- a/frontend/src/app/upload-british-sign-language-letter-template/page.tsx +++ b/frontend/src/app/upload-british-sign-language-letter-template/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; -import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { NHSNotifyFormProvider } from '@providers/form-provider'; import { fetchClient } from '@utils/server-features'; @@ -39,6 +41,7 @@ export default async function UploadLargePrintLetterTemplatePage() { +

{content.heading}

@@ -46,13 +49,16 @@ export default async function UploadLargePrintLetterTemplatePage() {
- + - + + {content.submitButton.text} + +
diff --git a/frontend/src/app/upload-large-print-letter-template/page.tsx b/frontend/src/app/upload-large-print-letter-template/page.tsx index ec185aa37..6dff3c65a 100644 --- a/frontend/src/app/upload-large-print-letter-template/page.tsx +++ b/frontend/src/app/upload-large-print-letter-template/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; -import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { NHSNotifyFormProvider } from '@providers/form-provider'; import { fetchClient } from '@utils/server-features'; @@ -39,6 +41,7 @@ export default async function UploadLargePrintLetterTemplatePage() { +

{content.heading}

@@ -46,13 +49,16 @@ export default async function UploadLargePrintLetterTemplatePage() {
- + - + + {content.submitButton.text} + +
diff --git a/frontend/src/app/upload-other-language-letter-template/page.tsx b/frontend/src/app/upload-other-language-letter-template/page.tsx index e9b41b973..f376d9293 100644 --- a/frontend/src/app/upload-other-language-letter-template/page.tsx +++ b/frontend/src/app/upload-other-language-letter-template/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; -import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { NHSNotifyFormProvider } from '@providers/form-provider'; import { fetchClient } from '@utils/server-features'; @@ -39,6 +41,7 @@ export default async function UploadOtherLanguageLetterTemplatePage() { +

{content.heading}

@@ -46,14 +49,17 @@ export default async function UploadOtherLanguageLetterTemplatePage() {
- + - + + {content.submitButton.text} + +
diff --git a/frontend/src/app/upload-standard-english-letter-template/page.tsx b/frontend/src/app/upload-standard-english-letter-template/page.tsx index 425acc552..0f2bc5228 100644 --- a/frontend/src/app/upload-standard-english-letter-template/page.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import copy from '@content/content'; -import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { NHSNotifyFormProvider } from '@providers/form-provider'; import { fetchClient } from '@utils/server-features'; @@ -39,6 +41,7 @@ export default async function UploadStandardLetterTemplatePage() { +

{content.heading}

@@ -46,13 +49,16 @@ export default async function UploadStandardLetterTemplatePage() {
- + - + + {content.submitButton.text} + +
diff --git a/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx b/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx new file mode 100644 index 000000000..7531bb6b4 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx @@ -0,0 +1,21 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import { ErrorMessage } from 'nhsuk-react-components'; +import { useNHSNotifyForm } from '@providers/form-provider'; + +export function NHSNotifyErrorMessage({ + className, + htmlFor, + ...props +}: Omit, 'children'> & { + htmlFor: string; +}) { + const [state] = useNHSNotifyForm(); + + const error = state.errorState?.fieldErrors?.[htmlFor]?.join(','); + + if (error) { + return {error}; + } +} diff --git a/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx b/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx new file mode 100644 index 000000000..c8e256493 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { + NhsNotifyErrorSummary as ErrorSummary, + NhsNotifyErrorSummaryProps, +} from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; +import { useNHSNotifyForm } from '@providers/form-provider'; + +export function NHSNotifyErrorSummary( + props: Omit +) { + const [state] = useNHSNotifyForm(); + + return ; +} diff --git a/frontend/src/components/atoms/NHSNotifyForm/Form.tsx b/frontend/src/components/atoms/NHSNotifyForm/Form.tsx new file mode 100644 index 000000000..c200b11ca --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/Form.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type { HTMLProps } from 'react'; +import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; +import { useNHSNotifyForm } from '@providers/form-provider'; + +export function NHSNotifyForm({ + children, + formId, + ...props +}: Omit, 'action'> & { formId: string }) { + const [, action] = useNHSNotifyForm(); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/atoms/NHSNotifyForm/FormGroup.tsx b/frontend/src/components/atoms/NHSNotifyForm/FormGroup.tsx new file mode 100644 index 000000000..1d90308a2 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/FormGroup.tsx @@ -0,0 +1,29 @@ +'use client'; + +import type { HTMLProps } from 'react'; +import classNames from 'classnames'; +import { useNHSNotifyForm } from '@providers/form-provider'; + +export function NHSNotifyFormGroup({ + children, + className, + htmlFor, + ...props +}: HTMLProps & { htmlFor?: string }) { + const [state] = useNHSNotifyForm(); + + const error = Boolean( + htmlFor && state.errorState?.fieldErrors?.[htmlFor]?.length + ); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/atoms/NHSNotifyForm/Input.tsx b/frontend/src/components/atoms/NHSNotifyForm/Input.tsx new file mode 100644 index 000000000..b9a2df7e4 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/Input.tsx @@ -0,0 +1,22 @@ +'use client'; + +import type { HTMLProps } from 'react'; +import classNames from 'classnames'; +import { useNHSNotifyForm } from '@providers/form-provider'; + +export function NHSNotifyInput({ + className, + name, + ...props +}: Omit, 'defaultValue'>) { + const [state] = useNHSNotifyForm(); + + return ( + + ); +} diff --git a/frontend/src/components/atoms/NHSNotifyForm/Select.tsx b/frontend/src/components/atoms/NHSNotifyForm/Select.tsx new file mode 100644 index 000000000..59d9b0c97 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/Select.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { HTMLProps } from 'react'; +import classNames from 'classnames'; +import { useNHSNotifyForm } from '@providers/form-provider'; + +export function NHSNotifySelect({ + children, + className, + name, + ...props +}: Omit, 'defaultValue'>) { + const [state] = useNHSNotifyForm(); + + const error = Boolean(name && state.errorState?.fieldErrors?.[name]?.length); + + return ( + + ); +} diff --git a/frontend/src/components/atoms/NHSNotifyForm/index.ts b/frontend/src/components/atoms/NHSNotifyForm/index.ts new file mode 100644 index 000000000..5bd8601a6 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyForm/index.ts @@ -0,0 +1,6 @@ +export { NHSNotifyErrorMessage as ErrorMessage } from './ErrorMessage'; +export { NHSNotifyErrorSummary as ErrorSummary } from './ErrorSummary'; +export { NHSNotifyForm as Form } from './Form'; +export { NHSNotifyFormGroup as FormGroup } from './FormGroup'; +export { NHSNotifyInput as Input } from './Input'; +export { NHSNotifySelect as Select } from './Select'; diff --git a/frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx b/frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx deleted file mode 100644 index 93a14d431..000000000 --- a/frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import classNames from 'classnames'; -import { HTMLProps } from 'react'; - -export function NHSNotifyFormGroup({ - children, - className, - error, - ...props -}: HTMLProps & { error: boolean }) { - return ( -
- {children} -
- ); -} diff --git a/frontend/src/components/atoms/nhsuk-components/index.ts b/frontend/src/components/atoms/nhsuk-components/index.ts index b344c5cd8..806d7ad88 100644 --- a/frontend/src/components/atoms/nhsuk-components/index.ts +++ b/frontend/src/components/atoms/nhsuk-components/index.ts @@ -5,7 +5,13 @@ 'use client'; import { Details, SummaryList } from 'nhsuk-react-components'; -export { Details, SummaryList, Tag } from 'nhsuk-react-components'; +export { + Details, + HintText, + Label, + SummaryList, + Tag, +} from 'nhsuk-react-components'; export const DetailsSummary = Details.Summary; export const DetailsText = Details.Text; diff --git a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx b/frontend/src/components/forms/UploadDocxLetterTemplateForm/index.tsx similarity index 60% rename from frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx rename to frontend/src/components/forms/UploadDocxLetterTemplateForm/index.tsx index 12f05bb11..a4b1b6653 100644 --- a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx +++ b/frontend/src/components/forms/UploadDocxLetterTemplateForm/index.tsx @@ -1,14 +1,7 @@ 'use client'; -import { useState, type PropsWithChildren } from 'react'; -import classNames from 'classnames'; -import { - Button, - ErrorMessage, - HintText, - InsetText, - Label, -} from 'nhsuk-react-components'; +import { useState } from 'react'; +import { HintText, InsetText, Label } from 'nhsuk-react-components'; import { LANGUAGE_LIST } from 'nhs-notify-backend-client'; import { isLanguage, @@ -16,43 +9,26 @@ import { languageMapping, } from 'nhs-notify-web-template-management-utils'; import { FileUploadInput } from '@atoms/FileUpload/FileUpload'; -import { NHSNotifyFormGroup } from '@atoms/NHSNotifyFormGroup/NHSNotifyFormGroup'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import copy from '@content/content'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; -import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; import { TemplateNameGuidance } from '@molecules/TemplateNameGuidance'; import { useNHSNotifyForm } from '@providers/form-provider'; const content = copy.components.uploadDocxLetterTemplateForm; -export function Form({ - children, - formId, -}: PropsWithChildren<{ formId: string }>) { - const [, action] = useNHSNotifyForm(); - - return ( - - {children} - - - ); -} - export function NameField() { const [state] = useNHSNotifyForm(); - const error = state.errorState?.fieldErrors?.name?.join(','); - return ( - + {content.fields.name.hint} - {error && {error}} + - + ); } export function CampaignIdField({ campaignIds }: { campaignIds: string[] }) { - const [state] = useNHSNotifyForm(); - - const error = state.errorState?.fieldErrors?.campaignId?.join(','); - return ( - + @@ -88,49 +63,37 @@ export function CampaignIdField({ campaignIds }: { campaignIds: string[] }) { ) : ( <> {content.fields.campaignId.select.hint} - {error && {error}} - + )} - + ); } export function FileField() { - const [state] = useNHSNotifyForm(); - - const error = state.errorState?.fieldErrors?.file?.join(','); - return ( - + - {error && {error}} + - + ); } @@ -141,25 +104,21 @@ export function LanguageField() { const [selectedLanguage, setLanguage] = useState(state.fields?.language); - const error = state.errorState?.fieldErrors?.language?.join(','); - return ( <> - + {content.fields.language.hint} - {error && {error}} - - + + {isLanguage(selectedLanguage) && isRightToLeft(selectedLanguage) && ( diff --git a/frontend/src/components/molecules/TemplateNameGuidance/TemplateNameGuidance.tsx b/frontend/src/components/molecules/TemplateNameGuidance/TemplateNameGuidance.tsx index 758dc6e1d..1aa0516d9 100644 --- a/frontend/src/components/molecules/TemplateNameGuidance/TemplateNameGuidance.tsx +++ b/frontend/src/components/molecules/TemplateNameGuidance/TemplateNameGuidance.tsx @@ -1,3 +1,5 @@ +'use client'; + import type { HTMLProps } from 'react'; import type { TemplateType } from 'nhs-notify-backend-client'; import { Details } from 'nhsuk-react-components'; diff --git a/frontend/src/components/providers/form-provider.tsx b/frontend/src/components/providers/form-provider.tsx index 517e09289..591fc72e5 100644 --- a/frontend/src/components/providers/form-provider.tsx +++ b/frontend/src/components/providers/form-provider.tsx @@ -7,7 +7,6 @@ import { useContext, } from 'react'; import type { FormState } from 'nhs-notify-web-template-management-utils'; -import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; type NHSNotifyFormActionState = ReturnType< typeof useActionState @@ -27,11 +26,9 @@ export function useNHSNotifyForm() { export function NHSNotifyFormProvider({ children, - errorSummaryHint, initialState = {}, serverAction, }: PropsWithChildren<{ - errorSummaryHint?: string; initialState?: FormState; serverAction: (state: FormState, data: FormData) => Promise; }>) { @@ -42,10 +39,6 @@ export function NHSNotifyFormProvider({ return ( - {children} ); diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 73a7514e0..d85397588 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1633,9 +1633,6 @@ const uploadDocxLetterTemplateForm = { }, ] satisfies ContentBlock[], }, - submitButton: { - text: 'Upload letter template file', - }, }, errors: { name: { @@ -1696,9 +1693,32 @@ const uploadDocxLetterTemplatePage = (type: DocxTemplateType) => { }, }, ] satisfies ContentBlock[] as ContentBlock[], + submitButton: { + text: 'Upload letter template file', + }, }; }; +const editTemplateNamePage = { + pageTitle: generatePageTitle('Edit template name'), + form: { + name: { + label: 'Edit template name', + hint: 'This will not be visible to recipients', + errors: { + empty: 'Enter a template name', + }, + }, + submit: { + text: 'Save changes', + }, + }, + backLink: { + text: 'Go back', + href: (templateId: string) => `/preview-letter-template/${templateId}`, + }, +}; + const content = { global: { mainLayout }, components: { @@ -1751,6 +1771,7 @@ const content = { createMessagePlan, deleteTemplateErrorPage, editMessagePlanSettings, + editTemplateNamePage, error404, homePage, letterTemplateInvalidConfiguration, diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 67b0147f6..d609ce09f 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -14,6 +14,7 @@ const protectedPaths = [ /^\/edit-email-template\/[^/]+$/, /^\/edit-letter-template\/[^/]+$/, /^\/edit-nhs-app-template\/[^/]+$/, + /^\/edit-template-name\/[^/]+$/, /^\/edit-text-message-template\/[^/]+$/, /^\/email-template-submitted\/[^/]+$/, /^\/invalid-template$/, diff --git a/frontend/src/utils/form-actions.ts b/frontend/src/utils/form-actions.ts index 109416a18..9c4b4ea00 100644 --- a/frontend/src/utils/form-actions.ts +++ b/frontend/src/utils/form-actions.ts @@ -4,6 +4,7 @@ import { getSessionServer } from '@utils/amplify-utils'; import { $TemplateDto, CreateUpdateTemplate, + PatchTemplate, TemplateDto, } from 'nhs-notify-backend-client'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; @@ -85,6 +86,32 @@ export async function saveTemplate( return data; } +export async function patchTemplate( + templateId: string, + template: PatchTemplate, + lockNumber: number +): Promise { + const { accessToken } = await getSessionServer(); + + if (!accessToken) { + throw new Error('Failed to get access token'); + } + + const { data, error } = await templateApiClient.patchTemplate( + templateId, + template, + accessToken, + lockNumber + ); + + if (error) { + logger.error('Failed to save template', error); + throw new Error('Failed to save template data'); + } + + return data; +} + export async function setTemplateToSubmitted( templateId: string, lockNumber: number diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 1e853ccbd..1f4dd9a9d 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -57,6 +57,7 @@ No requirements. | [lambda\_validate\_letter\_template\_files](#module\_lambda\_validate\_letter\_template\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [list\_routing\_configs\_lambda](#module\_list\_routing\_configs\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [list\_template\_lambda](#module\_list\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [patch\_template\_lambda](#module\_patch\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [request\_proof\_lambda](#module\_request\_proof\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [s3bucket\_download](#module\_s3bucket\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | | [s3bucket\_internal](#module\_s3bucket\_internal) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf index 174b3b7c0..6e922395f 100644 --- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf @@ -61,6 +61,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.get_template_lambda.function_arn, module.list_routing_configs_lambda.function_arn, module.list_template_lambda.function_arn, + module.patch_template_lambda.function_arn, module.request_proof_lambda.function_arn, module.submit_template_lambda.function_arn, module.submit_routing_config_lambda.function_arn, diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index eb1749107..aab36c950 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -25,6 +25,7 @@ locals { GET_ROUTING_CONFIGS_BY_TEMPLATE_ID_LAMBDA_ARN = module.get_routing_configs_by_template_id_lambda.function_arn LIST_LAMBDA_ARN = module.list_template_lambda.function_arn LIST_ROUTING_CONFIGS_LAMBDA_ARN = module.list_routing_configs_lambda.function_arn + PATCH_TEMPLATE_LAMBDA_ARN = module.patch_template_lambda.function_arn REQUEST_PROOF_LAMBDA_ARN = module.request_proof_lambda.function_arn SUBMIT_LAMBDA_ARN = module.submit_template_lambda.function_arn SUBMIT_ROUTING_CONFIG_LAMBDA_ARN = module.submit_routing_config_lambda.function_arn diff --git a/infrastructure/terraform/modules/backend-api/module_patch_template_lambda.tf b/infrastructure/terraform/modules/backend-api/module_patch_template_lambda.tf new file mode 100644 index 000000000..59c2a8920 --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_patch_template_lambda.tf @@ -0,0 +1,67 @@ +module "patch_template_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" + + project = var.project + environment = var.environment + component = var.component + aws_account_id = var.aws_account_id + region = var.region + + kms_key_arn = var.kms_key_arn + + function_name = "patch-template" + + function_module_name = "patch-template" + handler_function_name = "handler" + description = "Patch template API endpoint" + + memory = 2048 + timeout = 20 + runtime = "nodejs22.x" + + log_retention_in_days = var.log_retention_in_days + iam_policy_document = { + body = data.aws_iam_policy_document.patch_template_lambda_policy.json + } + + lambda_env_vars = local.backend_lambda_environment_variables + function_s3_bucket = var.function_s3_bucket + function_code_base_path = local.lambdas_dir + function_code_dir = "backend-api/dist/patch-template" + + send_to_firehose = var.send_to_firehose + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn +} + +data "aws_iam_policy_document" "patch_template_lambda_policy" { + statement { + sid = "AllowDynamoAccess" + effect = "Allow" + + actions = [ + "dynamodb:UpdateItem", + ] + + resources = [ + aws_dynamodb_table.templates.arn, + ] + } + + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 504c2b3ac..31f1b02ec 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -634,6 +634,15 @@ ], "type": "object" }, + "PatchTemplate": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + }, "PdfLetterFiles": { "properties": { "pdfTemplate": { @@ -1866,8 +1875,91 @@ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LAMBDA_ARN}/invocations" } }, + "patch": { + "description": "Partial update of a template by Id", + "parameters": [ + { + "description": "ID of template to update", + "in": "path", + "name": "templateId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Lock number of the current version of the template", + "in": "header", + "name": "X-Lock-Number", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchTemplate" + } + } + }, + "description": "Updates to apply", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateSuccess" + } + } + }, + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Update a template", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_TEMPLATE_LAMBDA_ARN}/invocations" + } + }, "put": { - "description": "Update a template template by Id", + "description": "Update a template by Id", "parameters": [ { "description": "ID of template to update", diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index fac8d30df..73b3d546a 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -27,6 +27,7 @@ npx esbuild \ src/get.ts \ src/list-routing-configs.ts \ src/list.ts \ + src/patch-template.ts \ src/process-proof.ts \ src/proof.ts \ src/set-letter-upload-virus-scan-status.ts \ diff --git a/lambdas/backend-api/src/__tests__/api/patch-template.test.ts b/lambdas/backend-api/src/__tests__/api/patch-template.test.ts new file mode 100644 index 000000000..59fadbc05 --- /dev/null +++ b/lambdas/backend-api/src/__tests__/api/patch-template.test.ts @@ -0,0 +1,294 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import { TemplateDto } from 'nhs-notify-backend-client'; +import { createHandler } from '../../api/patch-template'; +import { TemplateClient } from '../../app/template-client'; + +const setup = () => { + const templateClient = mock(); + + const handler = createHandler({ templateClient }); + + return { handler, mocks: { templateClient } }; +}; + +describe('Template API - Patch', () => { + beforeEach(jest.resetAllMocks); + + test.each([ + ['undefined', undefined], + ['missing user', { clientId: 'client-id', internalUserId: undefined }], + ['missing client', { clientId: undefined, internalUserId: 'user-1234' }], + ])( + 'should return 400 - Invalid request when requestContext is %s', + async (_, ctx) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + pathParameters: { templateId: 'id' }, + body: JSON.stringify({ name: 'Updated Name' }), + headers: { + 'X-Lock-Number': '5', + }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.templateClient.patchTemplate).not.toHaveBeenCalled(); + } + ); + + test('should return 400 - Invalid request when no user in requestContext', async () => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: undefined }, + body: JSON.stringify({ name: 'Updated Name' }), + pathParameters: { templateId: '1-2-3' }, + headers: { + 'X-Lock-Number': '5', + }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.templateClient.patchTemplate).not.toHaveBeenCalled(); + }); + + test('should return 400 - Invalid request when no body', async () => { + const { handler, mocks } = setup(); + + mocks.templateClient.patchTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Validation failed', + }, + }, + data: undefined, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { templateId: '1-2-3' }, + body: undefined, + headers: { + 'X-Lock-Number': '5', + }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Validation failed', + }), + }); + + expect(mocks.templateClient.patchTemplate).toHaveBeenCalledWith( + '1-2-3', + {}, + { internalUserId: 'user-1234', clientId: 'nhs-notify-client-id' }, + '5' + ); + }); + + test('should return 400 - Invalid request when no templateId', async () => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + body: JSON.stringify({ name: 'Updated Name' }), + pathParameters: { templateId: undefined }, + headers: { + 'X-Lock-Number': '5', + }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.templateClient.patchTemplate).not.toHaveBeenCalled(); + }); + + test('should return error when patching template fails', async () => { + const { handler, mocks } = setup(); + + mocks.templateClient.patchTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + body: JSON.stringify({ name: 'Updated Name' }), + pathParameters: { templateId: '1-2-3' }, + headers: { + 'X-Lock-Number': '5', + }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.templateClient.patchTemplate).toHaveBeenCalledWith( + '1-2-3', + { name: 'Updated Name' }, + { internalUserId: 'user-1234', clientId: 'nhs-notify-client-id' }, + '5' + ); + }); + + test('should return patched template', async () => { + const { handler, mocks } = setup(); + + const updates = { + name: 'Updated Template Name', + }; + const response: TemplateDto = { + id: '1-2-3', + name: 'Updated Template Name', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + sidesCount: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lockNumber: 6, + }; + + mocks.templateClient.patchTemplate.mockResolvedValueOnce({ + data: response, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + body: JSON.stringify(updates), + pathParameters: { templateId: '1-2-3' }, + headers: { + 'X-Lock-Number': '5', + }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, data: response }), + }); + + expect(mocks.templateClient.patchTemplate).toHaveBeenCalledWith( + '1-2-3', + updates, + { internalUserId: 'user-1234', clientId: 'nhs-notify-client-id' }, + '5' + ); + }); + + test('coerces lock number header to empty string if missing', async () => { + const { handler, mocks } = setup(); + + const updates = { + name: 'Updated Name', + }; + + mocks.templateClient.patchTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 409, + description: + 'Lock number mismatch - Template has been modified since last read', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + body: JSON.stringify(updates), + pathParameters: { templateId: '1-2-3' }, + headers: {}, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 409, + body: JSON.stringify({ + statusCode: 409, + technicalMessage: + 'Lock number mismatch - Template has been modified since last read', + }), + }); + + expect(mocks.templateClient.patchTemplate).toHaveBeenCalledWith( + '1-2-3', + updates, + { internalUserId: 'user-1234', clientId: 'nhs-notify-client-id' }, + '' + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/app/template-client.test.ts b/lambdas/backend-api/src/__tests__/app/template-client.test.ts index 9d6052d65..d1a4c5a31 100644 --- a/lambdas/backend-api/src/__tests__/app/template-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/template-client.test.ts @@ -1538,6 +1538,272 @@ describe('templateClient', () => { }); }); + describe('patchTemplate', () => { + test('should return patched template', async () => { + const { templateClient, mocks } = setup(); + + const updates = { + name: 'Updated Template Name', + }; + + const template: TemplateDto = { + id: templateId, + name: 'Updated Template Name', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + sidesCount: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lockNumber: 6, + }; + + mocks.templateRepository.patch.mockResolvedValueOnce({ + data: { ...template, owner: `CLIENT#${user.clientId}`, version: 1 }, + }); + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + 5 + ); + + expect(mocks.templateRepository.patch).toHaveBeenCalledWith( + templateId, + updates, + user, + 5 + ); + + expect(result).toEqual({ + data: template, + }); + }); + + test('should return a failure result, when patch data is invalid', async () => { + const { templateClient } = setup(); + + const updates = { + name: '', + }; + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + 5 + ); + + expect(result).toEqual({ + error: expect.objectContaining({ + errorMeta: expect.objectContaining({ + code: 400, + description: 'Request failed validation', + }), + }), + }); + }); + + test('should return a failure result when no fields are provided', async () => { + const { templateClient } = setup(); + + const updates = {}; + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + 5 + ); + + expect(result).toEqual({ + error: expect.objectContaining({ + errorMeta: expect.objectContaining({ + code: 400, + description: 'Request failed validation', + }), + }), + }); + }); + + describe('lock number parsing', () => { + const errorCases: [string, string | number][] = [ + ['empty', ''], + ['negative', -1], + ['negative stringified', -1], + ['non-number string', 'a'], + ['NaN', Number.NaN], + ['NaN stringified', 'NaN'], + ]; + test.each(errorCases)( + 'should return a failure result when lockNumber is invalid: %s', + async (_, lockNumber) => { + const { templateClient } = setup(); + + const updates = { + name: 'Updated Name', + }; + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + lockNumber + ); + + expect(result).toEqual({ + error: expect.objectContaining({ + errorMeta: { + code: 400, + description: 'Invalid lock number provided', + }, + }), + }); + } + ); + + test('coerces stringified lock number to number', async () => { + const { templateClient, mocks } = setup(); + + const updates = { + name: 'Updated Name', + }; + + const template: TemplateDto = { + id: templateId, + name: 'Updated Name', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + sidesCount: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lockNumber: 11, + }; + + mocks.templateRepository.patch.mockResolvedValueOnce({ + data: { ...template, owner: `CLIENT#${user.clientId}`, version: 1 }, + }); + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + '10' + ); + + expect(result).toEqual({ data: template }); + + expect(mocks.templateRepository.patch).toHaveBeenCalledWith( + templateId, + updates, + user, + 10 + ); + }); + }); + + test('should return a failure result when saving to the database unexpectedly fails', async () => { + const { templateClient, mocks } = setup(); + + const updates = { + name: 'Updated Name', + }; + + mocks.templateRepository.patch.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + 5 + ); + + expect(mocks.templateRepository.patch).toHaveBeenCalledWith( + templateId, + updates, + user, + 5 + ); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + }); + + test('should return a failure result when patched database template is invalid', async () => { + const { templateClient, mocks } = setup(); + + const updates = { + name: 'Updated Name', + }; + + const expectedTemplateDto: TemplateDto = { + id: templateId, + name: 'Updated Name', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + sidesCount: 1, + createdAt: undefined as unknown as string, + updatedAt: new Date().toISOString(), + lockNumber: 6, + }; + + const template: DatabaseTemplate = { + ...expectedTemplateDto, + owner: `CLIENT#${user.clientId}`, + version: 1, + }; + + mocks.templateRepository.patch.mockResolvedValueOnce({ + data: template, + }); + + const result = await templateClient.patchTemplate( + templateId, + updates, + user, + 5 + ); + + expect(mocks.templateRepository.patch).toHaveBeenCalledWith( + templateId, + updates, + user, + 5 + ); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 500, + description: 'Error retrieving template', + }, + }, + }); + }); + }); + describe('getTemplate', () => { test('should return a failure result, when fetching from the database unexpectedly fails', async () => { const { templateClient, mocks } = setup(); diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts index 005286649..5b5df2249 100644 --- a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts @@ -562,6 +562,185 @@ describe('templateRepository', () => { }); }); + describe('patch', () => { + test('should correctly patch template and return updated template', async () => { + const { templateRepository, mocks } = setup(); + + const updated: DatabaseTemplate = { + id: 'abc-def-ghi-jkl-123', + owner: ownerWithClientPrefix, + clientId: clientId, + version: 1, + name: 'Updated Template Name', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + createdAt: '2024-12-27T00:00:00.000Z', + updatedAt: '2024-12-27T00:00:00.000Z', + createdBy: `INTERNAL_USER#${internalUserId}`, + updatedBy: `INTERNAL_USER#${internalUserId}`, + lockNumber: 6, + }; + + mocks.ddbDocClient + .on(UpdateCommand, { + TableName: templatesTableName, + Key: { id: 'abc-def-ghi-jkl-123', owner: ownerWithClientPrefix }, + }) + .resolves({ + Attributes: updated, + }); + + const response = await templateRepository.patch( + 'abc-def-ghi-jkl-123', + { name: 'Updated Template Name' }, + user, + 5 + ); + + expect(mocks.ddbDocClient).toHaveReceivedCommandWith(UpdateCommand, { + TableName: 'templates', + Key: { id: 'abc-def-ghi-jkl-123', owner: 'CLIENT#client-id' }, + ReturnValues: 'ALL_NEW', + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + ExpressionAttributeNames: { + '#id': 'id', + '#lockNumber': 'lockNumber', + '#name': 'name', + '#templateStatus': 'templateStatus', + '#updatedAt': 'updatedAt', + '#updatedBy': 'updatedBy', + }, + ExpressionAttributeValues: { + ':condition_2_1_templateStatus': 'DELETED', + ':condition_2_2_templateStatus': 'SUBMITTED', + ':condition_3_1_lockNumber': 5, + ':lockNumber': 1, + ':name': 'Updated Template Name', + ':updatedAt': '2024-12-27T00:00:00.000Z', + ':updatedBy': `INTERNAL_USER#${internalUserId}`, + }, + UpdateExpression: + 'SET #name = :name, #updatedAt = :updatedAt, #updatedBy = :updatedBy ADD #lockNumber :lockNumber', + ConditionExpression: + 'attribute_exists (#id) AND NOT #templateStatus IN (:condition_2_1_templateStatus, :condition_2_2_templateStatus) AND (#lockNumber = :condition_3_1_lockNumber OR attribute_not_exists (#lockNumber))', + }); + + expect(response).toEqual({ + data: updated, + }); + }); + + describe('ConditionalCheckException handling', () => { + const cases = [ + { + testName: 'when no item is returned in the error', + errorCode: 404, + errorMeta: { + description: 'Template not found', + }, + }, + { + testName: 'when templateStatus is already SUBMITTED', + Item: { + templateType: { S: 'LETTER' }, + templateStatus: { S: 'SUBMITTED' }, + lockNumber: { N: '5' }, + }, + errorCode: 400, + errorMeta: { + description: 'Template with status SUBMITTED cannot be updated', + }, + }, + { + testName: 'when templateStatus is already DELETED', + Item: { + templateType: { S: 'LETTER' }, + templateStatus: { S: 'DELETED' }, + lockNumber: { N: '5' }, + }, + errorCode: 404, + errorMeta: { + description: 'Template not found', + }, + }, + { + testName: + 'when lockNumber in database does not match the user-provided value', + Item: { + templateType: { S: 'LETTER' }, + templateStatus: { S: 'NOT_YET_SUBMITTED' }, + lockNumber: { N: '7' }, + }, + errorCode: 409, + errorMeta: { + description: + 'Lock number mismatch - Template has been modified since last read', + }, + }, + ]; + + test.each(cases)( + 'should return $errorCode error when ConditionalCheckFailedException occurs: $testName', + async ({ Item, errorCode, errorMeta }) => { + const { templateRepository, mocks } = setup(); + + const error = new ConditionalCheckFailedException({ + message: 'mock', + $metadata: {}, + Item, + }); + + mocks.ddbDocClient.on(UpdateCommand).rejects(error); + + const response = await templateRepository.patch( + 'abc-def-ghi-jkl-123', + { name: 'Updated Name' }, + user, + 5 + ); + + expect(response).toEqual({ + error: { + actualError: error, + errorMeta: { + code: errorCode, + ...errorMeta, + }, + }, + }); + } + ); + }); + + test('should return error when an unexpected error occurs', async () => { + const { templateRepository, mocks } = setup(); + + const error = new Error('mocked'); + + mocks.ddbDocClient.on(UpdateCommand).rejects(error); + + const response = await templateRepository.patch( + 'abc-def-ghi-jkl-123', + { name: 'Updated Name' }, + user, + 5 + ); + + expect(response).toEqual({ + error: { + actualError: error, + errorMeta: { + code: 500, + description: 'Failed to update template', + }, + }, + }); + }); + }); + describe('submit', () => { test.each([ { diff --git a/lambdas/backend-api/src/api/patch-template.ts b/lambdas/backend-api/src/api/patch-template.ts new file mode 100644 index 000000000..0a3168a26 --- /dev/null +++ b/lambdas/backend-api/src/api/patch-template.ts @@ -0,0 +1,39 @@ +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { apiFailure, apiSuccess } from '@backend-api/api/responses'; +import type { TemplateClient } from '@backend-api/app/template-client'; +import { toHeaders } from '@backend-api/utils/headers'; + +export function createHandler({ + templateClient, +}: { + templateClient: TemplateClient; +}): APIGatewayProxyHandler { + return async function (event) { + const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; + + const templateId = event.pathParameters?.templateId; + + const updates = JSON.parse(event.body || '{}'); + + if (!internalUserId || !templateId || !clientId) { + return apiFailure(400, 'Invalid request'); + } + + const { data, error } = await templateClient.patchTemplate( + templateId, + updates, + { internalUserId, clientId }, + toHeaders(event.headers).get('X-Lock-Number') ?? '' + ); + + if (error) { + return apiFailure( + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details + ); + } + + return apiSuccess(200, data); + }; +} diff --git a/lambdas/backend-api/src/app/template-client.ts b/lambdas/backend-api/src/app/template-client.ts index d63d50a1a..ddf89bbb1 100644 --- a/lambdas/backend-api/src/app/template-client.ts +++ b/lambdas/backend-api/src/app/template-client.ts @@ -12,6 +12,8 @@ import { $LockNumber, $TemplateDto, $TemplateFilter, + PatchTemplate, + $PatchTemplate, } from 'nhs-notify-backend-client'; import { LETTER_MULTIPART } from 'nhs-notify-backend-client/src/schemas/constants'; import { @@ -321,6 +323,66 @@ export class TemplateClient { return success(templateDTO); } + async patchTemplate( + templateId: string, + updates: PatchTemplate, + user: User, + lockNumber: number | string + ): Promise> { + const log = this.logger.child({ + templateId, + updates, + user, + }); + + const validationResult = await validate($PatchTemplate, updates); + + if (validationResult.error) { + log + .child(validationResult.error.errorMeta) + .error('Invalid template updates', validationResult.error.actualError); + + return validationResult; + } + + const lockNumberValidation = $LockNumber.safeParse(lockNumber); + + if (!lockNumberValidation.success) { + log.error( + 'Lock number failed validation', + z.treeifyError(lockNumberValidation.error) + ); + + return failure( + ErrorCase.VALIDATION_FAILED, + 'Invalid lock number provided' + ); + } + + const patchResult = await this.templateRepository.patch( + templateId, + validationResult.data, + user, + lockNumberValidation.data + ); + + if (patchResult.error) { + log + .child(patchResult.error.errorMeta) + .error('Failed to update template', patchResult.error.actualError); + + return patchResult; + } + + const templateDTO = this.mapDatabaseObjectToDTO(patchResult.data); + + if (!templateDTO) { + return failure(ErrorCase.INTERNAL, 'Error retrieving template'); + } + + return success(templateDTO); + } + async submitTemplate( templateId: string, user: User, diff --git a/lambdas/backend-api/src/infra/template-repository/repository.ts b/lambdas/backend-api/src/infra/template-repository/repository.ts index 3e579b3c3..9c4b9f977 100644 --- a/lambdas/backend-api/src/infra/template-repository/repository.ts +++ b/lambdas/backend-api/src/infra/template-repository/repository.ts @@ -16,6 +16,8 @@ import { VirusScanStatus, ProofFileDetails, CreateUpdateTemplate, + PatchTemplate, + TemplateDto, } from 'nhs-notify-backend-client'; import { TemplateUpdateBuilder } from 'nhs-notify-entity-update-command-builder'; import type { @@ -154,6 +156,44 @@ export class TemplateRepository { } } + async patch( + templateId: string, + updates: PatchTemplate, + user: User, + lockNumber: number + ): Promise> { + const update = new TemplateUpdateBuilder( + this.templatesTableName, + user.clientId, + templateId, + { + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + ReturnValues: 'ALL_NEW', + } + ); + + if (updates.name) { + update.setName(updates.name); + } + + update + .expectTemplateExists() + .expectNotFinalStatus() + .expectLockNumber(lockNumber) + .setUpdatedByUserAt(this.internalUserKey(user)) + .incrementLockNumber(); + + try { + const response = await this.client.send( + new UpdateCommand(update.build()) + ); + + return success(response.Attributes as DatabaseTemplate); + } catch (error) { + return this._handleUpdateError(error, updates, lockNumber); + } + } + async delete( templateId: string, user: User, @@ -770,7 +810,7 @@ export class TemplateRepository { private _handleUpdateError( error: unknown, - template: Exclude, + updates: Partial, lockNumber: number ) { if (error instanceof ConditionalCheckFailedException) { @@ -788,13 +828,16 @@ export class TemplateRepository { ); } - if (oldItem.templateType !== template.templateType) { + if ( + updates.templateType && + oldItem.templateType !== updates.templateType + ) { return failure( ErrorCase.CANNOT_CHANGE_TEMPLATE_TYPE, 'Can not change template templateType', error, { - templateType: `Expected ${oldItem.templateType} but got ${template.templateType}`, + templateType: `Expected ${oldItem.templateType} but got ${updates.templateType}`, } ); } diff --git a/lambdas/backend-api/src/patch-template.ts b/lambdas/backend-api/src/patch-template.ts new file mode 100644 index 000000000..b40f636a6 --- /dev/null +++ b/lambdas/backend-api/src/patch-template.ts @@ -0,0 +1,4 @@ +import { createHandler } from './api/patch-template'; +import { templatesContainer } from './container/templates'; + +export const handler = createHandler(templatesContainer()); diff --git a/lambdas/backend-client/src/__tests__/schemas/template.test.ts b/lambdas/backend-client/src/__tests__/schemas/template.test.ts index 97d2a60a4..b5a68e2b4 100644 --- a/lambdas/backend-client/src/__tests__/schemas/template.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/template.test.ts @@ -4,6 +4,7 @@ import { $CreateUpdateNonLetter, $CreateUpdateTemplate, $LetterProperties, + $PatchTemplate, $PdfLetterProperties, $TemplateDto, $TemplateFilter, @@ -593,4 +594,55 @@ describe('Template schemas', () => { }); }); }); + + describe('$PatchTemplate', () => { + it('should pass validation when name is provided', () => { + const result = $PatchTemplate.safeParse({ + name: 'Updated Template Name', + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + name: 'Updated Template Name', + }); + }); + + it('should fail validation when name is empty', () => { + const result = $PatchTemplate.safeParse({ + name: '', + }); + + expect(result.error?.flatten()).toEqual( + expect.objectContaining({ + fieldErrors: { + name: ['Too small: expected string to have >=1 characters'], + }, + }) + ); + }); + + it('should fail validation when name is whitespace only', () => { + const result = $PatchTemplate.safeParse({ + name: ' ', + }); + + expect(result.error?.flatten()).toEqual( + expect.objectContaining({ + fieldErrors: { + name: ['Too small: expected string to have >=1 characters'], + }, + }) + ); + }); + + it('should fail validation when no fields are provided', () => { + const result = $PatchTemplate.safeParse({}); + + expect(result.error?.flatten()).toEqual( + expect.objectContaining({ + formErrors: expect.arrayContaining([expect.any(String)]), + }) + ); + }); + }); }); diff --git a/lambdas/backend-client/src/__tests__/template-api-client.test.ts b/lambdas/backend-client/src/__tests__/template-api-client.test.ts index 5782f7650..cd913d476 100644 --- a/lambdas/backend-client/src/__tests__/template-api-client.test.ts +++ b/lambdas/backend-client/src/__tests__/template-api-client.test.ts @@ -243,6 +243,74 @@ describe('TemplateAPIClient', () => { expect(headers ? headers['X-Lock-Number'] : null).toEqual('1'); }); + test('patchTemplate - should return error', async () => { + axiosMock.onPatch('/v1/template/real-id').reply(400, { + statusCode: 400, + technicalMessage: 'Bad request', + details: { + message: 'Invalid patch data', + }, + }); + + const result = await client.patchTemplate( + 'real-id', + { + name: 'Updated Name', + }, + testToken, + 5 + ); + + expect(result.error).toEqual({ + errorMeta: { + code: 400, + description: 'Bad request', + details: { + message: 'Invalid patch data', + }, + }, + }); + + expect(result.data).toBeUndefined(); + + expect(axiosMock.history.patch.length).toBe(1); + }); + + test('patchTemplate - should return template', async () => { + const data = { + id: 'real-id', + name: 'Updated Template Name', + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + letterType: 'x1', + language: 'en', + letterVersion: 'AUTHORING', + lockNumber: 6, + }; + + axiosMock.onPatch('/v1/template/real-id').reply(200, { + statusCode: 200, + data, + }); + + const result = await client.patchTemplate( + 'real-id', + { + name: 'Updated Template Name', + }, + testToken, + 5 + ); + + expect(result.data).toEqual(data); + + expect(result.error).toBeUndefined(); + + const headers = axiosMock.history.at(0)?.headers; + + expect(headers ? headers['X-Lock-Number'] : null).toEqual('5'); + }); + test('getTemplate - should return error', async () => { axiosMock.onGet('/v1/template/real-id').reply(404, { statusCode: 404, diff --git a/lambdas/backend-client/src/schemas/template.ts b/lambdas/backend-client/src/schemas/template.ts index e58f4d378..c852b4612 100644 --- a/lambdas/backend-client/src/schemas/template.ts +++ b/lambdas/backend-client/src/schemas/template.ts @@ -1,23 +1,24 @@ import { z } from 'zod/v4'; -import type { - AuthoringLetterProperties, - BaseCreatedTemplate, - BaseTemplate, - CreatePdfLetterProperties, - CreateUpdateTemplate, - EmailProperties, - Language, - LetterType, - NhsAppProperties, - PdfLetterFiles, - PdfLetterProperties, - ProofFileDetails, - SmsProperties, - TemplateDto, - TemplateStatus, - TemplateStatusActive, - TemplateType, - VersionedFileDetails, +import { + PatchTemplate, + type AuthoringLetterProperties, + type BaseCreatedTemplate, + type BaseTemplate, + type CreatePdfLetterProperties, + type CreateUpdateTemplate, + type EmailProperties, + type Language, + type LetterType, + type NhsAppProperties, + type PdfLetterFiles, + type PdfLetterProperties, + type ProofFileDetails, + type SmsProperties, + type TemplateDto, + type TemplateStatus, + type TemplateStatusActive, + type TemplateType, + type VersionedFileDetails, } from '../types/generated'; import { MAX_EMAIL_CHARACTER_LENGTH, @@ -134,9 +135,11 @@ export const $LetterProperties = z.discriminatedUnion('letterVersion', [ $AuthoringLetterProperties, ]); +const $TemplateName = z.string().trim().min(1); + export const $BaseTemplateSchema = schemaFor()( z.object({ - name: z.string().trim().min(1), + name: $TemplateName, templateType: z.enum(TEMPLATE_TYPE_LIST), }) ); @@ -160,6 +163,17 @@ export const $CreateUpdateTemplate = schemaFor()( ]) ); +export const $PatchTemplate = schemaFor()( + z + .object({ + name: $TemplateName.optional(), + }) + .refine( + (data) => Object.values(data).some((value) => value !== undefined), + { error: 'Unexpected empty object' } + ) +); + export const $LockNumber = z.coerce .string() .trim() diff --git a/lambdas/backend-client/src/template-api-client.ts b/lambdas/backend-client/src/template-api-client.ts index a50dd1383..052da2627 100644 --- a/lambdas/backend-client/src/template-api-client.ts +++ b/lambdas/backend-client/src/template-api-client.ts @@ -3,6 +3,7 @@ import { TemplateSuccess, TemplateSuccessList, TemplateDto, + PatchTemplate, } from './types/generated'; import { Result } from './types/result'; import { catchAxiosError, createAxiosClient } from './axios-client'; @@ -97,6 +98,36 @@ export const templateApiClient = { }; }, + async patchTemplate( + templateId: string, + template: PatchTemplate, + token: string, + lockNumber: number + ): Promise> { + const response = await catchAxiosError( + httpClient.patch( + `/v1/template/${encodeURIComponent(templateId)}`, + template, + { + headers: { + Authorization: token, + 'X-Lock-Number': String(lockNumber), + }, + } + ) + ); + + if (response.error) { + return { + error: response.error, + }; + } + + return { + data: response.data.data, + }; + }, + async getTemplate( templateId: string, token: string diff --git a/lambdas/backend-client/src/types/generated/index.ts b/lambdas/backend-client/src/types/generated/index.ts index 852b67dce..f0b7f14e6 100644 --- a/lambdas/backend-client/src/types/generated/index.ts +++ b/lambdas/backend-client/src/types/generated/index.ts @@ -77,6 +77,7 @@ export type { LetterType, LetterVersion, NhsAppProperties, + PatchTemplate, PatchV1RoutingConfigurationByRoutingConfigIdData, PatchV1RoutingConfigurationByRoutingConfigIdError, PatchV1RoutingConfigurationByRoutingConfigIdErrors, @@ -87,6 +88,11 @@ export type { PatchV1RoutingConfigurationByRoutingConfigIdSubmitErrors, PatchV1RoutingConfigurationByRoutingConfigIdSubmitResponse, PatchV1RoutingConfigurationByRoutingConfigIdSubmitResponses, + PatchV1TemplateByTemplateIdData, + PatchV1TemplateByTemplateIdError, + PatchV1TemplateByTemplateIdErrors, + PatchV1TemplateByTemplateIdResponse, + PatchV1TemplateByTemplateIdResponses, PatchV1TemplateByTemplateIdSubmitData, PatchV1TemplateByTemplateIdSubmitError, PatchV1TemplateByTemplateIdSubmitErrors, diff --git a/lambdas/backend-client/src/types/generated/types.gen.ts b/lambdas/backend-client/src/types/generated/types.gen.ts index 1006e804e..a2e9c325c 100644 --- a/lambdas/backend-client/src/types/generated/types.gen.ts +++ b/lambdas/backend-client/src/types/generated/types.gen.ts @@ -199,6 +199,10 @@ export type NhsAppProperties = { templateType: 'NHS_APP'; }; +export type PatchTemplate = { + name?: string; +}; + export type PdfLetterFiles = { pdfTemplate: VersionedFileDetails; proofs?: { @@ -716,6 +720,47 @@ export type GetV1TemplateByTemplateIdResponses = { export type GetV1TemplateByTemplateIdResponse = GetV1TemplateByTemplateIdResponses[keyof GetV1TemplateByTemplateIdResponses]; +export type PatchV1TemplateByTemplateIdData = { + /** + * Updates to apply + */ + body: PatchTemplate; + headers: { + /** + * Lock number of the current version of the template + */ + 'X-Lock-Number': number; + }; + path: { + /** + * ID of template to update + */ + templateId: string; + }; + query?: never; + url: '/v1/template/{templateId}'; +}; + +export type PatchV1TemplateByTemplateIdErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PatchV1TemplateByTemplateIdError = + PatchV1TemplateByTemplateIdErrors[keyof PatchV1TemplateByTemplateIdErrors]; + +export type PatchV1TemplateByTemplateIdResponses = { + /** + * 200 response + */ + 200: TemplateSuccess; +}; + +export type PatchV1TemplateByTemplateIdResponse = + PatchV1TemplateByTemplateIdResponses[keyof PatchV1TemplateByTemplateIdResponses]; + export type PutV1TemplateByTemplateIdData = { /** * Template to update diff --git a/tests/test-team/pages/letter/template-mgmt-edit-template-name-page.ts b/tests/test-team/pages/letter/template-mgmt-edit-template-name-page.ts new file mode 100644 index 000000000..03f144642 --- /dev/null +++ b/tests/test-team/pages/letter/template-mgmt-edit-template-name-page.ts @@ -0,0 +1,18 @@ +import type { Locator, Page } from '@playwright/test'; +import { TemplateMgmtBasePage } from '../template-mgmt-base-page'; + +export class TemplateMgmtEditTemplateNamePage extends TemplateMgmtBasePage { + static readonly pathTemplate = '/edit-template-name/:templateId'; + + nameInput: Locator; + + submitButton: Locator; + + constructor(page: Page) { + super(page); + + this.nameInput = page.getByLabel('Edit template name'); + + this.submitButton = page.getByRole('button', { name: 'Save changes' }); + } +} diff --git a/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts b/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts new file mode 100644 index 000000000..869692d90 --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts @@ -0,0 +1,425 @@ +import { test, expect } from '@playwright/test'; +import { + createAuthHelper, + TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { isoDateRegExp } from 'nhs-notify-web-template-management-test-helper-utils'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { randomUUID } from 'node:crypto'; + +test.describe('PUT /v1/template/:templateId', () => { + const authHelper = createAuthHelper(); + const templateStorageHelper = new TemplateStorageHelper(); + let user1: TestUser; + let user2: TestUser; + let userSharedClient: TestUser; + + test.beforeAll(async () => { + user1 = await authHelper.getTestUser(testUsers.User1.userId); + user2 = await authHelper.getTestUser(testUsers.User2.userId); + userSharedClient = await authHelper.getTestUser(testUsers.User7.userId); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('returns 401 if no auth token', async ({ request }) => { + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/some-template`, + { + headers: { + 'X-Lock-Number': '1', + }, + data: { + name: 'New template name', + }, + } + ); + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ + message: 'Unauthorized', + }); + }); + + test('returns 404 if template does not exist', async ({ request }) => { + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/noexist`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': '1', + }, + data: { + name: 'New template name', + }, + } + ); + expect(response.status()).toBe(404); + expect(await response.json()).toEqual({ + statusCode: 404, + technicalMessage: 'Template not found', + }); + }); + + test('returns 404 if template exists but is owned by a different client', async ({ + request, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user2.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(404); + expect(await response.json()).toEqual({ + statusCode: 404, + technicalMessage: 'Template not found', + }); + }); + + test('returns 400 if no body on request', async ({ request }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + } + ); + + expect(response.status()).toBe(400); + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + $root: 'Unexpected empty object', + }, + }); + }); + + test('returns 400 if request body is empty', async ({ request }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: {}, + } + ); + + expect(response.status()).toBe(400); + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + $root: 'Unexpected empty object', + }, + }); + }); + + test('returns 200 and the updated template data', async ({ request }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name', + 'NOT_YET_SUBMITTED', + { letterVariantId: 'letter-variant' } + ); + + await templateStorageHelper.seedTemplateData([template]); + + const start = new Date(); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(200); + + const body = await response.json(); + + const { owner, proofingEnabled, version, ...dto } = template; + + expect(body).toEqual({ + statusCode: 200, + data: { + ...dto, + name: 'New template name', + updatedAt: expect.stringMatching(isoDateRegExp), + lockNumber: template.lockNumber + 1, + updatedBy: `INTERNAL_USER#${user1.internalUserId}`, + }, + }); + + expect(body.data.updatedAt).toBeDateRoughlyBetween([start, new Date()]); + }); + + test('returns 400 - cannot update a submitted template', async ({ + request, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name', + 'SUBMITTED' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(400); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 400, + technicalMessage: 'Template with status SUBMITTED cannot be updated', + }); + }); + + test('returns 404 - cannot update a deleted template', async ({ + request, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name', + 'DELETED' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(404); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 404, + technicalMessage: 'Template not found', + }); + }); + + test('returns 400 if template name is empty', async ({ request }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: '', + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + details: { + name: 'Too small: expected string to have >=1 characters', + }, + statusCode: 400, + technicalMessage: 'Request failed validation', + }); + }); + + test('returns 400 if the lock number header is not set', async ({ + request, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(400); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 400, + technicalMessage: 'Invalid lock number provided', + }); + }); + + test('returns 409 if the lock number header does not match the current one', async ({ + request, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber + 1), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(409); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 409, + technicalMessage: + 'Lock number mismatch - Template has been modified since last read', + }); + }); + + test('user belonging to the same client as the creator can update', async ({ + request, + }) => { + expect(user1.clientId).toBe(userSharedClient.clientId); + + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name', + 'NOT_YET_SUBMITTED', + { + letterVariantId: 'letter-variant', + } + ); + + await templateStorageHelper.seedTemplateData([template]); + + const start = new Date(); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await userSharedClient.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(200); + + const body = await response.json(); + + const { owner, proofingEnabled, version, ...dto } = template; + + expect(body).toEqual({ + statusCode: 200, + data: { + ...dto, + name: 'New template name', + updatedAt: expect.stringMatching(isoDateRegExp), + lockNumber: template.lockNumber + 1, + updatedBy: `INTERNAL_USER#${userSharedClient.internalUserId}`, + }, + }); + + expect(body.data.updatedAt).toBeDateRoughlyBetween([start, new Date()]); + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-edit-template-name.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-edit-template-name.component.spec.ts new file mode 100644 index 000000000..c047379fd --- /dev/null +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-edit-template-name.component.spec.ts @@ -0,0 +1,275 @@ +import { randomUUID } from 'node:crypto'; +import { test, expect } from '@playwright/test'; +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { loginAsUser } from 'helpers/auth/login-as-user'; +import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { + assertAndClickBackLinkBottom, + assertBackLinkTopNotPresent, + assertFooterLinks, + assertHeaderLogoLink, + assertSignOutLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { TemplateMgmtEditTemplateNamePage } from 'pages/letter/template-mgmt-edit-template-name-page'; +import { TemplateMgmtPreviewLetterPage } from 'pages/letter/template-mgmt-preview-letter-page'; + +test.describe('Edit Template Name page', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + const templateStorageHelper = new TemplateStorageHelper(); + + let user: TestUser; + let userAuthoringDisabled: TestUser; + + test.beforeAll(async () => { + user = await createAuthHelper().getTestUser( + testUsers.UserLetterAuthoringEnabled.userId + ); + userAuthoringDisabled = await createAuthHelper().getTestUser( + testUsers.User1.userId + ); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteSeededTemplates(); + }); + + test.describe('with letter authoring enabled', () => { + test.beforeEach(async ({ page }) => { + await loginAsUser(user, page); + }); + + test('common page tests', async ({ page, baseURL }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user, + 'Letter Template' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const props = { + page: new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkTopNotPresent(props); + await assertAndClickBackLinkBottom({ + ...props, + expectedUrl: `templates/preview-letter-template/${template.id}`, + }); + }); + + test('updates the template name and redirects back to the preview page', async ({ + page, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user, + 'Letter Template' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(editPage.nameInput).toHaveValue(template.name); + + await editPage.nameInput.fill('New Template Name'); + await editPage.submitButton.click(); + + await expect(page).toHaveURL( + `/templates/preview-letter-template/${template.id}` + ); + + const previewPage = new TemplateMgmtPreviewLetterPage(page); + + await expect(previewPage.pageHeading).toHaveText('New Template Name'); + }); + + test('shows error when submitting an empty form', async ({ page }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user, + 'Letter Template' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(editPage.errorSummary).toBeHidden(); + + await editPage.nameInput.fill(''); + + await editPage.submitButton.click(); + + await expect(page).toHaveURL( + `/templates/edit-template-name/${template.id}` + ); + + await expect(editPage.errorSummaryList).toHaveText([ + 'Enter a template name', + ]); + }); + + test("redirects to invalid template page if template doesn't exist", async ({ + page, + }) => { + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + 'no-exist' + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL('/templates/invalid-template'); + }); + + test('redirects to template list page if template type is NHS_APP', async ({ + page, + }) => { + const template = TemplateFactory.createNhsAppTemplate(randomUUID(), user); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL('/templates/message-templates'); + }); + + test('redirects to template list page if template type is EMAIL', async ({ + page, + }) => { + const template = TemplateFactory.createEmailTemplate(randomUUID(), user); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL('/templates/message-templates'); + }); + + test('redirects to template list page if template type is SMS', async ({ + page, + }) => { + const template = TemplateFactory.createSmsTemplate(randomUUID(), user); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL('/templates/message-templates'); + }); + + test('redirects to template preview page if template is a PDF letter', async ({ + page, + }) => { + const template = TemplateFactory.uploadLetterTemplate( + randomUUID(), + user, + 'PDF Letter Template' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL( + `/templates/preview-letter-template/${template.id}` + ); + }); + + test('redirects to preview submitted template page if template is submitted', async ({ + page, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user, + 'Letter Template', + 'SUBMITTED' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL( + `/templates/preview-submitted-letter-template/${template.id}` + ); + }); + }); + + test.describe('with letter authoring disabled', () => { + test.beforeEach(async ({ page }) => { + await loginAsUser(userAuthoringDisabled, page); + }); + + test('redirects to template list page', async ({ page }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + userAuthoringDisabled, + 'Letter Template' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const editPage = new TemplateMgmtEditTemplateNamePage(page).setPathParam( + 'templateId', + template.id + ); + + await editPage.loadPage(); + + await expect(page).toHaveURL('/templates/message-templates'); + }); + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts index 232ee82a7..b61c75dc5 100644 --- a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts @@ -11,6 +11,7 @@ import { TemplateMgmtCreateSmsPage } from '../pages/sms/template-mgmt-create-sms import { TemplateMgmtDeletePage } from '../pages/template-mgmt-delete-page'; import { TemplateMgmtDeleteErrorPage } from 'pages/template-mgmt-delete-error-page'; import { TemplateMgmtEditEmailPage } from '../pages/email/template-mgmt-edit-email-page'; +import { TemplateMgmtEditTemplateNamePage } from 'pages/letter/template-mgmt-edit-template-name-page'; import { TemplateMgmtEditNhsAppPage } from '../pages/nhs-app/template-mgmt-edit-nhs-app-page'; import { TemplateMgmtEditSmsPage } from '../pages/sms/template-mgmt-edit-sms-page'; import { TemplateMgmtInvalidTemplatePage } from '../pages/template-mgmt-invalid-tempate-page'; @@ -94,6 +95,7 @@ const protectedPages = [ TemplateMgmtEditEmailPage, TemplateMgmtEditNhsAppPage, TemplateMgmtEditSmsPage, + TemplateMgmtEditTemplateNamePage, TemplateMgmtInvalidTemplatePage, TemplateMgmtMessageTemplatesPage, TemplateMgmtPreviewEmailPage, diff --git a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts index d2bfd3c66..49a9bd9fa 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts @@ -176,9 +176,10 @@ function createTemplates(user: TestUser) { test.describe('Routing - Choose Templates page', () => { let messagePlans: ReturnType; let templates: ReturnType; + let user: TestUser; test.beforeAll(async () => { - const user = await createAuthHelper().getTestUser(testUsers.User1.userId); + user = await createAuthHelper().getTestUser(testUsers.User1.userId); messagePlans = await createRoutingConfigs(user); templates = createTemplates(user); @@ -189,6 +190,7 @@ test.describe('Routing - Choose Templates page', () => { test.afterAll(async () => { await routingConfigStorageHelper.deleteSeeded(); + await routingConfigStorageHelper.deleteAdHoc(); await templateStorageHelper.deleteSeededTemplates(); }); @@ -220,6 +222,11 @@ test.describe('Routing - Choose Templates page', () => { expect(messagePlanId).not.toBeUndefined(); + routingConfigStorageHelper.addAdHocKey({ + id: messagePlanId, + clientId: user.clientId, + }); + chooseTemplatesPage.setPathParam('messagePlanId', messagePlanId!); const messagePlanChannels = messageOrder.split(','); @@ -380,6 +387,11 @@ test.describe('Routing - Choose Templates page', () => { expect(messagePlanId).not.toBeUndefined(); + routingConfigStorageHelper.addAdHocKey({ + id: messagePlanId, + clientId: user.clientId, + }); + await test.step('app channel with no template has only choose link', async () => { await expect(chooseTemplatesPage.nhsApp.templateName).toBeHidden(); await expect(chooseTemplatesPage.nhsApp.chooseTemplateLink).toBeVisible(); From f14e9b3c61e8186b03944f873ffb14f2033b2cbf Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Fri, 6 Feb 2026 17:57:21 +0000 Subject: [PATCH 2/6] CCM-14169: full stops --- .../__snapshots__/page.test.tsx.snap | 12 +++++----- .../__snapshots__/page.test.tsx.snap | 12 +++++----- .../__snapshots__/page.test.tsx.snap | 24 +++++++++---------- .../__snapshots__/page.test.tsx.snap | 12 +++++----- frontend/src/content/content.ts | 6 ++--- lambdas/backend-api/README.md | 15 ++++++++++++ 6 files changed, 48 insertions(+), 33 deletions(-) diff --git a/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap index c6fa1ce4d..50bc4c0ca 100644 --- a/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap @@ -60,7 +60,7 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`]
- This will not be visible to recipients. + This will not be visible to recipients
- Choose which campaign this letter is for. + Choose which campaign this letter is for
- This will not be visible to recipients. + This will not be visible to recipients
- Choose which campaign this letter is for. + Choose which campaign this letter is for
- This will not be visible to recipients. + This will not be visible to recipients
- This will not be visible to recipients. + This will not be visible to recipients
- This will not be visible to recipients. + This will not be visible to recipients
- Choose which campaign this letter is for. + Choose which campaign this letter is for
- This will not be visible to recipients. + This will not be visible to recipients
- Choose which campaign this letter is for. + Choose which campaign this letter is for
- Choose the language used in this template. + Choose the language used in this template
- This will not be visible to recipients. + This will not be visible to recipients
- Choose the language used in this template. + Choose the language used in this template
- This will not be visible to recipients. + This will not be visible to recipients
- Choose which campaign this letter is for. + Choose which campaign this letter is for
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +`; diff --git a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx index 7e6c3456b..95735dfb3 100644 --- a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx @@ -19,7 +19,7 @@ import { SummaryListValue, Tag, } from '@atoms/nhsuk-components'; -import { NHSNotifyErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; +import { NHSNotifyFormErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; import { MessagePlanChooseTemplatesMoveToProductionForm } from '@forms/ChooseTemplates/MovetoProduction'; @@ -79,7 +79,9 @@ export default async function ChooseTemplatesPage(props: MessagePlanPageProps) {
- + {content.headerCaption}

{messagePlan.name} diff --git a/frontend/src/app/message-plans/create-message-plan/page.tsx b/frontend/src/app/message-plans/create-message-plan/page.tsx index 39d2f5a92..bfb1aceea 100644 --- a/frontend/src/app/message-plans/create-message-plan/page.tsx +++ b/frontend/src/app/message-plans/create-message-plan/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { z } from 'zod/v4'; import { MESSAGE_ORDER_OPTIONS_LIST } from 'nhs-notify-web-template-management-utils'; -import { NHSNotifyErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; +import { NHSNotifyFormErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; @@ -49,7 +49,7 @@ export default async function CreateMessagePlanPage({ return ( - +

{pageContent.pageHeading}

diff --git a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx index 7629ca6e1..ee15b6a54 100644 --- a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import type { MessagePlanPageProps } from 'nhs-notify-web-template-management-utils'; -import { NHSNotifyErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; +import { NHSNotifyFormErrorSummary } from '@atoms/NHSNotifyForm/ErrorSummary'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; @@ -40,7 +40,7 @@ export default async function EditMessagePlanSettingsPage({ return ( - +

{pageContent.pageHeading}

diff --git a/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx b/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx index 7531bb6b4..77835c709 100644 --- a/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx +++ b/frontend/src/components/atoms/NHSNotifyForm/ErrorMessage.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from 'react'; import { ErrorMessage } from 'nhsuk-react-components'; import { useNHSNotifyForm } from '@providers/form-provider'; -export function NHSNotifyErrorMessage({ +export function NHSNotifyFormErrorMessage({ className, htmlFor, ...props diff --git a/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx b/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx index c8e256493..c3cbd02b5 100644 --- a/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx +++ b/frontend/src/components/atoms/NHSNotifyForm/ErrorSummary.tsx @@ -6,7 +6,7 @@ import { } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; import { useNHSNotifyForm } from '@providers/form-provider'; -export function NHSNotifyErrorSummary( +export function NHSNotifyFormErrorSummary( props: Omit ) { const [state] = useNHSNotifyForm(); diff --git a/frontend/src/components/atoms/NHSNotifyForm/Input.tsx b/frontend/src/components/atoms/NHSNotifyForm/Input.tsx index b9a2df7e4..5b07c1787 100644 --- a/frontend/src/components/atoms/NHSNotifyForm/Input.tsx +++ b/frontend/src/components/atoms/NHSNotifyForm/Input.tsx @@ -4,7 +4,7 @@ import type { HTMLProps } from 'react'; import classNames from 'classnames'; import { useNHSNotifyForm } from '@providers/form-provider'; -export function NHSNotifyInput({ +export function NHSNotifyFormInput({ className, name, ...props diff --git a/frontend/src/components/atoms/NHSNotifyForm/Select.tsx b/frontend/src/components/atoms/NHSNotifyForm/Select.tsx index 59d9b0c97..66bc80ced 100644 --- a/frontend/src/components/atoms/NHSNotifyForm/Select.tsx +++ b/frontend/src/components/atoms/NHSNotifyForm/Select.tsx @@ -4,7 +4,7 @@ import type { HTMLProps } from 'react'; import classNames from 'classnames'; import { useNHSNotifyForm } from '@providers/form-provider'; -export function NHSNotifySelect({ +export function NHSNotifyFormSelect({ children, className, name, diff --git a/frontend/src/components/atoms/NHSNotifyForm/index.ts b/frontend/src/components/atoms/NHSNotifyForm/index.ts index 5bd8601a6..901920a96 100644 --- a/frontend/src/components/atoms/NHSNotifyForm/index.ts +++ b/frontend/src/components/atoms/NHSNotifyForm/index.ts @@ -1,6 +1,6 @@ -export { NHSNotifyErrorMessage as ErrorMessage } from './ErrorMessage'; -export { NHSNotifyErrorSummary as ErrorSummary } from './ErrorSummary'; +export { NHSNotifyFormErrorMessage as ErrorMessage } from './ErrorMessage'; +export { NHSNotifyFormErrorSummary as ErrorSummary } from './ErrorSummary'; export { NHSNotifyForm as Form } from './Form'; export { NHSNotifyFormGroup as FormGroup } from './FormGroup'; -export { NHSNotifyInput as Input } from './Input'; -export { NHSNotifySelect as Select } from './Select'; +export { NHSNotifyFormInput as Input } from './Input'; +export { NHSNotifyFormSelect as Select } from './Select'; diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts index 5b5df2249..5933c79d2 100644 --- a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts @@ -616,7 +616,8 @@ describe('templateRepository', () => { ExpressionAttributeValues: { ':condition_2_1_templateStatus': 'DELETED', ':condition_2_2_templateStatus': 'SUBMITTED', - ':condition_3_1_lockNumber': 5, + ':condition_3_1_templateStatus': 'PROOF_APPROVED', + ':condition_4_1_lockNumber': 5, ':lockNumber': 1, ':name': 'Updated Template Name', ':updatedAt': '2024-12-27T00:00:00.000Z', @@ -625,7 +626,7 @@ describe('templateRepository', () => { UpdateExpression: 'SET #name = :name, #updatedAt = :updatedAt, #updatedBy = :updatedBy ADD #lockNumber :lockNumber', ConditionExpression: - 'attribute_exists (#id) AND NOT #templateStatus IN (:condition_2_1_templateStatus, :condition_2_2_templateStatus) AND (#lockNumber = :condition_3_1_lockNumber OR attribute_not_exists (#lockNumber))', + 'attribute_exists (#id) AND NOT #templateStatus IN (:condition_2_1_templateStatus, :condition_2_2_templateStatus) AND NOT #templateStatus IN (:condition_3_1_templateStatus) AND (#lockNumber = :condition_4_1_lockNumber OR attribute_not_exists (#lockNumber))', }); expect(response).toEqual({ @@ -654,6 +655,19 @@ describe('templateRepository', () => { description: 'Template with status SUBMITTED cannot be updated', }, }, + { + testName: 'when templateStatus is already PROOF_APPROVED', + Item: { + templateType: { S: 'LETTER' }, + templateStatus: { S: 'PROOF_APPROVED' }, + lockNumber: { N: '5' }, + }, + errorCode: 400, + errorMeta: { + description: + 'Template with status PROOF_APPROVED cannot be updated', + }, + }, { testName: 'when templateStatus is already DELETED', Item: { diff --git a/lambdas/backend-api/src/infra/template-repository/repository.ts b/lambdas/backend-api/src/infra/template-repository/repository.ts index 9c4b9f977..6a1dfe8fd 100644 --- a/lambdas/backend-api/src/infra/template-repository/repository.ts +++ b/lambdas/backend-api/src/infra/template-repository/repository.ts @@ -176,9 +176,12 @@ export class TemplateRepository { update.setName(updates.name); } + const bannedStatuses: TemplateStatus[] = ['PROOF_APPROVED']; + update .expectTemplateExists() .expectNotFinalStatus() + .expectNotStatus(bannedStatuses) .expectLockNumber(lockNumber) .setUpdatedByUserAt(this.internalUserKey(user)) .incrementLockNumber(); @@ -190,7 +193,12 @@ export class TemplateRepository { return success(response.Attributes as DatabaseTemplate); } catch (error) { - return this._handleUpdateError(error, updates, lockNumber); + return this._handleUpdateError( + error, + updates, + lockNumber, + bannedStatuses + ); } } @@ -811,7 +819,8 @@ export class TemplateRepository { private _handleUpdateError( error: unknown, updates: Partial, - lockNumber: number + lockNumber: number, + blockedStatuses: TemplateStatus[] = [] ) { if (error instanceof ConditionalCheckFailedException) { if (!error.Item || error.Item.templateStatus.S === 'DELETED') { @@ -820,7 +829,7 @@ export class TemplateRepository { const oldItem = unmarshall(error.Item); - if (oldItem.templateStatus === 'SUBMITTED') { + if (['SUBMITTED', ...blockedStatuses].includes(oldItem.templateStatus)) { return failure( ErrorCase.ALREADY_SUBMITTED, `Template with status ${oldItem.templateStatus} cannot be updated`, diff --git a/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts b/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts index 869692d90..b0a0e4779 100644 --- a/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts @@ -200,6 +200,41 @@ test.describe('PUT /v1/template/:templateId', () => { expect(body.data.updatedAt).toBeDateRoughlyBetween([start, new Date()]); }); + test('returns 400 - cannot update an approved template', async ({ + request, + }) => { + const template = TemplateFactory.createAuthoringLetterTemplate( + randomUUID(), + user1, + 'Old template name', + 'PROOF_APPROVED' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/template/${template.id}`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(template.lockNumber), + }, + data: { + name: 'New template name', + }, + } + ); + + expect(response.status()).toBe(400); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 400, + technicalMessage: 'Template with status PROOF_APPROVED cannot be updated', + }); + }); + test('returns 400 - cannot update a submitted template', async ({ request, }) => { diff --git a/utils/entity-update-command-builder/src/__tests__/template-update-builder.test.ts b/utils/entity-update-command-builder/src/__tests__/template-update-builder.test.ts index ad0087cfb..981a6f8b2 100644 --- a/utils/entity-update-command-builder/src/__tests__/template-update-builder.test.ts +++ b/utils/entity-update-command-builder/src/__tests__/template-update-builder.test.ts @@ -619,4 +619,47 @@ describe('TemplateUpdateBuilder', () => { }); }); }); + + describe('expectNotStatus', () => { + test('adds condition that status is not the given status', () => { + const builder = new TemplateUpdateBuilder( + mockTableName, + mockOwner, + mockId + ); + + expect(builder.expectNotStatus('PENDING_UPLOAD').build()).toMatchObject({ + ExpressionAttributeNames: { + '#templateStatus': 'templateStatus', + }, + ExpressionAttributeValues: { + ':condition_1_templateStatus': 'PENDING_UPLOAD', + }, + ConditionExpression: + 'NOT #templateStatus = :condition_1_templateStatus', + }); + }); + + test('adds condition that status is not in the given status list', () => { + const builder = new TemplateUpdateBuilder( + mockTableName, + mockOwner, + mockId + ); + + expect( + builder.expectNotStatus(['PENDING_UPLOAD', 'VIRUS_SCAN_FAILED']).build() + ).toMatchObject({ + ExpressionAttributeNames: { + '#templateStatus': 'templateStatus', + }, + ExpressionAttributeValues: { + ':condition_1_1_templateStatus': 'PENDING_UPLOAD', + ':condition_1_2_templateStatus': 'VIRUS_SCAN_FAILED', + }, + ConditionExpression: + 'NOT #templateStatus IN (:condition_1_1_templateStatus, :condition_1_2_templateStatus)', + }); + }); + }); }); diff --git a/utils/entity-update-command-builder/src/template-update-builder.ts b/utils/entity-update-command-builder/src/template-update-builder.ts index 6a6e4d6dd..49b92ba1f 100644 --- a/utils/entity-update-command-builder/src/template-update-builder.ts +++ b/utils/entity-update-command-builder/src/template-update-builder.ts @@ -130,15 +130,29 @@ export class TemplateUpdateBuilder extends EntityUpdateBuilder return this; } - expectNotFinalStatus() { - this.updateBuilder.conditions.andIn( + expectNotStatus(expectedStatus: TemplateStatus | TemplateStatus[]) { + if (Array.isArray(expectedStatus)) { + this.updateBuilder.conditions.andIn( + 'templateStatus', + expectedStatus, + true + ); + return this; + } + this.updateBuilder.conditions.and( 'templateStatus', - ['DELETED', 'SUBMITTED'], + '=', + expectedStatus, true ); return this; } + expectNotFinalStatus() { + this.expectNotStatus(['DELETED', 'SUBMITTED']); + return this; + } + build() { return this.updateBuilder.finalise(); } From 88c3d3befc9f47248458db31540adf106082fa97 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Mon, 9 Feb 2026 15:24:04 +0000 Subject: [PATCH 4/6] CCM-14169: pr feedback --- .../helpers/auth/auth-context-file.ts | 74 +++++++++++-- .../helpers/auth/cognito-auth-helper.ts | 100 +++++++++++++----- .../test-team/helpers/client/client-helper.ts | 46 +++++--- .../patch-template.api.spec.ts | 8 +- 4 files changed, 172 insertions(+), 56 deletions(-) diff --git a/tests/test-team/helpers/auth/auth-context-file.ts b/tests/test-team/helpers/auth/auth-context-file.ts index b9b5e6e8b..a763020b9 100644 --- a/tests/test-team/helpers/auth/auth-context-file.ts +++ b/tests/test-team/helpers/auth/auth-context-file.ts @@ -3,9 +3,13 @@ import { Buffer } from 'node:buffer'; import fs from 'node:fs'; import path from 'node:path'; import { Mutex } from 'async-mutex'; +import type { ClientConfiguration } from '../client/client-helper'; import type { TestUserContext } from './cognito-auth-helper'; -type AuthContextNamespace = Record; +type AuthContextNamespace = { + users: Record; + clients: Record; +}; export class AuthContextFile { public readonly path: string; @@ -24,26 +28,69 @@ export class AuthContextFile { } } - async set(namespace: string, key: string, value: Partial) { + async setUser( + namespace: string, + id: string, + value: Partial + ) { await this.mutex.runExclusive(() => { const data = this.read(); - const nsData = data[namespace] || {}; - const keyData = nsData[key] || {}; + const nsData = this.parseNamespace(data[namespace]); - nsData[key] = { ...keyData, ...value }; + const keyData = nsData.users[id] || {}; + + nsData.users[id] = { ...keyData, ...value }; data[namespace] = nsData; this.write(data); }); } - async get(namespace: string, key: string): Promise { - return this.mutex.runExclusive(() => this.read()[namespace]?.[key] || null); + async getUser( + namespace: string, + id: string + ): Promise { + return this.mutex.runExclusive( + () => this.read()[namespace]?.users?.[id] ?? null + ); } - async values(namespace: string): Promise { + async userValues(namespace: string): Promise { return this.mutex.runExclusive(() => - Object.values(this.read()[namespace] || {}) + Object.values(this.read()[namespace]?.users ?? {}) + ); + } + + async setClient( + namespace: string, + id: string, + value: Partial + ) { + await this.mutex.runExclusive(() => { + const data = this.read(); + const nsData = this.parseNamespace(data[namespace]); + + const keyData = nsData.clients[id] || {}; + + nsData.clients[id] = { ...keyData, ...value }; + data[namespace] = nsData; + + this.write(data); + }); + } + + async getClient( + namespace: string, + id: string + ): Promise { + return this.mutex.runExclusive( + () => this.read()[namespace]?.clients?.[id] ?? null + ); + } + + async clientValues(namespace: string): Promise { + return this.mutex.runExclusive(() => + Object.values(this.read()[namespace]?.clients ?? {}) ); } @@ -55,6 +102,15 @@ export class AuthContextFile { }); } + private parseNamespace( + namespace: AuthContextNamespace | undefined + ): AuthContextNamespace { + return { + users: namespace?.users ?? {}, + clients: namespace?.clients ?? {}, + }; + } + private write(data: Record) { fs.writeFileSync(this.path, JSON.stringify(data, null, 2)); } diff --git a/tests/test-team/helpers/auth/cognito-auth-helper.ts b/tests/test-team/helpers/auth/cognito-auth-helper.ts index 94cc5654f..e1f0d0e8f 100644 --- a/tests/test-team/helpers/auth/cognito-auth-helper.ts +++ b/tests/test-team/helpers/auth/cognito-auth-helper.ts @@ -13,7 +13,6 @@ import { AuthContextFile } from './auth-context-file'; import { ClientConfiguration, ClientConfigurationHelper, - testClients, type ClientKey, } from '../client/client-helper'; @@ -23,9 +22,9 @@ export type UserIdentityAttributes = | 'preferred_username'; type TestUserStaticDetails = { + clientId: string; userId: string; internalUserId: string; - clientKey: ClientKey; /** * If `userAttributes` is omitted, user will be created with full identity attributes: * preferred_username, given_name, and family_name. @@ -37,7 +36,6 @@ type TestUserDynamicDetails = { email: string; campaignId?: string; campaignIds?: string[]; - clientId: string; clientName?: string; identityAttributes?: Partial>; }; @@ -50,7 +48,10 @@ export type TestUserContext = TestUserStaticDetails & password: string; }; -export const testUsers: Record = { +export const testUsers: Record< + string, + Omit & { clientKey: ClientKey } +> = { /** * User1 is generally the signed in user */ @@ -195,36 +196,46 @@ export class CognitoAuthHelper { ) { this.notifyClientHelper = new ClientConfigurationHelper( clientSsmKeyPrefix, - runId + runId, + CognitoAuthHelper.authContextFile ); } public async setup() { const users = Object.values(testUsers); - await Promise.all([ - ...users.map((userDetails) => this.createUser(userDetails)), - this.notifyClientHelper.setup(), - ]); + await this.notifyClientHelper.setup(); + + await Promise.all( + users.map(async (userDetails) => + this.createUser({ + ...userDetails, + clientId: this.notifyClientHelper.clientIdFromKey( + userDetails.clientKey + ), + }) + ) + ); } public async teardown() { - const runCtx = await CognitoAuthHelper.authContextFile.values(this.runId); - - const clientIds = [ - ...new Set(runCtx.flatMap(({ clientId }) => clientId ?? [])), - ]; + const runCtx = await CognitoAuthHelper.authContextFile.userValues( + this.runId + ); await Promise.all([ ...runCtx.map(({ email }) => this.deleteUser(email)), - this.notifyClientHelper.teardown(clientIds), + this.notifyClientHelper.teardown(), ]); await CognitoAuthHelper.authContextFile.destroyNamespace(this.runId); } public async getAccessToken(id: string) { - const userCtx = await CognitoAuthHelper.authContextFile.get(this.runId, id); + const userCtx = await CognitoAuthHelper.authContextFile.getUser( + this.runId, + id + ); if (userCtx) { if (!CognitoAuthHelper.isTokenExpired(userCtx.accessToken)) { @@ -245,7 +256,10 @@ export class CognitoAuthHelper { } public async getIdToken(id: string): Promise { - const userCtx = await CognitoAuthHelper.authContextFile.get(this.runId, id); + const userCtx = await CognitoAuthHelper.authContextFile.getUser( + this.runId, + id + ); if (userCtx && !CognitoAuthHelper.isTokenExpired(userCtx.idToken)) { return userCtx.idToken; @@ -264,7 +278,10 @@ export class CognitoAuthHelper { } public async getTestUser(id: string): Promise { - const userCtx = await CognitoAuthHelper.authContextFile.get(this.runId, id); + const userCtx = await CognitoAuthHelper.authContextFile.getUser( + this.runId, + id + ); if (!userCtx) { throw new Error('User not found'); @@ -277,13 +294,13 @@ export class CognitoAuthHelper { getAccessToken: () => this.getAccessToken(id), getIdToken: () => this.getAccessToken(id), async setUpdatedPassword(password) { - await CognitoAuthHelper.authContextFile.set(runId, id, { + await CognitoAuthHelper.authContextFile.setUser(runId, id, { password, }); }, async getPassword() { - const u = await CognitoAuthHelper.authContextFile.get(runId, id); + const u = await CognitoAuthHelper.authContextFile.getUser(runId, id); if (!u) { throw new Error('User not found'); @@ -300,9 +317,9 @@ export class CognitoAuthHelper { const email = faker.internet.exampleEmail(); const tempPassword = CognitoAuthHelper.generatePassword(); - const clientId = `${userDetails.clientKey}--${this.runId}`; - const clientConfig: ClientConfiguration | undefined = - testClients[userDetails.clientKey]; + const { clientId } = userDetails; + + const clientConfig = await this.notifyClientHelper.getClient(clientId); const { name: clientName, campaignIds } = clientConfig ?? {}; @@ -359,7 +376,7 @@ export class CognitoAuthHelper { const sub = user.User?.Attributes?.find((attr) => attr.Name === 'sub')?.Value ?? ''; - await CognitoAuthHelper.authContextFile.set( + await CognitoAuthHelper.authContextFile.setUser( this.runId, userDetails.userId, { @@ -367,7 +384,6 @@ export class CognitoAuthHelper { userId: sub, campaignIds, clientId, - clientKey: userDetails.clientKey, clientName, identityAttributes, password: tempPassword, @@ -395,7 +411,10 @@ export class CognitoAuthHelper { } private async passwordAuth(id: string, allowPasswordChange = false) { - const userCtx = await CognitoAuthHelper.authContextFile.get(this.runId, id); + const userCtx = await CognitoAuthHelper.authContextFile.getUser( + this.runId, + id + ); if (!userCtx?.email || !userCtx.password) { throw new Error('Unable to retrieve credentials'); @@ -432,7 +451,7 @@ export class CognitoAuthHelper { }) ); - await CognitoAuthHelper.authContextFile.set(this.runId, id, { + await CognitoAuthHelper.authContextFile.setUser(this.runId, id, { password: newPassword, }); @@ -446,7 +465,7 @@ export class CognitoAuthHelper { const accessToken = authResult.AccessToken || ''; const idToken = authResult.IdToken || ''; - await CognitoAuthHelper.authContextFile.set(this.runId, id, { + await CognitoAuthHelper.authContextFile.setUser(this.runId, id, { accessToken, refreshToken: authResult.RefreshToken || '', idToken, @@ -467,7 +486,7 @@ export class CognitoAuthHelper { }) ); - await CognitoAuthHelper.authContextFile.set(this.runId, id, { + await CognitoAuthHelper.authContextFile.setUser(this.runId, id, { accessToken: response.AuthenticationResult?.AccessToken || '', refreshToken: response.AuthenticationResult?.RefreshToken || refreshToken, idToken: response.AuthenticationResult?.IdToken || '', @@ -498,6 +517,29 @@ export class CognitoAuthHelper { return !exp || exp * 1000 < Date.now(); } + + public async createClient(client: ClientConfiguration) { + const id = crypto.randomUUID(); + await this.notifyClientHelper.createClient(id, client); + return id; + } + + public async getStaticClient(clientKey: ClientKey) { + const id = this.notifyClientHelper.clientIdFromKey(clientKey); + return this.notifyClientHelper.getClient(id); + } + + public async createAdHocUser(clientId: string) { + const user: TestUserStaticDetails = { + clientId, + userId: crypto.randomUUID(), + internalUserId: crypto.randomUUID(), + }; + + await this.createUser(user); + + return this.getTestUser(user.userId); + } } export function createAuthHelper() { diff --git a/tests/test-team/helpers/client/client-helper.ts b/tests/test-team/helpers/client/client-helper.ts index f0676ab54..e3f44432c 100644 --- a/tests/test-team/helpers/client/client-helper.ts +++ b/tests/test-team/helpers/client/client-helper.ts @@ -3,6 +3,7 @@ import { PutParameterCommand, SSMClient, } from '@aws-sdk/client-ssm'; +import type { AuthContextFile } from 'helpers/auth/auth-context-file'; export type ClientConfiguration = { campaignIds?: string[]; @@ -17,9 +18,7 @@ export type ClientConfiguration = { export type ClientKey = `Client${1 | 2 | 3 | 4 | 5 | 6 | 'WithMultipleCampaigns' | 'RoutingEnabled' | 'LetterAuthoringEnabled'}`; -type TestClients = Record; - -export const testClients: TestClients = { +export const testClients: Record = { /** * Client1 has proofing and routing enabled * This is the default client for the component tests. @@ -130,29 +129,27 @@ export class ClientConfigurationHelper { constructor( private readonly clientSSMKeyPrefix: string, - private readonly runId: string + private readonly runId: string, + private authContextFile: AuthContextFile ) {} async setup() { return Promise.all( Object.entries(testClients).map(async ([clientKey, value]) => { - const id = `${clientKey}--${this.runId}`; - if (value !== undefined) { - await this.ssmClient.send( - new PutParameterCommand({ - Name: this.ssmKey(id), - Value: JSON.stringify(value), - Type: 'String', - Overwrite: true, - }) + await this.createClient( + this.clientIdFromKey(clientKey as ClientKey), + value ); } }) ); } - async teardown(ids: string[]) { + async teardown() { + const values = await this.authContextFile.clientValues(this.runId); + const ids = Object.keys(values); + if (ids.length > 0) { await this.ssmClient.send( new DeleteParametersCommand({ @@ -162,6 +159,27 @@ export class ClientConfigurationHelper { } } + async createClient(id: string, value: ClientConfiguration) { + await this.ssmClient.send( + new PutParameterCommand({ + Name: this.ssmKey(id), + Value: JSON.stringify(value), + Type: 'String', + Overwrite: true, + }) + ); + + await this.authContextFile.setClient(this.runId, id, value); + } + + getClient(clientId: string) { + return this.authContextFile.getClient(this.runId, clientId); + } + + clientIdFromKey(clientKey: ClientKey) { + return `${clientKey}--${this.runId}`; + } + private ssmKey(clientId: string) { return `${this.clientSSMKeyPrefix}/${clientId}`; } diff --git a/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts b/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts index b0a0e4779..db286585f 100644 --- a/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/patch-template.api.spec.ts @@ -9,16 +9,16 @@ import { isoDateRegExp } from 'nhs-notify-web-template-management-test-helper-ut import { TemplateFactory } from 'helpers/factories/template-factory'; import { randomUUID } from 'node:crypto'; -test.describe('PUT /v1/template/:templateId', () => { +test.describe('PATCH /v1/template/:templateId', () => { const authHelper = createAuthHelper(); const templateStorageHelper = new TemplateStorageHelper(); let user1: TestUser; - let user2: TestUser; + let userDifferentClient: TestUser; let userSharedClient: TestUser; test.beforeAll(async () => { user1 = await authHelper.getTestUser(testUsers.User1.userId); - user2 = await authHelper.getTestUser(testUsers.User2.userId); + userDifferentClient = await authHelper.getTestUser(testUsers.User2.userId); userSharedClient = await authHelper.getTestUser(testUsers.User7.userId); }); @@ -79,7 +79,7 @@ test.describe('PUT /v1/template/:templateId', () => { `${process.env.API_BASE_URL}/v1/template/${template.id}`, { headers: { - Authorization: await user2.getAccessToken(), + Authorization: await userDifferentClient.getAccessToken(), 'X-Lock-Number': String(template.lockNumber), }, data: { From 8557f7a7ea8a49f8d16cd26a5ce2a410f9f879ab Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Mon, 9 Feb 2026 15:45:32 +0000 Subject: [PATCH 5/6] CCM-14169: typecheck fix --- tests/test-team/helpers/auth/cognito-auth-helper.ts | 4 ++++ .../get-client-configuration.api.spec.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test-team/helpers/auth/cognito-auth-helper.ts b/tests/test-team/helpers/auth/cognito-auth-helper.ts index e1f0d0e8f..dd56c6524 100644 --- a/tests/test-team/helpers/auth/cognito-auth-helper.ts +++ b/tests/test-team/helpers/auth/cognito-auth-helper.ts @@ -524,6 +524,10 @@ export class CognitoAuthHelper { return id; } + public async getClient(clientId: string) { + return this.notifyClientHelper.getClient(clientId); + } + public async getStaticClient(clientKey: ClientKey) { const id = this.notifyClientHelper.clientIdFromKey(clientKey); return this.notifyClientHelper.getClient(id); diff --git a/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts index 425955270..e96b2aeba 100644 --- a/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts @@ -53,12 +53,14 @@ test.describe('GET /v1/client-configuration', () => { }, }); + const client = await authHelper.getClient(userWithClientConfig.clientId); + expect(response.status()).toBe(200); expect(await response.json()).toEqual({ statusCode: 200, clientConfiguration: { - campaignIds: userWithClientConfig.campaignIds, - features: testClients[userWithClientConfig.clientKey]?.features, + campaignIds: client?.campaignIds, + features: client?.features, }, }); }); From 9ef863e3771cbec30322ba4237b41b338839b1c9 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Mon, 9 Feb 2026 15:57:08 +0000 Subject: [PATCH 6/6] CC-14169: tidy imports --- .../template-mgmt-api-tests/get-client-configuration.api.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts index e96b2aeba..f23a96acc 100644 --- a/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-client-configuration.api.spec.ts @@ -4,7 +4,6 @@ import { type TestUser, testUsers, } from '../helpers/auth/cognito-auth-helper'; -import { testClients } from '../helpers/client/client-helper'; test.describe('GET /v1/client-configuration', () => { const authHelper = createAuthHelper();