From c8dbf32845bc7296ee209f68051012071e7f4a9b Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Tue, 24 Feb 2026 16:37:01 +0000 Subject: [PATCH 01/31] CCM-14190: letter variants --- .../__snapshots__/page.test.tsx.snap | 698 +++++++++ .../[templateId]/page.test.tsx | 415 ++++++ .../[templateId]/server-action.test.ts | 112 ++ .../__snapshots__/page.test.tsx.snap | 4 +- .../[templateId]/page.test.tsx | 68 +- .../__snapshots__/page.test.tsx.snap | 4 +- .../[templateId]/page.test.tsx | 63 +- .../__snapshots__/page.test.tsx.snap | 16 +- .../app/preview-letter-template/page.test.tsx | 36 +- .../NHSNotifyForm/NHSNotifyForm.test.tsx | 93 +- .../__snapshots__/NHSNotifyForm.test.tsx.snap | 72 + .../molecules/PreviewTemplateDetails.test.tsx | 13 + .../PreviewTemplateDetails.test.tsx.snap | 238 ++- frontend/src/__tests__/helpers/helpers.ts | 20 +- .../src/__tests__/utils/form-actions.test.ts | 165 ++- .../[templateId]/page.tsx | 242 +++ .../[templateId]/server-action.ts | 38 + .../[templateId]/page.tsx | 26 +- .../edit-template-name/[templateId]/page.tsx | 22 +- .../[templateId]/page.tsx | 13 +- .../atoms/NHSNotifyForm/RadioInput.tsx | 25 + .../components/atoms/NHSNotifyForm/index.ts | 1 + .../atoms/nhsuk-components/index.ts | 8 +- .../PreviewTemplateDetailsAuthoringLetter.tsx | 10 +- frontend/src/content/content.ts | 55 +- frontend/src/middleware.ts | 1 + frontend/src/utils/form-actions.ts | 50 +- .../terraform/modules/backend-api/README.md | 2 + .../dynamodb_table_letter_variants.tf | 68 + .../iam_role_api_gateway_execution_role.tf | 2 + .../terraform/modules/backend-api/locals.tf | 4 + .../module_get_letter_variant_lambda.tf | 50 + ...ule_get_template_letter_variants_lambda.tf | 63 + .../module_patch_template_lambda.tf | 16 +- .../modules/backend-api/spec.tmpl.json | 274 +++- lambdas/backend-api/build.sh | 2 + .../__tests__/api/get-letter-variant.test.ts | 158 ++ .../api/get-template-letter-variants.test.ts | 171 +++ .../src/__tests__/api/patch-template.test.ts | 37 +- .../app/letter-variant-client.test.ts | 128 ++ .../src/__tests__/app/template-client.test.ts | 1295 +++++++++++++++-- .../infra/letter-variant-repository.test.ts | 465 ++++++ .../template-repository/repository.test.ts | 132 +- .../backend-api/src/api/get-letter-variant.ts | 45 + .../src/api/get-template-letter-variants.ts | 45 + lambdas/backend-api/src/api/patch-template.ts | 2 +- lambdas/backend-api/src/api/responses.ts | 3 + .../src/app/letter-variant-client.ts | 28 + .../backend-api/src/app/template-client.ts | 407 ++++-- .../src/container/letter-variant.ts | 30 + .../backend-api/src/container/templates.ts | 8 + lambdas/backend-api/src/get-letter-variant.ts | 4 + .../src/get-template-letter-variants.ts | 4 + lambdas/backend-api/src/infra/config.ts | 4 + .../infra/letter-variant-repository/index.ts | 1 + .../letter-variant-repository/repository.ts | 232 +++ .../infra/template-repository/repository.ts | 44 +- .../letter-variant-api-client.test.ts | 71 + .../__tests__/schemas/letter-variant.test.ts | 101 ++ .../src/__tests__/schemas/template.test.ts | 67 +- .../src/__tests__/template-api-client.test.ts | 79 +- lambdas/backend-client/src/index.ts | 1 + .../src/letter-variant-api-client.ts | 24 + .../src/schemas/letter-variant.ts | 28 + .../backend-client/src/schemas/template.ts | 6 +- .../backend-client/src/template-api-client.ts | 32 +- .../src/types/generated/index.ts | 15 +- .../src/types/generated/types.gen.ts | 104 +- scripts/sandbox_auth.sh | 11 +- .../seed_letter_authoring_virus_scan_test.sh | 152 ++ tests/test-team/helpers/chunk.ts | 12 + .../test-team/helpers/client/client-helper.ts | 16 +- .../db/routing-config-storage-helper.ts | 26 +- .../helpers/db/template-storage-helper.ts | 32 +- ...t-edit-template-campaign.component.spec.ts | 93 +- ...-mgmt-edit-template-name.component.spec.ts | 89 +- .../__tests__/template-update-builder.test.ts | 86 ++ .../src/template-update-builder.ts | 21 +- .../src/__tests__/in-memory-cache.test.ts | 45 + utils/utils/src/in-memory-cache.ts | 36 + utils/utils/src/index.ts | 1 + 81 files changed, 6837 insertions(+), 543 deletions(-) create mode 100644 frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/page.test.tsx create mode 100644 frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/server-action.test.ts create mode 100644 frontend/src/app/choose-printing-and-postage/[templateId]/page.tsx create mode 100644 frontend/src/app/choose-printing-and-postage/[templateId]/server-action.ts create mode 100644 frontend/src/components/atoms/NHSNotifyForm/RadioInput.tsx create mode 100644 infrastructure/terraform/modules/backend-api/dynamodb_table_letter_variants.tf create mode 100644 infrastructure/terraform/modules/backend-api/module_get_letter_variant_lambda.tf create mode 100644 infrastructure/terraform/modules/backend-api/module_get_template_letter_variants_lambda.tf create mode 100644 lambdas/backend-api/src/__tests__/api/get-letter-variant.test.ts create mode 100644 lambdas/backend-api/src/__tests__/api/get-template-letter-variants.test.ts create mode 100644 lambdas/backend-api/src/__tests__/app/letter-variant-client.test.ts create mode 100644 lambdas/backend-api/src/__tests__/infra/letter-variant-repository.test.ts create mode 100644 lambdas/backend-api/src/api/get-letter-variant.ts create mode 100644 lambdas/backend-api/src/api/get-template-letter-variants.ts create mode 100644 lambdas/backend-api/src/app/letter-variant-client.ts create mode 100644 lambdas/backend-api/src/container/letter-variant.ts create mode 100644 lambdas/backend-api/src/get-letter-variant.ts create mode 100644 lambdas/backend-api/src/get-template-letter-variants.ts create mode 100644 lambdas/backend-api/src/infra/letter-variant-repository/index.ts create mode 100644 lambdas/backend-api/src/infra/letter-variant-repository/repository.ts create mode 100644 lambdas/backend-client/src/__tests__/letter-variant-api-client.test.ts create mode 100644 lambdas/backend-client/src/__tests__/schemas/letter-variant.test.ts create mode 100644 lambdas/backend-client/src/letter-variant-api-client.ts create mode 100644 lambdas/backend-client/src/schemas/letter-variant.ts create mode 100755 scripts/seed_letter_authoring_virus_scan_test.sh create mode 100644 tests/test-team/helpers/chunk.ts create mode 100644 utils/utils/src/__tests__/in-memory-cache.test.ts create mode 100644 utils/utils/src/in-memory-cache.ts diff --git a/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..b038abe66 --- /dev/null +++ b/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/__snapshots__/page.test.tsx.snap @@ -0,0 +1,698 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`valid template matches snapshot on initial render 1`] = ` + +
+
+
+
+

+ Choose a printing and postage option +

+

+ We only show options that are available for this letter template. Select one option. +

+

+ + Learn about printing and postage (opens in a new tab). + +

+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ Select + + Name + + Details +
+ + + Select First Class Option + +
+ + + + +
+
+ + + Name + + + + + + Details + +
    +
  • + Sheet size: A4 +
  • +
  • + Maximum number of sheets: 4 +
  • +
  • + Print on both sides: yes +
  • +
  • + Print colour: colour +
  • +
  • + Envelope size: C4 +
  • +
  • + Dispatch time: standard +
  • +
  • + Postage: first class +
  • +
+
+ + + Select Economy Option + +
+ + + + +
+
+ + + Name + + + + + + Details + +
    +
  • + Sheet size: A4 +
  • +
  • + Maximum number of sheets: 2 +
  • +
  • + Print on both sides: no +
  • +
  • + Print colour: black +
  • +
  • + Envelope size: C5 +
  • +
  • + Dispatch time: standard +
  • +
  • + Postage: economy +
  • +
+
+
+
+ + + Go back + +
+
+
+
+
+
+
+`; + +exports[`valid template renders errors when no variant is selected and error state is returned 1`] = ` + +
+
+ +
+
+

+ Choose a printing and postage option +

+

+ We only show options that are available for this letter template. Select one option. +

+

+ + Learn about printing and postage (opens in a new tab). + +

+
+ + + + +
+ + + Error: + + Choose a printing and postage option + + + + + + + + + + + + + + + + + + + + + +
+ Select + + Name + + Details +
+ + + Select First Class Option + +
+ + + + +
+
+ + + Name + + + + + + Details + +
    +
  • + Sheet size: A4 +
  • +
  • + Maximum number of sheets: 4 +
  • +
  • + Print on both sides: yes +
  • +
  • + Print colour: colour +
  • +
  • + Envelope size: C4 +
  • +
  • + Dispatch time: standard +
  • +
  • + Postage: first class +
  • +
+
+ + + Select Economy Option + +
+ + + + +
+
+ + + Name + + + + + + Details + +
    +
  • + Sheet size: A4 +
  • +
  • + Maximum number of sheets: 2 +
  • +
  • + Print on both sides: no +
  • +
  • + Print colour: black +
  • +
  • + Envelope size: C5 +
  • +
  • + Dispatch time: standard +
  • +
  • + Postage: economy +
  • +
+
+
+
+ + + Go back + +
+
+
+
+
+
+
+`; diff --git a/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/page.test.tsx b/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/page.test.tsx new file mode 100644 index 000000000..e69225de0 --- /dev/null +++ b/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/page.test.tsx @@ -0,0 +1,415 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { LetterVariant, TemplateDto } from 'nhs-notify-backend-client'; +import { fetchClient } from '@utils/server-features'; +import { getLetterVariantsForTemplate, getTemplate } from '@utils/form-actions'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { choosePrintingAndPostage } from '@app/choose-printing-and-postage/[templateId]/server-action'; +import Page, { + metadata, +} from '@app/choose-printing-and-postage/[templateId]/page'; + +jest.mock('next/navigation'); +jest.mock('@utils/server-features'); +jest.mock('@utils/form-actions'); +jest.mock('@app/choose-printing-and-postage/[templateId]/server-action'); +jest.mock('@utils/csrf-utils'); + +const mockTemplate: TemplateDto = { + id: 'template-123', + name: 'Test Letter Template', + templateType: 'LETTER', + letterVersion: 'AUTHORING', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 5, + language: 'en', + letterType: 'x0', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + clientId: 'client-123', + letterVariantId: 'variant-1', + files: { + initialRender: { + pageCount: 2, + currentVersion: 'version', + fileName: 'name.pdf', + status: 'RENDERED', + }, + }, +}; + +const mockVariants: LetterVariant[] = [ + { + id: 'variant-1', + name: 'First Class Option', + bothSides: true, + dispatchTime: 'standard', + envelopeSize: 'C4', + maxSheets: 4, + postage: 'first class', + printColour: 'colour', + sheetSize: 'A4', + status: 'PROD', + type: 'STANDARD', + }, + { + id: 'variant-2', + name: 'Economy Option', + bothSides: false, + dispatchTime: 'standard', + envelopeSize: 'C5', + maxSheets: 2, + postage: 'economy', + printColour: 'black', + sheetSize: 'A4', + status: 'PROD', + type: 'STANDARD', + }, +]; + +const validSearchParams = Promise.resolve({ lockNumber: '7' }); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(choosePrintingAndPostage).mockResolvedValue({ + errorState: { + formErrors: [], + fieldErrors: {}, + }, + fields: {}, + }); + jest.mocked(fetchClient).mockResolvedValue({ + features: { + letterAuthoring: true, + }, + }); + jest.mocked(getTemplate).mockResolvedValue(mockTemplate); + jest.mocked(getLetterVariantsForTemplate).mockResolvedValue(mockVariants); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); +}); + +test('metadata', () => { + expect(metadata).toEqual({ + title: 'Choose a printing and postage option - 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' }), + searchParams: validSearchParams, + }); + + 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' }), + searchParams: validSearchParams, + }); + + expect(redirect).toHaveBeenCalledWith( + '/templates/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' }), + searchParams: validSearchParams, + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('client has letter authoring feature flag disabled', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + features: { + letterAuthoring: false, + }, + }); + }); + + it('redirects to message templates page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); + + 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' }), + searchParams: validSearchParams, + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-submitted-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('lockNumber search parameter validation fails', () => { + it('redirects to preview letter template page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: Promise.resolve({ lockNumber: 'invalid-lock-number' }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('variants is undefined', () => { + beforeEach(() => { + jest.mocked(getLetterVariantsForTemplate).mockResolvedValue(undefined); + }); + + it('redirects to preview letter template page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('variants is empty array', () => { + beforeEach(() => { + jest.mocked(getLetterVariantsForTemplate).mockResolvedValue([]); + }); + + it('redirects to preview letter template page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-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' }), + searchParams: validSearchParams, + }) + ).asFragment() + ).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render( + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) + ); + + const variantRadio = screen.getByRole('radio', { name: 'Economy Option' }); + await user.click(variantRadio); + + await user.click(screen.getByRole('button', { name: 'Save and continue' })); + + expect(choosePrintingAndPostage).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(choosePrintingAndPostage).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('letterVariantId')).toBe('variant-2'); + expect(formData.get('templateId')).toBe('template-123'); + expect(formData.get('lockNumber')).toBe('7'); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when no variant is selected and error state is returned', async () => { + jest.mocked(choosePrintingAndPostage).mockResolvedValue({ + errorState: { + formErrors: [], + fieldErrors: { + letterVariantId: ['Choose a printing and postage option'], + }, + }, + fields: {}, + }); + + const user = userEvent.setup(); + + const page = render( + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) + ); + + await user.click(screen.getByRole('button', { name: 'Save and continue' })); + + expect(choosePrintingAndPostage).toHaveBeenCalledTimes(1); + + expect( + await screen.findByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); + + it('displays the back link with correct href', async () => { + render( + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) + ); + + const backLink = screen.getByRole('link', { name: 'Go back' }); + expect(backLink).toHaveAttribute( + 'href', + '/preview-letter-template/template-123' + ); + }); + + it('displays variant details correctly', async () => { + render( + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) + ); + + expect( + screen.getByRole('radio', { name: 'First Class Option' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: 'Economy Option' }) + ).toBeInTheDocument(); + + for (const [index, variant] of mockVariants.entries()) { + expect(screen.getAllByText(/Sheet size/)[index]).toHaveTextContent( + variant.sheetSize + ); + expect( + screen.getAllByText(/Maximum number of sheets/)[index] + ).toHaveTextContent(String(variant.maxSheets)); + expect( + screen.getAllByText(/Print on both sides/)[index] + ).toHaveTextContent(variant.bothSides ? 'yes' : 'no'); + expect(screen.getAllByText(/Print colour/)[index]).toHaveTextContent( + variant.printColour + ); + expect(screen.getAllByText(/Envelope size/)[index]).toHaveTextContent( + variant.envelopeSize + ); + expect(screen.getAllByText(/Dispatch time/)[index]).toHaveTextContent( + variant.dispatchTime + ); + expect(screen.getAllByText(/Postage/)[index]).toHaveTextContent( + variant.postage + ); + } + }); + + it('pre-selects the current letterVariantId from template', async () => { + render( + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) + ); + + const standardOptionRadio = screen.getByRole('radio', { + name: 'First Class Option', + }); + expect(standardOptionRadio).toBeChecked(); + }); +}); diff --git a/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/server-action.test.ts b/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/server-action.test.ts new file mode 100644 index 000000000..4fd67bd45 --- /dev/null +++ b/frontend/src/__tests__/app/choose-printing-and-postage/[templateId]/server-action.test.ts @@ -0,0 +1,112 @@ +import { redirect } from 'next/navigation'; +import { NextRedirectError } from '@testhelpers/next-redirect'; +import { patchTemplate } from '@utils/form-actions'; +import { choosePrintingAndPostage } from '@app/choose-printing-and-postage/[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('choosePrintingAndPostage', () => { + it('patches the template and redirects when all fields are valid', async () => { + const formData = new FormData(); + formData.append('letterVariantId', 'variant-456'); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '5'); + + await expect(choosePrintingAndPostage({}, formData)).rejects.toMatchObject({ + message: 'NEXT_REDIRECT', + url: '/preview-letter-template/template-123', + }); + + expect(patchTemplate).toHaveBeenCalledWith( + 'template-123', + { letterVariantId: 'variant-456' }, + 5 + ); + }); + + it('returns validation error when letterVariantId is empty', async () => { + const formData = new FormData(); + formData.append('letterVariantId', ''); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '5'); + + const result = await choosePrintingAndPostage({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + letterVariantId: ['Choose a printing and postage option'], + }, + }); + }); + + it('returns validation error when letterVariantId is missing', async () => { + const formData = new FormData(); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '5'); + + const result = await choosePrintingAndPostage({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + letterVariantId: ['Choose a printing and postage option'], + }, + }); + }); + + it('returns validation error when templateId is missing', async () => { + const formData = new FormData(); + formData.append('letterVariantId', 'variant-456'); + formData.append('lockNumber', '5'); + + const result = await choosePrintingAndPostage({}, 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('letterVariantId', 'variant-456'); + formData.append('templateId', 'template-123'); + + const result = await choosePrintingAndPostage({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + lockNumber: [expect.any(String)], + }, + }); + }); + + it('returns validation error when lockNumber is invalid', async () => { + const formData = new FormData(); + formData.append('letterVariantId', 'variant-456'); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', 'invalid'); + + const result = await choosePrintingAndPostage({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + lockNumber: [expect.any(String)], + }, + }); + }); +}); diff --git a/frontend/src/__tests__/app/edit-template-campaign/[templateId]/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/edit-template-campaign/[templateId]/__snapshots__/page.test.tsx.snap index 84294951f..3071625cb 100644 --- a/frontend/src/__tests__/app/edit-template-campaign/[templateId]/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/edit-template-campaign/[templateId]/__snapshots__/page.test.tsx.snap @@ -41,7 +41,7 @@ exports[`valid template matches snapshot on initial render 1`] = ` name="lockNumber" readonly="" type="hidden" - value="5" + value="7" />
{ jest.resetAllMocks(); jest.mocked(editTemplateCampaign).mockResolvedValue({}); @@ -62,7 +64,10 @@ describe('template does not exist', () => { }); it('redirects to invalid template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/invalid-template', @@ -88,7 +93,10 @@ describe('template is not a letter', () => { }); it('redirects to message templates page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/message-templates', @@ -122,7 +130,10 @@ describe('letter version is not AUTHORING', () => { }); it('redirects to preview letter template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/preview-letter-template/template-123', @@ -142,7 +153,10 @@ describe('client has letter authoring feature flag disabled', () => { }); it('redirects to message templates page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/message-templates', @@ -160,7 +174,10 @@ describe('template has been submitted', () => { }); it('redirects to preview submitted letter template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/preview-submitted-letter-template/template-123', @@ -180,7 +197,24 @@ describe('client only has one campaign', () => { }); it('redirects to preview letter template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-letter-template/template-123', + RedirectType.replace + ); + }); +}); + +describe('lockNumber search parameter validation fails', () => { + it('redirects to preview letter template page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: Promise.resolve({ lockNumber: 'invalid-lock-number' }), + }); expect(redirect).toHaveBeenCalledWith( '/preview-letter-template/template-123', @@ -193,7 +227,10 @@ describe('valid template', () => { it('matches snapshot on initial render', async () => { expect( render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ).asFragment() ).toMatchSnapshot(); }); @@ -202,7 +239,10 @@ describe('valid template', () => { const user = userEvent.setup(); render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ); const campaignSelect = screen.getByLabelText('Edit template campaign'); @@ -220,7 +260,7 @@ describe('valid template', () => { expect(formData.get('campaignId')).toBe('Campaign 2'); expect(formData.get('templateId')).toBe('template-123'); - expect(formData.get('lockNumber')).toBe('5'); + expect(formData.get('lockNumber')).toBe('7'); expect( screen.queryByRole('alert', { name: 'There is a problem' }) @@ -239,7 +279,10 @@ describe('valid template', () => { const user = userEvent.setup(); const page = render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ); const campaignSelect = screen.getByLabelText('Edit template campaign'); @@ -258,7 +301,10 @@ describe('valid template', () => { it('displays the cancel link with correct href', async () => { render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ); const cancelLink = screen.getByRole('link', { name: 'Go back' }); 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 index 25706850d..fe3d1460e 100644 --- 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 @@ -41,7 +41,7 @@ exports[`valid template matches snapshot on initial render 1`] = ` name="lockNumber" readonly="" type="hidden" - value="5" + value="7" />
{ jest.resetAllMocks(); jest.mocked(editTemplateName).mockResolvedValue({}); @@ -62,7 +64,10 @@ describe('template does not exist', () => { }); it('redirects to invalid template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/invalid-template', @@ -88,7 +93,10 @@ describe('template is not a letter', () => { }); it('redirects to message templates page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/message-templates', @@ -122,7 +130,10 @@ describe('letter version is not AUTHORING', () => { }); it('redirects to preview letter template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/preview-letter-template/template-123', @@ -142,7 +153,10 @@ describe('client has letter authoring feature flag disabled', () => { }); it('redirects to message templates page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/message-templates', @@ -160,7 +174,10 @@ describe('template has been submitted', () => { }); it('redirects to preview submitted letter template page', async () => { - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }); + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }); expect(redirect).toHaveBeenCalledWith( '/preview-submitted-letter-template/template-123', @@ -169,11 +186,28 @@ describe('template has been submitted', () => { }); }); +describe('lockNumber search parameter validation fails', () => { + it('redirects to preview letter template page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: Promise.resolve({ lockNumber: 'invalid-lock-number' }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/preview-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' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ).asFragment() ).toMatchSnapshot(); }); @@ -182,7 +216,10 @@ describe('valid template', () => { const user = userEvent.setup(); render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ); const nameInput = screen.getByLabelText('Edit template name'); @@ -202,7 +239,7 @@ describe('valid template', () => { expect(formData.get('name')).toBe('Updated Template Name'); expect(formData.get('templateId')).toBe('template-123'); - expect(formData.get('lockNumber')).toBe('5'); + expect(formData.get('lockNumber')).toBe('7'); expect( screen.queryByRole('alert', { name: 'There is a problem' }) @@ -221,7 +258,10 @@ describe('valid template', () => { const user = userEvent.setup(); const page = render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ); const nameInput = screen.getByLabelText('Edit template name'); @@ -240,7 +280,10 @@ describe('valid template', () => { it('displays the cancel link with correct href', async () => { render( - await Page({ params: Promise.resolve({ templateId: 'template-123' }) }) + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + searchParams: validSearchParams, + }) ); const cancelLink = screen.getByRole('link', { name: 'Go back' }); diff --git a/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap index fb78c9781..c360634be 100644 --- a/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap @@ -565,7 +565,7 @@ exports[`authoring letter with validation errors matches snapshot with error and >