From 7fdbac02ef26da4114f691673732c38ff6bd500b Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Thu, 13 Nov 2025 12:47:26 +0000 Subject: [PATCH 1/9] CCM-11537: Backend filter --- .../forms/CopyTemplate/CopyTemplate.test.tsx | 14 +- .../src/__tests__/utils/form-actions.test.ts | 35 +- .../src/__tests__/utils/message-plans.test.ts | 16 +- .../app/copy-template/[templateId]/page.tsx | 8 +- .../forms/CopyTemplate/CopyTemplate.tsx | 4 +- .../forms/CopyTemplate/server-action.ts | 4 +- frontend/src/utils/form-actions.ts | 19 +- frontend/src/utils/message-plans.ts | 2 +- .../modules/backend-api/spec.tmpl.json | 49 +- .../src/__tests__/api/list.test.ts | 96 +++- .../src/__tests__/app/template-client.test.ts | 111 +++-- .../src/__tests__/fixtures/template.ts | 154 ++++++ .../routing-config-repository/query.test.ts | 16 +- .../infra/template-repository/query.test.ts | 466 ++++++++++++++++++ .../repository.test.ts} | 204 ++------ lambdas/backend-api/src/api/list.ts | 11 +- .../backend-api/src/app/template-client.ts | 45 +- .../backend-api/src/infra/abstract-query.ts | 143 ++++++ .../infra/routing-config-repository/query.ts | 145 +----- .../src/infra/template-repository/index.ts | 1 + .../src/infra/template-repository/query.ts | 63 +++ .../repository.ts} | 50 +- .../routing-config-api-client.test.ts | 4 +- ...mplate-schema.test.ts => template.test.ts} | 55 +-- lambdas/backend-client/src/index.ts | 2 +- .../src/routing-config-api-client.ts | 12 +- lambdas/backend-client/src/schemas/index.ts | 2 +- .../src/schemas/routing-config.ts | 2 +- .../{template-schema.ts => template.ts} | 98 ++-- .../backend-client/src/template-api-client.ts | 7 +- lambdas/backend-client/src/types/filters.ts | 7 + .../src/types/generated/types.gen.ts | 23 +- utils/utils/src/types.ts | 7 + utils/utils/src/zod-validators.ts | 22 +- 34 files changed, 1324 insertions(+), 573 deletions(-) create mode 100644 lambdas/backend-api/src/__tests__/fixtures/template.ts create mode 100644 lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts rename lambdas/backend-api/src/__tests__/infra/{template-repository.test.ts => template-repository/repository.test.ts} (92%) create mode 100644 lambdas/backend-api/src/infra/abstract-query.ts create mode 100644 lambdas/backend-api/src/infra/template-repository/index.ts create mode 100644 lambdas/backend-api/src/infra/template-repository/query.ts rename lambdas/backend-api/src/infra/{template-repository.ts => template-repository/repository.ts} (94%) rename lambdas/backend-client/src/__tests__/schemas/{template-schema.test.ts => template.test.ts} (80%) rename lambdas/backend-client/src/schemas/{template-schema.ts => template.ts} (63%) create mode 100644 lambdas/backend-client/src/types/filters.ts diff --git a/frontend/src/__tests__/components/forms/CopyTemplate/CopyTemplate.test.tsx b/frontend/src/__tests__/components/forms/CopyTemplate/CopyTemplate.test.tsx index 09b41bb21..e436e597e 100644 --- a/frontend/src/__tests__/components/forms/CopyTemplate/CopyTemplate.test.tsx +++ b/frontend/src/__tests__/components/forms/CopyTemplate/CopyTemplate.test.tsx @@ -5,7 +5,7 @@ import { mockDeep } from 'jest-mock-extended'; import { render, screen, fireEvent } from '@testing-library/react'; import { CopyTemplate, ValidCopyType } from '@forms/CopyTemplate/CopyTemplate'; import { TemplateFormState } from 'nhs-notify-web-template-management-utils'; -import { ValidatedTemplateDto } from 'nhs-notify-backend-client'; +import { TemplateDto } from 'nhs-notify-backend-client'; jest.mock('@utils/amplify-utils'); @@ -32,9 +32,7 @@ describe('Choose template page', () => { it('selects one radio button at a time', () => { const container = render( ()} + template={mockDeep()} /> ); expect(container.asFragment()).toMatchSnapshot(); @@ -84,9 +82,7 @@ describe('Choose template page', () => { const container = render( ()} + template={mockDeep()} /> ); expect(container.asFragment()).toMatchSnapshot(); @@ -95,9 +91,7 @@ describe('Choose template page', () => { test('Client-side validation triggers', () => { const container = render( ()} + template={mockDeep()} /> ); const submitButton = screen.getByTestId('submit-button'); diff --git a/frontend/src/__tests__/utils/form-actions.test.ts b/frontend/src/__tests__/utils/form-actions.test.ts index 60f0ab629..829799d35 100644 --- a/frontend/src/__tests__/utils/form-actions.test.ts +++ b/frontend/src/__tests__/utils/form-actions.test.ts @@ -18,7 +18,7 @@ import { createRoutingConfig, } from '@utils/form-actions'; import { getSessionServer } from '@utils/amplify-utils'; -import { TemplateDto } from 'nhs-notify-backend-client'; +import { TemplateDto, TemplateStatus } from 'nhs-notify-backend-client'; import { templateApiClient } from 'nhs-notify-backend-client/src/template-api-client'; import { routingConfigurationApiClient } from 'nhs-notify-backend-client/src/routing-config-api-client'; import { randomUUID } from 'node:crypto'; @@ -539,6 +539,39 @@ describe('form-actions', () => { expect(actualOrder).toEqual(expectedOrder); }); + test('getTemplates - invalid templates are not listed', async () => { + const validTemplate: TemplateDto = { + templateType: 'SMS', + templateStatus: 'SUBMITTED', + name: 'Template', + message: 'Message', + createdAt: '2020-01-01T00:00:00.000Z', + id: '02', + updatedAt: '2021-01-01T00:00:00.000Z', + lockNumber: 1, + }; + + mockedTemplateClient.listTemplates.mockResolvedValueOnce({ + data: [ + { + templateType: 'SMS', + templateStatus: undefined as unknown as TemplateStatus, + name: 'Template', + message: 'Message', + createdAt: '2020-01-01T00:00:00.000Z', + id: '01', + updatedAt: '2021-01-01T00:00:00.000Z', + lockNumber: 1, + }, + validTemplate, + ], + }); + + const response = await getTemplates(); + + expect(response).toEqual([validTemplate]); + }); + describe('setTemplateToSubmitted', () => { test('submitTemplate successfully', async () => { const responseData = { diff --git a/frontend/src/__tests__/utils/message-plans.test.ts b/frontend/src/__tests__/utils/message-plans.test.ts index 380e53e55..9d8d22a45 100644 --- a/frontend/src/__tests__/utils/message-plans.test.ts +++ b/frontend/src/__tests__/utils/message-plans.test.ts @@ -251,17 +251,13 @@ describe('Message plans actions', () => { const completedCount = await countRoutingConfigs('COMPLETED'); expect(draftCount).toEqual(1); - expect(routingConfigApiMock.count).toHaveBeenNthCalledWith( - 1, - 'token', - 'DRAFT' - ); + expect(routingConfigApiMock.count).toHaveBeenNthCalledWith(1, 'token', { + status: 'DRAFT', + }); expect(completedCount).toEqual(5); - expect(routingConfigApiMock.count).toHaveBeenNthCalledWith( - 2, - 'token', - 'COMPLETED' - ); + expect(routingConfigApiMock.count).toHaveBeenNthCalledWith(2, 'token', { + status: 'COMPLETED', + }); }); }); diff --git a/frontend/src/app/copy-template/[templateId]/page.tsx b/frontend/src/app/copy-template/[templateId]/page.tsx index 20334e4a4..62889fe79 100644 --- a/frontend/src/app/copy-template/[templateId]/page.tsx +++ b/frontend/src/app/copy-template/[templateId]/page.tsx @@ -2,16 +2,18 @@ import { redirect, RedirectType } from 'next/navigation'; import { CopyTemplate } from '@forms/CopyTemplate/CopyTemplate'; -import { TemplatePageProps } from 'nhs-notify-web-template-management-utils'; +import { + TemplatePageProps, + validateTemplate, +} from 'nhs-notify-web-template-management-utils'; import { getTemplate } from '@utils/form-actions'; -import { isTemplateDtoValid } from 'nhs-notify-backend-client'; const CopyTemplatePage = async (props: TemplatePageProps) => { const { templateId } = await props.params; const template = await getTemplate(templateId); - const validatedTemplate = isTemplateDtoValid(template); + const validatedTemplate = validateTemplate(template); if (!validatedTemplate) { return redirect('/invalid-template', RedirectType.replace); diff --git a/frontend/src/components/forms/CopyTemplate/CopyTemplate.tsx b/frontend/src/components/forms/CopyTemplate/CopyTemplate.tsx index 361f5465e..8aabfbf69 100644 --- a/frontend/src/components/forms/CopyTemplate/CopyTemplate.tsx +++ b/frontend/src/components/forms/CopyTemplate/CopyTemplate.tsx @@ -10,7 +10,7 @@ import { } from 'nhs-notify-web-template-management-utils'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import { $CopyTemplate, copyTemplateAction } from './server-action'; -import { TemplateType, ValidatedTemplateDto } from 'nhs-notify-backend-client'; +import { TemplateDto, TemplateType } from 'nhs-notify-backend-client'; import { validate } from '@utils/client-validate-form'; import Link from 'next/link'; import NotifyBackLink from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; @@ -18,7 +18,7 @@ import NotifyBackLink from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; export type ValidCopyType = Exclude; type CopyTemplate = { - template: ValidatedTemplateDto & { templateType: ValidCopyType }; + template: TemplateDto & { templateType: ValidCopyType }; }; export const CopyTemplate = ({ template }: CopyTemplate) => { diff --git a/frontend/src/components/forms/CopyTemplate/server-action.ts b/frontend/src/components/forms/CopyTemplate/server-action.ts index a14d6db6c..9a0274ad3 100644 --- a/frontend/src/components/forms/CopyTemplate/server-action.ts +++ b/frontend/src/components/forms/CopyTemplate/server-action.ts @@ -5,8 +5,8 @@ import { createTemplate } from '@utils/form-actions'; import { format } from 'date-fns/format'; import { TEMPLATE_TYPE_LIST, + TemplateDto, TemplateType, - ValidatedTemplateDto, } from 'nhs-notify-backend-client'; import content from '@content/content'; @@ -17,7 +17,7 @@ export const $CopyTemplate = z.object({ }); type CopyTemplateActionState = FormState & { - template: ValidatedTemplateDto & { + template: TemplateDto & { templateType: Exclude; }; }; diff --git a/frontend/src/utils/form-actions.ts b/frontend/src/utils/form-actions.ts index fd3c57379..7cfffebb8 100644 --- a/frontend/src/utils/form-actions.ts +++ b/frontend/src/utils/form-actions.ts @@ -2,11 +2,10 @@ import { getSessionServer } from '@utils/amplify-utils'; import { + $TemplateDto, CreateUpdateTemplate, - isTemplateDtoValid, RoutingConfig, TemplateDto, - ValidatedTemplateDto, } from 'nhs-notify-backend-client'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; import { templateApiClient } from 'nhs-notify-backend-client/src/template-api-client'; @@ -191,13 +190,17 @@ export async function getTemplates(): Promise { return []; } - const sortedData = data - .map((template) => isTemplateDtoValid(template)) - .filter( - (template): template is ValidatedTemplateDto => template !== undefined - ); + const valid = data.filter((d) => { + const { error: validationError, success } = $TemplateDto.safeParse(d); - return sortAscByUpdatedAt(sortedData); + if (!success) { + logger.error('Listed invalid template', validationError); + } + + return success; + }); + + return sortAscByUpdatedAt(valid); } export async function createRoutingConfig( diff --git a/frontend/src/utils/message-plans.ts b/frontend/src/utils/message-plans.ts index 2c6f31675..d8cc32935 100644 --- a/frontend/src/utils/message-plans.ts +++ b/frontend/src/utils/message-plans.ts @@ -53,7 +53,7 @@ export async function countRoutingConfigs( const { data, error } = await routingConfigurationApiClient.count( accessToken, - status + { status } ); if (error) { diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index f61fe4033..f9d0763af 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -766,7 +766,7 @@ ], "type": "object" }, - "TemplateStatus": { + "TemplateStatusActive": { "enum": [ "DELETED", "NOT_YET_SUBMITTED", @@ -781,6 +781,19 @@ ], "type": "string" }, + "TemplateStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/TemplateStatusActive" + }, + { + "enum": [ + "DELETED" + ], + "type": "string" + } + ] + }, "TemplateSuccess": { "properties": { "data": { @@ -1942,6 +1955,40 @@ "/v1/templates": { "get": { "description": "List all templates", + "parameters": [ + { + "description": "Filter by a single active status", + "in": "query", + "name": "templateStatus", + "schema": { + "$ref": "#/components/schemas/TemplateStatusActive" + } + }, + { + "description": "Filter by a single template type", + "in": "query", + "name": "templateType", + "schema": { + "$ref": "#/components/schemas/TemplateType" + } + }, + { + "description": "Filter by a single language", + "in": "query", + "name": "language", + "schema": { + "$ref": "#/components/schemas/Language" + } + }, + { + "description": "Filter by a single accessible letter type", + "in": "query", + "name": "letterType", + "schema": { + "$ref": "#/components/schemas/LetterType" + } + } + ], "responses": { "200": { "content": { diff --git a/lambdas/backend-api/src/__tests__/api/list.test.ts b/lambdas/backend-api/src/__tests__/api/list.test.ts index c7d083dca..78301a255 100644 --- a/lambdas/backend-api/src/__tests__/api/list.test.ts +++ b/lambdas/backend-api/src/__tests__/api/list.test.ts @@ -76,12 +76,13 @@ describe('Template API - List', () => { }, }); - const event = mock({ - requestContext: { - authorizer: { user: 'sub', clientId: 'nhs-notify-client-id' }, - }, - pathParameters: { templateId: '1' }, - }); + const event = mock(); + event.requestContext.authorizer = { + user: 'sub', + clientId: 'nhs-notify-client-id', + }; + event.pathParameters = { templateId: '1' }; + event.queryStringParameters = null; const result = await handler(event, mock(), jest.fn()); @@ -93,13 +94,16 @@ describe('Template API - List', () => { }), }); - expect(mocks.templateClient.listTemplates).toHaveBeenCalledWith({ - userId: 'sub', - clientId: 'nhs-notify-client-id', - }); + expect(mocks.templateClient.listTemplates).toHaveBeenCalledWith( + { + userId: 'sub', + clientId: 'nhs-notify-client-id', + }, + null + ); }); - test('should return template', async () => { + test('should return template with no filters', async () => { const { handler, mocks } = setup(); const template: TemplateDto = { @@ -118,11 +122,13 @@ describe('Template API - List', () => { data: [template], }); - const event = mock({ - requestContext: { - authorizer: { user: 'sub', clientId: 'nhs-notify-client-id' }, - }, - }); + const event = mock(); + event.requestContext.authorizer = { + user: 'sub', + clientId: 'nhs-notify-client-id', + }; + + event.queryStringParameters = null; const result = await handler(event, mock(), jest.fn()); @@ -131,9 +137,63 @@ describe('Template API - List', () => { body: JSON.stringify({ statusCode: 200, data: [template] }), }); - expect(mocks.templateClient.listTemplates).toHaveBeenCalledWith({ - userId: 'sub', + expect(mocks.templateClient.listTemplates).toHaveBeenCalledWith( + { + userId: 'sub', + clientId: 'nhs-notify-client-id', + }, + null + ); + }); + + test('should return template with filters', async () => { + const { handler, mocks } = setup(); + + const template: TemplateDto = { + id: 'id', + templateType: 'EMAIL', + name: 'name', + message: 'message', + subject: 'subject', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }; + + mocks.templateClient.listTemplates.mockResolvedValueOnce({ + data: [template], + }); + + const event = mock(); + event.requestContext.authorizer = { + user: 'sub', clientId: 'nhs-notify-client-id', + }; + + event.queryStringParameters = { + templateType: 'LETTER', + language: 'en', + letterType: 'x0', + }; + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, data: [template] }), }); + + expect(mocks.templateClient.listTemplates).toHaveBeenCalledWith( + { + userId: 'sub', + clientId: 'nhs-notify-client-id', + }, + { + templateType: 'LETTER', + language: 'en', + letterType: 'x0', + } + ); }); }); 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 c6207416c..b872a16c0 100644 --- a/lambdas/backend-api/src/__tests__/app/template-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/template-client.test.ts @@ -10,12 +10,16 @@ import type { import { TemplateRepository } from '../../infra'; import { TemplateClient } from '../../app/template-client'; import { LetterUploadRepository } from '../../infra/letter-upload-repository'; -import { DatabaseTemplate } from 'nhs-notify-web-template-management-utils'; +import { + DatabaseTemplate, + TemplateFilter, +} from 'nhs-notify-web-template-management-utils'; import { ProofingQueue } from '../../infra/proofing-queue'; import { createMockLogger } from 'nhs-notify-web-template-management-test-helper-utils/mock-logger'; import { isoDateRegExp } from 'nhs-notify-web-template-management-test-helper-utils'; import { ClientConfigRepository } from '../../infra/client-config-repository'; import { isRightToLeft } from 'nhs-notify-web-template-management-utils/enum'; +import { TemplateQuery } from '../../infra/template-repository/query'; jest.mock('node:crypto'); jest.mock('nhs-notify-web-template-management-utils/enum'); @@ -50,6 +54,14 @@ const setup = () => { isRightToLeftMock.mockReturnValueOnce(false); + const queryMock = mock({ + templateStatus: jest.fn().mockReturnThis(), + excludeTemplateStatus: jest.fn().mockReturnThis(), + templateType: jest.fn().mockReturnThis(), + language: jest.fn().mockReturnThis(), + letterType: jest.fn().mockReturnThis(), + }); + return { templateClient, mocks: { @@ -59,6 +71,7 @@ const setup = () => { logger, clientConfigRepository, isRightToLeftMock, + queryMock, }, logMessages, }; @@ -1570,9 +1583,13 @@ describe('templateClient', () => { describe('listTemplates', () => { test('listTemplates should return a failure result, when fetching from the database unexpectedly fails', async () => { - const { templateClient, mocks } = setup(); + const { + templateClient, + mocks: { templateRepository, queryMock }, + } = setup(); - mocks.templateRepository.list.mockResolvedValueOnce({ + templateRepository.query.mockReturnValueOnce(queryMock); + queryMock.list.mockResolvedValueOnce({ error: { errorMeta: { code: 500, @@ -1583,7 +1600,7 @@ describe('templateClient', () => { const result = await templateClient.listTemplates(user); - expect(mocks.templateRepository.list).toHaveBeenCalledWith(user.clientId); + expect(templateRepository.query).toHaveBeenCalledWith(user.clientId); expect(result).toEqual({ error: { @@ -1595,8 +1612,11 @@ describe('templateClient', () => { }); }); - test('should filter out invalid templates', async () => { - const { templateClient, mocks } = setup(); + test('should return templates', async () => { + const { + templateClient, + mocks: { templateRepository, queryMock }, + } = setup(); const template: TemplateDto = { id: templateId, @@ -1609,37 +1629,63 @@ describe('templateClient', () => { templateStatus: 'NOT_YET_SUBMITTED', lockNumber: 1, }; - const template2: TemplateDto = { - id: undefined as unknown as string, - templateType: 'EMAIL', - name: undefined as unknown as string, - message: 'message', - subject: 'subject', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - templateStatus: 'NOT_YET_SUBMITTED', - lockNumber: 1, - }; - mocks.templateRepository.list.mockResolvedValueOnce({ - data: [ - { ...template, owner: user.userId, version: 1 }, - { ...template2, owner: user.userId, version: 1 }, - ], + templateRepository.query.mockReturnValueOnce(queryMock); + + queryMock.list.mockResolvedValueOnce({ + data: [template], }); - const result = await templateClient.listTemplates(user); + const result = await templateClient.listTemplates(user, null); - expect(mocks.templateRepository.list).toHaveBeenCalledWith(user.clientId); + expect(templateRepository.query).toHaveBeenCalledWith(user.clientId); + expect(queryMock.excludeTemplateStatus).toHaveBeenCalledWith(['DELETED']); + expect(queryMock.templateStatus).toHaveBeenCalledWith([]); + expect(queryMock.templateType).toHaveBeenCalledWith([]); + expect(queryMock.language).toHaveBeenCalledWith([]); + expect(queryMock.letterType).toHaveBeenCalledWith([]); expect(result).toEqual({ data: [template], }); }); - test('should return templates', async () => { + it('validates status filter parameter', async () => { const { templateClient, mocks } = setup(); + const result = await templateClient.listTemplates(user, { + templateType: 'INVALID', + }); + + expect(result).toEqual({ + error: expect.objectContaining({ + errorMeta: { + code: 400, + description: 'Request failed validation', + details: { + templateType: + 'Invalid option: expected one of "NHS_APP"|"EMAIL"|"SMS"|"LETTER"', + }, + }, + }), + }); + + expect(mocks.templateRepository.query).not.toHaveBeenCalled(); + }); + + test('uses filters', async () => { + const { + templateClient, + mocks: { templateRepository, queryMock }, + } = setup(); + + const filter: TemplateFilter = { + templateStatus: 'SUBMITTED', + templateType: 'NHS_APP', + language: 'en', + letterType: 'x0', + }; + const template: TemplateDto = { id: templateId, templateType: 'EMAIL', @@ -1652,13 +1698,20 @@ describe('templateClient', () => { lockNumber: 1, }; - mocks.templateRepository.list.mockResolvedValueOnce({ - data: [{ ...template, owner: user.userId, version: 1 }], + templateRepository.query.mockReturnValueOnce(queryMock); + + queryMock.list.mockResolvedValueOnce({ + data: [template], }); - const result = await templateClient.listTemplates(user); + const result = await templateClient.listTemplates(user, filter); - expect(mocks.templateRepository.list).toHaveBeenCalledWith(user.clientId); + expect(templateRepository.query).toHaveBeenCalledWith(user.clientId); + expect(queryMock.excludeTemplateStatus).toHaveBeenCalledWith(['DELETED']); + expect(queryMock.templateStatus).toHaveBeenCalledWith(['SUBMITTED']); + expect(queryMock.templateType).toHaveBeenCalledWith(['NHS_APP']); + expect(queryMock.language).toHaveBeenCalledWith(['en']); + expect(queryMock.letterType).toHaveBeenCalledWith(['x0']); expect(result).toEqual({ data: [template], diff --git a/lambdas/backend-api/src/__tests__/fixtures/template.ts b/lambdas/backend-api/src/__tests__/fixtures/template.ts new file mode 100644 index 000000000..0d05e27b4 --- /dev/null +++ b/lambdas/backend-api/src/__tests__/fixtures/template.ts @@ -0,0 +1,154 @@ +import type { + CreateUpdateTemplate, + EmailProperties, + NhsAppProperties, + SmsProperties, + TemplateDto, + UploadLetterProperties, +} from 'nhs-notify-backend-client'; +import { WithAttachments } from '../../infra/template-repository'; +import { DatabaseTemplate } from 'nhs-notify-web-template-management-utils'; + +export const userId = 'user-id'; +export const clientId = 'client-id'; +export const ownerWithClientPrefix = `CLIENT#${clientId}`; +export const user = { userId, clientId }; + +const emailProperties: EmailProperties = { + message: 'message', + subject: 'pickles', + templateType: 'EMAIL', +}; + +const smsProperties: SmsProperties = { + message: 'message', + templateType: 'SMS', +}; + +const nhsAppProperties: NhsAppProperties = { + message: 'message', + templateType: 'NHS_APP', +}; + +const letterProperties: WithAttachments = { + templateType: 'LETTER', + letterType: 'x0', + language: 'en', + files: { + pdfTemplate: { + fileName: 'template.pdf', + currentVersion: 'a', + virusScanStatus: 'PENDING', + }, + testDataCsv: { + fileName: 'test.csv', + currentVersion: 'a', + virusScanStatus: 'PENDING', + }, + }, + campaignId: 'campaign-id', +}; + +const createTemplateProperties = { name: 'name' }; + +const dtoProperties = { + templateStatus: 'NOT_YET_SUBMITTED' as const, + id: 'abc-def-ghi-jkl-123', + createdAt: '2024-12-27T00:00:00.000Z', + updatedAt: '2024-12-27T00:00:00.000Z', + updatedBy: userId, + clientId, + createdBy: userId, + lockNumber: 0, +}; + +const databaseTemplateProperties = { + owner: ownerWithClientPrefix, + version: 1, +}; + +export type TemplateFixture = { + createUpdateTemplate: WithAttachments & T; + dtoTemplate: TemplateDto & T; + databaseTemplate: DatabaseTemplate & T; +}; + +export const makeAppTemplate = ( + overrides: Partial = {} +): TemplateFixture => { + const createUpdateTemplate = { + ...createTemplateProperties, + ...nhsAppProperties, + ...overrides, + }; + const dtoTemplate = { + ...createUpdateTemplate, + ...dtoProperties, + }; + const databaseTemplate = { + ...dtoTemplate, + ...databaseTemplateProperties, + }; + + return { createUpdateTemplate, dtoTemplate, databaseTemplate }; +}; + +export const makeEmailTemplate = ( + overrides: Partial = {} +): TemplateFixture => { + const createUpdateTemplate = { + ...createTemplateProperties, + ...emailProperties, + ...overrides, + }; + const dtoTemplate = { + ...createUpdateTemplate, + ...dtoProperties, + }; + const databaseTemplate = { + ...dtoTemplate, + ...databaseTemplateProperties, + }; + + return { createUpdateTemplate, dtoTemplate, databaseTemplate }; +}; + +export const makeSmsTemplate = ( + overrides: Partial = {} +): TemplateFixture => { + const createUpdateTemplate = { + ...createTemplateProperties, + ...smsProperties, + ...overrides, + }; + const dtoTemplate = { + ...createUpdateTemplate, + ...dtoProperties, + }; + const databaseTemplate = { + ...dtoTemplate, + ...databaseTemplateProperties, + }; + + return { createUpdateTemplate, dtoTemplate, databaseTemplate }; +}; + +export const makeLetterTemplate = ( + overrides: Partial> = {} +): TemplateFixture => { + const createUpdateTemplate = { + ...createTemplateProperties, + ...letterProperties, + ...overrides, + }; + const dtoTemplate = { + ...createUpdateTemplate, + ...dtoProperties, + }; + const databaseTemplate = { + ...dtoTemplate, + ...databaseTemplateProperties, + }; + + return { createUpdateTemplate, dtoTemplate, databaseTemplate }; +}; diff --git a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/query.test.ts b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/query.test.ts index 21c36fdd8..0624cf08c 100644 --- a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/query.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/query.test.ts @@ -127,16 +127,16 @@ describe('RoutingConfigRepo#query', () => { TableName: TABLE_NAME, KeyConditionExpression: '#owner = :owner', FilterExpression: - '(#status <> :notStatus0 AND #status <> :notStatus1 AND #status <> :notStatus2)', + 'NOT(#status IN (:notstatus0, :notstatus1, :notstatus2))', ExpressionAttributeNames: { '#owner': 'owner', '#status': 'status', }, ExpressionAttributeValues: { ':owner': clientOwnerKey, - ':notStatus0': 'COMPLETED', - ':notStatus1': 'DELETED', - ':notStatus2': 'DRAFT', + ':notstatus0': 'COMPLETED', + ':notstatus1': 'DELETED', + ':notstatus2': 'DRAFT', }, }); }); @@ -159,14 +159,14 @@ describe('RoutingConfigRepo#query', () => { TableName: TABLE_NAME, KeyConditionExpression: '#owner = :owner', FilterExpression: - '(#status IN (:status0)) AND (#status <> :notStatus0)', + '(#status IN (:status0)) AND NOT(#status IN (:notstatus0))', ExpressionAttributeNames: { '#owner': 'owner', '#status': 'status', }, ExpressionAttributeValues: { ':owner': clientOwnerKey, - ':notStatus0': 'DELETED', + ':notstatus0': 'DELETED', ':status0': 'DRAFT', }, }); @@ -192,14 +192,14 @@ describe('RoutingConfigRepo#query', () => { TableName: TABLE_NAME, KeyConditionExpression: '#owner = :owner', FilterExpression: - '(#status IN (:status0)) AND (#status <> :notStatus0)', + '(#status IN (:status0)) AND NOT(#status IN (:notstatus0))', ExpressionAttributeNames: { '#owner': 'owner', '#status': 'status', }, ExpressionAttributeValues: { ':owner': clientOwnerKey, - ':notStatus0': 'DELETED', + ':notstatus0': 'DELETED', ':status0': 'DRAFT', }, }); diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts new file mode 100644 index 000000000..fb664ad9a --- /dev/null +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts @@ -0,0 +1,466 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import 'aws-sdk-client-mock-jest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { TemplateRepository } from '../../../infra'; +import { + makeAppTemplate, + makeEmailTemplate, + makeLetterTemplate, + makeSmsTemplate, +} from '../../fixtures/template'; +import { DatabaseTemplate } from 'nhs-notify-web-template-management-utils'; + +jest.mock('nhs-notify-web-template-management-utils/logger'); + +const TABLE_NAME = 'template-table-name'; + +const clientId = '89077697-ca6d-47fc-b233-3281fbd15579'; +const clientOwnerKey = `CLIENT#${clientId}`; + +const appTemplates = makeAppTemplate(); +const emailTemplates = makeEmailTemplate(); +const smsTemplates = makeSmsTemplate(); +const letterTemplates = makeLetterTemplate(); + +function setup() { + const dynamo = mockClient(DynamoDBDocumentClient); + + const repo = new TemplateRepository( + // pass an actual doc client - it gets intercepted up by mockClient, + // but paginateQuery needs the real deal + DynamoDBDocumentClient.from(new DynamoDBClient({})), + TABLE_NAME + ); + + const mocks = { dynamo }; + + return { mocks, repo }; +} + +describe('TemplateRepo#query', () => { + describe('list', () => { + test('queries by owner, paginates across pages, returns all items', async () => { + const { repo, mocks } = setup(); + + const page1: DatabaseTemplate[] = [ + appTemplates.databaseTemplate, + emailTemplates.databaseTemplate, + ]; + const page2: DatabaseTemplate[] = [ + smsTemplates.databaseTemplate, + letterTemplates.databaseTemplate, + ]; + + mocks.dynamo + .on(QueryCommand) + .resolvesOnce({ + Items: page1, + LastEvaluatedKey: { + owner: clientOwnerKey, + id: emailTemplates.databaseTemplate.id, + }, + }) + .resolvesOnce({ + Items: page2, + }); + + const result = await repo.query(clientId).list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + }, + ExclusiveStartKey: { + owner: clientOwnerKey, + id: emailTemplates.databaseTemplate.id, + }, + }); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + }, + }); + + expect(result.data).toEqual([ + appTemplates.dtoTemplate, + emailTemplates.dtoTemplate, + smsTemplates.dtoTemplate, + letterTemplates.dtoTemplate, + ]); + }); + + test('supports filtering by status (chainable)', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(clientId) + .templateStatus(['SUBMITTED', 'DELETED']) + .templateStatus(['NOT_YET_SUBMITTED']) + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#templateStatus IN (:templateStatus0, :templateStatus1, :templateStatus2))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#templateStatus': 'templateStatus', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':templateStatus0': 'SUBMITTED', + ':templateStatus1': 'DELETED', + ':templateStatus2': 'NOT_YET_SUBMITTED', + }, + }); + }); + + test('supports excluding statuses (chainable)', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(clientId) + .excludeTemplateStatus(['SUBMITTED', 'DELETED']) + .excludeTemplateStatus(['NOT_YET_SUBMITTED']) + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + 'NOT(#templateStatus IN (:nottemplateStatus0, :nottemplateStatus1, :nottemplateStatus2))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#templateStatus': 'templateStatus', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':nottemplateStatus0': 'SUBMITTED', + ':nottemplateStatus1': 'DELETED', + ':nottemplateStatus2': 'NOT_YET_SUBMITTED', + }, + }); + }); + + test('supports filtering by template type (chainable)', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(clientId) + .templateType(['SMS', 'NHS_APP']) + .templateType(['EMAIL']) + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#templateType IN (:templateType0, :templateType1, :templateType2))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#templateType': 'templateType', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':templateType0': 'SMS', + ':templateType1': 'NHS_APP', + ':templateType2': 'EMAIL', + }, + }); + }); + + test('supports filtering by language(chainable)', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo.query(clientId).language(['en', 'fr']).language(['es']).list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: '(#language IN (:language0, :language1, :language2))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#language': 'language', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':language0': 'en', + ':language1': 'fr', + ':language2': 'es', + }, + }); + }); + + test('supports filtering by letter type (chainable)', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(clientId) + .letterType(['x0', 'x1']) + .letterType(['q4']) + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#letterType IN (:letterType0, :letterType1, :letterType2))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#letterType': 'letterType', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':letterType0': 'x0', + ':letterType1': 'x1', + ':letterType2': 'q4', + }, + }); + }); + + test('supports mixed filters', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(clientId) + .templateStatus(['SUBMITTED']) + .excludeTemplateStatus(['DELETED']) + .templateType(['LETTER']) + .language(['en']) + .letterType(['x0']) + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#templateStatus IN (:templateStatus0)) AND NOT(#templateStatus IN (:nottemplateStatus0)) AND (#templateType IN (:templateType0)) AND (#language IN (:language0)) AND (#letterType IN (:letterType0))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#templateStatus': 'templateStatus', + '#templateType': 'templateType', + '#language': 'language', + '#letterType': 'letterType', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':nottemplateStatus0': 'DELETED', + ':templateStatus0': 'SUBMITTED', + ':templateType0': 'LETTER', + ':language0': 'en', + ':letterType0': 'x0', + }, + }); + }); + + test('dedupes filters', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + await repo + .query(clientId) + .templateStatus(['SUBMITTED']) + .templateStatus(['SUBMITTED']) + .excludeTemplateStatus(['DELETED']) + .excludeTemplateStatus(['DELETED']) + .templateType(['LETTER']) + .templateType(['LETTER']) + .language(['en']) + .language(['en']) + .letterType(['x0']) + .letterType(['x0']) + .list(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); + expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + FilterExpression: + '(#templateStatus IN (:templateStatus0)) AND NOT(#templateStatus IN (:nottemplateStatus0)) AND (#templateType IN (:templateType0)) AND (#language IN (:language0)) AND (#letterType IN (:letterType0))', + ExpressionAttributeNames: { + '#owner': 'owner', + '#templateStatus': 'templateStatus', + '#templateType': 'templateType', + '#language': 'language', + '#letterType': 'letterType', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + ':nottemplateStatus0': 'DELETED', + ':templateStatus0': 'SUBMITTED', + ':templateType0': 'LETTER', + ':language0': 'en', + ':letterType0': 'x0', + }, + }); + }); + + test('filters out invalid template items', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({ + Items: [ + appTemplates.databaseTemplate, + { owner: clientOwnerKey, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' }, + emailTemplates.databaseTemplate, + ], + }); + + const result = await repo.query(clientId).list(); + + expect(result.data).toEqual([ + appTemplates.dtoTemplate, + emailTemplates.dtoTemplate, + ]); + }); + + test('handles no items from dynamo', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({}); + + const result = await repo.query(clientId).list(); + + expect(result.data).toEqual([]); + }); + + test('handles exceptions from dynamodb', async () => { + const { repo, mocks } = setup(); + + const e = new Error('oh no'); + + mocks.dynamo.on(QueryCommand).rejectsOnce(e); + + const result = await repo.query(clientId).list(); + + expect(result.error).toMatchObject({ + actualError: e, + errorMeta: expect.objectContaining({ code: 500 }), + }); + expect(result.data).toBeUndefined(); + }); + }); + + describe('count', () => { + test('queries by owner, paginates across pages, returns count of items', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo + .on(QueryCommand) + .resolvesOnce({ + Count: 2, + LastEvaluatedKey: { + owner: clientOwnerKey, + id: emailTemplates.databaseTemplate.id, + }, + }) + .resolvesOnce({ + Count: 1, + }); + + const result = await repo.query(clientId).count(); + + expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + }, + Select: 'COUNT', + ExclusiveStartKey: { + owner: clientOwnerKey, + id: emailTemplates.databaseTemplate.id, + }, + }); + expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { + TableName: TABLE_NAME, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: { + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':owner': clientOwnerKey, + }, + Select: 'COUNT', + }); + + expect(result.data).toEqual({ count: 3 }); + }); + + test('handles no items from dynamo', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo.on(QueryCommand).resolvesOnce({}); + + const result = await repo.query(clientId).count(); + + expect(result.data).toEqual({ count: 0 }); + }); + + test('handles exceptions from dynamodb', async () => { + const { repo, mocks } = setup(); + + const e = new Error('oh no'); + + mocks.dynamo.on(QueryCommand).rejectsOnce(e); + + const result = await repo.query(clientId).count(); + + expect(result.error).toMatchObject({ + actualError: e, + errorMeta: expect.objectContaining({ code: 500 }), + }); + expect(result.data).toBeUndefined(); + }); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts similarity index 92% rename from lambdas/backend-api/src/__tests__/infra/template-repository.test.ts rename to lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts index 3bee7e17e..ec1525d69 100644 --- a/lambdas/backend-api/src/__tests__/infra/template-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts @@ -9,18 +9,22 @@ import { } from '@aws-sdk/lib-dynamodb'; import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { mockClient } from 'aws-sdk-client-mock'; -import { - EmailProperties, - NhsAppProperties, - SmsProperties, - UploadLetterProperties, - ValidatedCreateUpdateTemplateNonLetter, -} from 'nhs-notify-backend-client'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; -import { TemplateRepository, WithAttachments } from '../../infra'; +import { TemplateRepository } from '../../../infra'; import { marshall } from '@aws-sdk/util-dynamodb'; -import { DatabaseTemplate } from 'nhs-notify-web-template-management-utils'; +import { + CreateUpdateEmailTemplate, + CreateUpdateNHSAppTemplate, + CreateUpdateSMSTemplate, + DatabaseTemplate, +} from 'nhs-notify-web-template-management-utils'; import { calculateTTL } from '@backend-api/utils/calculate-ttl'; +import { + makeAppTemplate, + makeEmailTemplate, + makeLetterTemplate, + makeSmsTemplate, +} from '@backend-api/__tests__/fixtures/template'; jest.mock('nhs-notify-web-template-management-utils/logger'); jest.mock('node:crypto'); @@ -29,6 +33,11 @@ jest.mock('@backend-api/utils/calculate-ttl'); const templateId = 'abc-def-ghi-jkl-123'; const templatesTableName = 'templates'; +const appTemplates = makeAppTemplate(); +const emailTemplates = makeEmailTemplate(); +const smsTemplates = makeSmsTemplate(); +const letterTemplates = makeLetterTemplate(); + const setup = () => { const ddbDocClient = mockClient(DynamoDBDocumentClient); @@ -45,80 +54,6 @@ const clientId = 'client-id'; const ownerWithClientPrefix = `CLIENT#${clientId}`; const user = { userId, clientId }; -const emailProperties: EmailProperties = { - message: 'message', - subject: 'pickles', - templateType: 'EMAIL', -}; - -const smsProperties: SmsProperties = { - message: 'message', - templateType: 'SMS', -}; - -const nhsAppProperties: NhsAppProperties = { - message: 'message', - templateType: 'NHS_APP', -}; - -const letterProperties: WithAttachments = { - templateType: 'LETTER', - letterType: 'x0', - language: 'en', - files: { - pdfTemplate: { - fileName: 'template.pdf', - currentVersion: 'a', - virusScanStatus: 'PENDING', - }, - testDataCsv: { - fileName: 'test.csv', - currentVersion: 'a', - virusScanStatus: 'PENDING', - }, - }, - campaignId: 'campaign-id', -}; - -const createTemplateProperties = { name: 'name' }; - -const updateTemplateProperties = { - ...createTemplateProperties, - templateStatus: 'NOT_YET_SUBMITTED' as const, -}; - -const databaseTemplateProperties = { - ...updateTemplateProperties, - id: 'abc-def-ghi-jkl-123', - owner: `CLIENT#${clientId}`, - version: 1, - createdAt: '2024-12-27T00:00:00.000Z', - updatedAt: '2024-12-27T00:00:00.000Z', - updatedBy: userId, - clientId, - createdBy: userId, -}; - -const emailTemplate: DatabaseTemplate = { - ...emailProperties, - ...databaseTemplateProperties, -}; - -const smsTemplate: DatabaseTemplate = { - ...smsProperties, - ...databaseTemplateProperties, -}; - -const nhsAppTemplate: DatabaseTemplate = { - ...nhsAppProperties, - ...databaseTemplateProperties, -}; - -const letterTemplate: DatabaseTemplate = { - ...letterProperties, - ...databaseTemplateProperties, -}; - describe('templateRepository', () => { beforeAll(() => { jest.useFakeTimers(); @@ -218,63 +153,11 @@ describe('templateRepository', () => { TableName: templatesTableName, Key: { id: templateId, owner: ownerWithClientPrefix }, }) - .resolves({ Item: emailTemplate }); + .resolves({ Item: emailTemplates.databaseTemplate }); const response = await templateRepository.get(templateId, clientId); - expect(response).toEqual({ data: emailTemplate }); - }); - }); - - describe('list', () => { - test('should return an empty array when no items', async () => { - const { templateRepository, mocks } = setup(); - - mocks.ddbDocClient.on(QueryCommand).resolves({ Items: undefined }); - - const response = await templateRepository.list(clientId); - - expect(response).toEqual({ data: [] }); - }); - - test('should error when unexpected error occurs', async () => { - const { templateRepository, mocks } = setup(); - - mocks.ddbDocClient - .on(QueryCommand) - .rejects(new Error('InternalServerError')); - const response = await templateRepository.list(clientId); - - expect(response).toEqual({ - error: { - errorMeta: { - code: 500, - description: 'Failed to list templates', - }, - actualError: new Error('InternalServerError'), - }, - }); - }); - - test('should return templates', async () => { - const { templateRepository, mocks } = setup(); - - mocks.ddbDocClient - .on(QueryCommand, { - TableName: templatesTableName, - KeyConditionExpression: '#owner = :owner', - ExpressionAttributeNames: { '#owner': 'owner' }, - ExpressionAttributeValues: { ':owner': ownerWithClientPrefix }, - }) - .resolves({ - Items: [emailTemplate, smsTemplate, nhsAppTemplate, letterTemplate], - }); - - const response = await templateRepository.list(clientId); - - expect(response).toEqual({ - data: [emailTemplate, smsTemplate, nhsAppTemplate, letterTemplate], - }); + expect(response).toEqual({ data: emailTemplates.databaseTemplate }); }); }); @@ -307,43 +190,32 @@ describe('templateRepository', () => { }); }); - test.each([ - emailProperties, - smsProperties, - nhsAppProperties, - letterProperties, - ])( + test.each([emailTemplates, smsTemplates, appTemplates, letterTemplates])( 'should create template of type $templateType', - async (channelProperties) => { + async ({ createUpdateTemplate, databaseTemplate }) => { const { templateRepository, mocks } = setup(); - const template = { - ...channelProperties, - ...databaseTemplateProperties, - lockNumber: 0, - }; - mocks.ddbDocClient .on(PutCommand, { TableName: templatesTableName, - Item: template, + Item: databaseTemplate, }) .resolves({}); const response = await templateRepository.create( - { ...channelProperties, ...createTemplateProperties }, + createUpdateTemplate, user, 'NOT_YET_SUBMITTED', 'campaign-id' ); expect(response).toEqual({ - data: template, + data: databaseTemplate, }); expect(mocks.ddbDocClient).toHaveReceivedCommandWith(PutCommand, { ConditionExpression: 'attribute_not_exists(id)', - Item: template, + Item: databaseTemplate, TableName: templatesTableName, }); } @@ -354,15 +226,13 @@ describe('templateRepository', () => { test('should correctly update email template and return updated value', async () => { const { templateRepository, mocks } = setup(); - const requestedUpdate: ValidatedCreateUpdateTemplateNonLetter = { - ...emailProperties, - ...updateTemplateProperties, + const requestedUpdate: CreateUpdateEmailTemplate = { + ...emailTemplates.createUpdateTemplate, name: 'updated-name', }; const updated: DatabaseTemplate = { - ...emailProperties, - ...databaseTemplateProperties, + ...emailTemplates.databaseTemplate, ...requestedUpdate, lockNumber: 2, }; @@ -427,15 +297,13 @@ describe('templateRepository', () => { test('should correctly update sms template and return updated value', async () => { const { templateRepository, mocks } = setup(); - const requestedUpdate: ValidatedCreateUpdateTemplateNonLetter = { - ...smsProperties, - ...updateTemplateProperties, + const requestedUpdate: CreateUpdateSMSTemplate = { + ...smsTemplates.createUpdateTemplate, name: 'updated-name', }; const updated: DatabaseTemplate = { - ...smsProperties, - ...databaseTemplateProperties, + ...smsTemplates.databaseTemplate, ...requestedUpdate, lockNumber: 2, }; @@ -498,15 +366,13 @@ describe('templateRepository', () => { test('should correctly update nhsapp template and return updated value', async () => { const { templateRepository, mocks } = setup(); - const requestedUpdate: ValidatedCreateUpdateTemplateNonLetter = { - ...nhsAppProperties, - ...updateTemplateProperties, + const requestedUpdate: CreateUpdateNHSAppTemplate = { + ...appTemplates.createUpdateTemplate, name: 'updated-name', }; const updated: DatabaseTemplate = { - ...nhsAppProperties, - ...databaseTemplateProperties, + ...appTemplates.databaseTemplate, ...requestedUpdate, lockNumber: 2, }; diff --git a/lambdas/backend-api/src/api/list.ts b/lambdas/backend-api/src/api/list.ts index b6f285edf..34177b64e 100644 --- a/lambdas/backend-api/src/api/list.ts +++ b/lambdas/backend-api/src/api/list.ts @@ -14,10 +14,13 @@ export function createHandler({ return apiFailure(400, 'Invalid request'); } - const { data, error } = await templateClient.listTemplates({ - userId, - clientId, - }); + const { data, error } = await templateClient.listTemplates( + { + userId, + clientId, + }, + event.queryStringParameters + ); if (error) { return apiFailure( diff --git a/lambdas/backend-api/src/app/template-client.ts b/lambdas/backend-api/src/app/template-client.ts index 5c9388818..56a5112ab 100644 --- a/lambdas/backend-api/src/app/template-client.ts +++ b/lambdas/backend-api/src/app/template-client.ts @@ -6,14 +6,15 @@ import { TemplateDto, CreateUpdateTemplate, ErrorCase, - isTemplateDtoValid, LetterFiles, TemplateStatus, $CreateUpdateNonLetter, ClientConfiguration, $LockNumber, + $TemplateDto, + $ListTemplateFilters, + ListTemplateFilters, } from 'nhs-notify-backend-client'; -import { TemplateRepository } from '../infra'; import { LETTER_MULTIPART } from 'nhs-notify-backend-client/src/schemas/constants'; import { $UploadLetterTemplate, @@ -27,6 +28,7 @@ import { z } from 'zod/v4'; import { LetterUploadRepository } from '../infra/letter-upload-repository'; import { ProofingQueue } from '../infra/proofing-queue'; import { ClientConfigRepository } from '../infra/client-config-repository'; +import { TemplateRepository } from '../infra'; export class TemplateClient { private $LetterForProofing = z.intersection( @@ -415,22 +417,33 @@ export class TemplateClient { return success(templateDTO); } - async listTemplates(user: User): Promise> { - const listResult = await this.templateRepository.list(user.clientId); + async listTemplates( + user: User, + filters?: unknown + ): Promise> { + let parsedFilters: ListTemplateFilters = {}; + + if (filters) { + const validation = await validate($ListTemplateFilters, filters); - if (listResult.error) { - this.logger - .child({ ...listResult.error.errorMeta, user }) - .error('Failed to list templates', listResult.error.actualError); + if (validation.error) { + return validation; + } - return listResult; + parsedFilters = validation.data; } - const templateDTOs = listResult.data - .map((template) => this.mapDatabaseObjectToDTO(template)) - .flatMap((t) => t ?? []); + const { templateStatus, templateType, language, letterType } = + parsedFilters; + const query = this.templateRepository + .query(user.clientId) + .excludeTemplateStatus(['DELETED']) + .templateStatus(templateStatus ? [templateStatus] : []) + .templateType(templateType ? [templateType] : []) + .language(language ? [language] : []) + .letterType(letterType ? [letterType] : []); - return success(templateDTOs); + return query.list(); } async requestProof( @@ -627,6 +640,10 @@ export class TemplateClient { private mapDatabaseObjectToDTO( databaseTemplate: DatabaseTemplate ): TemplateDto | undefined { - return isTemplateDtoValid(databaseTemplate); + const parseResult = $TemplateDto.safeParse(databaseTemplate); + if (!parseResult.success) { + this.logger.child(databaseTemplate).error('Failed to parse template'); + } + return parseResult.data; } } diff --git a/lambdas/backend-api/src/infra/abstract-query.ts b/lambdas/backend-api/src/infra/abstract-query.ts new file mode 100644 index 000000000..22b48bfb1 --- /dev/null +++ b/lambdas/backend-api/src/infra/abstract-query.ts @@ -0,0 +1,143 @@ +import { z } from 'zod/v4'; +import { + paginateQuery, + type DynamoDBDocumentClient, + type NativeAttributeValue, + type QueryCommandInput, +} from '@aws-sdk/lib-dynamodb'; +import { ApplicationResult, failure, success } from '@backend-api/utils/result'; +import { ErrorCase } from 'nhs-notify-backend-client'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; + +export type FilterAction = 'INCLUDE' | 'EXCLUDE'; + +export abstract class AbstractQuery { + private returnCount = false; + + private ExpressionAttributeNames: Record = {}; + private ExpressionAttributeValues: Record = {}; + private filters: string[] = []; + + constructor( + private readonly docClient: DynamoDBDocumentClient, + private readonly objectName: string, + private readonly objectSchema: z.ZodType, + private readonly tableName: string, + private readonly owner: string + ) {} + + /** Execute the query and return a list of all matching items */ + async list(): Promise> { + try { + this.returnCount = false; + + const query = this.build(); + + const collected: T[] = []; + + const paginator = paginateQuery({ client: this.docClient }, query); + + for await (const page of paginator) { + for (const item of page.Items ?? []) { + const parsed = this.objectSchema.safeParse(item); + if (parsed.success) { + collected.push(parsed.data); + } else { + logger.warn(`Filtered out invalid ${this.objectName} item`, { + owner: this.owner, + id: item.id, + issues: parsed.error.issues, + }); + } + } + } + + return success(collected); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + `Error listing ${this.objectName}s`, + error + ); + } + } + + /** Execute the query and return a count of all matching items */ + async count(): Promise> { + try { + this.returnCount = true; + + const query = this.build(); + + let count = 0; + + const paginator = paginateQuery({ client: this.docClient }, query); + + for await (const page of paginator) { + if (page.Count) { + count += page.Count; + } + } + + return success({ count }); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + `Error counting ${this.objectName}s`, + error + ); + } + } + + private build() { + this.ExpressionAttributeNames['#owner'] = 'owner'; + this.ExpressionAttributeValues[':owner'] = this.owner; + + this.addFilters(); + + const query: QueryCommandInput = { + TableName: this.tableName, + KeyConditionExpression: '#owner = :owner', + ExpressionAttributeNames: this.ExpressionAttributeNames, + ExpressionAttributeValues: this.ExpressionAttributeValues, + }; + + if (this.filters.length > 0) { + query.FilterExpression = this.filters.join(' AND '); + } + + if (this.returnCount) { + query.Select = 'COUNT'; + } + + return query; + } + + protected abstract addFilters(): void; + + protected addFilterToQuery( + fieldName: string, + action: FilterAction, + fieldValues: string[] + ) { + if (fieldValues.length > 0) { + const attributeName = `#${fieldName}`; + if (!this.ExpressionAttributeNames[attributeName]) { + this.ExpressionAttributeNames[attributeName] = fieldName; + } + + const uniqueValues = [...new Set(fieldValues)]; + const attributeValues: string[] = []; + + for (const [i, value] of uniqueValues.entries()) { + const attributeValue = `:${action === 'EXCLUDE' ? 'not' : ''}${fieldName}${i}`; + this.ExpressionAttributeValues[attributeValue] = value; + attributeValues.push(attributeValue); + } + + this.filters.push( + `${action === 'EXCLUDE' ? 'NOT' : ''}(${attributeName} IN (${attributeValues.join(', ')}))` + ); + } + } +} diff --git a/lambdas/backend-api/src/infra/routing-config-repository/query.ts b/lambdas/backend-api/src/infra/routing-config-repository/query.ts index 7f56e430a..127db786a 100644 --- a/lambdas/backend-api/src/infra/routing-config-repository/query.ts +++ b/lambdas/backend-api/src/infra/routing-config-repository/query.ts @@ -1,28 +1,22 @@ -import { - paginateQuery, - type DynamoDBDocumentClient, - type NativeAttributeValue, - type QueryCommandInput, -} from '@aws-sdk/lib-dynamodb'; -import { ApplicationResult, failure, success } from '@backend-api/utils/result'; +import { type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { $RoutingConfig, - ErrorCase, type RoutingConfig, type RoutingConfigStatus, } from 'nhs-notify-backend-client'; -import { logger } from 'nhs-notify-web-template-management-utils/logger'; +import { AbstractQuery } from '../abstract-query'; -export class RoutingConfigQuery { +export class RoutingConfigQuery extends AbstractQuery { private includeStatuses: RoutingConfigStatus[] = []; private excludeStatuses: RoutingConfigStatus[] = []; - private returnCount = false; constructor( - private readonly docClient: DynamoDBDocumentClient, - private readonly tableName: string, - private readonly owner: string - ) {} + docClient: DynamoDBDocumentClient, + tableName: string, + owner: string + ) { + super(docClient, 'Routing Config', $RoutingConfig, tableName, owner); + } /** Include items with any of the given statuses. */ status(...statuses: RoutingConfigStatus[]) { @@ -36,123 +30,8 @@ export class RoutingConfigQuery { return this; } - /** Execute the query and return a list of all matching RoutingConfigs */ - async list(): Promise> { - try { - this.returnCount = false; - - const query = this.build(); - - const collected: RoutingConfig[] = []; - - const paginator = paginateQuery({ client: this.docClient }, query); - - for await (const page of paginator) { - for (const item of page.Items ?? []) { - const parsed = $RoutingConfig.safeParse(item); - if (parsed.success) { - collected.push(parsed.data); - } else { - logger.warn('Filtered out invalid RoutingConfig item', { - owner: this.owner, - id: item.id, - issues: parsed.error.issues, - }); - } - } - } - - return success(collected); - } catch (error) { - return failure( - ErrorCase.INTERNAL, - 'Error listing Routing Configs', - error - ); - } - } - - /** Execute the query and return a count of all matching RoutingConfigs */ - async count(): Promise> { - try { - this.returnCount = true; - - const query = this.build(); - - let count = 0; - - const paginator = paginateQuery({ client: this.docClient }, query); - - for await (const page of paginator) { - if (page.Count) { - count += page.Count; - } - } - - return success({ count }); - } catch (error) { - return failure( - ErrorCase.INTERNAL, - 'Error counting Routing Configs', - error - ); - } - } - - private build() { - const query: QueryCommandInput = { - TableName: this.tableName, - KeyConditionExpression: '#owner = :owner', - }; - - const ExpressionAttributeNames: Record = { - '#owner': 'owner', - }; - - const ExpressionAttributeValues: Record = { - ':owner': this.owner, - }; - - query.ExpressionAttributeNames = ExpressionAttributeNames; - query.ExpressionAttributeValues = ExpressionAttributeValues; - - const filters: string[] = []; - - if (this.includeStatuses.length + this.excludeStatuses.length > 0) { - ExpressionAttributeNames['#status'] = 'status'; - } - - if (this.includeStatuses.length > 0) { - const uniq = [...new Set(this.includeStatuses)]; - const placeholders: string[] = []; - - for (const [i, s] of uniq.entries()) { - const ph = `:status${i}`; - ExpressionAttributeValues[ph] = s; - placeholders.push(ph); - } - filters.push(`(#status IN (${placeholders.join(', ')}))`); - } - - if (this.excludeStatuses.length > 0) { - const uniq = [...new Set(this.excludeStatuses)]; - const parts: string[] = []; - for (const [i, s] of uniq.entries()) { - const ph = `:notStatus${i}`; - ExpressionAttributeValues[ph] = s; - parts.push(`#status <> ${ph}`); - } - filters.push(`(${parts.join(' AND ')})`); - } - - if (filters.length > 0) { - query.FilterExpression = filters.join(' AND '); - } - - if (this.returnCount) { - query.Select = 'COUNT'; - } - - return query; + protected addFilters(): void { + this.addFilterToQuery('status', 'INCLUDE', this.includeStatuses); + this.addFilterToQuery('status', 'EXCLUDE', this.excludeStatuses); } } diff --git a/lambdas/backend-api/src/infra/template-repository/index.ts b/lambdas/backend-api/src/infra/template-repository/index.ts new file mode 100644 index 000000000..6dcbad91f --- /dev/null +++ b/lambdas/backend-api/src/infra/template-repository/index.ts @@ -0,0 +1 @@ +export * from './repository'; diff --git a/lambdas/backend-api/src/infra/template-repository/query.ts b/lambdas/backend-api/src/infra/template-repository/query.ts new file mode 100644 index 000000000..fc4c12152 --- /dev/null +++ b/lambdas/backend-api/src/infra/template-repository/query.ts @@ -0,0 +1,63 @@ +import { type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { + $TemplateDto, + Language, + LetterType, + TemplateDto, + TemplateStatus, + TemplateType, +} from 'nhs-notify-backend-client'; +import { AbstractQuery } from '../abstract-query'; + +export class TemplateQuery extends AbstractQuery { + private includeStatuses: TemplateStatus[] = []; + private excludeStatuses: TemplateStatus[] = []; + private includeTemplateTypes: TemplateType[] = []; + private includeLanguages: Language[] = []; + private includeLetterTypes: LetterType[] = []; + + constructor( + docClient: DynamoDBDocumentClient, + tableName: string, + owner: string + ) { + super(docClient, 'Template', $TemplateDto, tableName, owner); + } + + /** Include items with any of the given template statuses. */ + templateStatus(statuses: TemplateStatus[]) { + this.includeStatuses.push(...statuses); + return this; + } + + /** Exclude items with any of the given template statuses. */ + excludeTemplateStatus(statuses: TemplateStatus[]) { + this.excludeStatuses.push(...statuses); + return this; + } + + /** Include items with any of the given template types. */ + templateType(templateTypes: TemplateType[]) { + this.includeTemplateTypes.push(...templateTypes); + return this; + } + + /** Include items with any of the given languages. */ + language(languages: Language[]) { + this.includeLanguages.push(...languages); + return this; + } + + letterType(letterTypes: LetterType[]) { + this.includeLetterTypes.push(...letterTypes); + return this; + } + + protected addFilters(): void { + this.addFilterToQuery('templateStatus', 'INCLUDE', this.includeStatuses); + this.addFilterToQuery('templateStatus', 'EXCLUDE', this.excludeStatuses); + this.addFilterToQuery('templateType', 'INCLUDE', this.includeTemplateTypes); + this.addFilterToQuery('language', 'INCLUDE', this.includeLanguages); + this.addFilterToQuery('letterType', 'INCLUDE', this.includeLetterTypes); + } +} diff --git a/lambdas/backend-api/src/infra/template-repository.ts b/lambdas/backend-api/src/infra/template-repository/repository.ts similarity index 94% rename from lambdas/backend-api/src/infra/template-repository.ts rename to lambdas/backend-api/src/infra/template-repository/repository.ts index 2a2ea1f3a..a738f75dd 100644 --- a/lambdas/backend-api/src/infra/template-repository.ts +++ b/lambdas/backend-api/src/infra/template-repository/repository.ts @@ -5,7 +5,6 @@ import { GetCommand, PutCommand, QueryCommand, - QueryCommandInput, UpdateCommand, UpdateCommandInput, } from '@aws-sdk/lib-dynamodb'; @@ -14,10 +13,9 @@ import { ErrorCase, LetterFiles, TemplateStatus, - ValidatedCreateUpdateTemplate, VirusScanStatus, ProofFileDetails, - ValidatedCreateUpdateTemplateNonLetter, + CreateUpdateTemplate, } from 'nhs-notify-backend-client'; import { TemplateUpdateBuilder } from 'nhs-notify-entity-update-command-builder'; import type { @@ -28,7 +26,8 @@ import type { } from 'nhs-notify-web-template-management-utils'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; import { calculateTTL } from '@backend-api/utils/calculate-ttl'; -import { ApplicationResult, failure, success } from '../utils'; +import { ApplicationResult, failure, success } from '../../utils'; +import { TemplateQuery } from './query'; export type WithAttachments = T extends { templateType: 'LETTER' } ? T & { files: LetterFiles } @@ -71,7 +70,7 @@ export class TemplateRepository { } async create( - template: WithAttachments, + template: WithAttachments, user: User, initialStatus: TemplateStatus = 'NOT_YET_SUBMITTED', campaignId?: string @@ -111,7 +110,7 @@ export class TemplateRepository { async update( templateId: string, - template: ValidatedCreateUpdateTemplateNonLetter, + template: Exclude, user: User, expectedStatus: TemplateStatus, lockNumber: number @@ -282,37 +281,12 @@ export class TemplateRepository { } } - async list(clientId: string): Promise> { - try { - const input: QueryCommandInput = { - TableName: this.templatesTableName, - KeyConditionExpression: '#owner = :owner', - ExpressionAttributeNames: { - '#owner': 'owner', - '#status': 'templateStatus', - }, - ExpressionAttributeValues: { - ':owner': this.clientOwnerKey(clientId), - ':deletedStatus': 'DELETED', - }, - FilterExpression: '#status <> :deletedStatus', - }; - - const items: DatabaseTemplate[] = []; - - do { - // eslint-disable-next-line no-await-in-loop - const { Items = [], LastEvaluatedKey } = await this.client.send( - new QueryCommand(input) - ); - - input.ExclusiveStartKey = LastEvaluatedKey; - items.push(...(Items as DatabaseTemplate[])); - } while (input.ExclusiveStartKey); - return success(items); - } catch (error) { - return failure(ErrorCase.INTERNAL, 'Failed to list templates', error); - } + query(clientId: string): TemplateQuery { + return new TemplateQuery( + this.client, + this.templatesTableName, + this.clientOwnerKey(clientId) + ); } async setLetterValidationResult( @@ -727,7 +701,7 @@ export class TemplateRepository { private _handleUpdateError( error: unknown, - template: ValidatedCreateUpdateTemplateNonLetter, + template: Exclude, lockNumber: number ) { if (error instanceof ConditionalCheckFailedException) { diff --git a/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts b/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts index 7e55e5861..edab46a1f 100644 --- a/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts +++ b/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts @@ -85,7 +85,7 @@ describe('RoutingConfigurationApiClient', () => { }, }); - const response = await client.count('token', 'DRAFT'); + const response = await client.count('token', { status: 'DRAFT' }); expect(response.error).toEqual({ errorMeta: { @@ -108,7 +108,7 @@ describe('RoutingConfigurationApiClient', () => { }) .reply(200, { data: { count: 10 } }); - const response = await client.count('token', 'COMPLETED'); + const response = await client.count('token', { status: 'COMPLETED' }); expect(response.data).toEqual({ count: 10 }); diff --git a/lambdas/backend-client/src/__tests__/schemas/template-schema.test.ts b/lambdas/backend-client/src/__tests__/schemas/template.test.ts similarity index 80% rename from lambdas/backend-client/src/__tests__/schemas/template-schema.test.ts rename to lambdas/backend-client/src/__tests__/schemas/template.test.ts index e0403abf0..12178e2d0 100644 --- a/lambdas/backend-client/src/__tests__/schemas/template-schema.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/template.test.ts @@ -2,10 +2,8 @@ import { $UploadLetterProperties, $CreateUpdateNonLetter, $CreateUpdateTemplate, - isCreateUpdateTemplateValid, - isTemplateDtoValid, } from '../../schemas'; -import type { CreateUpdateTemplate, TemplateDto } from '../../types/generated'; +import type { CreateUpdateTemplate } from '../../types/generated'; describe('Template schemas', () => { test.each([ @@ -251,55 +249,4 @@ describe('Template schemas', () => { expect(result.data).toEqual(template); }); }); - - describe('isCreateUpdateTemplateValid', () => { - const template: CreateUpdateTemplate = { - name: 'Test Template', - message: 'This is a test template', - templateType: 'NHS_APP', - }; - - test('Should return template on pass', async () => { - const result = isCreateUpdateTemplateValid(template); - - expect(result).toEqual(template); - }); - - test('Should return undefined on fail', async () => { - const result = isCreateUpdateTemplateValid({ - ...template, - name: undefined, - }); - - expect(result).toEqual(undefined); - }); - }); - - describe('isTemplateDtoValid', () => { - const template: TemplateDto = { - name: 'Test Template', - message: 'This is a test template', - templateType: 'NHS_APP', - templateStatus: 'NOT_YET_SUBMITTED', - id: 'id', - createdAt: '2025-01-13T10:19:25.579Z', - updatedAt: '2025-01-13T10:19:25.579Z', - lockNumber: 1, - }; - - test('Should return template on pass', async () => { - const result = isTemplateDtoValid(template); - - expect(result).toEqual(template); - }); - - test('Should return undefined on fail', async () => { - const result = isTemplateDtoValid({ - ...template, - name: undefined, - }); - - expect(result).toEqual(undefined); - }); - }); }); diff --git a/lambdas/backend-client/src/index.ts b/lambdas/backend-client/src/index.ts index 788a6c137..867416128 100644 --- a/lambdas/backend-client/src/index.ts +++ b/lambdas/backend-client/src/index.ts @@ -1,7 +1,7 @@ export * from './schemas/client'; export * from './schemas/routing-config'; export * from './schemas/schema-for'; -export * from './schemas/template-schema'; +export * from './schemas/template'; export * from './schemas/union-lists'; export * from './types/error-cases'; export * from './types/generated'; diff --git a/lambdas/backend-client/src/routing-config-api-client.ts b/lambdas/backend-client/src/routing-config-api-client.ts index 922a4043b..e8f5158a1 100644 --- a/lambdas/backend-client/src/routing-config-api-client.ts +++ b/lambdas/backend-client/src/routing-config-api-client.ts @@ -6,7 +6,6 @@ import type { GetV1RoutingConfigurationByRoutingConfigIdData, RoutingConfig, RoutingConfigSuccess, - RoutingConfigStatusActive, RoutingConfigSuccessList, PostV1RoutingConfigurationData, PutV1RoutingConfigurationByRoutingConfigIdData, @@ -17,6 +16,7 @@ import { catchAxiosError, createAxiosClient } from './axios-client'; import { Result } from './types/result'; import { OpenApiToTemplate } from './types/open-api-helper'; import { z } from 'zod'; +import { RoutingConfigFilter } from './types/filters'; const uuidSchema = z.uuidv4(); @@ -52,7 +52,7 @@ export const routingConfigurationApiClient = { async count( token: string, - status: RoutingConfigStatusActive + filters?: RoutingConfigFilter ): Promise> { const url = '/v1/routing-configurations/count' satisfies GetV1RoutingConfigurationsCountData['url']; @@ -60,7 +60,7 @@ export const routingConfigurationApiClient = { const { data, error } = await catchAxiosError( httpClient.get(url, { headers: { Authorization: token }, - params: { status }, + params: filters, }) ); @@ -105,13 +105,17 @@ export const routingConfigurationApiClient = { return { ...data }; }, - async list(token: string): Promise> { + async list( + token: string, + filters?: RoutingConfigFilter + ): Promise> { const url = '/v1/routing-configurations' satisfies GetV1RoutingConfigurationsData['url']; const { data, error } = await catchAxiosError( httpClient.get(url, { headers: { Authorization: token }, + params: filters, }) ); diff --git a/lambdas/backend-client/src/schemas/index.ts b/lambdas/backend-client/src/schemas/index.ts index fc52936ba..1b4a22a41 100644 --- a/lambdas/backend-client/src/schemas/index.ts +++ b/lambdas/backend-client/src/schemas/index.ts @@ -1 +1 @@ -export * from './template-schema'; +export * from './template'; diff --git a/lambdas/backend-client/src/schemas/routing-config.ts b/lambdas/backend-client/src/schemas/routing-config.ts index 70cc8f144..e6a78ff54 100644 --- a/lambdas/backend-client/src/schemas/routing-config.ts +++ b/lambdas/backend-client/src/schemas/routing-config.ts @@ -18,7 +18,7 @@ import type { UpdateRoutingConfig, } from '../types/generated'; import { schemaFor } from './schema-for'; -import { $Language, $LetterType } from './template-schema'; +import { $Language, $LetterType } from './template'; import { CASCADE_GROUP_NAME_LIST, CHANNEL_LIST, diff --git a/lambdas/backend-client/src/schemas/template-schema.ts b/lambdas/backend-client/src/schemas/template.ts similarity index 63% rename from lambdas/backend-client/src/schemas/template-schema.ts rename to lambdas/backend-client/src/schemas/template.ts index 89fbbdffe..78cb678a0 100644 --- a/lambdas/backend-client/src/schemas/template-schema.ts +++ b/lambdas/backend-client/src/schemas/template.ts @@ -13,6 +13,10 @@ import { TemplateDto, LetterType, Language, + BaseCreatedTemplate, + TemplateStatus, + TemplateStatusActive, + TemplateType, } from '../types/generated'; import { MAX_EMAIL_CHARACTER_LENGTH, @@ -28,17 +32,6 @@ import { VIRUS_SCAN_STATUS_LIST, } from './union-lists'; -export type ValidatedCreateUpdateTemplate = CreateUpdateTemplate & - (EmailProperties | NhsAppProperties | SmsProperties | UploadLetterProperties); - -export type ValidatedCreateUpdateTemplateNonLetter = Exclude< - ValidatedCreateUpdateTemplate, - { templateType: 'LETTER' } ->; - -export type ValidatedTemplateDto = TemplateDto & - (EmailProperties | NhsAppProperties | SmsProperties | LetterProperties); - export const $LetterType = schemaFor()(z.enum(LETTER_TYPE_LIST)); export const $Language = schemaFor()(z.enum(LANGUAGE_LIST)); @@ -96,13 +89,15 @@ export const $BaseLetterTemplateProperties = z.object({ }); export const $UploadLetterProperties = schemaFor()( - $BaseLetterTemplateProperties.extend({ + z.object({ + ...$BaseLetterTemplateProperties.shape, campaignId: z.string(), }) ); export const $LetterProperties = schemaFor()( - $BaseLetterTemplateProperties.extend({ + z.object({ + ...$BaseLetterTemplateProperties.shape, files: $LetterFiles, personalisationParameters: z.array(z.string()).optional(), proofingEnabled: z.boolean().optional(), @@ -117,25 +112,21 @@ export const $BaseTemplateSchema = schemaFor()( ); export const $CreateUpdateNonLetter = schemaFor< - ValidatedCreateUpdateTemplateNonLetter, Exclude >()( z.discriminatedUnion('templateType', [ - $BaseTemplateSchema.merge($NhsAppProperties), - $BaseTemplateSchema.merge($EmailProperties), - $BaseTemplateSchema.merge($SmsProperties), + $BaseTemplateSchema.extend($NhsAppProperties.shape), + $BaseTemplateSchema.extend($EmailProperties.shape), + $BaseTemplateSchema.extend($SmsProperties.shape), ]) ); -export const $CreateUpdateTemplate = schemaFor< - ValidatedCreateUpdateTemplate, - CreateUpdateTemplate ->()( +export const $CreateUpdateTemplate = schemaFor()( z.discriminatedUnion('templateType', [ - $BaseTemplateSchema.merge($NhsAppProperties), - $BaseTemplateSchema.merge($EmailProperties), - $BaseTemplateSchema.merge($SmsProperties), - $BaseTemplateSchema.merge($UploadLetterProperties), + $BaseTemplateSchema.extend($NhsAppProperties.shape), + $BaseTemplateSchema.extend($EmailProperties.shape), + $BaseTemplateSchema.extend($SmsProperties.shape), + $BaseTemplateSchema.extend($UploadLetterProperties.shape), ]) ); @@ -146,35 +137,58 @@ export const $LockNumber = z.coerce .transform(Number) .pipe(z.number().int().min(0)); -const $TemplateDtoFields = z - .object({ +const $TemplateStatus = schemaFor()( + z.enum(TEMPLATE_STATUS_LIST) +); + +const $TemplateStatusActive = schemaFor()( + $TemplateStatus.exclude(['DELETED']) +); + +const $TemplateType = schemaFor()(z.enum(TEMPLATE_TYPE_LIST)); + +const $BaseTemplateDto = schemaFor< + BaseCreatedTemplate, + Omit +>()( + z.object({ + ...$BaseTemplateSchema.shape, campaignId: z.string().optional(), clientId: z.string().optional(), createdAt: z.string(), lockNumber: $LockNumber.default(0), id: z.string().trim().min(1), - templateStatus: z.enum(TEMPLATE_STATUS_LIST), + templateStatus: $TemplateStatus, updatedAt: z.string(), + createdBy: z.string().optional(), + updatedBy: z.string().optional(), }) - .merge($BaseTemplateSchema); +); -export const $TemplateDtoSchema = schemaFor< +export const $TemplateDto = schemaFor< TemplateDto, - Omit + Omit >()( z.discriminatedUnion('templateType', [ - $TemplateDtoFields.merge($NhsAppProperties), - $TemplateDtoFields.merge($EmailProperties), - $TemplateDtoFields.merge($SmsProperties), - $TemplateDtoFields.merge($LetterProperties), + $BaseTemplateDto.extend($NhsAppProperties.shape), + $BaseTemplateDto.extend($EmailProperties.shape), + $BaseTemplateDto.extend($SmsProperties.shape), + $BaseTemplateDto.extend($LetterProperties.shape), ]) ); -export const isCreateUpdateTemplateValid = ( - input: unknown -): ValidatedCreateUpdateTemplate | undefined => - $CreateUpdateTemplate.safeParse(input).data; +export type ListTemplateFilters = { + templateStatus?: TemplateStatus; + templateType?: TemplateType; + language?: Language; + letterType?: LetterType; +}; -export const isTemplateDtoValid = ( - input: unknown -): ValidatedTemplateDto | undefined => $TemplateDtoSchema.safeParse(input).data; +export const $ListTemplateFilters = schemaFor()( + z.object({ + templateStatus: $TemplateStatusActive.optional(), + templateType: $TemplateType.optional(), + language: $Language.optional(), + letterType: $LetterType.optional(), + }) +); diff --git a/lambdas/backend-client/src/template-api-client.ts b/lambdas/backend-client/src/template-api-client.ts index fc293bbd5..a50dd1383 100644 --- a/lambdas/backend-client/src/template-api-client.ts +++ b/lambdas/backend-client/src/template-api-client.ts @@ -7,6 +7,7 @@ import { import { Result } from './types/result'; import { catchAxiosError, createAxiosClient } from './axios-client'; import { LETTER_MULTIPART } from './schemas/constants'; +import { TemplateFilter } from './types/filters'; export const httpClient = createAxiosClient(); @@ -120,10 +121,14 @@ export const templateApiClient = { }; }, - async listTemplates(token: string): Promise> { + async listTemplates( + token: string, + filters?: TemplateFilter + ): Promise> { const response = await catchAxiosError( httpClient.get('/v1/templates', { headers: { Authorization: token }, + params: filters, }) ); diff --git a/lambdas/backend-client/src/types/filters.ts b/lambdas/backend-client/src/types/filters.ts new file mode 100644 index 000000000..83667c2d9 --- /dev/null +++ b/lambdas/backend-client/src/types/filters.ts @@ -0,0 +1,7 @@ +import { + GetV1RoutingConfigurationsData, + GetV1TemplatesData, +} from './generated'; + +export type TemplateFilter = GetV1TemplatesData['query']; +export type RoutingConfigFilter = GetV1RoutingConfigurationsData['query']; diff --git a/lambdas/backend-client/src/types/generated/types.gen.ts b/lambdas/backend-client/src/types/generated/types.gen.ts index cdc91463c..824b2e460 100644 --- a/lambdas/backend-client/src/types/generated/types.gen.ts +++ b/lambdas/backend-client/src/types/generated/types.gen.ts @@ -223,7 +223,7 @@ export type SmsProperties = { export type TemplateDto = BaseCreatedTemplate & (SmsProperties | EmailProperties | NhsAppProperties | LetterProperties); -export type TemplateStatus = +export type TemplateStatusActive = | 'DELETED' | 'NOT_YET_SUBMITTED' | 'PENDING_PROOF_REQUEST' @@ -235,6 +235,8 @@ export type TemplateStatus = | 'WAITING_FOR_PROOF' | 'PROOF_AVAILABLE'; +export type TemplateStatus = TemplateStatusActive | 'DELETED'; + export type TemplateSuccess = { data: TemplateDto; statusCode: number; @@ -774,7 +776,24 @@ export type PatchV1TemplateByTemplateIdSubmitResponse = export type GetV1TemplatesData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Filter by a single active status + */ + templateStatus?: TemplateStatusActive; + /** + * Filter by a single template type + */ + templateType?: TemplateType; + /** + * Filter by a single language + */ + language?: Language; + /** + * Filter by a single accessible letter type + */ + letterType?: LetterType; + }; url: '/v1/templates'; }; diff --git a/utils/utils/src/types.ts b/utils/utils/src/types.ts index b0a0a081d..421a22329 100644 --- a/utils/utils/src/types.ts +++ b/utils/utils/src/types.ts @@ -125,6 +125,13 @@ export type DatabaseTemplate = { supplierReferences?: Record; } & DbOnlyTemplateProperties; +export type TemplateFilter = Partial< + Pick< + DatabaseTemplate, + 'templateStatus' | 'templateType' | 'language' | 'letterType' + > +>; + type DbOnlyTemplateProperties = { owner: string; version: number; diff --git a/utils/utils/src/zod-validators.ts b/utils/utils/src/zod-validators.ts index 3399b0d03..11ee265f0 100644 --- a/utils/utils/src/zod-validators.ts +++ b/utils/utils/src/zod-validators.ts @@ -7,7 +7,7 @@ import { $LetterProperties, $NhsAppProperties, $SmsProperties, - $TemplateDtoSchema, + $TemplateDto, TEMPLATE_STATUS_LIST, TemplateDto, } from 'nhs-notify-backend-client'; @@ -24,14 +24,14 @@ export const zodValidate = ( }; export const $SubmittedTemplate = z.intersection( - $TemplateDtoSchema, + $TemplateDto, z.object({ templateStatus: z.literal('SUBMITTED'), }) ); export const $NonSubmittedTemplate = z.intersection( - $TemplateDtoSchema, + $TemplateDto, z.object({ templateStatus: z.enum(TEMPLATE_STATUS_LIST).exclude(['SUBMITTED']), }) @@ -41,10 +41,7 @@ export const $CreateNHSAppTemplate = z.intersection( $CreateUpdateTemplate, $NhsAppProperties ); -export const $NHSAppTemplate = z.intersection( - $TemplateDtoSchema, - $NhsAppProperties -); +export const $NHSAppTemplate = z.intersection($TemplateDto, $NhsAppProperties); export const $SubmittedNHSAppTemplate = z.intersection( $SubmittedTemplate, $NHSAppTemplate @@ -55,10 +52,7 @@ export const $CreateEmailTemplate = z.intersection( $EmailProperties ); -export const $EmailTemplate = z.intersection( - $TemplateDtoSchema, - $EmailProperties -); +export const $EmailTemplate = z.intersection($TemplateDto, $EmailProperties); export const $SubmittedEmailTemplate = z.intersection( $SubmittedTemplate, @@ -69,7 +63,7 @@ export const $CreateSMSTemplate = z.intersection( $CreateUpdateTemplate, $SmsProperties ); -export const $SMSTemplate = z.intersection($TemplateDtoSchema, $SmsProperties); +export const $SMSTemplate = z.intersection($TemplateDto, $SmsProperties); export const $SubmittedSMSTemplate = z.intersection( $SubmittedTemplate, @@ -81,7 +75,7 @@ export const $UploadLetterTemplate = z.intersection( $UploadLetterProperties ); export const $LetterTemplate = z.intersection( - $TemplateDtoSchema, + $TemplateDto, $LetterProperties.extend({ files: $LetterFiles }) ); export const $SubmittedLetterTemplate = z.intersection( @@ -111,7 +105,7 @@ export const validateSubmittedNHSAppTemplate = (template?: TemplateDto) => zodValidate($SubmittedNHSAppTemplate, template); export const validateTemplate = (template?: TemplateDto) => - zodValidate($TemplateDtoSchema, template); + zodValidate($TemplateDto, template); export const validateSubmittedLetterTemplate = (template?: TemplateDto) => zodValidate($SubmittedLetterTemplate, template); From 662ec784a086c2f13d1772a16e4c265742df5c11 Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Thu, 13 Nov 2025 12:47:26 +0000 Subject: [PATCH 2/9] CCM-11537: Frontend --- .../__snapshots__/page.test.tsx.snap | 6 +- .../__snapshots__/page.test.tsx.snap | 254 ++ .../choose-email-template/page.test.tsx | 84 + .../__snapshots__/page.test.tsx.snap | 254 ++ .../choose-nhs-app-template/page.test.tsx | 84 + .../__snapshots__/page.test.tsx.snap | 254 ++ .../page.test.tsx | 88 + .../__snapshots__/page.test.tsx.snap | 254 ++ .../page.test.tsx | 82 + .../page.test.tsx | 8 +- .../page.test.tsx | 8 +- .../page.test.tsx | 8 +- .../page.test.tsx | 8 +- .../ChooseChannelTemplate.test.tsx | 196 ++ .../ChooseChannelTemplate.test.tsx.snap | 2233 +++++++++++++++++ .../server-action.test.ts | 78 + .../molecules/ChannelTemplates.test.tsx | 78 + .../PreviewSubmittedTemplate.test.tsx | 177 ++ .../molecules/PreviewTemplateDetails.test.tsx | 12 +- .../molecules/ViewEmailTemplate.test.tsx | 27 - .../molecules/ViewLetterTemplate.test.tsx | 42 - .../molecules/ViewNHSAppTemplate.test.tsx | 26 - .../molecules/ViewSMSTemplate.test.tsx | 26 - .../ChannelTemplates.test.tsx.snap | 1391 ++++++++++ .../PreviewSubmittedTemplate.test.tsx.snap | 968 +++++++ .../PreviewTemplateDetails.test.tsx.snap | 18 +- .../ViewEmailTemplate.test.tsx.snap | 167 -- .../ViewLetterTemplate.test.tsx.snap | 197 -- .../ViewNHSAppTemplate.test.tsx.snap | 142 -- .../ViewSMSTemplate.test.tsx.snap | 142 -- .../CreateEditMessagePlan.test.tsx.snap | 6 +- frontend/src/__tests__/helpers/helpers.ts | 55 +- .../src/__tests__/utils/form-actions.test.ts | 5 +- .../[routingConfigId]/page.tsx | 49 + .../[routingConfigId]/page.tsx | 51 + .../[routingConfigId]/page.tsx | 54 + .../[routingConfigId]/page.tsx | 51 + .../[templateId]/page.tsx | 10 +- .../[templateId]/page.tsx | 10 +- .../[templateId]/page.tsx | 10 +- .../[templateId]/page.tsx | 10 +- .../ChooseChannelTemplate.tsx | 118 + .../choose-channel-template.types.ts | 8 + .../forms/ChooseChannelTemplate/index.ts | 2 + .../ChooseChannelTemplate/server-action.ts | 43 + .../PreviewEmailTemplate.tsx | 10 +- .../PreviewNHSAppTemplate.tsx | 8 +- .../PreviewSMSTemplate/PreviewSMSTemplate.tsx | 8 +- .../ChannelTemplates.module.scss | 4 + .../ChannelTemplates/ChannelTemplates.tsx | 116 + .../NhsNotifyErrorSummary.tsx | 5 +- .../PreviewSubmittedTemplate.tsx | 69 + .../PreviewTemplateDetailsEmail.tsx | 13 +- .../PreviewTemplateDetailsLetter.tsx | 5 + .../PreviewTemplateDetailsNhsApp.tsx | 10 +- .../PreviewTemplateDetailsSms.tsx | 10 +- .../PreviewTemplateDetails/common.tsx | 48 +- .../ViewEmailTemplate/ViewEmailTemplate.tsx | 48 - .../ViewLetterTemplate/ViewLetterTemplate.tsx | 44 - .../ViewNHSAppTemplate/ViewNHSAppTemplate.tsx | 46 - .../ViewSMSTemplate/ViewSMSTemplate.tsx | 43 - frontend/src/content/content.ts | 56 +- frontend/src/middleware.ts | 6 +- frontend/src/utils/form-actions.ts | 10 +- frontend/src/utils/interpolate.ts | 2 +- 65 files changed, 7303 insertions(+), 1052 deletions(-) create mode 100644 frontend/src/__tests__/app/message-plans/choose-email-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/message-plans/choose-email-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/message-plans/choose-nhs-app-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/message-plans/choose-nhs-app-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/message-plans/choose-standard-english-letter-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/message-plans/choose-standard-english-letter-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/message-plans/choose-text-message-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/message-plans/choose-text-message-template/page.test.tsx create mode 100644 frontend/src/__tests__/components/forms/ChooseChannelTemplate/ChooseChannelTemplate.test.tsx create mode 100644 frontend/src/__tests__/components/forms/ChooseChannelTemplate/__snapshots__/ChooseChannelTemplate.test.tsx.snap create mode 100644 frontend/src/__tests__/components/forms/ChooseChannelTemplate/server-action.test.ts create mode 100644 frontend/src/__tests__/components/molecules/ChannelTemplates.test.tsx create mode 100644 frontend/src/__tests__/components/molecules/PreviewSubmittedTemplate.test.tsx delete mode 100644 frontend/src/__tests__/components/molecules/ViewEmailTemplate.test.tsx delete mode 100644 frontend/src/__tests__/components/molecules/ViewLetterTemplate.test.tsx delete mode 100644 frontend/src/__tests__/components/molecules/ViewNHSAppTemplate.test.tsx delete mode 100644 frontend/src/__tests__/components/molecules/ViewSMSTemplate.test.tsx create mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/ChannelTemplates.test.tsx.snap create mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/PreviewSubmittedTemplate.test.tsx.snap delete mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/ViewEmailTemplate.test.tsx.snap delete mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/ViewLetterTemplate.test.tsx.snap delete mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/ViewNHSAppTemplate.test.tsx.snap delete mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/ViewSMSTemplate.test.tsx.snap create mode 100644 frontend/src/app/message-plans/choose-email-template/[routingConfigId]/page.tsx create mode 100644 frontend/src/app/message-plans/choose-nhs-app-template/[routingConfigId]/page.tsx create mode 100644 frontend/src/app/message-plans/choose-standard-english-letter-template/[routingConfigId]/page.tsx create mode 100644 frontend/src/app/message-plans/choose-text-message-template/[routingConfigId]/page.tsx create mode 100644 frontend/src/components/forms/ChooseChannelTemplate/ChooseChannelTemplate.tsx create mode 100644 frontend/src/components/forms/ChooseChannelTemplate/choose-channel-template.types.ts create mode 100644 frontend/src/components/forms/ChooseChannelTemplate/index.ts create mode 100644 frontend/src/components/forms/ChooseChannelTemplate/server-action.ts create mode 100644 frontend/src/components/molecules/ChannelTemplates/ChannelTemplates.module.scss create mode 100644 frontend/src/components/molecules/ChannelTemplates/ChannelTemplates.tsx create mode 100644 frontend/src/components/molecules/PreviewSubmittedTemplate/PreviewSubmittedTemplate.tsx delete mode 100644 frontend/src/components/molecules/ViewEmailTemplate/ViewEmailTemplate.tsx delete mode 100644 frontend/src/components/molecules/ViewLetterTemplate/ViewLetterTemplate.tsx delete mode 100644 frontend/src/components/molecules/ViewNHSAppTemplate/ViewNHSAppTemplate.tsx delete mode 100644 frontend/src/components/molecules/ViewSMSTemplate/ViewSMSTemplate.tsx diff --git a/frontend/src/__tests__/app/choose-templates/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/choose-templates/__snapshots__/page.test.tsx.snap index 0a3996d36..d240745d1 100644 --- a/frontend/src/__tests__/app/choose-templates/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/choose-templates/__snapshots__/page.test.tsx.snap @@ -102,7 +102,7 @@ exports[`ChooseTemplatesPage renders correctly for a message plan with multiple class="nhsuk-u-margin-bottom-2" data-testid="template-name-NHSAPP" > - name + app template name