From 8c32b934c2119787a75970b60e2a595b29785665 Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Wed, 4 Feb 2026 15:23:55 +0000 Subject: [PATCH 01/30] CCM-12685: Validate routing config submit --- .../src/__tests__/utils/routing-utils.test.ts | 73 --- frontend/src/utils/routing-utils.ts | 13 +- .../module_submit_routing_config_lambda.tf | 14 + .../modules/backend-api/spec.tmpl.json | 2 - .../app/routing-config-client.test.ts | 36 +- .../repository.test.ts | 499 ++++++++++++++++-- .../src/app/routing-config-client.ts | 2 +- .../routing-config-repository/repository.ts | 167 +++++- .../__tests__/schemas/routing-config.test.ts | 158 +++++- .../src/schemas/routing-config.ts | 35 +- .../backend-client/src/schemas/template.ts | 4 +- .../src/types/generated/types.gen.ts | 4 +- .../get-routing-config.api.spec.ts | 6 +- .../submit-routing-config.api.spec.ts | 413 ++++++++++++++- .../routing-config.event.spec.ts | 16 +- 15 files changed, 1261 insertions(+), 181 deletions(-) diff --git a/frontend/src/__tests__/utils/routing-utils.test.ts b/frontend/src/__tests__/utils/routing-utils.test.ts index 84413decb..696ef20ea 100644 --- a/frontend/src/__tests__/utils/routing-utils.test.ts +++ b/frontend/src/__tests__/utils/routing-utils.test.ts @@ -176,27 +176,6 @@ describe('getSelectedLanguageTemplateIds', () => { ]); }); - it('should filter out language templates with null templateId', () => { - const cascadeItem: CascadeItem = { - cascadeGroups: ['translations'], - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: 'default-template', - conditionalTemplates: [ - { templateId: 'template-1', language: 'fr' }, - { templateId: null, language: 'pl' }, - { templateId: 'template-3', language: 'es' }, - ], - }; - - const result = getSelectedLanguageTemplateIds(cascadeItem); - - expect(result).toEqual([ - { language: 'fr', templateId: 'template-1' }, - { language: 'es', templateId: 'template-3' }, - ]); - }); - it('should return empty array when no conditional templates exist', () => { const cascadeItem: CascadeItem = { cascadeGroups: ['standard'], @@ -287,20 +266,6 @@ describe('removeTemplatesFromConditionalTemplates', () => { expect(result).toEqual([{ templateId: 'template-2', language: 'es' }]); }); - it('should keep templates with null templateId', () => { - const conditionalTemplates: ConditionalTemplate[] = [ - { templateId: 'template-1', language: 'fr' }, - { templateId: null, language: 'es' }, - ]; - - const result = removeTemplatesFromConditionalTemplates( - conditionalTemplates, - ['template-1'] - ); - - expect(result).toEqual([{ templateId: null, language: 'es' }]); - }); - it('should return empty array when all templates are removed', () => { const conditionalTemplates: ConditionalTemplate[] = [ { templateId: 'template-1', language: 'fr' }, @@ -512,26 +477,6 @@ describe('getConditionalTemplatesForItem', () => { }); }); - it('should filter out templates with a missing/invalid templateId', () => { - const cascadeItem: CascadeItem = { - cascadeGroups: ['standard', 'translations'], - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: 'template-1', - conditionalTemplates: [ - { templateId: 'template-2', language: 'fr' }, - { templateId: null, language: 'es' }, - { accessibleFormat: 'x1' } as ConditionalTemplate, - ], - }; - - const result = getConditionalTemplatesForItem(cascadeItem, templates); - - expect(result).toEqual({ - 'template-2': templates['template-2'], - }); - }); - it('should not include templates that are missing from templates object', () => { const cascadeItem: CascadeItem = { cascadeGroups: ['standard', 'translations'], @@ -635,24 +580,6 @@ describe('buildCascadeGroupsForItem', () => { ]); }); - it('should return only standard group when conditional templates have missing templateIds', () => { - const cascadeItem: CascadeItem = { - cascadeGroups: ['standard'], - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: 'template-1', - conditionalTemplates: [ - { templateId: null, accessibleFormat: 'q4' }, - { templateId: 'template-2', language: 'fr' }, - ], - }; - - expect(buildCascadeGroupsForItem(cascadeItem)).toEqual([ - 'standard', - 'translations', - ]); - }); - it('should return only standard when conditional templates array is empty', () => { const cascadeItem: CascadeItem = { cascadeGroups: ['standard'], diff --git a/frontend/src/utils/routing-utils.ts b/frontend/src/utils/routing-utils.ts index a3efb13ac..f21a0deca 100644 --- a/frontend/src/utils/routing-utils.ts +++ b/frontend/src/utils/routing-utils.ts @@ -51,7 +51,7 @@ export function getConditionalTemplatesForItem( return Object.fromEntries( conditionalTemplateIds - .filter((id): id is string => id != null && id in templates) + .filter((id) => id in templates) .map((id) => [id, templates[id]]) ); } @@ -90,10 +90,8 @@ export function getSelectedLanguageTemplateIds( return cascadeItem.conditionalTemplates .filter( - ( - template - ): template is ConditionalTemplateLanguage & { templateId: string } => - 'language' in template && template.templateId !== null + (template): template is ConditionalTemplateLanguage => + 'language' in template ) .map(({ language, templateId }) => ({ language, @@ -137,10 +135,10 @@ export function buildCascadeGroupsForItem( cascadeItem.conditionalTemplates.length > 0 ) { const hasAccessibleFormat = cascadeItem.conditionalTemplates.some( - (template) => 'accessibleFormat' in template && template.templateId + (template) => 'accessibleFormat' in template ); const hasLanguage = cascadeItem.conditionalTemplates.some( - (template) => 'language' in template && template.templateId + (template) => 'language' in template ); if (hasAccessibleFormat) { @@ -190,7 +188,6 @@ export function removeTemplatesFromCascadeItem( return updatedCascadeItem; } - /** * Add default template to cascade at specific index */ diff --git a/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf b/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf index f3b5f1ec4..8f8dd9f99 100644 --- a/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf +++ b/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf @@ -42,6 +42,7 @@ data "aws_iam_policy_document" "submit_routing_config_lambda_policy" { actions = [ "dynamodb:UpdateItem", + "dynamodb:GetItem", ] resources = [ @@ -49,6 +50,19 @@ data "aws_iam_policy_document" "submit_routing_config_lambda_policy" { ] } + statement { + sid = "AllowConditionCheckDynamoAccess" + effect = "Allow" + + actions = [ + "dynamodb:ConditionCheckItem", + ] + + resources = [ + aws_dynamodb_table.templates.arn, + ] + } + statement { sid = "AllowKMSAccess" effect = "Allow" diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 504c2b3ac..42d17a46c 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -385,7 +385,6 @@ }, "templateId": { "format": "uuid", - "nullable": true, "type": "string" } }, @@ -409,7 +408,6 @@ }, "templateId": { "format": "uuid", - "nullable": true, "type": "string" } }, diff --git a/lambdas/backend-api/src/__tests__/app/routing-config-client.test.ts b/lambdas/backend-api/src/__tests__/app/routing-config-client.test.ts index 141ee19bb..f11478a74 100644 --- a/lambdas/backend-api/src/__tests__/app/routing-config-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/routing-config-client.test.ts @@ -102,7 +102,10 @@ describe('RoutingConfigClient', () => { expect(result).toEqual({ error: { - errorMeta: { code: 404, description: 'Routing Config not found' }, + errorMeta: { + code: 404, + description: 'Routing configuration not found', + }, }, }); @@ -516,6 +519,7 @@ describe('RoutingConfigClient', () => { const completed: RoutingConfig = { ...routingConfig, + id, status: 'COMPLETED', }; @@ -536,6 +540,36 @@ describe('RoutingConfigClient', () => { }); }); + test('returns failures from repository', async () => { + const { client, mocks } = setup(); + + mocks.clientConfigRepository.get.mockResolvedValueOnce({ + data: { features: { routing: true } }, + }); + + mocks.routingConfigRepository.submit.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: + 'All cascade items must have either a defaultTemplateId or conditionalTemplates', + }, + }, + }); + + const result = await client.submitRoutingConfig('some-id', user, '42'); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 400, + description: + 'All cascade items must have either a defaultTemplateId or conditionalTemplates', + }, + }, + }); + }); + test('returns failures from client config repository', async () => { const { client, mocks } = setup(); diff --git a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts index 43172a18f..58282091d 100644 --- a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts @@ -51,8 +51,10 @@ const TEMPLATE_TABLE_NAME = 'template-table-name'; const user = { internalUserId: 'user', clientId: 'nhs-notify-client-id' }; const clientOwnerKey = `CLIENT#${user.clientId}`; +const dynamo = mockClient(DynamoDBDocumentClient); + function setup() { - const dynamo = mockClient(DynamoDBDocumentClient); + dynamo.reset(); const mocks = { dynamo }; @@ -110,7 +112,10 @@ describe('RoutingConfigRepository', () => { expect(result).toEqual({ error: { - errorMeta: { code: 404, description: 'Routing Config not found' }, + errorMeta: { + code: 404, + description: 'Routing configuration not found', + }, }, }); @@ -252,51 +257,93 @@ describe('RoutingConfigRepository', () => { test('updates routing config to COMPLETED', async () => { const { repo, mocks } = setup(); + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + const completed: RoutingConfig = { ...routingConfig, status: 'COMPLETED', + lockNumber: 3, }; - mocks.dynamo.on(UpdateCommand).resolves({ Attributes: completed }); + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithLock }) + .resolvesOnce({ Item: completed }); + mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); const result = await repo.submit(routingConfig.id, user, 2); expect(result).toEqual({ data: completed }); - expect(mocks.dynamo).toHaveReceivedCommandWith(UpdateCommand, { - ConditionExpression: - '#status = :condition_1_status AND #lockNumber = :condition_2_lockNumber', - ExpressionAttributeNames: { - '#status': 'status', - '#updatedAt': 'updatedAt', - '#updatedBy': 'updatedBy', - '#lockNumber': 'lockNumber', - }, - ExpressionAttributeValues: { - ':condition_1_status': 'DRAFT', - ':condition_2_lockNumber': 2, - ':lockNumber': 1, - ':status': 'COMPLETED', - ':updatedAt': date.toISOString(), - ':updatedBy': `INTERNAL_USER#${user.internalUserId}`, - }, - Key: { - id: routingConfig.id, - owner: clientOwnerKey, + expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { + TransactItems: expect.arrayContaining([ + { + Update: expect.objectContaining({ + ConditionExpression: + '#status = :condition_1_status AND #lockNumber = :condition_2_lockNumber', + ExpressionAttributeNames: { + '#status': 'status', + '#updatedAt': 'updatedAt', + '#updatedBy': 'updatedBy', + '#lockNumber': 'lockNumber', + }, + ExpressionAttributeValues: { + ':condition_1_status': 'DRAFT', + ':condition_2_lockNumber': 2, + ':lockNumber': 1, + ':status': 'COMPLETED', + ':updatedAt': date.toISOString(), + ':updatedBy': `INTERNAL_USER#${user.internalUserId}`, + }, + Key: { + id: routingConfig.id, + owner: clientOwnerKey, + }, + ReturnValues: 'ALL_NEW', + TableName: TABLE_NAME, + UpdateExpression: + 'SET #status = :status, #updatedAt = :updatedAt, #updatedBy = :updatedBy ADD #lockNumber :lockNumber', + }), + }, + ]), + }); + }); + + test('returns failure on client error during initial get', async () => { + const { repo, mocks } = setup(); + + const err = new Error('ddb err'); + + mocks.dynamo.on(GetCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 500, + description: 'Error retrieving Routing Config', + }, }, - ReturnValues: 'ALL_NEW', - TableName: TABLE_NAME, - UpdateExpression: - 'SET #status = :status, #updatedAt = :updatedAt, #updatedBy = :updatedBy ADD #lockNumber :lockNumber', }); }); - test('returns failure on client error', async () => { + test('returns failure on client error during transaction', async () => { const { repo, mocks } = setup(); const err = new Error('ddb err'); - mocks.dynamo.on(UpdateCommand).rejects(err); + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -314,13 +361,22 @@ describe('RoutingConfigRepository', () => { test('returns failure if updated template is invalid', async () => { const { repo, mocks } = setup(); + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + const completedInvalid: RoutingConfig = { ...routingConfig, status: 'COMPLETED', name: 0 as unknown as string, }; - mocks.dynamo.on(UpdateCommand).resolves({ Attributes: completedInvalid }); + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithLock }) + .resolvesOnce({ Item: completedInvalid }); + mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); const result = await repo.submit(routingConfig.id, user, 2); @@ -347,12 +403,36 @@ describe('RoutingConfigRepository', () => { test('returns 404 failure if routing config does not exist', async () => { const { repo, mocks } = setup(); - const err = new ConditionalCheckFailedException({ + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: undefined }); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 404, + description: 'Routing configuration not found', + }, + }, + }); + }); + + test('returns 404 failure if transaction fails because routing config does not exist', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + const err = new TransactionCanceledException({ $metadata: {}, message: 'msg', + CancellationReasons: [{ Code: 'ConditionalCheckFailed' }], }); - mocks.dynamo.on(UpdateCommand).rejects(err); + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -369,13 +449,12 @@ describe('RoutingConfigRepository', () => { test('returns 404 failure if routing config is DELETED', async () => { const { repo, mocks } = setup(); - const err = new ConditionalCheckFailedException({ - Item: { status: { S: 'DELETED' satisfies RoutingConfigStatus } }, - $metadata: {}, - message: 'msg', - }); + const deletedRoutingConfig: RoutingConfig = { + ...routingConfig, + status: 'DELETED', + }; - mocks.dynamo.on(UpdateCommand).rejects(err); + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: deletedRoutingConfig }); const result = await repo.submit(routingConfig.id, user, 2); @@ -387,18 +466,21 @@ describe('RoutingConfigRepository', () => { }, }, }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); test('returns 400 failure if routing config is COMPLETED', async () => { const { repo, mocks } = setup(); - const err = new ConditionalCheckFailedException({ - Item: { status: { S: 'COMPLETED' satisfies RoutingConfigStatus } }, - $metadata: {}, - message: 'msg', - }); + const completedRoutingConfig: RoutingConfig = { + ...routingConfig, + status: 'COMPLETED', + }; - mocks.dynamo.on(UpdateCommand).rejects(err); + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: completedRoutingConfig }); const result = await repo.submit(routingConfig.id, user, 2); @@ -411,21 +493,134 @@ describe('RoutingConfigRepository', () => { }, }, }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); test("returns 409 failure if lock number doesn't match", async () => { const { repo, mocks } = setup(); - const err = new ConditionalCheckFailedException({ - Item: { - status: { S: 'DRAFT' satisfies RoutingConfigStatus }, - lockNumber: { N: '3' }, + const mismatchedLockRoutingConfig: RoutingConfig = { + ...routingConfig, + lockNumber: 3, + }; + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: mismatchedLockRoutingConfig }); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 409, + description: + 'Lock number mismatch - Message Plan has been modified since last read', + }, + }, + }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); + }); + + test('returns validation failure if cascade item has no defaultTemplateId or conditionalTemplates', async () => { + const { repo, mocks } = setup(); + + const invalidRoutingConfig: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + conditionalTemplates: [], + }, + ], + }; + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: invalidRoutingConfig }); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: expect.any(Error), + errorMeta: { + code: 400, + description: + 'Routing config is not ready for submission: all cascade items must have templates assigned', + }, }, + }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); + }); + + test('returns validation failure if defaultTemplateId is null with no conditionalTemplates', async () => { + const { repo, mocks } = setup(); + + const invalidRoutingConfig: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: null, + }, + ], + }; + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: invalidRoutingConfig }); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: expect.any(Error), + errorMeta: { + code: 400, + description: + 'Routing config is not ready for submission: all cascade items must have templates assigned', + }, + }, + }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); + }); + + test('returns 400 failure if template not found during submit', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithTemplate: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'missing-template-id', + }, + ], + }; + + const err = new TransactionCanceledException({ $metadata: {}, message: 'msg', + CancellationReasons: [ + { Code: 'None' }, // Update succeeded + { Code: 'ConditionalCheckFailed', Item: undefined }, // Template not found (no Item returned) + ], }); - mocks.dynamo.on(UpdateCommand).rejects(err); + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithTemplate }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -433,9 +628,211 @@ describe('RoutingConfigRepository', () => { error: { actualError: err, errorMeta: { - code: 409, + code: 400, + description: 'Some templates not found', + details: { templateIds: 'missing-template-id' }, + }, + }, + }); + }); + + test('returns 400 failure if template has DELETED status', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithTemplate: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'deleted-template-id', + }, + ], + }; + + const err = new TransactionCanceledException({ + $metadata: {}, + message: 'msg', + CancellationReasons: [ + { Code: 'None' }, // Update succeeded + { + Code: 'ConditionalCheckFailed', + Item: { + id: { S: 'deleted-template-id' }, + templateType: { S: 'SMS' }, + templateStatus: { S: 'DELETED' }, + }, + }, + ], + }); + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithTemplate }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 400, + description: 'Some templates not found', + details: { templateIds: 'deleted-template-id' }, + }, + }, + }); + }); + + test('returns 400 failure if LETTER template has invalid status', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLetter: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: 'letter-template-id', + }, + ], + }; + + const err = new TransactionCanceledException({ + $metadata: {}, + message: 'msg', + CancellationReasons: [ + { Code: 'None' }, + { + Code: 'ConditionalCheckFailed', + Item: { + id: { S: 'letter-template-id' }, + templateType: { S: 'LETTER' }, + templateStatus: { S: 'DRAFT' }, + }, + }, + ], + }); + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithLetter }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 400, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Letter templates must have status PROOF_APPROVED or SUBMITTED', + details: { templateIds: 'letter-template-id' }, + }, + }, + }); + + expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { + TransactItems: expect.arrayContaining([ + { + ConditionCheck: { + TableName: TEMPLATE_TABLE_NAME, + Key: { + id: 'letter-template-id', + owner: clientOwnerKey, + }, + ConditionExpression: + 'attribute_exists(id) AND templateStatus <> :deleted AND (templateType <> :letterType OR templateStatus IN (:proofApproved, :submitted))', + ExpressionAttributeValues: { + ':deleted': 'DELETED', + ':letterType': 'LETTER', + ':proofApproved': 'PROOF_APPROVED', + ':submitted': 'SUBMITTED', + }, + ReturnValuesOnConditionCheckFailure: + ReturnValuesOnConditionCheckFailure.ALL_OLD, + }, + }, + ]), + }); + }); + + test('returns 500 failure on unexpected TransactionCanceledException', async () => { + const { repo, mocks } = setup(); + + // Transaction cancelled with update succeeded but no template failures + // This is an edge case that shouldn't normally happen + const err = new TransactionCanceledException({ + $metadata: {}, + message: 'Unexpected cancellation', + CancellationReasons: [ + { Code: 'None' }, // Update succeeded + { Code: 'None' }, // Template check also passed (unexpected) + ], + }); + + const routingConfigWithTemplate: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'some-template-id', + }, + ], + }; + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithTemplate }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 500, + description: 'Failed to update routing config', + }, + }, + }); + }); + + test('returns 500 failure on TransactionCanceledException with undefined CancellationReasons', async () => { + const { repo, mocks } = setup(); + + const err = new TransactionCanceledException({ + $metadata: {}, + message: 'Transaction cancelled with no reasons', + CancellationReasons: undefined, + }); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 500, + description: 'Failed to update routing config', }, }, }); diff --git a/lambdas/backend-api/src/app/routing-config-client.ts b/lambdas/backend-api/src/app/routing-config-client.ts index 7e220e1ac..32b46dacb 100644 --- a/lambdas/backend-api/src/app/routing-config-client.ts +++ b/lambdas/backend-api/src/app/routing-config-client.ts @@ -189,7 +189,7 @@ export class RoutingConfigClient { const result = await this.routingConfigRepository.get(id, user.clientId); if (result.data?.status === 'DELETED') { - return failure(ErrorCase.NOT_FOUND, 'Routing Config not found'); + return failure(ErrorCase.NOT_FOUND, 'Routing configuration not found'); } return result; diff --git a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts index 5d34a209a..905bfa03a 100644 --- a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts @@ -13,6 +13,7 @@ import { } from '@backend-api/utils/result'; import { $RoutingConfig, + $SubmittableCascade, CascadeItem, type CreateRoutingConfig, ErrorCase, @@ -26,6 +27,7 @@ import { RoutingConfigQuery } from './query'; import { RoutingConfigUpdateBuilder } from 'nhs-notify-entity-update-command-builder'; import { ConditionalCheckFailedException, + ReturnValuesOnConditionCheckFailure, TransactionCanceledException, } from '@aws-sdk/client-dynamodb'; import { calculateTTL } from '@backend-api/utils/calculate-ttl'; @@ -168,7 +170,47 @@ export class RoutingConfigRepository { user: User, lockNumber: number ): Promise> { - const cmdInput = new RoutingConfigUpdateBuilder( + const existingConfig = await this.get(id, user.clientId); + + if (existingConfig.error) { + return existingConfig; + } + + const { + status, + cascade, + lockNumber: currentLockNumber, + } = existingConfig.data; + + // Check status before cascade validation + if (status === 'DELETED') { + return failure(ErrorCase.NOT_FOUND, 'Routing configuration not found'); + } + + if (status === 'COMPLETED') { + return failure( + ErrorCase.ALREADY_SUBMITTED, + 'Routing configuration with status COMPLETED cannot be updated' + ); + } + + // Check lock number before cascade validation + if (currentLockNumber !== lockNumber) { + return failure( + ErrorCase.CONFLICT, + 'Lock number mismatch - Message Plan has been modified since last read' + ); + } + + const submittableValidationError = + this.validateRoutingConfigIsSubmittable(cascade); + if (submittableValidationError) { + return submittableValidationError; + } + + const templateIds = this.extractTemplateIds(cascade); + + const update = new RoutingConfigUpdateBuilder( this.tableName, user.clientId, id, @@ -182,9 +224,45 @@ export class RoutingConfigRepository { .build(); try { - const result = await this.client.send(new UpdateCommand(cmdInput)); + await this.client.send( + new TransactWriteCommand({ + TransactItems: [ + { + Update: update, + }, + // Template existence check + For LETTER templates, check they have PROOF_APPROVED or SUBMITTED status + // Also exclude DELETED templates for all template types + ...templateIds.map((templateId) => ({ + ConditionCheck: { + TableName: this.templateTableName, + Key: { + id: templateId, + owner: this.clientOwnerKey(user.clientId), + }, + ConditionExpression: + 'attribute_exists(id) AND templateStatus <> :deleted AND (templateType <> :letterType OR templateStatus IN (:proofApproved, :submitted))', + ExpressionAttributeValues: { + ':deleted': 'DELETED', + ':letterType': 'LETTER', + ':proofApproved': 'PROOF_APPROVED', + ':submitted': 'SUBMITTED', + }, + ReturnValuesOnConditionCheckFailure: + ReturnValuesOnConditionCheckFailure.ALL_OLD, + }, + })), + ], + }) + ); - const parsed = $RoutingConfig.safeParse(result.Attributes); + const getResult = await this.client.send( + new GetCommand({ + TableName: this.tableName, + Key: { id, owner: this.clientOwnerKey(user.clientId) }, + }) + ); + + const parsed = $RoutingConfig.safeParse(getResult.Item); if (!parsed.success) { return failure( @@ -196,7 +274,7 @@ export class RoutingConfigRepository { return success(parsed.data); } catch (error) { - return this.handleUpdateError(error, lockNumber); + return this.handleSubmitTransactionError(error, lockNumber, templateIds); } } @@ -254,7 +332,7 @@ export class RoutingConfigRepository { ); if (!result.Item) { - return failure(ErrorCase.NOT_FOUND, 'Routing Config not found'); + return failure(ErrorCase.NOT_FOUND, 'Routing configuration not found'); } const parsed = $RoutingConfig.parse(result.Item); @@ -401,4 +479,83 @@ export class RoutingConfigRepository { ]) .filter((id): id is string => id != null); } + + private validateRoutingConfigIsSubmittable(cascade: CascadeItem[]) { + const result = $SubmittableCascade.safeParse(cascade); + + if (!result.success) { + return failure( + ErrorCase.VALIDATION_FAILED, + 'Routing config is not ready for submission: all cascade items must have templates assigned', + result.error + ); + } + + return null; + } + + private handleSubmitTransactionError( + err: unknown, + lockNumber: number, + templateIds: string[] + ): ApplicationResult { + if (!(err instanceof TransactionCanceledException)) { + return this.handleUpdateError(err, lockNumber); + } + + // Note: The first item will always be the update + // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CancellationReason.html + const [updateReason, ...templateReasons] = err.CancellationReasons ?? []; + + if (updateReason && updateReason.Code !== 'None') { + return this.handleUpdateError( + new ConditionalCheckFailedException({ + message: updateReason.Message!, + Item: updateReason.Item, + $metadata: err.$metadata, + }), + lockNumber + ); + } + + // Find which templates failed the condition check + const missingTemplateIds: string[] = []; + const invalidLetterTemplateIds: string[] = []; + + for (const [index, reason] of templateReasons.entries()) { + if (reason.Code === 'ConditionalCheckFailed') { + const templateId = templateIds[index]; + + if ( + !reason.Item || + reason.Item.templateStatus?.S === + ('DELETED' satisfies RoutingConfigStatus) + ) { + missingTemplateIds.push(templateId); + } else { + invalidLetterTemplateIds.push(templateId); + } + } + } + + if (missingTemplateIds.length > 0) { + return failure( + ErrorCase.ROUTING_CONFIG_TEMPLATES_NOT_FOUND, + 'Some templates not found', + err, + { templateIds: missingTemplateIds.join(',') } + ); + } + + if (invalidLetterTemplateIds.length > 0) { + return failure( + ErrorCase.VALIDATION_FAILED, + 'Letter templates must have status PROOF_APPROVED or SUBMITTED', + err, + { templateIds: invalidLetterTemplateIds.join(',') } + ); + } + + return this.handleUpdateError(err, lockNumber); + } } diff --git a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts index 575ae856b..085cb3337 100644 --- a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts @@ -3,6 +3,8 @@ import { $ListRoutingConfigFilters, $RoutingConfig, $UpdateRoutingConfig, + $SubmittableCascade, + $SubmittableCascadeItem, } from '../../schemas/routing-config'; const cascadeItemDefault = { @@ -90,33 +92,6 @@ describe.each([ expect(res.success).toBe(true); }); - test('valid with conditional template ids set to null', () => { - const res = $Schema.safeParse({ - ...baseInput, - cascade: [ - { - ...cascadeCondAcc, - conditionalTemplates: [ - { - accessibleFormat: 'x1', - templateId: null, - }, - ], - }, - { - ...cascadeCondLang, - conditionalTemplates: [ - { - language: 'ar', - templateId: null, - }, - ], - }, - ], - }); - expect(res.success).toBe(true); - }); - test('snapshot full error', () => { const res = $Schema.safeParse({}); @@ -416,3 +391,132 @@ describe('UpdateRoutingConfig', () => { expect(res.error).toMatchSnapshot(); }); }); + +describe('$SubmittableCascadeItem', () => { + test('valid with defaultTemplateId', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + }); + expect(res.success).toBe(true); + }); + + test('valid with defaultTemplateId and conditionalTemplates', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['standard', 'translations'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + conditionalTemplates: [ + { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' }, + ], + }); + expect(res.success).toBe(true); + }); + + test('valid with conditionalTemplates only (non-empty)', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['translations'], + channel: 'LETTER', + channelType: 'secondary', + conditionalTemplates: [ + { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' }, + ], + }); + expect(res.success).toBe(true); + }); + + test('valid with accessible conditional templates only', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['accessible'], + channel: 'LETTER', + channelType: 'secondary', + conditionalTemplates: [ + { + accessibleFormat: 'x1', + templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + }, + ], + }); + expect(res.success).toBe(true); + }); + + test('invalid without defaultTemplateId and without conditionalTemplates', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + }); + expect(res.success).toBe(false); + }); + + test('invalid with empty conditionalTemplates and no defaultTemplateId', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + conditionalTemplates: [], + }); + expect(res.success).toBe(false); + }); + + test('invalid with empty defaultTemplateId', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: '', + }); + expect(res.success).toBe(false); + }); +}); + +describe('$SubmittableCascade', () => { + const validCascadeItem = { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + }; + + test('valid with single item', () => { + const res = $SubmittableCascade.safeParse([validCascadeItem]); + expect(res.success).toBe(true); + }); + + test('valid with multiple items', () => { + const res = $SubmittableCascade.safeParse([ + validCascadeItem, + { + cascadeGroups: ['translations'], + channel: 'EMAIL', + channelType: 'secondary', + conditionalTemplates: [ + { + language: 'ar', + templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + }, + ], + }, + ]); + expect(res.success).toBe(true); + }); + + test('invalid with empty array', () => { + const res = $SubmittableCascade.safeParse([]); + expect(res.success).toBe(false); + }); + + test('invalid with invalid cascade item in array', () => { + const res = $SubmittableCascade.safeParse([ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + }, + ]); + expect(res.success).toBe(false); + }); +}); diff --git a/lambdas/backend-client/src/schemas/routing-config.ts b/lambdas/backend-client/src/schemas/routing-config.ts index 450418e99..a40826e7d 100644 --- a/lambdas/backend-client/src/schemas/routing-config.ts +++ b/lambdas/backend-client/src/schemas/routing-config.ts @@ -58,14 +58,14 @@ const $CascadeGroup = schemaFor()( ]) ); -export const $Channel = schemaFor()(z.enum(CHANNEL_LIST)); +const $Channel = schemaFor()(z.enum(CHANNEL_LIST)); const $ChannelType = schemaFor()(z.enum(CHANNEL_TYPE_LIST)); const $ConditionalTemplateLanguage = schemaFor()( z.object({ language: $Language, - templateId: z.string().nonempty().nullable(), + templateId: z.string().nonempty(), supplierReferences: z.record(z.string(), z.string()).optional(), }) ); @@ -74,7 +74,7 @@ const $ConditionalTemplateAccessible = schemaFor()( z.object({ accessibleFormat: $LetterType, - templateId: z.string().nonempty().nullable(), + templateId: z.string().nonempty(), supplierReferences: z.record(z.string(), z.string()).optional(), }) ); @@ -115,6 +115,35 @@ const $CascadeItem = schemaFor()( ) ); +export const $SubmittableCascadeItem = $CascadeItemBase.and( + z.union([ + z.object({ + defaultTemplateId: z.string().nonempty(), + conditionalTemplates: z + .array( + z.union([ + $ConditionalTemplateAccessible, + $ConditionalTemplateLanguage, + ]) + ) + .optional(), + }), + z.object({ + defaultTemplateId: z.string().nonempty().optional(), + conditionalTemplates: z + .array( + z.union([ + $ConditionalTemplateAccessible, + $ConditionalTemplateLanguage, + ]) + ) + .nonempty(), + }), + ]) +); + +export const $SubmittableCascade = z.array($SubmittableCascadeItem).nonempty(); + export const $CreateRoutingConfig = schemaFor()( z.object({ campaignId: z.string(), diff --git a/lambdas/backend-client/src/schemas/template.ts b/lambdas/backend-client/src/schemas/template.ts index e58f4d378..dee1ac4bb 100644 --- a/lambdas/backend-client/src/schemas/template.ts +++ b/lambdas/backend-client/src/schemas/template.ts @@ -84,7 +84,7 @@ export const $SmsProperties = schemaFor()( }) ); -export const $BaseLetterTemplateProperties = z.object({ +const $BaseLetterTemplateProperties = z.object({ templateType: z.literal('LETTER'), letterType: z.enum(LETTER_TYPE_LIST), language: z.enum(LANGUAGE_LIST), @@ -134,7 +134,7 @@ export const $LetterProperties = z.discriminatedUnion('letterVersion', [ $AuthoringLetterProperties, ]); -export const $BaseTemplateSchema = schemaFor()( +const $BaseTemplateSchema = schemaFor()( z.object({ name: z.string().trim().min(1), templateType: z.enum(TEMPLATE_TYPE_LIST), diff --git a/lambdas/backend-client/src/types/generated/types.gen.ts b/lambdas/backend-client/src/types/generated/types.gen.ts index 1006e804e..ebf392677 100644 --- a/lambdas/backend-client/src/types/generated/types.gen.ts +++ b/lambdas/backend-client/src/types/generated/types.gen.ts @@ -107,7 +107,7 @@ export type ConditionalTemplateAccessible = { supplierReferences?: { [key: string]: string; }; - templateId: string | null; + templateId: string; }; export type ConditionalTemplateLanguage = { @@ -115,7 +115,7 @@ export type ConditionalTemplateLanguage = { supplierReferences?: { [key: string]: string; }; - templateId: string | null; + templateId: string; }; export type CountSuccess = { diff --git a/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts index 3c8224ec3..3c2e0e2cc 100644 --- a/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts @@ -83,7 +83,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { expect(response.status()).toBe(404); expect(await response.json()).toEqual({ statusCode: 404, - technicalMessage: 'Routing Config not found', + technicalMessage: 'Routing configuration not found', }); }); @@ -104,7 +104,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { expect(response.status()).toBe(404); expect(await response.json()).toEqual({ statusCode: 404, - technicalMessage: 'Routing Config not found', + technicalMessage: 'Routing configuration not found', }); }); @@ -125,7 +125,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { expect(response.status()).toBe(404); expect(await response.json()).toEqual({ statusCode: 404, - technicalMessage: 'Routing Config not found', + technicalMessage: 'Routing configuration not found', }); }); diff --git a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts index ae68c2c73..3896523b9 100644 --- a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; import { createAuthHelper, type TestUser, @@ -6,12 +7,15 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { isoDateRegExp } from 'nhs-notify-web-template-management-test-helper-utils'; import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; +import { TemplateFactory } from '../helpers/factories/template-factory'; import { RoutingConfigStatus } from 'nhs-notify-backend-client'; test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { const authHelper = createAuthHelper(); const storageHelper = new RoutingConfigStorageHelper(); + const templateStorageHelper = new TemplateStorageHelper(); let user1: TestUser; let userDifferentClient: TestUser; let userSharedClient: TestUser; @@ -28,6 +32,7 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { test.afterAll(async () => { await storageHelper.deleteSeeded(); + await templateStorageHelper.deleteSeededTemplates(); }); test('returns 401 if no auth token', async ({ request }) => { @@ -97,7 +102,27 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { test('returns 200 and the updated routing config data', async ({ request, }) => { - const { dbEntry, apiResponse } = RoutingConfigFactory.create(user1); + const templateId = randomUUID(); + + // Create a template for the routing config to reference + const template = TemplateFactory.createNhsAppTemplate( + templateId, + user1, + 'Test Template for Submit' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry, apiResponse } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: templateId, + }, + ], + }); await storageHelper.seed([dbEntry]); @@ -193,7 +218,27 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { test('user belonging to the same client as the creator can submit', async ({ request, }) => { - const { dbEntry, apiResponse } = RoutingConfigFactory.create(user1); + const templateId = randomUUID(); + + // Create a template for the routing config to reference + const template = TemplateFactory.createNhsAppTemplate( + templateId, + user1, + 'Test Template for Shared Client Submit' + ); + + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry, apiResponse } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: templateId, + }, + ], + }); await storageHelper.seed([dbEntry]); @@ -298,4 +343,368 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { 'Lock number mismatch - Message Plan has been modified since last read', }); }); + + test.describe('cascade validation', () => { + test('returns 400 if cascade item has no defaultTemplateId and no conditionalTemplates', async ({ + request, + }) => { + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: null, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: + 'Routing config is not ready for submission: all cascade items must have templates assigned', + }); + }); + + test('returns 400 if cascade item has empty conditionalTemplates array', async ({ + request, + }) => { + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + conditionalTemplates: [], + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: + 'Routing config is not ready for submission: all cascade items must have templates assigned', + }); + }); + + test('returns 200 if cascade item has conditionalTemplates instead of defaultTemplateId', async ({ + request, + }) => { + const frenchTemplateId = randomUUID(); + const arabicTemplateId = randomUUID(); + + // Create templates for conditional templates + const frenchTemplate = TemplateFactory.uploadLetterTemplate( + frenchTemplateId, + user1, + 'French Letter Template', + 'PROOF_APPROVED', + 'PASSED', + { language: 'fr' } + ); + + const arabicTemplate = TemplateFactory.uploadLetterTemplate( + arabicTemplateId, + user1, + 'Arabic Letter Template', + 'PROOF_APPROVED', + 'PASSED', + { language: 'ar' } + ); + + await templateStorageHelper.seedTemplateData([ + frenchTemplate, + arabicTemplate, + ]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['translations'], + channel: 'LETTER', + channelType: 'primary', + // No defaultTemplateId - using conditionalTemplates instead + conditionalTemplates: [ + { language: 'fr', templateId: frenchTemplateId }, + { language: 'ar', templateId: arabicTemplateId }, + ], + }, + ], + cascadeGroupOverrides: [ + { name: 'translations', language: ['fr', 'ar'] }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + const responseBody = await response.json(); + expect(responseBody.data.status).toBe('COMPLETED'); + }); + }); + + test('returns 400 if referenced template does not exist', async ({ + request, + }) => { + const nonExistentTemplateId = 'non-existent-template-id'; + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: nonExistentTemplateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Some templates not found', + details: { + templateIds: nonExistentTemplateId, + }, + }); + }); + + test('returns 400 if referenced template has DELETED status', async ({ + request, + }) => { + const deletedTemplateId = randomUUID(); + + // Create a template with DELETED status + const deletedTemplate = TemplateFactory.createNhsAppTemplate( + deletedTemplateId, + user1, + 'Deleted Template' + ); + deletedTemplate.templateStatus = 'DELETED'; + + await templateStorageHelper.seedTemplateData([deletedTemplate]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: deletedTemplateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Some templates not found', + details: { + templateIds: deletedTemplateId, + }, + }); + }); + + test('returns 400 if LETTER template has status NOT_YET_SUBMITTED', async ({ + request, + }) => { + const letterTemplateId = randomUUID(); + + // Create a LETTER template with NOT_YET_SUBMITTED status + const letterTemplate = TemplateFactory.uploadLetterTemplate( + letterTemplateId, + user1, + 'Test Letter Template', + 'NOT_YET_SUBMITTED' + ); + + await templateStorageHelper.seedTemplateData([letterTemplate]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: + 'Letter templates must have status PROOF_APPROVED or SUBMITTED', + details: { + templateIds: letterTemplateId, + }, + }); + }); + + test('returns 200 if LETTER template has status PROOF_APPROVED', async ({ + request, + }) => { + const letterTemplateId = randomUUID(); + + // Create a LETTER template with PROOF_APPROVED status + const letterTemplate = TemplateFactory.uploadLetterTemplate( + letterTemplateId, + user1, + 'Test Letter Template', + 'PROOF_APPROVED' + ); + + await templateStorageHelper.seedTemplateData([letterTemplate]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + const responseBody = await response.json(); + expect(responseBody.data.status).toBe('COMPLETED'); + }); + + test('returns 200 if LETTER template has status SUBMITTED', async ({ + request, + }) => { + const letterTemplateId = randomUUID(); + + // Create a LETTER template with SUBMITTED status + const letterTemplate = TemplateFactory.uploadLetterTemplate( + letterTemplateId, + user1, + 'Test Letter Template', + 'SUBMITTED' + ); + + await templateStorageHelper.seedTemplateData([letterTemplate]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + const responseBody = await response.json(); + expect(responseBody.data.status).toBe('COMPLETED'); + }); }); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 8bbbffaef..8bb1f20a3 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { test, expect } from '@playwright/test'; import { createAuthHelper, @@ -7,10 +8,13 @@ import { import { EventCacheHelper } from '../helpers/events/event-cache-helper'; import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; test.describe('Event publishing - Routing Config', () => { const authHelper = createAuthHelper(); const storageHelper = new RoutingConfigStorageHelper(); + const templateStorageHelper = new TemplateStorageHelper(); const eventCacheHelper = new EventCacheHelper(); let user: TestUser; @@ -170,13 +174,23 @@ test.describe('Event publishing - Routing Config', () => { }); test('Expect a draft event and a completed event', async ({ request }) => { + const templateId = randomUUID(); + + const template = TemplateFactory.createNhsAppTemplate( + templateId, + user, + 'Test Template for Submit' + ); + + await templateStorageHelper.seedTemplateData([template]); + const payload = RoutingConfigFactory.create(user, { cascade: [ { cascadeGroups: ['standard'], channel: 'NHSAPP', channelType: 'primary', - defaultTemplateId: 'b1854a33-fc1b-4e7d-99d0-6f7b92b8c530', + defaultTemplateId: templateId, }, ], }).apiPayload; From 2e1c34f947de45e8acb0971c0ef3ff0843598a0d Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Wed, 4 Feb 2026 15:23:55 +0000 Subject: [PATCH 02/30] Review comments --- .../module_submit_routing_config_lambda.tf | 2 +- .../repository.test.ts | 31 +++++++++++++++---- .../routing-config-repository/repository.ts | 6 ++-- .../routing-config.event.spec.ts | 1 + 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf b/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf index 8f8dd9f99..0e5613fd6 100644 --- a/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf +++ b/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf @@ -41,8 +41,8 @@ data "aws_iam_policy_document" "submit_routing_config_lambda_policy" { effect = "Allow" actions = [ - "dynamodb:UpdateItem", "dynamodb:GetItem", + "dynamodb:UpdateItem", ] resources = [ diff --git a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts index 58282091d..5917ba6f0 100644 --- a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts @@ -279,16 +279,16 @@ describe('RoutingConfigRepository', () => { expect(result).toEqual({ data: completed }); expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { - TransactItems: expect.arrayContaining([ + TransactItems: [ { - Update: expect.objectContaining({ + Update: { ConditionExpression: '#status = :condition_1_status AND #lockNumber = :condition_2_lockNumber', ExpressionAttributeNames: { + '#lockNumber': 'lockNumber', '#status': 'status', '#updatedAt': 'updatedAt', '#updatedBy': 'updatedBy', - '#lockNumber': 'lockNumber', }, ExpressionAttributeValues: { ':condition_1_status': 'DRAFT', @@ -303,12 +303,31 @@ describe('RoutingConfigRepository', () => { owner: clientOwnerKey, }, ReturnValues: 'ALL_NEW', - TableName: TABLE_NAME, + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + TableName: 'routing-config-table-name', UpdateExpression: 'SET #status = :status, #updatedAt = :updatedAt, #updatedBy = :updatedBy ADD #lockNumber :lockNumber', - }), + }, }, - ]), + { + ConditionCheck: { + ConditionExpression: + 'attribute_exists(id) AND templateStatus <> :deleted AND (templateType <> :letterType OR templateStatus IN (:proofApproved, :submitted))', + ExpressionAttributeValues: { + ':deleted': 'DELETED', + ':letterType': 'LETTER', + ':proofApproved': 'PROOF_APPROVED', + ':submitted': 'SUBMITTED', + }, + Key: { + id: routingConfig.cascade[0].defaultTemplateId!, + owner: clientOwnerKey, + }, + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + TableName: 'template-table-name', + }, + }, + ], }); }); diff --git a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts index 905bfa03a..c557e8173 100644 --- a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts @@ -203,7 +203,7 @@ export class RoutingConfigRepository { } const submittableValidationError = - this.validateRoutingConfigIsSubmittable(cascade); + this.parseSubmittableRoutingConfig(cascade); if (submittableValidationError) { return submittableValidationError; } @@ -480,7 +480,7 @@ export class RoutingConfigRepository { .filter((id): id is string => id != null); } - private validateRoutingConfigIsSubmittable(cascade: CascadeItem[]) { + private parseSubmittableRoutingConfig(cascade: CascadeItem[]) { const result = $SubmittableCascade.safeParse(cascade); if (!result.success) { @@ -490,8 +490,6 @@ export class RoutingConfigRepository { result.error ); } - - return null; } private handleSubmitTransactionError( diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 8bb1f20a3..75c13f23e 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -25,6 +25,7 @@ test.describe('Event publishing - Routing Config', () => { test.afterAll(async () => { await storageHelper.deleteSeeded(); + await templateStorageHelper.deleteSeededTemplates(); }); test('Expect a draft event and a deleted event when some template IDs are null', async ({ From acca8d53de6e90f65224a1f60534dd2897e73578 Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Wed, 4 Feb 2026 15:23:55 +0000 Subject: [PATCH 03/30] Code review --- .../api/delete-routing-config.test.ts | 4 +-- .../api/submit-routing-config.test.ts | 4 +-- .../api/update-routing-config.test.ts | 4 +-- .../repository.test.ts | 16 +++++----- .../routing-config-repository/repository.ts | 32 +++++++------------ .../__tests__/schemas/routing-config.test.ts | 18 ++++++++++- .../delete-routing-config.api.spec.ts | 2 +- .../submit-routing-config.api.spec.ts | 2 +- .../update-routing-config.api.spec.ts | 2 +- 9 files changed, 46 insertions(+), 38 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/api/delete-routing-config.test.ts b/lambdas/backend-api/src/__tests__/api/delete-routing-config.test.ts index 43141ba18..aeb3b8e42 100644 --- a/lambdas/backend-api/src/__tests__/api/delete-routing-config.test.ts +++ b/lambdas/backend-api/src/__tests__/api/delete-routing-config.test.ts @@ -169,7 +169,7 @@ describe('Delete Routing Config Handler', () => { errorMeta: { code: 409, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }, }, }); @@ -192,7 +192,7 @@ describe('Delete Routing Config Handler', () => { body: JSON.stringify({ statusCode: 409, technicalMessage: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }), }); diff --git a/lambdas/backend-api/src/__tests__/api/submit-routing-config.test.ts b/lambdas/backend-api/src/__tests__/api/submit-routing-config.test.ts index 681255539..48d9abea6 100644 --- a/lambdas/backend-api/src/__tests__/api/submit-routing-config.test.ts +++ b/lambdas/backend-api/src/__tests__/api/submit-routing-config.test.ts @@ -173,7 +173,7 @@ describe('Submit Routing Config Handler', () => { errorMeta: { code: 409, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }, }, }); @@ -196,7 +196,7 @@ describe('Submit Routing Config Handler', () => { body: JSON.stringify({ statusCode: 409, technicalMessage: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }), }); diff --git a/lambdas/backend-api/src/__tests__/api/update-routing-config.test.ts b/lambdas/backend-api/src/__tests__/api/update-routing-config.test.ts index 99a38d6c8..ff4c44014 100644 --- a/lambdas/backend-api/src/__tests__/api/update-routing-config.test.ts +++ b/lambdas/backend-api/src/__tests__/api/update-routing-config.test.ts @@ -255,7 +255,7 @@ describe('Update Routing Config Handler', () => { errorMeta: { code: 409, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }, }, }); @@ -283,7 +283,7 @@ describe('Update Routing Config Handler', () => { body: JSON.stringify({ statusCode: 409, technicalMessage: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }), }); diff --git a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts index 5917ba6f0..0bc78b049 100644 --- a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts @@ -345,7 +345,7 @@ describe('RoutingConfigRepository', () => { actualError: err, errorMeta: { code: 500, - description: 'Error retrieving Routing Config', + description: 'Error retrieving routing configuration', }, }, }); @@ -419,7 +419,7 @@ describe('RoutingConfigRepository', () => { }); }); - test('returns 404 failure if routing config does not exist', async () => { + test('returns 404 failure if routing config does not exist on get', async () => { const { repo, mocks } = setup(); mocks.dynamo.on(GetCommand).resolvesOnce({ Item: undefined }); @@ -436,7 +436,7 @@ describe('RoutingConfigRepository', () => { }); }); - test('returns 404 failure if transaction fails because routing config does not exist', async () => { + test('returns 404 failure if routing config does not exist on write', async () => { const { repo, mocks } = setup(); const routingConfigWithLock: RoutingConfig = { @@ -535,7 +535,7 @@ describe('RoutingConfigRepository', () => { errorMeta: { code: 409, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }, }, }); @@ -543,7 +543,7 @@ describe('RoutingConfigRepository', () => { expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); - test('returns validation failure if cascade item has no defaultTemplateId or conditionalTemplates', async () => { + test('returns validation failure if cascade item conditionalTemplates is empty with no defaultTemplateId', async () => { const { repo, mocks } = setup(); const invalidRoutingConfig: RoutingConfig = { @@ -577,7 +577,7 @@ describe('RoutingConfigRepository', () => { expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); - test('returns validation failure if defaultTemplateId is null with no conditionalTemplates', async () => { + test('returns validation failure if defaultTemplateId is null', async () => { const { repo, mocks } = setup(); const invalidRoutingConfig: RoutingConfig = { @@ -1047,7 +1047,7 @@ describe('RoutingConfigRepository', () => { errorMeta: { code: 409, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }, }, }); @@ -1911,7 +1911,7 @@ describe('RoutingConfigRepository', () => { errorMeta: { code: 409, description: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }, }, }); diff --git a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts index c557e8173..37b4347cf 100644 --- a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts @@ -198,14 +198,18 @@ export class RoutingConfigRepository { if (currentLockNumber !== lockNumber) { return failure( ErrorCase.CONFLICT, - 'Lock number mismatch - Message Plan has been modified since last read' + 'Lock number mismatch - Routing configuration has been modified since last read' ); } - const submittableValidationError = - this.parseSubmittableRoutingConfig(cascade); - if (submittableValidationError) { - return submittableValidationError; + const submittableCascadeValidation = $SubmittableCascade.safeParse(cascade); + + if (!submittableCascadeValidation.success) { + return failure( + ErrorCase.VALIDATION_FAILED, + 'Routing config is not ready for submission: all cascade items must have templates assigned', + submittableCascadeValidation.error + ); } const templateIds = this.extractTemplateIds(cascade); @@ -230,7 +234,7 @@ export class RoutingConfigRepository { { Update: update, }, - // Template existence check + For LETTER templates, check they have PROOF_APPROVED or SUBMITTED status + // Template existence & ownership check + For LETTER templates, check they have PROOF_APPROVED or SUBMITTED status // Also exclude DELETED templates for all template types ...templateIds.map((templateId) => ({ ConditionCheck: { @@ -341,7 +345,7 @@ export class RoutingConfigRepository { } catch (error) { return failure( ErrorCase.INTERNAL, - 'Error retrieving Routing Config', + 'Error retrieving routing configuration', error ); } @@ -376,7 +380,7 @@ export class RoutingConfigRepository { if (item.lockNumber !== expectedLockNumber) { return failure( ErrorCase.CONFLICT, - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', err ); } @@ -480,18 +484,6 @@ export class RoutingConfigRepository { .filter((id): id is string => id != null); } - private parseSubmittableRoutingConfig(cascade: CascadeItem[]) { - const result = $SubmittableCascade.safeParse(cascade); - - if (!result.success) { - return failure( - ErrorCase.VALIDATION_FAILED, - 'Routing config is not ready for submission: all cascade items must have templates assigned', - result.error - ); - } - } - private handleSubmitTransactionError( err: unknown, lockNumber: number, diff --git a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts index 085cb3337..1da4f5288 100644 --- a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts @@ -452,7 +452,7 @@ describe('$SubmittableCascadeItem', () => { expect(res.success).toBe(false); }); - test('invalid with empty conditionalTemplates and no defaultTemplateId', () => { + test('invalid with empty conditionalTemplates without defaultTemplateId', () => { const res = $SubmittableCascadeItem.safeParse({ cascadeGroups: ['standard'], channel: 'LETTER', @@ -468,6 +468,22 @@ describe('$SubmittableCascadeItem', () => { channel: 'LETTER', channelType: 'primary', defaultTemplateId: '', + conditionalTemplates: [ + { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' }, + ], + }); + expect(res.success).toBe(false); + }); + + test('invalid with null defaultTemplateId', () => { + const res = $SubmittableCascadeItem.safeParse({ + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: null, + conditionalTemplates: [ + { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' }, + ], }); expect(res.success).toBe(false); }); diff --git a/tests/test-team/template-mgmt-api-tests/delete-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/delete-routing-config.api.spec.ts index a1cdd7022..3d59f3214 100644 --- a/tests/test-team/template-mgmt-api-tests/delete-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/delete-routing-config.api.spec.ts @@ -254,7 +254,7 @@ test.describe('DELETE /v1/routing-configuration/:routingConfigId', () => { expect(await response.json()).toEqual({ statusCode: 409, technicalMessage: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }); }); }); diff --git a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts index 3896523b9..8ccf2bfdd 100644 --- a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts @@ -340,7 +340,7 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { expect(await response.json()).toEqual({ statusCode: 409, technicalMessage: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }); }); diff --git a/tests/test-team/template-mgmt-api-tests/update-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/update-routing-config.api.spec.ts index 0fbd7553e..59bb1c974 100644 --- a/tests/test-team/template-mgmt-api-tests/update-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/update-routing-config.api.spec.ts @@ -720,7 +720,7 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId', () => { expect(await response.json()).toEqual({ statusCode: 409, technicalMessage: - 'Lock number mismatch - Message Plan has been modified since last read', + 'Lock number mismatch - Routing configuration has been modified since last read', }); }); }); From d055d74f0f4b472d143810308c6ae187a082b5b4 Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Wed, 4 Feb 2026 15:23:56 +0000 Subject: [PATCH 04/30] Update templates to SUBMITTED --- .../repository.test.ts | 567 +++++++++++++++--- .../routing-config-repository/repository.ts | 203 +++++-- .../submit-routing-config.api.spec.ts | 381 ++++++++++++ 3 files changed, 1016 insertions(+), 135 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts index 0bc78b049..8e9385c46 100644 --- a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts @@ -1,4 +1,5 @@ import { + BatchGetCommand, DynamoDBDocumentClient, GetCommand, PutCommand, @@ -53,6 +54,25 @@ const clientOwnerKey = `CLIENT#${user.clientId}`; const dynamo = mockClient(DynamoDBDocumentClient); +// Helper to create valid template mocks that pass $TemplateDto validation +const makeTemplateMock = ( + overrides: { + id?: string; + templateType?: 'SMS' | 'EMAIL' | 'NHS_APP' | 'LETTER'; + templateStatus?: string; + lockNumber?: number; + } = {} +) => ({ + id: overrides.id ?? 'template-id', + name: 'Test Template', + templateType: overrides.templateType ?? 'SMS', + templateStatus: overrides.templateStatus ?? 'NOT_YET_SUBMITTED', + lockNumber: overrides.lockNumber ?? 1, + message: 'Test message content', + createdAt: date.toISOString(), + updatedAt: date.toISOString(), +}); + function setup() { dynamo.reset(); @@ -254,7 +274,7 @@ describe('RoutingConfigRepository', () => { }); describe('submit', () => { - test('updates routing config to COMPLETED', async () => { + test('updates routing config to COMPLETED and templates to SUBMITTED', async () => { const { repo, mocks } = setup(); const routingConfigWithLock: RoutingConfig = { @@ -268,16 +288,41 @@ describe('RoutingConfigRepository', () => { lockNumber: 3, }; + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateType: 'SMS', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + mocks.dynamo .on(GetCommand) .resolvesOnce({ Item: routingConfigWithLock }) .resolvesOnce({ Item: completed }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); const result = await repo.submit(routingConfig.id, user, 2); expect(result).toEqual({ data: completed }); + expect(mocks.dynamo).toHaveReceivedCommandWith(BatchGetCommand, { + RequestItems: { + [TEMPLATE_TABLE_NAME]: { + Keys: [ + { + id: routingConfig.cascade[0].defaultTemplateId!, + owner: clientOwnerKey, + }, + ], + }, + }, + }); + expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { TransactItems: [ { @@ -310,27 +355,155 @@ describe('RoutingConfigRepository', () => { }, }, { - ConditionCheck: { + Update: { ConditionExpression: - 'attribute_exists(id) AND templateStatus <> :deleted AND (templateType <> :letterType OR templateStatus IN (:proofApproved, :submitted))', + '#templateStatus = :condition_1_templateStatus AND (#lockNumber = :condition_2_1_lockNumber OR attribute_not_exists (#lockNumber))', + ExpressionAttributeNames: { + '#lockNumber': 'lockNumber', + '#templateStatus': 'templateStatus', + '#updatedAt': 'updatedAt', + '#updatedBy': 'updatedBy', + }, ExpressionAttributeValues: { - ':deleted': 'DELETED', - ':letterType': 'LETTER', - ':proofApproved': 'PROOF_APPROVED', - ':submitted': 'SUBMITTED', + ':condition_1_templateStatus': 'NOT_YET_SUBMITTED', + ':condition_2_1_lockNumber': 1, + ':lockNumber': 1, + ':templateStatus': 'SUBMITTED', + ':updatedAt': date.toISOString(), + ':updatedBy': `INTERNAL_USER#${user.internalUserId}`, }, Key: { id: routingConfig.cascade[0].defaultTemplateId!, owner: clientOwnerKey, }, + ReturnValues: 'ALL_NEW', ReturnValuesOnConditionCheckFailure: 'ALL_OLD', TableName: 'template-table-name', + UpdateExpression: + 'SET #templateStatus = :templateStatus, #updatedAt = :updatedAt, #updatedBy = :updatedBy ADD #lockNumber :lockNumber', }, }, ], }); }); + test('uses ConditionCheck for templates already SUBMITTED', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + const completed: RoutingConfig = { + ...routingConfig, + status: 'COMPLETED', + lockNumber: 3, + }; + + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateType: 'SMS', + templateStatus: 'SUBMITTED', + lockNumber: 5, + }); + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithLock }) + .resolvesOnce({ Item: completed }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); + mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ data: completed }); + + expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { + TransactItems: [ + { + Update: expect.objectContaining({ + TableName: TABLE_NAME, + Key: { + id: routingConfig.id, + owner: clientOwnerKey, + }, + }), + }, + { + ConditionCheck: { + TableName: TEMPLATE_TABLE_NAME, + Key: { + id: routingConfig.cascade[0].defaultTemplateId!, + owner: clientOwnerKey, + }, + ConditionExpression: + 'attribute_exists(id) AND lockNumber = :lockNumber AND templateStatus = :submitted', + ExpressionAttributeValues: { + ':lockNumber': 5, + ':submitted': 'SUBMITTED', + }, + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + }, + }, + ], + }); + }); + + test('uses ConditionCheck with default lockNumber 0 for SUBMITTED template without lockNumber', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + const completed: RoutingConfig = { + ...routingConfig, + status: 'COMPLETED', + lockNumber: 3, + }; + + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateType: 'SMS', + templateStatus: 'SUBMITTED', + lockNumber: undefined, // intentionally omitted + }); + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithLock }) + .resolvesOnce({ Item: completed }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); + mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ data: completed }); + + expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { + TransactItems: expect.arrayContaining([ + expect.objectContaining({ + ConditionCheck: expect.objectContaining({ + ExpressionAttributeValues: { + ':lockNumber': 0, // defaults to 0 when lockNumber is undefined + ':submitted': 'SUBMITTED', + }, + }), + }), + ]), + }); + }); + test('returns failure on client error during initial get', async () => { const { repo, mocks } = setup(); @@ -361,7 +534,19 @@ describe('RoutingConfigRepository', () => { lockNumber: 2, }; + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateType: 'SMS', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -391,10 +576,22 @@ describe('RoutingConfigRepository', () => { name: 0 as unknown as string, }; + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateType: 'SMS', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + mocks.dynamo .on(GetCommand) .resolvesOnce({ Item: routingConfigWithLock }) .resolvesOnce({ Item: completedInvalid }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); const result = await repo.submit(routingConfig.id, user, 2); @@ -444,6 +641,12 @@ describe('RoutingConfigRepository', () => { lockNumber: 2, }; + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + const err = new TransactionCanceledException({ $metadata: {}, message: 'msg', @@ -451,6 +654,11 @@ describe('RoutingConfigRepository', () => { }); mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -627,25 +835,19 @@ describe('RoutingConfigRepository', () => { ], }; - const err = new TransactionCanceledException({ - $metadata: {}, - message: 'msg', - CancellationReasons: [ - { Code: 'None' }, // Update succeeded - { Code: 'ConditionalCheckFailed', Item: undefined }, // Template not found (no Item returned) - ], - }); - mocks.dynamo .on(GetCommand) .resolvesOnce({ Item: routingConfigWithTemplate }); - mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [], + }, + }); const result = await repo.submit(routingConfig.id, user, 2); expect(result).toEqual({ error: { - actualError: err, errorMeta: { code: 400, description: 'Some templates not found', @@ -653,6 +855,8 @@ describe('RoutingConfigRepository', () => { }, }, }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); test('returns 400 failure if template has DELETED status', async () => { @@ -671,32 +875,28 @@ describe('RoutingConfigRepository', () => { ], }; - const err = new TransactionCanceledException({ - $metadata: {}, - message: 'msg', - CancellationReasons: [ - { Code: 'None' }, // Update succeeded - { - Code: 'ConditionalCheckFailed', - Item: { - id: { S: 'deleted-template-id' }, - templateType: { S: 'SMS' }, - templateStatus: { S: 'DELETED' }, - }, - }, - ], - }); - mocks.dynamo .on(GetCommand) .resolvesOnce({ Item: routingConfigWithTemplate }); - mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + // Template exists but is DELETED + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [ + { + id: 'deleted-template-id', + owner: clientOwnerKey, + templateType: 'SMS', + templateStatus: 'DELETED', + lockNumber: 1, + }, + ], + }, + }); const result = await repo.submit(routingConfig.id, user, 2); expect(result).toEqual({ error: { - actualError: err, errorMeta: { code: 400, description: 'Some templates not found', @@ -704,6 +904,8 @@ describe('RoutingConfigRepository', () => { }, }, }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); test('returns 400 failure if LETTER template has invalid status', async () => { @@ -722,32 +924,28 @@ describe('RoutingConfigRepository', () => { ], }; - const err = new TransactionCanceledException({ - $metadata: {}, - message: 'msg', - CancellationReasons: [ - { Code: 'None' }, - { - Code: 'ConditionalCheckFailed', - Item: { - id: { S: 'letter-template-id' }, - templateType: { S: 'LETTER' }, - templateStatus: { S: 'DRAFT' }, - }, - }, - ], - }); - mocks.dynamo .on(GetCommand) .resolvesOnce({ Item: routingConfigWithLetter }); - mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + // LETTER template with invalid status (not PROOF_APPROVED or SUBMITTED) + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [ + { + id: 'letter-template-id', + owner: clientOwnerKey, + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }, + ], + }, + }); const result = await repo.submit(routingConfig.id, user, 2); expect(result).toEqual({ error: { - actualError: err, errorMeta: { code: 400, description: @@ -757,29 +955,7 @@ describe('RoutingConfigRepository', () => { }, }); - expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { - TransactItems: expect.arrayContaining([ - { - ConditionCheck: { - TableName: TEMPLATE_TABLE_NAME, - Key: { - id: 'letter-template-id', - owner: clientOwnerKey, - }, - ConditionExpression: - 'attribute_exists(id) AND templateStatus <> :deleted AND (templateType <> :letterType OR templateStatus IN (:proofApproved, :submitted))', - ExpressionAttributeValues: { - ':deleted': 'DELETED', - ':letterType': 'LETTER', - ':proofApproved': 'PROOF_APPROVED', - ':submitted': 'SUBMITTED', - }, - ReturnValuesOnConditionCheckFailure: - ReturnValuesOnConditionCheckFailure.ALL_OLD, - }, - }, - ]), - }); + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); }); test('returns 500 failure on unexpected TransactionCanceledException', async () => { @@ -792,7 +968,7 @@ describe('RoutingConfigRepository', () => { message: 'Unexpected cancellation', CancellationReasons: [ { Code: 'None' }, // Update succeeded - { Code: 'None' }, // Template check also passed (unexpected) + { Code: 'None' }, // Template update also passed (unexpected) ], }); @@ -809,9 +985,20 @@ describe('RoutingConfigRepository', () => { ], }; + const template = makeTemplateMock({ + id: 'some-template-id', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + mocks.dynamo .on(GetCommand) .resolvesOnce({ Item: routingConfigWithTemplate }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -841,7 +1028,18 @@ describe('RoutingConfigRepository', () => { lockNumber: 2, }; + const template = makeTemplateMock({ + id: routingConfig.cascade[0].defaultTemplateId!, + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); const result = await repo.submit(routingConfig.id, user, 2); @@ -856,6 +1054,219 @@ describe('RoutingConfigRepository', () => { }, }); }); + + test('returns 409 failure if template was modified since retrieval', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithTemplate: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'modified-template-id', + }, + ], + }; + + const template = makeTemplateMock({ + id: 'modified-template-id', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + + const err = new TransactionCanceledException({ + $metadata: {}, + message: 'Transaction cancelled', + CancellationReasons: [ + { Code: 'None' }, // Routing config update succeeded + { Code: 'ConditionalCheckFailed' }, // Template lock number mismatch + ], + }); + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithTemplate }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); + mocks.dynamo.on(TransactWriteCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 409, + description: + 'Some templates have been modified since they were retrieved', + details: { templateIds: 'modified-template-id' }, + }, + }, + }); + }); + + test('returns 500 failure if BatchGetCommand fails', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + const err = new Error('BatchGet failed'); + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(BatchGetCommand).rejectsOnce(err); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 500, + description: 'Failed to retrieve templates', + }, + }, + }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); + }); + + test('succeeds when routing config has no templates (empty cascade)', async () => { + const { repo, mocks } = setup(); + + const routingConfigNoTemplates: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + conditionalTemplates: [ + { language: 'en', templateId: 'english-template' }, + ], + }, + ], + }; + + const completed: RoutingConfig = { + ...routingConfigNoTemplates, + status: 'COMPLETED', + lockNumber: 3, + }; + + const template = makeTemplateMock({ + id: 'english-template', + templateType: 'NHS_APP', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }); + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigNoTemplates }) + .resolvesOnce({ Item: completed }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); + mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ data: completed }); + }); + + test('handles template with undefined lockNumber', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + const completed: RoutingConfig = { + ...routingConfig, + status: 'COMPLETED', + lockNumber: 3, + }; + + const template = { + id: routingConfig.cascade[0].defaultTemplateId!, + owner: clientOwnerKey, + templateType: 'SMS', + templateStatus: 'NOT_YET_SUBMITTED', + name: 'Test Template', + message: 'Test message content', + createdAt: date.toISOString(), + updatedAt: date.toISOString(), + }; + + mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: routingConfigWithLock }) + .resolvesOnce({ Item: completed }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [template], + }, + }); + mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ data: completed }); + + expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { + TransactItems: expect.arrayContaining([ + expect.objectContaining({ + Update: expect.objectContaining({ + ExpressionAttributeValues: expect.objectContaining({ + ':condition_2_1_lockNumber': 0, + }), + }), + }), + ]), + }); + }); + + test('handles BatchGetCommand returning empty Responses', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: {}, + }); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: undefined, + errorMeta: { + code: 400, + description: 'Some templates not found', + details: { + templateIds: routingConfig.cascade[0].defaultTemplateId, + }, + }, + }, + }); + }); }); describe('delete', () => { diff --git a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts index 37b4347cf..ed6d2226c 100644 --- a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { + BatchGetCommand, GetCommand, PutCommand, TransactWriteCommand, @@ -14,17 +15,22 @@ import { import { $RoutingConfig, $SubmittableCascade, + $TemplateDto, CascadeItem, type CreateRoutingConfig, ErrorCase, type RoutingConfig, type RoutingConfigReference, RoutingConfigStatus, + type TemplateDto, type UpdateRoutingConfig, } from 'nhs-notify-backend-client'; import type { User } from 'nhs-notify-web-template-management-utils'; import { RoutingConfigQuery } from './query'; -import { RoutingConfigUpdateBuilder } from 'nhs-notify-entity-update-command-builder'; +import { + RoutingConfigUpdateBuilder, + TemplateUpdateBuilder, +} from 'nhs-notify-entity-update-command-builder'; import { ConditionalCheckFailedException, ReturnValuesOnConditionCheckFailure, @@ -182,7 +188,6 @@ export class RoutingConfigRepository { lockNumber: currentLockNumber, } = existingConfig.data; - // Check status before cascade validation if (status === 'DELETED') { return failure(ErrorCase.NOT_FOUND, 'Routing configuration not found'); } @@ -194,7 +199,6 @@ export class RoutingConfigRepository { ); } - // Check lock number before cascade validation if (currentLockNumber !== lockNumber) { return failure( ErrorCase.CONFLICT, @@ -214,7 +218,47 @@ export class RoutingConfigRepository { const templateIds = this.extractTemplateIds(cascade); - const update = new RoutingConfigUpdateBuilder( + const templatesResult = await this.getTemplates(templateIds, user.clientId); + + if (templatesResult.error) { + return templatesResult; + } + + const templates = templatesResult.data; + + const missingTemplateIds = templateIds.filter( + (tid) => + !templates.some((t) => t.id === tid && t.templateStatus !== 'DELETED') + ); + + if (missingTemplateIds.length > 0) { + return failure( + ErrorCase.ROUTING_CONFIG_TEMPLATES_NOT_FOUND, + 'Some templates not found', + undefined, + { templateIds: missingTemplateIds.join(',') } + ); + } + + const invalidLetterTemplateIds = templates + .filter( + (t) => + t.templateType === 'LETTER' && + t.templateStatus !== 'PROOF_APPROVED' && + t.templateStatus !== 'SUBMITTED' + ) + .map((t) => t.id); + + if (invalidLetterTemplateIds.length > 0) { + return failure( + ErrorCase.VALIDATION_FAILED, + 'Letter templates must have status PROOF_APPROVED or SUBMITTED', + undefined, + { templateIds: invalidLetterTemplateIds.join(',') } + ); + } + + const routingConfigUpdate = new RoutingConfigUpdateBuilder( this.tableName, user.clientId, id, @@ -227,34 +271,50 @@ export class RoutingConfigRepository { .incrementLockNumber() .build(); + // add an update to each template to set status to SUBMITTED, or a condition check if already SUBMITTED + const templateUpdatesAndChecks = templates.map((template) => + template.templateStatus === 'SUBMITTED' + ? { + ConditionCheck: { + TableName: this.templateTableName, + Key: { + id: template.id, + owner: this.clientOwnerKey(user.clientId), + }, + ConditionExpression: + 'attribute_exists(id) AND lockNumber = :lockNumber AND templateStatus = :submitted', + ExpressionAttributeValues: { + ':lockNumber': template.lockNumber ?? 0, + ':submitted': 'SUBMITTED', + }, + ReturnValuesOnConditionCheckFailure: + ReturnValuesOnConditionCheckFailure.ALL_OLD, + }, + } + : { + Update: new TemplateUpdateBuilder( + this.templateTableName, + user.clientId, + template.id, + this.updateCmdOpts + ) + .setStatus('SUBMITTED') + .expectStatus(template.templateStatus) + .expectLockNumber(template.lockNumber ?? 0) + .incrementLockNumber() + .setUpdatedByUserAt(this.internalUserKey(user)) + .build(), + } + ); + try { await this.client.send( new TransactWriteCommand({ TransactItems: [ { - Update: update, + Update: routingConfigUpdate, }, - // Template existence & ownership check + For LETTER templates, check they have PROOF_APPROVED or SUBMITTED status - // Also exclude DELETED templates for all template types - ...templateIds.map((templateId) => ({ - ConditionCheck: { - TableName: this.templateTableName, - Key: { - id: templateId, - owner: this.clientOwnerKey(user.clientId), - }, - ConditionExpression: - 'attribute_exists(id) AND templateStatus <> :deleted AND (templateType <> :letterType OR templateStatus IN (:proofApproved, :submitted))', - ExpressionAttributeValues: { - ':deleted': 'DELETED', - ':letterType': 'LETTER', - ':proofApproved': 'PROOF_APPROVED', - ':submitted': 'SUBMITTED', - }, - ReturnValuesOnConditionCheckFailure: - ReturnValuesOnConditionCheckFailure.ALL_OLD, - }, - })), + ...templateUpdatesAndChecks, ], }) ); @@ -282,6 +342,53 @@ export class RoutingConfigRepository { } } + private async getTemplates( + templateIds: string[], + clientId: string + ): Promise> { + // istanbul ignore next -- defensive check; $SubmittableCascade validation ensures at least one template + if (templateIds.length === 0) { + return success([]); + } + + try { + const result = await this.client.send( + new BatchGetCommand({ + RequestItems: { + [this.templateTableName]: { + Keys: templateIds.map((tid) => ({ + id: tid, + owner: this.clientOwnerKey(clientId), + })), + }, + }, + }) + ); + + const rawTemplates = result.Responses?.[this.templateTableName] ?? []; + + const templates: TemplateDto[] = []; + + for (const item of rawTemplates) { + const parsed = $TemplateDto.safeParse(item); + + if (!parsed.success) { + return failure( + ErrorCase.INTERNAL, + 'Error parsing template from database', + parsed.error + ); + } + + templates.push(parsed.data); + } + + return success(templates); + } catch (error) { + return failure(ErrorCase.INTERNAL, 'Failed to retrieve templates', error); + } + } + async delete( id: string, user: User, @@ -493,56 +600,38 @@ export class RoutingConfigRepository { return this.handleUpdateError(err, lockNumber); } - // Note: The first item will always be the update + // Note: The first item will always be the routing config update // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CancellationReason.html - const [updateReason, ...templateReasons] = err.CancellationReasons ?? []; + const [routingConfigReason, ...templateReasons] = + err.CancellationReasons ?? []; - if (updateReason && updateReason.Code !== 'None') { + if (routingConfigReason && routingConfigReason.Code !== 'None') { return this.handleUpdateError( new ConditionalCheckFailedException({ - message: updateReason.Message!, - Item: updateReason.Item, + message: routingConfigReason.Message!, + Item: routingConfigReason.Item, $metadata: err.$metadata, }), lockNumber ); } - // Find which templates failed the condition check - const missingTemplateIds: string[] = []; - const invalidLetterTemplateIds: string[] = []; + // Find which template updates failed - likely due to lock number mismatch + const failedTemplateIds: string[] = []; for (const [index, reason] of templateReasons.entries()) { if (reason.Code === 'ConditionalCheckFailed') { const templateId = templateIds[index]; - - if ( - !reason.Item || - reason.Item.templateStatus?.S === - ('DELETED' satisfies RoutingConfigStatus) - ) { - missingTemplateIds.push(templateId); - } else { - invalidLetterTemplateIds.push(templateId); - } + failedTemplateIds.push(templateId); } } - if (missingTemplateIds.length > 0) { - return failure( - ErrorCase.ROUTING_CONFIG_TEMPLATES_NOT_FOUND, - 'Some templates not found', - err, - { templateIds: missingTemplateIds.join(',') } - ); - } - - if (invalidLetterTemplateIds.length > 0) { + if (failedTemplateIds.length > 0) { return failure( - ErrorCase.VALIDATION_FAILED, - 'Letter templates must have status PROOF_APPROVED or SUBMITTED', + ErrorCase.CONFLICT, + 'Some templates have been modified since they were retrieved', err, - { templateIds: invalidLetterTemplateIds.join(',') } + { templateIds: failedTemplateIds.join(',') } ); } diff --git a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts index 8ccf2bfdd..b99b6f3c9 100644 --- a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts @@ -707,4 +707,385 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { const responseBody = await response.json(); expect(responseBody.data.status).toBe('COMPLETED'); }); + + test.describe('template status updates on submit', () => { + test('updates NHSAPP template status to SUBMITTED after routing config submit', async ({ + request, + }) => { + const templateId = randomUUID(); + + // Create a template with NOT_YET_SUBMITTED status + const template = TemplateFactory.createNhsAppTemplate( + templateId, + user1, + 'Test Template for Status Update' + ); + template.templateStatus = 'NOT_YET_SUBMITTED'; + template.lockNumber = 5; + + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: templateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + // Verify template was updated to SUBMITTED + const updatedTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId, + }); + + expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedTemplate.lockNumber).toBe(6); + }); + + test('updates EMAIL template status to SUBMITTED after routing config submit', async ({ + request, + }) => { + const templateId = randomUUID(); + + // Create an EMAIL template with NOT_YET_SUBMITTED status + const template = TemplateFactory.createEmailTemplate( + templateId, + user1, + 'Test Email Template for Status Update' + ); + template.templateStatus = 'NOT_YET_SUBMITTED'; + template.lockNumber = 3; + + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: templateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + // Verify template was updated to SUBMITTED + const updatedTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId, + }); + + expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedTemplate.lockNumber).toBe(4); + }); + + test('updates SMS template status to SUBMITTED after routing config submit', async ({ + request, + }) => { + const templateId = randomUUID(); + + // Create an SMS template with NOT_YET_SUBMITTED status + const template = TemplateFactory.createSmsTemplate( + templateId, + user1, + 'Test SMS Template for Status Update' + ); + template.templateStatus = 'NOT_YET_SUBMITTED'; + template.lockNumber = 1; + + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: templateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + // Verify template was updated to SUBMITTED + const updatedTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId, + }); + + expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedTemplate.lockNumber).toBe(2); + }); + + test('updates LETTER template lockNumber without changing SUBMITTED status', async ({ + request, + }) => { + const templateId = randomUUID(); + + // Create a LETTER template that is already SUBMITTED + const template = TemplateFactory.uploadLetterTemplate( + templateId, + user1, + 'Test Letter Template Already Submitted', + 'SUBMITTED' + ); + template.lockNumber = 10; + + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: templateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + // Verify template lockNumber was incremented but status remains SUBMITTED + const updatedTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId, + }); + + expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedTemplate.lockNumber).toBe(11); + }); + + test('updates multiple templates to SUBMITTED after routing config submit', async ({ + request, + }) => { + const nhsAppTemplateId = randomUUID(); + const emailTemplateId = randomUUID(); + const smsTemplateId = randomUUID(); + + // Create multiple templates with NOT_YET_SUBMITTED status + const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( + nhsAppTemplateId, + user1, + 'NHS App Template' + ); + nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; + nhsAppTemplate.lockNumber = 1; + + const emailTemplate = TemplateFactory.createEmailTemplate( + emailTemplateId, + user1, + 'Email Template' + ); + emailTemplate.templateStatus = 'NOT_YET_SUBMITTED'; + emailTemplate.lockNumber = 2; + + const smsTemplate = TemplateFactory.createSmsTemplate( + smsTemplateId, + user1, + 'SMS Template' + ); + smsTemplate.templateStatus = 'NOT_YET_SUBMITTED'; + smsTemplate.lockNumber = 3; + + await templateStorageHelper.seedTemplateData([ + nhsAppTemplate, + emailTemplate, + smsTemplate, + ]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: nhsAppTemplateId, + }, + { + cascadeGroups: ['standard'], + channel: 'EMAIL', + channelType: 'secondary', + defaultTemplateId: emailTemplateId, + }, + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'secondary', + defaultTemplateId: smsTemplateId, + }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + // Verify all templates were updated to SUBMITTED + const updatedNhsAppTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: nhsAppTemplateId, + }); + const updatedEmailTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: emailTemplateId, + }); + const updatedSmsTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: smsTemplateId, + }); + + expect(updatedNhsAppTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedNhsAppTemplate.lockNumber).toBe(2); + + expect(updatedEmailTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedEmailTemplate.lockNumber).toBe(3); + + expect(updatedSmsTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedSmsTemplate.lockNumber).toBe(4); + }); + + test('updates conditionalTemplates to SUBMITTED after routing config submit', async ({ + request, + }) => { + const englishTemplateId = randomUUID(); + const frenchTemplateId = randomUUID(); + + // Create conditional templates + const englishTemplate = TemplateFactory.createNhsAppTemplate( + englishTemplateId, + user1, + 'English Template' + ); + englishTemplate.templateStatus = 'NOT_YET_SUBMITTED'; + englishTemplate.lockNumber = 1; + + const frenchTemplate = TemplateFactory.createNhsAppTemplate( + frenchTemplateId, + user1, + 'French Template' + ); + frenchTemplate.templateStatus = 'NOT_YET_SUBMITTED'; + frenchTemplate.lockNumber = 2; + + await templateStorageHelper.seedTemplateData([ + englishTemplate, + frenchTemplate, + ]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [ + { + cascadeGroups: ['translations'], + channel: 'NHSAPP', + channelType: 'primary', + conditionalTemplates: [ + { language: 'en', templateId: englishTemplateId }, + { language: 'fr', templateId: frenchTemplateId }, + ], + }, + ], + cascadeGroupOverrides: [ + { name: 'translations', language: ['en', 'fr'] }, + ], + }); + + await storageHelper.seed([dbEntry]); + + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + expect(response.status()).toBe(200); + + // Verify both conditional templates were updated to SUBMITTED + const updatedEnglishTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: englishTemplateId, + }); + const updatedFrenchTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: frenchTemplateId, + }); + + expect(updatedEnglishTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedEnglishTemplate.lockNumber).toBe(2); + + expect(updatedFrenchTemplate.templateStatus).toBe('SUBMITTED'); + expect(updatedFrenchTemplate.lockNumber).toBe(3); + }); + }); }); From afb7968711cb331aaa30e00cd10fbcecd9de186c Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Tue, 3 Feb 2026 09:20:07 +0000 Subject: [PATCH 05/30] Submit plan page --- .../__snapshots__/page.test.tsx.snap | 747 ++++++++++++++++++ .../[routingConfigId]/page.test.tsx | 450 +++++++++++ .../MessagePlanCascadePreview.test.tsx | 194 +++++ .../MessagePlanCascadePreview.test.tsx.snap | 527 ++++++++++++ .../src/__tests__/utils/message-plans.test.ts | 73 ++ .../[routingConfigId]/page.tsx | 204 +---- .../[routingConfigId]/actions.test.ts | 26 + .../[routingConfigId]/actions.ts | 12 + .../[routingConfigId]/page.tsx | 96 +++ .../MessagePlanCascadePreview.tsx | 217 +++++ frontend/src/content/content.ts | 21 + frontend/src/middleware.ts | 1 + frontend/src/utils/message-plans.ts | 31 + lambdas/AGENTS.md | 365 +++++++++ .../routing-config-api-client.test.ts | 60 ++ .../src/routing-config-api-client.ts | 27 + tests/test-team/AGENTS.md | 257 ++++++ tests/test-team/pages/routing/index.ts | 1 + .../review-and-move-to-production-page.ts | 70 ++ ...emplate-protected-routes.component.spec.ts | 2 + ...ve-to-production.routing-component.spec.ts | 375 +++++++++ 21 files changed, 3558 insertions(+), 198 deletions(-) create mode 100644 frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx create mode 100644 frontend/src/__tests__/components/molecules/MessagePlanCascadePreview.test.tsx create mode 100644 frontend/src/__tests__/components/molecules/__snapshots__/MessagePlanCascadePreview.test.tsx.snap create mode 100644 frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts create mode 100644 frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts create mode 100644 frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx create mode 100644 frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx create mode 100644 lambdas/AGENTS.md create mode 100644 tests/test-team/AGENTS.md create mode 100644 tests/test-team/pages/routing/review-and-move-to-production-page.ts create mode 100644 tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts diff --git a/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..f0b0b4138 --- /dev/null +++ b/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/__snapshots__/page.test.tsx.snap @@ -0,0 +1,747 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Review and move to production page matches snapshot for full cascade 1`] = ` + +
+
+
+ + Step 2 of 2 + +

+ Review and move message plan to production +

+
+
+
+ Name +
+
+ Test config +
+
+
+

+ +

+
    +
  • + +

    + First message +

    +
    +
    +

    + NHS App +

    +

    + app template name +

    +
    + + + Preview + + NHS App + + template + + +
    +
    +

    + message +

    + + +
    +
    +
    +
    +
    +
  • +
  • + +
    + + + Fallback conditions + + +
    +
      +
    • + + If first message read within 24 hours, no further messages sent. +
    • +
    • + + If first message not read within 24 hours, second message sent. +
    • +
    +
    +
    +
  • +
  • + +

    + Second message +

    +
    +
    +

    + Email +

    +

    + email template name +

    +
    + + + Preview + + Email + + template + + +
    +
    +

    + message +

    + + +
    +
    +
    +
    +
    +
  • +
  • + +
    + + + Fallback conditions + + +
    +
      +
    • + + If second message delivered within 72 hours, no further messages sent. +
    • +
    • + + If second message not delivered within 72 hours, third message sent. +
    • +
    +
    +
    +
  • +
  • + +

    + Third message +

    +
    +
    +

    + Text message (SMS) +

    +

    + sms template name +

    +
    + + + Preview + + Text message (SMS) + + template + + +
    +
    +

    + message +

    + + +
    +
    +
    +
    +
    +
  • +
  • + +
    + + + Fallback conditions + + +
    +
      +
    • + + If third message delivered within 72 hours, no further messages sent. +
    • +
    • + + If third message not delivered within 72 hours, fourth message sent. +
    • +
    +
    +
    +
  • +
  • + +

    + Fourth message +

    +
    +
    +

    + Standard English letter +

    +

    + + letter template name + +

    +
    +
    +
      +
    • + +
      + + + Conditions for accessible and language letters + + +
      +
        +
      • + + The relevant accessible or language letter will be sent instead of the standard English letter if, both: +
          +
        • + the recipient has requested an accessible or language letter in PDS +
        • +
        • + you've included the relevant template in this message plan +
        • +
        +
      • +
      +
      +
      +
    • +
    • +
      +
      +

      + Large print letter (optional) +

      +

      + + letter template name + +

      +
      +
      +
    • +
    • +
      +
      +

      + Other language letters (optional) +

      +

      + + letter template name + +

      +

      + + letter template name + +

      +
      +
      +
    • +
    +
  • +
+
+
+ +
+ + Keep in draft + +
+
+
+
+
+`; diff --git a/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx b/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx new file mode 100644 index 000000000..4152e5121 --- /dev/null +++ b/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx @@ -0,0 +1,450 @@ +import { redirect, RedirectType } from 'next/navigation'; +import { render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { RoutingConfig } from 'nhs-notify-backend-client'; +import { + EMAIL_TEMPLATE, + PDF_LETTER_TEMPLATE, + NHS_APP_TEMPLATE, + SMS_TEMPLATE, +} from '@testhelpers/helpers'; +import { RoutingConfigFactory } from '@testhelpers/routing-config-factory'; +import { + getMessagePlanTemplates, + getRoutingConfig, +} from '@utils/message-plans'; +import type { MessagePlanTemplates } from '@utils/routing-utils'; + +import ReviewAndMoveMessagePlanPage, { + metadata, +} from '@app/message-plans/review-and-move-to-production/[routingConfigId]/page'; + +jest.mock('next/navigation'); +jest.mock('@utils/message-plans'); +jest.mock( + '@app/message-plans/review-and-move-to-production/[routingConfigId]/actions', + () => ({ + moveToProduction: jest.fn(), + }) +); + +function createRoutingConfig(data?: Partial) { + return RoutingConfigFactory.create({ + id: 'rc-123', + campaignId: 'cmp-1', + status: 'DRAFT', + lockNumber: 5, + ...data, + }); +} + +async function renderPage(routingConfig?: RoutingConfig, id?: string) { + jest.mocked(getRoutingConfig).mockResolvedValue(routingConfig); + + const page = await ReviewAndMoveMessagePlanPage({ + params: Promise.resolve({ routingConfigId: id ?? routingConfig?.id ?? '' }), + }); + + return render(page); +} + +const appTemplateId = '8f9df705-fa06-4882-a7ca-02a257fbeb60'; +const emailTemplateId = 'e1095ace-6c32-476b-9467-89e60323c7c4'; +const smsTemplateId = '920f7ad7-8cf6-4dfb-a08d-a7782860375e'; +const letterTemplateId = '278e1a92-353f-42a3-b08d-565ea1c9d763'; +const kuTemplateId = '31399023-08a2-4dc7-81c7-e25b284b2aab'; +const sqTemplateId = '35746144-cac4-4e1f-b92b-4f58e9f1154f'; +const largePrintTemplateId = '72ebc15c-d950-4e2e-99d4-3de7f174fba6'; + +const templates: MessagePlanTemplates = { + [appTemplateId]: { ...NHS_APP_TEMPLATE, id: appTemplateId }, + [emailTemplateId]: { ...EMAIL_TEMPLATE, id: emailTemplateId }, + [smsTemplateId]: { ...SMS_TEMPLATE, id: smsTemplateId }, + [letterTemplateId]: { ...PDF_LETTER_TEMPLATE, id: letterTemplateId }, + [kuTemplateId]: { ...PDF_LETTER_TEMPLATE, id: kuTemplateId }, + [sqTemplateId]: { ...PDF_LETTER_TEMPLATE, id: sqTemplateId }, + [largePrintTemplateId]: { ...PDF_LETTER_TEMPLATE, id: largePrintTemplateId }, +}; + +beforeEach(() => { + jest.mocked(getMessagePlanTemplates).mockResolvedValue(templates); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('Review and move to production page', () => { + it('has correct page metadata', () => { + expect(metadata).toEqual({ + title: 'Review and move message plan to production - NHS Notify', + }); + }); + + it('redirects to invalid when message plan not found', async () => { + await renderPage(undefined, 'rc-unknown'); + + expect(getRoutingConfig).toHaveBeenCalledWith('rc-unknown'); + expect(redirect).toHaveBeenCalledWith( + '/message-plans/invalid', + RedirectType.replace + ); + }); + + it('redirects to message plans if status is not DRAFT', async () => { + const routingConfig = createRoutingConfig({ status: 'COMPLETED' }); + + await renderPage(routingConfig); + + expect(redirect).toHaveBeenCalledWith( + '/message-plans', + RedirectType.replace + ); + }); + + it('renders the page heading and step counter', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + expect(screen.getByText('Step 2 of 2')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { + level: 1, + name: 'Review and move message plan to production', + }) + ).toBeInTheDocument(); + }); + + it('renders summary list with message plan name only', async () => { + const routingConfig = createRoutingConfig({ + name: 'My Test Plan', + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + const summaryList = screen.getByTestId('message-plan-details'); + expect(within(summaryList).getByTestId('plan-name')).toHaveTextContent( + 'My Test Plan' + ); + + // Should only have name row, not ID, campaign or status + expect( + within(summaryList).queryByTestId('plan-id') + ).not.toBeInTheDocument(); + expect( + within(summaryList).queryByTestId('campaign-id') + ).not.toBeInTheDocument(); + expect(within(summaryList).queryByTestId('status')).not.toBeInTheDocument(); + }); + + it('renders move to production button with warning style', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + const moveButton = screen.getByTestId('move-to-production-button'); + expect(moveButton).toBeInTheDocument(); + expect(moveButton).toHaveTextContent('Move to production'); + expect(moveButton).toHaveClass('nhsuk-button--warning'); + }); + + it('renders keep in draft link with correct href', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + const keepInDraftLink = screen.getByTestId('keep-in-draft-link'); + expect(keepInDraftLink).toHaveTextContent('Keep in draft'); + expect(keepInDraftLink).toHaveAttribute( + 'href', + '/message-plans/choose-templates/rc-123' + ); + }); + + describe('cascade channel display', () => { + it('renders cascade channel list', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + { + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: emailTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + const channelList = screen.getByTestId('cascade-channel-list'); + expect(channelList).toBeInTheDocument(); + + expect( + within(channelList).getByTestId('message-plan-block-NHSAPP') + ).toBeInTheDocument(); + expect( + within(channelList).getByTestId('message-plan-block-EMAIL') + ).toBeInTheDocument(); + }); + + it('shows open/close all button for digital channels', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + expect( + screen.getByRole('button', { name: /open all template previews/i }) + ).toBeInTheDocument(); + }); + + it('does not show open/close all button when only letters in cascade', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + expect( + screen.queryByRole('button', { name: /open all template previews/i }) + ).not.toBeInTheDocument(); + }); + + it('renders template preview details for digital channels', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + const block = screen.getByTestId('message-plan-block-NHSAPP'); + expect(within(block).getByTestId('template-name')).toHaveTextContent( + templates[appTemplateId].name + ); + expect( + within(block).getByTestId('preview-template-summary') + ).toBeInTheDocument(); + }); + + it('renders letter template as link', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + const block = screen.getByTestId('message-plan-block-LETTER'); + const templateName = within(block).getByTestId('template-name'); + const link = within(templateName).getByRole('link'); + + expect(link).toHaveTextContent(templates[letterTemplateId].name); + expect(link).toHaveAttribute( + 'href', + `/preview-submitted-letter-template/${letterTemplateId}` + ); + }); + + it('renders fallback conditions between channels', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + { + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: emailTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + expect( + screen.getByTestId('message-plan-fallback-conditions-NHSAPP') + ).toBeInTheDocument(); + }); + }); + + describe('conditional templates', () => { + it('renders accessible format templates', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + cascadeGroups: ['standard', 'accessible'], + conditionalTemplates: [ + { templateId: largePrintTemplateId, accessibleFormat: 'x1' }, + ], + }, + ], + }); + + await renderPage(routingConfig); + + expect(screen.getByTestId('conditional-template-x1')).toBeInTheDocument(); + }); + + it('renders language templates', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + cascadeGroups: ['standard', 'translations'], + conditionalTemplates: [ + { templateId: kuTemplateId, language: 'ku' }, + { templateId: sqTemplateId, language: 'sq' }, + ], + }, + ], + }); + + await renderPage(routingConfig); + + expect( + screen.getByTestId('conditional-template-languages') + ).toBeInTheDocument(); + }); + }); + + it('does not render back to all message plans link', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + ], + }); + + await renderPage(routingConfig); + + expect( + screen.queryByText('Back to all message plans') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('back-link-top')).not.toBeInTheDocument(); + expect(screen.queryByTestId('back-link-bottom')).not.toBeInTheDocument(); + }); + + it('matches snapshot for full cascade', async () => { + const routingConfig = createRoutingConfig({ + cascade: [ + { + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + cascadeGroups: ['standard'], + }, + { + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: emailTemplateId, + cascadeGroups: ['standard'], + }, + { + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: smsTemplateId, + cascadeGroups: ['standard'], + }, + { + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: letterTemplateId, + cascadeGroups: ['standard', 'accessible', 'translations'], + conditionalTemplates: [ + { templateId: kuTemplateId, language: 'ku' }, + { templateId: sqTemplateId, language: 'sq' }, + { templateId: largePrintTemplateId, accessibleFormat: 'x1' }, + ], + }, + ], + }); + + const { asFragment } = await renderPage(routingConfig); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/components/molecules/MessagePlanCascadePreview.test.tsx b/frontend/src/__tests__/components/molecules/MessagePlanCascadePreview.test.tsx new file mode 100644 index 000000000..815048687 --- /dev/null +++ b/frontend/src/__tests__/components/molecules/MessagePlanCascadePreview.test.tsx @@ -0,0 +1,194 @@ +import { render, screen } from '@testing-library/react'; +import { MessagePlanCascadePreview } from '@molecules/MessagePlanCascadePreview/MessagePlanCascadePreview'; +import { + EMAIL_TEMPLATE, + NHS_APP_TEMPLATE, + PDF_LETTER_TEMPLATE, + ROUTING_CONFIG, + SMS_TEMPLATE, +} from '@testhelpers/helpers'; + +describe('MessagePlanCascadePreview', () => { + const templates = { + [NHS_APP_TEMPLATE.id]: NHS_APP_TEMPLATE, + [EMAIL_TEMPLATE.id]: EMAIL_TEMPLATE, + [SMS_TEMPLATE.id]: SMS_TEMPLATE, + [PDF_LETTER_TEMPLATE.id]: PDF_LETTER_TEMPLATE, + }; + + it('renders cascade preview with all channels', () => { + render( + + ); + + expect(screen.getByTestId('cascade-channel-list')).toBeInTheDocument(); + expect(screen.getByTestId('message-plan-block-NHSAPP')).toBeInTheDocument(); + expect(screen.getByTestId('message-plan-block-EMAIL')).toBeInTheDocument(); + expect(screen.getByTestId('message-plan-block-SMS')).toBeInTheDocument(); + expect(screen.getByTestId('message-plan-block-LETTER')).toBeInTheDocument(); + }); + + it('renders template names', () => { + render( + + ); + + const templateNames = screen.getAllByTestId('template-name'); + expect(templateNames).toHaveLength(4); + expect(templateNames[0]).toHaveTextContent(NHS_APP_TEMPLATE.name); + expect(templateNames[1]).toHaveTextContent(EMAIL_TEMPLATE.name); + expect(templateNames[2]).toHaveTextContent(SMS_TEMPLATE.name); + expect(templateNames[3]).toHaveTextContent(PDF_LETTER_TEMPLATE.name); + }); + + it('renders open/close all previews button when non-letter channels present', () => { + render( + + ); + + expect( + screen.getByRole('button', { name: 'Open all template previews' }) + ).toBeInTheDocument(); + }); + + it('does not render open/close button when only letter channel present', () => { + const letterOnlyRoutingConfig = { + ...ROUTING_CONFIG, + cascade: [ + { + cascadeGroups: ['standard' as const], + channel: 'LETTER' as const, + channelType: 'primary' as const, + defaultTemplateId: PDF_LETTER_TEMPLATE.id, + }, + ], + }; + + render( + + ); + + expect( + screen.queryByRole('button', { name: 'Open all template previews' }) + ).not.toBeInTheDocument(); + }); + + it('renders fallback conditions between cascade items', () => { + render( + + ); + + expect( + screen.getByTestId('message-plan-fallback-conditions-NHSAPP') + ).toBeInTheDocument(); + expect( + screen.getByTestId('message-plan-fallback-conditions-EMAIL') + ).toBeInTheDocument(); + expect( + screen.getByTestId('message-plan-fallback-conditions-SMS') + ).toBeInTheDocument(); + }); + + it('does not render fallback conditions for single channel', () => { + const singleChannelRoutingConfig = { + ...ROUTING_CONFIG, + cascade: [ + { + cascadeGroups: ['standard' as const], + channel: 'NHSAPP' as const, + channelType: 'primary' as const, + defaultTemplateId: NHS_APP_TEMPLATE.id, + }, + ], + }; + + render( + + ); + + expect( + screen.queryByTestId('message-plan-fallback-conditions-NHSAPP') + ).not.toBeInTheDocument(); + }); + + it('renders letter template as link', () => { + render( + + ); + + const letterBlock = screen.getByTestId('message-plan-block-LETTER'); + const link = letterBlock.querySelector('a'); + expect(link).toHaveAttribute( + 'href', + `/preview-submitted-letter-template/${PDF_LETTER_TEMPLATE.id}` + ); + expect(link).toHaveTextContent(PDF_LETTER_TEMPLATE.name); + }); + + it('renders non-letter templates with preview details', () => { + render( + + ); + + const previewSummaries = screen.getAllByTestId('preview-template-summary'); + expect(previewSummaries).toHaveLength(3); + }); + + it('does not render cascade item when template is missing', () => { + const templatesWithMissing = { + [NHS_APP_TEMPLATE.id]: NHS_APP_TEMPLATE, + // EMAIL_TEMPLATE is missing + [SMS_TEMPLATE.id]: SMS_TEMPLATE, + [PDF_LETTER_TEMPLATE.id]: PDF_LETTER_TEMPLATE, + }; + + render( + + ); + + expect(screen.getByTestId('message-plan-block-NHSAPP')).toBeInTheDocument(); + expect( + screen.queryByTestId('message-plan-block-EMAIL') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('message-plan-block-SMS')).toBeInTheDocument(); + expect(screen.getByTestId('message-plan-block-LETTER')).toBeInTheDocument(); + }); + + it('matches snapshot', () => { + const { asFragment } = render( + + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/components/molecules/__snapshots__/MessagePlanCascadePreview.test.tsx.snap b/frontend/src/__tests__/components/molecules/__snapshots__/MessagePlanCascadePreview.test.tsx.snap new file mode 100644 index 000000000..6b30e872a --- /dev/null +++ b/frontend/src/__tests__/components/molecules/__snapshots__/MessagePlanCascadePreview.test.tsx.snap @@ -0,0 +1,527 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessagePlanCascadePreview matches snapshot 1`] = ` + +

+ +

+
    +
  • + +

    + First message +

    +
    +
    +

    + NHS App +

    +

    + app template name +

    +
    + + + Preview + + NHS App + + template + + +
    +
    +

    + message +

    + + +
    +
    +
    +
    +
    +
  • +
  • + +
    + + + Fallback conditions + + +
    +
      +
    • + + If first message read within 24 hours, no further messages sent. +
    • +
    • + + If first message not read within 24 hours, second message sent. +
    • +
    +
    +
    +
  • +
  • + +

    + Second message +

    +
    +
    +

    + Email +

    +

    + email template name +

    +
    + + + Preview + + Email + + template + + +
    +
    +

    + message +

    + + +
    +
    +
    +
    +
    +
  • +
  • + +
    + + + Fallback conditions + + +
    +
      +
    • + + If second message delivered within 72 hours, no further messages sent. +
    • +
    • + + If second message not delivered within 72 hours, third message sent. +
    • +
    +
    +
    +
  • +
  • + +

    + Third message +

    +
    +
    +

    + Text message (SMS) +

    +

    + sms template name +

    +
    + + + Preview + + Text message (SMS) + + template + + +
    +
    +

    + message +

    + + +
    +
    +
    +
    +
    +
  • +
  • + +
    + + + Fallback conditions + + +
    +
      +
    • + + If third message delivered within 72 hours, no further messages sent. +
    • +
    • + + If third message not delivered within 72 hours, fourth message sent. +
    • +
    +
    +
    +
  • +
  • + +

    + Fourth message +

    +
    +
    +

    + Standard English letter +

    +

    + + letter template name + +

    +
    +
    +
  • +
+
+`; diff --git a/frontend/src/__tests__/utils/message-plans.test.ts b/frontend/src/__tests__/utils/message-plans.test.ts index 7f12bfb50..386514e28 100644 --- a/frontend/src/__tests__/utils/message-plans.test.ts +++ b/frontend/src/__tests__/utils/message-plans.test.ts @@ -7,6 +7,7 @@ import { countRoutingConfigs, createRoutingConfig, getRoutingConfigReferencesByTemplateId, + submitRoutingConfig, } from '@utils/message-plans'; import { getSessionServer } from '@utils/amplify-utils'; import { routingConfigurationApiClient } from 'nhs-notify-backend-client/src/routing-config-api-client'; @@ -747,4 +748,76 @@ describe('Message plans actions', () => { expect(result).toEqual(references); }); }); + + describe('submitRoutingConfig', () => { + test('should throw error when no token', async () => { + getSessionServerMock.mockResolvedValueOnce({ + accessToken: undefined, + clientId: undefined, + }); + + await expect( + submitRoutingConfig(validRoutingConfigId, 1) + ).rejects.toThrow('Failed to get access token'); + + expect(routingConfigApiMock.submit).not.toHaveBeenCalled(); + }); + + test('should log and throw error on API error', async () => { + routingConfigApiMock.submit.mockResolvedValueOnce({ + error: { + errorMeta: { code: 500, description: 'Internal server error' }, + }, + }); + + await expect( + submitRoutingConfig(validRoutingConfigId, 1) + ).rejects.toThrow('Failed to submit message plan'); + + expect(routingConfigApiMock.submit).toHaveBeenCalledWith( + 'mock-token', + validRoutingConfigId, + 1 + ); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Failed to submit message plan', + expect.objectContaining({ + errorMeta: expect.objectContaining({ code: 500 }), + }) + ); + }); + + test('should throw error when no data returned', async () => { + routingConfigApiMock.submit.mockResolvedValueOnce({ + data: undefined as unknown as RoutingConfig, + }); + + await expect( + submitRoutingConfig(validRoutingConfigId, 1) + ).rejects.toThrow('No data returned from submit'); + + expect(routingConfigApiMock.submit).toHaveBeenCalledWith( + 'mock-token', + validRoutingConfigId, + 1 + ); + }); + + test('should return submitted routing config', async () => { + const submittedConfig = { ...baseConfig, status: 'COMPLETED' as const }; + + routingConfigApiMock.submit.mockResolvedValueOnce({ + data: submittedConfig, + }); + + const result = await submitRoutingConfig(validRoutingConfigId, 1); + + expect(routingConfigApiMock.submit).toHaveBeenCalledWith( + 'mock-token', + validRoutingConfigId, + 1 + ); + expect(result).toEqual(submittedConfig); + }); + }); }); diff --git a/frontend/src/app/message-plans/preview-message-plan/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/preview-message-plan/[routingConfigId]/page.tsx index 068539b38..483387735 100644 --- a/frontend/src/app/message-plans/preview-message-plan/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/preview-message-plan/[routingConfigId]/page.tsx @@ -1,17 +1,14 @@ -import { Fragment } from 'react'; import type { Metadata } from 'next'; import Link from 'next/link'; import { redirect, RedirectType } from 'next/navigation'; import { - accessibleFormatDisplayMappings, - channelDisplayMappings, messagePlanStatusToDisplayText, messagePlanStatusToTagColour, type MessagePlanPageProps, } from 'nhs-notify-web-template-management-utils'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import { NHSNotifyWarningCallout } from '@atoms/NHSNotifyWarningCallout/NHSNotifyWarningCallout'; -import { DetailsSummary, DetailsText, Tag } from '@atoms/nhsuk-components'; +import { Tag } from '@atoms/nhsuk-components'; import { NHSNotifySummaryList, NHSNotifySummaryListKey, @@ -20,33 +17,11 @@ import { } from '@atoms/NHSNotifySummaryList/NHSNotifySummaryList'; import content from '@content/content'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; -import { MessagePlanBlock } from '@molecules/MessagePlanBlock/MessagePlanBlock'; -import { MessagePlanChannelList } from '@molecules/MessagePlanChannelList/MessagePlanChannelList'; -import { MessagePlanChannelCard } from '@molecules/MessagePlanChannelCard/MessagePlanChannelCard'; -import { - MessagePlanConditionalTemplatesList, - MessagePlanConditionalTemplatesListItem, -} from '@molecules/MessagePlanConditionalTemplatesList/MessagePlanConditionalTemplatesList'; -import { - MessagePlanFallbackConditionsDetails, - MessagePlanFallbackConditionsListItem, -} from '@molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions'; -import { - ControlledDetails, - DetailsOpenButton, - DetailsOpenProvider, -} from '@providers/details-open'; -import { interpolate } from '@utils/interpolate'; +import { MessagePlanCascadePreview } from '@molecules/MessagePlanCascadePreview/MessagePlanCascadePreview'; import { getMessagePlanTemplates, getRoutingConfig, } from '@utils/message-plans'; -import { renderTemplateMarkdown } from '@utils/render-template-markdown'; -import { - getAccessibleTemplatesForCascadeItem, - getDefaultTemplateForItem, - getLanguageTemplatesForCascadeItem, -} from '@utils/routing-utils'; const pageContent = content.pages.previewMessagePlan; @@ -132,177 +107,10 @@ export default async function PreviewMessagePlanPage({ - - {messagePlan.cascade.some((item) => item.channel !== 'LETTER') ? ( -

- -

- ) : null} - - - {messagePlan.cascade.map((cascadeItem, index) => { - const channelDisplayName = channelDisplayMappings( - cascadeItem.channel - ); - - const defaultTemplate = getDefaultTemplateForItem( - cascadeItem, - templates - ); - - const accessibleTemplates = - getAccessibleTemplatesForCascadeItem( - cascadeItem, - templates - ); - - const languageTemplates = getLanguageTemplatesForCascadeItem( - cascadeItem, - templates - ); - - const conditionalTemplatesCount = - accessibleTemplates.length + languageTemplates.length; - - if (!defaultTemplate) { - return null; - } - return ( - - - - {cascadeItem.channel === 'LETTER' ? ( -

- - {defaultTemplate.name} - -

- ) : ( - <> -

- {defaultTemplate.name} -

- - - Preview{' '} - - {channelDisplayName} - {' '} - template - - -
- - - - )} - - - {conditionalTemplatesCount > 0 && ( - - - - - {accessibleTemplates.map( - ([accessibleFormat, template]) => ( - - -

- - {template.name} - -

-
-
- ) - )} - - {languageTemplates.length > 0 && ( - - - {languageTemplates.map((template) => ( -

- - {template.name} - -

- ))} -
-
- )} -
- )} - - - {messagePlan.cascade.length > 1 && - index < messagePlan.cascade.length - 1 && ( - - - - )} - - ); - })} - - +

diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts new file mode 100644 index 000000000..428b22e17 --- /dev/null +++ b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts @@ -0,0 +1,26 @@ +import { submitRoutingConfig } from '../../../../utils/message-plans'; +import { moveToProduction } from './actions'; +import { redirect, RedirectType } from 'next/navigation'; + +jest.mock('../../../../utils/message-plans', () => ({ + submitRoutingConfig: jest.fn(), +})); + +type RedirectFn = typeof redirect & { url?: string; type?: RedirectType }; +jest.mock('next/navigation', () => ({ + redirect: ((url?: string, type?: RedirectType) => { + (redirect as RedirectFn).url = url; + (redirect as RedirectFn).type = type; + throw Object.assign(new Error('NEXT_REDIRECT'), { url, type }); + }) as RedirectFn, + RedirectType: { replace: 'replace' }, +})); + +describe('actions: moveToProduction', () => { + it('submits with routingConfigId and lockNumber and redirects to message plans', async () => { + await expect(moveToProduction('rc-123', 5)).rejects.toMatchObject({ + url: '/message-plans', + }); + expect(submitRoutingConfig).toHaveBeenCalledWith('rc-123', 5); + }); +}); diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts new file mode 100644 index 000000000..c472664c7 --- /dev/null +++ b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts @@ -0,0 +1,12 @@ +'use server'; + +import { redirect, RedirectType } from 'next/navigation'; +import { submitRoutingConfig } from '@utils/message-plans'; + +export async function moveToProduction( + routingConfigId: string, + lockNumber: number +) { + await submitRoutingConfig(routingConfigId, lockNumber); + redirect('/message-plans', RedirectType.replace); +} diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx new file mode 100644 index 000000000..11d98c654 --- /dev/null +++ b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx @@ -0,0 +1,96 @@ +import type { Metadata } from 'next'; +import { redirect, RedirectType } from 'next/navigation'; +import { type MessagePlanPageProps } from 'nhs-notify-web-template-management-utils'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import { + NHSNotifySummaryList, + NHSNotifySummaryListKey, + NHSNotifySummaryListRow, + NHSNotifySummaryListValue, +} from '@atoms/NHSNotifySummaryList/NHSNotifySummaryList'; +import content from '@content/content'; +import { MessagePlanCascadePreview } from '@molecules/MessagePlanCascadePreview/MessagePlanCascadePreview'; +import { + getMessagePlanTemplates, + getRoutingConfig, +} from '@utils/message-plans'; +import { moveToProduction as moveToProductionAction } from './actions'; + +const pageContent = content.pages.reviewAndMoveToProduction; + +export const metadata: Metadata = { + title: pageContent.pageTitle, +}; + +export default async function ReviewAndMoveMessagePlanPage({ + params, +}: MessagePlanPageProps) { + const { routingConfigId } = await params; + + const messagePlan = await getRoutingConfig(routingConfigId); + + if (!messagePlan) { + return redirect('/message-plans/invalid', RedirectType.replace); + } + + if (messagePlan.status !== 'DRAFT') { + return redirect('/message-plans', RedirectType.replace); + } + + const templates = await getMessagePlanTemplates(messagePlan); + + return ( + +

+
+ {pageContent.headerCaption} +

{pageContent.pageHeading}

+ + + + + {pageContent.summaryTable.rowHeadings.name} + + + {messagePlan.name} + + + + + + +
+
+ + {pageContent.buttons.moveToProduction} + +
+ + + {pageContent.buttons.keepInDraft} + +
+
+
+ + ); +} diff --git a/frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx b/frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx new file mode 100644 index 000000000..ba7e34da6 --- /dev/null +++ b/frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { Fragment } from 'react'; +import Link from 'next/link'; +import type { RoutingConfig } from 'nhs-notify-backend-client'; +import { + accessibleFormatDisplayMappings, + channelDisplayMappings, +} from 'nhs-notify-web-template-management-utils'; +import { DetailsSummary, DetailsText } from '@atoms/nhsuk-components'; +import { MessagePlanBlock } from '@molecules/MessagePlanBlock/MessagePlanBlock'; +import { MessagePlanChannelList } from '@molecules/MessagePlanChannelList/MessagePlanChannelList'; +import { MessagePlanChannelCard } from '@molecules/MessagePlanChannelCard/MessagePlanChannelCard'; +import { + MessagePlanConditionalTemplatesList, + MessagePlanConditionalTemplatesListItem, +} from '@molecules/MessagePlanConditionalTemplatesList/MessagePlanConditionalTemplatesList'; +import { + MessagePlanFallbackConditionsDetails, + MessagePlanFallbackConditionsListItem, +} from '@molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions'; +import { + ControlledDetails, + DetailsOpenButton, + DetailsOpenProvider, +} from '@providers/details-open'; +import { interpolate } from '@utils/interpolate'; +import { renderTemplateMarkdown } from '@utils/render-template-markdown'; +import { + getAccessibleTemplatesForCascadeItem, + getDefaultTemplateForItem, + getLanguageTemplatesForCascadeItem, + type MessagePlanTemplates, +} from '@utils/routing-utils'; + +const content = { + detailsOpenButton: { + openText: 'Close all template previews', + closedText: 'Open all template previews', + }, + languageFormatsCardHeading: 'Other language letters (optional)', +}; + +export type MessagePlanCascadePreviewProps = { + messagePlan: RoutingConfig; + templates: MessagePlanTemplates; +}; + +export function MessagePlanCascadePreview({ + messagePlan, + templates, +}: MessagePlanCascadePreviewProps) { + return ( + + {messagePlan.cascade.some((item) => item.channel !== 'LETTER') ? ( +

+ +

+ ) : null} + + + {messagePlan.cascade.map((cascadeItem, index) => { + const channelDisplayName = channelDisplayMappings( + cascadeItem.channel + ); + + const defaultTemplate = getDefaultTemplateForItem( + cascadeItem, + templates + ); + + const accessibleTemplates = getAccessibleTemplatesForCascadeItem( + cascadeItem, + templates + ); + + const languageTemplates = getLanguageTemplatesForCascadeItem( + cascadeItem, + templates + ); + + const conditionalTemplatesCount = + accessibleTemplates.length + languageTemplates.length; + + if (!defaultTemplate) { + return null; + } + + return ( + + + + {cascadeItem.channel === 'LETTER' ? ( +

+ + {defaultTemplate.name} + +

+ ) : ( + <> +

{defaultTemplate.name}

+ + + Preview{' '} + + {channelDisplayName} + {' '} + template + + +
+ + + + )} + + + {conditionalTemplatesCount > 0 && ( + + + + + {accessibleTemplates.map(([accessibleFormat, template]) => ( + + +

+ + {template.name} + +

+
+
+ ))} + + {languageTemplates.length > 0 && ( + + + {languageTemplates.map((template) => ( +

+ + {template.name} + +

+ ))} +
+
+ )} +
+ )} + + + {messagePlan.cascade.length > 1 && + index < messagePlan.cascade.length - 1 && ( + + + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 9208ffafb..49544ac60 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1566,6 +1566,26 @@ const previewMessagePlan = { languageFormatsCardHeading: 'Other language letters (optional)', }; +const reviewAndMoveToProduction = { + pageTitle: generatePageTitle('Review and move message plan to production'), + headerCaption: 'Step 2 of 2', + pageHeading: 'Review and move message plan to production', + summaryTable: { + rowHeadings: { + name: 'Name', + }, + }, + detailsOpenButton: { + openText: 'Close all template previews', + closedText: 'Open all template previews', + }, + languageFormatsCardHeading: 'Other language letters (optional)', + buttons: { + moveToProduction: 'Move to production', + keepInDraft: 'Keep in draft', + }, +}; + const content = { global: { mainLayout }, components: { @@ -1627,6 +1647,7 @@ const content = { previewLargePrintLetterTemplate, previewOtherLanguageLetterTemplate, previewMessagePlan, + reviewAndMoveToProduction, submitLetterTemplate: submitLetterTemplatePage, }, }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 67b0147f6..8175603d8 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -38,6 +38,7 @@ const protectedPaths = [ /^\/message-plans\/get-ready-to-move\/[^/]+$/, /^\/message-plans\/invalid$/, /^\/message-plans\/preview-message-plan\/[^/]+$/, + /^\/message-plans\/review-and-move-to-production\/[^/]+$/, /^\/message-plans$/, /^\/message-templates$/, /^\/nhs-app-template-submitted\/[^/]+$/, diff --git a/frontend/src/utils/message-plans.ts b/frontend/src/utils/message-plans.ts index 21ee06049..b2b4b3b14 100644 --- a/frontend/src/utils/message-plans.ts +++ b/frontend/src/utils/message-plans.ts @@ -225,3 +225,34 @@ export async function getRoutingConfigReferencesByTemplateId( return data; } + +/** + * Submits a routing configuration to move it from DRAFT to COMPLETED status. + */ +export async function submitRoutingConfig( + routingConfigId: string, + lockNumber: number +): Promise { + const { accessToken } = await getSessionServer(); + + if (!accessToken) { + throw new Error('Failed to get access token'); + } + + const { data, error } = await routingConfigurationApiClient.submit( + accessToken, + routingConfigId, + lockNumber + ); + + if (error) { + logger.error('Failed to submit message plan', error); + throw new Error('Failed to submit message plan'); + } + + if (!data) { + throw new Error('No data returned from submit'); + } + + return data; +} diff --git a/lambdas/AGENTS.md b/lambdas/AGENTS.md new file mode 100644 index 000000000..91b9064fa --- /dev/null +++ b/lambdas/AGENTS.md @@ -0,0 +1,365 @@ +# AGENTS.md - Lambdas + + +## Scope + +This file provides guidance for AI agents working on Lambda functions in this repository. For general repository guidance, see the root `AGENTS.md`. + +## Directory Structure + +Each lambda project follows this structure: + +```text +lambdas/{name}/ +├── package.json # Workspace package +├── jest.config.ts # Jest configuration +├── tsconfig.json # TypeScript configuration +├── build.sh # Build script (if custom build needed) +└── src/ + ├── __tests__/ # Test files mirroring src structure + │ ├── app/ # Client/business logic tests + │ ├── infra/ # Repository tests + │ └── fixtures/ # Test data factories + ├── api/ # API request/response handling + ├── app/ # Business logic clients + ├── container/ # Dependency injection + ├── domain/ # Domain types and schemas + ├── infra/ # Data access (repositories) + ├── utils/ # Shared utilities + └── *.ts # Lambda entry points +``` + +## Architecture Patterns + +### Layered Architecture + +The `backend-api` lambda follows a layered architecture: + +1. **Entry Points** (`src/*.ts`) - Lambda handlers, minimal logic +2. **API Layer** (`src/api/`) - Request parsing, response formatting +3. **App Layer** (`src/app/`) - Business logic clients +4. **Infra Layer** (`src/infra/`) - Data access repositories + +### Repository Pattern + +Repositories handle all DynamoDB operations. Key patterns: + +```typescript +// Repository methods return ApplicationResult +async get(id: string, clientId: string): Promise> +async create(input: CreateEntity, user: User): Promise> +async update(id: string, data: UpdateEntity, user: User, lockNumber: number): Promise> +async submit(id: string, user: User, lockNumber: number): Promise> +async delete(id: string, user: User, lockNumber: number): Promise> +``` + +### Result Type Pattern + +All repository and client methods return `ApplicationResult`: + +```typescript +type ApplicationResult = SuccessResult | FailureResult; + +// Usage +const result = await repository.get(id, clientId); +if (result.error) { + return result; // Propagate failure +} +const data = result.data; // Access success data +``` + +Use `success()` and `failure()` helpers from `@backend-api/utils/result`: + +```typescript +return success(data); +return failure(ErrorCase.NOT_FOUND, 'Entity not found'); +return failure(ErrorCase.VALIDATION_FAILED, 'Invalid input', error, { details: 'extra info' }); +``` + +### Optimistic Locking + +Entities use `lockNumber` for optimistic concurrency control: + +- Client must provide current `lockNumber` for update/delete/submit operations +- Repository increments `lockNumber` on successful write +- Returns `409 CONFLICT` if lock number mismatch + +### DynamoDB Transaction Pattern + +For operations requiring atomic writes with validation, use `TransactWriteCommand`: + +```typescript +await this.client.send( + new TransactWriteCommand({ + TransactItems: [ + { + Update: updateCommand, // Main entity update + }, + // ConditionChecks for related entities + ...relatedIds.map((id) => ({ + ConditionCheck: { + TableName: this.relatedTableName, + Key: { id, owner: this.clientOwnerKey(clientId) }, + ConditionExpression: 'attribute_exists(id) AND someCondition', + ExpressionAttributeValues: { ... }, + }, + })), + ], + }) +); +``` + +**Important:** DynamoDB ConditionChecks can only validate: + +- Existence of items (`attribute_exists`) +- Simple attribute comparisons +- Status checks (`attribute IN (:val1, :val2)`) + +They **cannot** iterate over arrays or validate complex nested structures. For array/document validation, fetch the data first and validate in application code. + +### Error Handling in Transactions + +Handle `TransactionCanceledException` by inspecting `CancellationReasons`: + +```typescript +private handleTransactionError( + err: unknown, + lockNumber: number, + relatedIds: string[] +): ApplicationResult { + if (!(err instanceof TransactionCanceledException)) { + return this.handleUpdateError(err, lockNumber); + } + + // First item is always the main update + const [updateReason, ...relatedReasons] = err.CancellationReasons ?? []; + + if (updateReason && updateReason.Code !== 'None') { + // Main update failed - handle status/lock errors + return this.handleUpdateError( + new ConditionalCheckFailedException({ + message: updateReason.Message!, + Item: updateReason.Item, + $metadata: err.$metadata, + }), + lockNumber + ); + } + + // Check which related items failed + const failedIds = relatedReasons + .map((reason, index) => + reason.Code === 'ConditionalCheckFailed' ? relatedIds[index] : null + ) + .filter((id): id is string => id != null); + + if (failedIds.length > 0) { + return failure(ErrorCase.VALIDATION_FAILED, 'Related items validation failed', err, { + ids: failedIds.join(','), + }); + } + + return this.handleUpdateError(err, lockNumber); +} +``` + +### Validation Pattern + +For complex validation that can't be done in DynamoDB: + +1. **Fetch** the entity first +2. **Validate** in application code +3. **Execute** the transaction + +```typescript +async submit(id: string, user: User, lockNumber: number): Promise> { + // 1. Fetch for validation + const existing = await this.get(id, user.clientId); + if (existing.error) return existing; + + // 2. Validate in application code + const validationError = this.validateForSubmit(existing.data); + if (validationError) return validationError; + + // 3. Execute transaction with DynamoDB-level checks + try { + await this.client.send(new TransactWriteCommand({ ... })); + // ... + } catch (error) { + return this.handleTransactionError(error, lockNumber, ids); + } +} +``` + +## Testing Patterns + +### Repository Tests + +Located in `src/__tests__/infra/{repository-name}/`. Use `aws-sdk-client-mock`: + +```typescript +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, GetCommand, TransactWriteCommand } from '@aws-sdk/lib-dynamodb'; + +const dynamo = mockClient(DynamoDBDocumentClient); + +function setup() { + dynamo.reset(); // Reset mock state between tests + // ... +} +``` + +**Chaining mock responses** for multiple calls to the same command: + +```typescript +// Correct: chain resolvesOnce calls +mocks.dynamo + .on(GetCommand) + .resolvesOnce({ Item: firstResponse }) + .resolvesOnce({ Item: secondResponse }); + +// Wrong: separate calls don't queue +mocks.dynamo.on(GetCommand).resolvesOnce({ Item: firstResponse }); +mocks.dynamo.on(GetCommand).resolvesOnce({ Item: secondResponse }); // Overwrites! +``` + +### Client Tests + +Located in `src/__tests__/app/`. Mock repositories using `jest-mock-extended`: + +```typescript +import { mock } from 'jest-mock-extended'; +import type { Repository } from '../../infra/repository'; + +const repository = mock(); +``` + +### Test Fixtures + +Use factory functions in `src/__tests__/fixtures/`: + +```typescript +export const entity: Entity = { /* default values */ }; + +export const makeEntity = (overrides: Partial = {}): Entity => ({ + ...entity, + id: randomUUID(), + ...overrides, +}); +``` + +## Running Tests + +```bash +# From lambda directory +npm run test:unit + +# Specific test file +npx jest src/__tests__/infra/repository.test.ts --no-coverage + +# Specific test pattern +npx jest --testPathPattern="repository" --testNamePattern="submit" +``` + +## Common Error Cases + +From `nhs-notify-backend-client`: + +| ErrorCase | HTTP Code | Use For | +| ------------------------------------- | --------- | ------------------------------------ | +| `NOT_FOUND` | 404 | Entity doesn't exist or is deleted | +| `VALIDATION_FAILED` | 400 | Input validation failures | +| `ALREADY_SUBMITTED` | 400 | Entity already in final state | +| `CONFLICT` | 409 | Lock number mismatch | +| `INTERNAL` | 500 | Unexpected errors | +| `ROUTING_CONFIG_TEMPLATES_NOT_FOUND` | 400 | Referenced templates missing | + +## Agent Checklist + +When modifying lambda code: + +- [ ] Follow the layered architecture (entry → api → app → infra) +- [ ] Return `ApplicationResult` from repository/client methods +- [ ] Use `TransactWriteCommand` for atomic operations with related entity checks +- [ ] Handle `TransactionCanceledException` by inspecting `CancellationReasons` +- [ ] Add/update tests mirroring the source structure +- [ ] Run `npm run test:unit` in the lambda directory +- [ ] Run `npm run typecheck` to verify types + +## Schema and Type System + +### Type Generation Pipeline + +Types are generated from OpenAPI specifications. The pipeline flows: + +```text +spec.tmpl.json → (Terraform templating) → spec.json → (openapi-typescript) → generated.d.ts +``` + +**Key files:** + +- `infrastructure/terraform/modules/backend-api/spec.tmpl.json` - Source OpenAPI spec (with Terraform template vars) +- `lambdas/backend-client/src/types/generated.d.ts` - Generated TypeScript types + +**To regenerate types after spec changes:** + +```bash +cd lambdas/backend-client +npm run generate-dependencies +``` + +### Zod Schemas with `schemaFor` + +Zod schemas in `backend-client` use the `schemaFor` helper for type-safety: + +```typescript +import { z } from 'zod/v4'; +import type { MyType } from '../types/generated'; +import { schemaFor } from './schema-for'; + +// This ensures the Zod schema matches the generated TypeScript type +const $MyType = schemaFor()( + z.object({ + field: z.string(), + }) +); +``` + +**Why `schemaFor`?** It provides compile-time verification that the Zod schema produces the same shape as the generated type. If the schema doesn't match the type, TypeScript will error. + +### Validation Schemas (Submittable Pattern) + +For workflows that require stricter validation than the base schema (e.g., "submit" operations), create extended schemas: + +```typescript +// Base schema allows optional/nullable fields for draft state +const $CascadeItem = schemaFor()( + z.object({ + defaultTemplateId: z.string().nonempty().nullable(), // Can be null in drafts + // ... + }) +); + +// Submittable schema enforces fields required for submission +export const $SubmittableCascadeItem = $CascadeItem.and( + z.object({ + // Override: defaultTemplateId can be missing, but can't be null + defaultTemplateId: z.string().nonempty().optional(), + }) +); + +export const $SubmittableCascade = z.array($SubmittableCascadeItem).nonempty(); +``` + +**Usage in repository:** + +```typescript +const parseResult = $SubmittableCascade.safeParse(existing.data.cascade); +if (!parseResult.success) { + return failure( + ErrorCase.VALIDATION_FAILED, + 'Routing config is not ready for submission', + new Error(parseResult.error.message) + ); +} +``` 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 79c2d816e..faaab1ac3 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 @@ -378,4 +378,64 @@ describe('RoutingConfigurationApiClient', () => { expect(axiosMock.history.get.length).toBe(1); }); }); + + describe('submit', () => { + it('should return error when failing to submit', async () => { + axiosMock + .onPatch(`/v1/routing-configuration/${validRoutingConfigId}/submit`) + .reply(400, { + statusCode: 400, + technicalMessage: 'Bad request', + details: { + message: 'Cannot submit routing configuration', + }, + }); + + const response = await client.submit('token', validRoutingConfigId, 1); + + expect(response.error).toEqual({ + errorMeta: { + code: 400, + description: 'Bad request', + details: { + message: 'Cannot submit routing configuration', + }, + }, + }); + expect(response.data).toBeUndefined(); + expect(axiosMock.history.patch.length).toBe(1); + }); + + it('should return routing configuration on successful submit', async () => { + const data: RoutingConfig = { + id: validRoutingConfigId, + name: 'Test message plan', + status: 'COMPLETED', + clientId: 'client-1', + campaignId: 'campaign-1', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + cascade: [], + cascadeGroupOverrides: [], + lockNumber: 2, + defaultCascadeGroup: 'standard', + }; + + axiosMock + .onPatch(`/v1/routing-configuration/${validRoutingConfigId}/submit`) + .reply(200, { + data, + }); + + const response = await client.submit('token', validRoutingConfigId, 1); + + expect(response.data).toEqual(data); + expect(response.error).toBeUndefined(); + expect(axiosMock.history.patch.length).toBe(1); + expect(axiosMock.history.patch[0].headers).toMatchObject({ + Authorization: 'token', + 'X-Lock-Number': '1', + }); + }); + }); }); diff --git a/lambdas/backend-client/src/routing-config-api-client.ts b/lambdas/backend-client/src/routing-config-api-client.ts index 0531ae462..1e45f4b02 100644 --- a/lambdas/backend-client/src/routing-config-api-client.ts +++ b/lambdas/backend-client/src/routing-config-api-client.ts @@ -154,4 +154,31 @@ export const routingConfigurationApiClient = { return { ...data }; }, + + async submit( + token: string, + id: RoutingConfig['id'], + lockNumber: number + ): Promise> { + const url = `/v1/routing-configuration/${encodeURIComponent(id)}/submit`; + + const { data, error } = await catchAxiosError( + httpClient.patch( + url, + {}, + { + headers: { + Authorization: token, + 'X-Lock-Number': String(lockNumber), + }, + } + ) + ); + + if (error) { + return { error }; + } + + return { ...data }; + }, }; diff --git a/tests/test-team/AGENTS.md b/tests/test-team/AGENTS.md new file mode 100644 index 000000000..e3da42f53 --- /dev/null +++ b/tests/test-team/AGENTS.md @@ -0,0 +1,257 @@ +# AGENTS.md - Test Team + + +## Scope + +This file provides guidance for AI agents working on automated tests in this repository. For general repository guidance, see the root `AGENTS.md`. + +## Directory Structure + +```text +tests/test-team/ +├── template-mgmt-api-tests/ # API integration tests +├── template-mgmt-component-tests/ # Component tests (page-level) +├── template-mgmt-e2e-tests/ # End-to-end user journey tests +├── template-mgmt-event-tests/ # Event-driven tests +├── template-mgmt-routing-component-tests/ # Routing config component tests +├── helpers/ +│ ├── auth/ # Cognito authentication helpers +│ ├── db/ # Database storage helpers +│ ├── factories/ # Test data factories +│ ├── client/ # API client helpers +│ └── ... +├── fixtures/ # Shared test fixtures +└── config/ # Playwright configuration +``` + +## API Tests + +API tests are located in `template-mgmt-api-tests/` and use Playwright's request API to test backend endpoints directly. + +### Test File Structure + +```typescript +import { test, expect } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { + createAuthHelper, + type TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; +import { TemplateFactory } from '../helpers/factories/template-factory'; + +test.describe('PATCH /v1/routing-configuration/:id/submit', () => { + const authHelper = createAuthHelper(); + const storageHelper = new RoutingConfigStorageHelper(); + const templateStorageHelper = new TemplateStorageHelper(); + let user1: TestUser; + + test.beforeAll(async () => { + user1 = await authHelper.getTestUser(testUsers.User1.userId); + }); + + test.afterAll(async () => { + await storageHelper.deleteSeeded(); + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('returns 200 and the updated data', async ({ request }) => { + // 1. Create test data using factories + const templateId = randomUUID(); + const template = TemplateFactory.createNhsAppTemplate(templateId, user1, 'Test'); + await templateStorageHelper.seedTemplateData([template]); + + const { dbEntry } = RoutingConfigFactory.create(user1, { /* overrides */ }); + await storageHelper.seed([dbEntry]); + + // 2. Make the API request + const response = await request.patch( + `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, + { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + } + ); + + // 3. Assert response + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.data.status).toBe('COMPLETED'); + }); +}); +``` + +### Key Helpers + +#### Authentication (`helpers/auth/cognito-auth-helper.ts`) + +```typescript +const authHelper = createAuthHelper(); +const user = await authHelper.getTestUser(testUsers.User1.userId); + +// Get access token for API requests +const token = await user.getAccessToken(); + +// User properties +user.clientId; // Client ID for ownership checks +user.campaignId; // Default campaign ID +``` + +Pre-configured test users with different permissions: + +- `testUsers.User1` - Standard user +- `testUsers.User2` - User with routing disabled +- `testUsers.User7` - User sharing same client as User1 +- `testUsers.UserRoutingEnabled` - User with different client + +#### Storage Helpers (`helpers/db/`) + +Seed test data directly into DynamoDB: + +```typescript +const storageHelper = new RoutingConfigStorageHelper(); +const templateStorageHelper = new TemplateStorageHelper(); + +// Seed data +await storageHelper.seed([dbEntry1, dbEntry2]); +await templateStorageHelper.seedTemplateData([template1, template2]); + +// Clean up in afterAll +await storageHelper.deleteSeeded(); +await templateStorageHelper.deleteSeededTemplates(); +``` + +#### Factories (`helpers/factories/`) + +Create test data with sensible defaults: + +```typescript +// Routing configs +const { dbEntry, apiResponse } = RoutingConfigFactory.create(user, { + status: 'DRAFT', + cascade: [{ /* cascade item */ }], +}); + +// Templates +const nhsAppTemplate = TemplateFactory.createNhsAppTemplate(id, user, 'Name'); +const emailTemplate = TemplateFactory.createEmailTemplate(id, user, 'Name', 'NOT_YET_SUBMITTED'); +const smsTemplate = TemplateFactory.createSmsTemplate(id, user, 'Name'); +const letterTemplate = TemplateFactory.uploadLetterTemplate( + id, + user, + 'Name', + 'PROOF_APPROVED', // templateStatus + 'PASSED', // virusScanStatus + { language: 'fr', letterType: 'x0' } // options +); +``` + +### Test Patterns + +#### Testing Validation Errors + +```typescript +test('returns 400 if validation fails', async ({ request }) => { + const { dbEntry } = RoutingConfigFactory.create(user1, { + cascade: [{ /* invalid cascade */ }], + }); + await storageHelper.seed([dbEntry]); + + const response = await request.patch(`${url}/${dbEntry.id}/submit`, { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + }); + + expect(response.status()).toBe(400); + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Expected error message', + }); +}); +``` + +#### Testing Authorization + +```typescript +test('returns 401 if no auth token', async ({ request }) => { + const response = await request.patch(url, { + headers: { 'X-Lock-Number': '0' }, // No Authorization header + }); + expect(response.status()).toBe(401); +}); + +test('returns 404 if owned by different client', async ({ request }) => { + const { dbEntry } = RoutingConfigFactory.create(user1); + await storageHelper.seed([dbEntry]); + + const response = await request.patch(`${url}/${dbEntry.id}`, { + headers: { + Authorization: await userDifferentClient.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber), + }, + }); + expect(response.status()).toBe(404); // Not 403 - don't leak existence +}); +``` + +#### Testing Optimistic Locking + +```typescript +test('returns 409 if lock number mismatch', async ({ request }) => { + const { dbEntry } = RoutingConfigFactory.create(user1); + await storageHelper.seed([dbEntry]); + + const response = await request.patch(`${url}/${dbEntry.id}`, { + headers: { + Authorization: await user1.getAccessToken(), + 'X-Lock-Number': String(dbEntry.lockNumber + 1), // Wrong lock number + }, + }); + expect(response.status()).toBe(409); +}); +``` + +### Avoiding Redundant Tests + +When adding tests, consider whether the scenario is already covered: + +1. **Happy path with full response validation** - One comprehensive test is usually enough +2. **Each distinct error type** - One test per unique error message/code +3. **Boundary conditions** - Test the boundaries, not every value + +For example, if testing template status validation: + +- ✅ One test for invalid status (e.g., `NOT_YET_SUBMITTED`) +- ✅ One test for valid status (e.g., `PROOF_APPROVED`) +- ❌ Don't need separate tests for every valid status (`PROOF_APPROVED` AND `SUBMITTED`) + +### Running API Tests + +```bash +# From tests/test-team directory +npm run test:api + +# Run specific test file +npx playwright test submit-routing-config.api.spec.ts + +# Run with UI mode for debugging +npx playwright test --ui +``` + +## Quality Checklist + +Before submitting API test changes: + +- [ ] Tests clean up seeded data in `afterAll` +- [ ] Use `randomUUID()` for IDs to avoid collisions +- [ ] Test both success and failure paths +- [ ] Assert on response status AND body +- [ ] No redundant tests covering same scenario +- [ ] Factory methods used (don't construct raw objects) diff --git a/tests/test-team/pages/routing/index.ts b/tests/test-team/pages/routing/index.ts index 8052b1bfb..d5fab41f7 100644 --- a/tests/test-team/pages/routing/index.ts +++ b/tests/test-team/pages/routing/index.ts @@ -4,3 +4,4 @@ export * from './choose-templates-page'; export * from './create-message-plan-page'; export * from './message-plans-page'; export * from './invalid-message-plan-page'; +export * from './review-and-move-to-production-page'; diff --git a/tests/test-team/pages/routing/review-and-move-to-production-page.ts b/tests/test-team/pages/routing/review-and-move-to-production-page.ts new file mode 100644 index 000000000..a38437e7a --- /dev/null +++ b/tests/test-team/pages/routing/review-and-move-to-production-page.ts @@ -0,0 +1,70 @@ +import type { Locator, Page } from '@playwright/test'; +import type { Channel, LetterType } from 'nhs-notify-backend-client'; +import { TemplateMgmtBasePage } from 'pages/template-mgmt-base-page'; + +export class RoutingReviewAndMoveToProductionPage extends TemplateMgmtBasePage { + static readonly pathTemplate = + '/message-plans/review-and-move-to-production/:messagePlanId'; + + public readonly messagePlanName: Locator; + + public readonly previewToggleButton: Locator; + + public readonly detailsSections: Locator; + + public readonly moveToProductionButton: Locator; + + public readonly keepInDraftButton: Locator; + + constructor(page: Page) { + super(page); + + this.messagePlanName = page.getByTestId('plan-name'); + this.previewToggleButton = page.getByRole('button', { + name: /^(Open|Close) all template previews$/, + }); + this.detailsSections = page.locator('details'); + this.moveToProductionButton = page.getByTestId('move-to-production-button'); + this.keepInDraftButton = page.getByTestId('keep-in-draft-link'); + } + + getTemplateBlock(channel: Channel) { + const block = this.page.getByTestId(`message-plan-block-${channel}`); + + const defaultTemplateCard = this.getCard(block); + + const conditionalTemplates = block.getByTestId('conditional-templates'); + + return { + locator: block, + number: block.locator('[class*=message-plan-block-number]'), + defaultTemplateCard, + getAccessibilityFormatCard: (format: LetterType) => { + return this.getCard( + conditionalTemplates.getByTestId(`conditional-template-${format}`) + ); + }, + getLanguagesCard: () => { + return this.getCard( + conditionalTemplates.getByTestId('conditional-template-languages') + ); + }, + }; + } + + getFallbackBlock(channel: Channel) { + return this.page.getByTestId(`message-plan-fallback-conditions-${channel}`); + } + + private getCard(templateBlock: Locator) { + const card = templateBlock.getByTestId('channel-card').first(); + + return { + locator: card, + templateName: card.getByTestId('template-name'), + previewTemplateSummary: card.getByTestId('preview-template-summary'), + previewTemplateText: card.getByTestId('preview-template-text'), + templateLink: card.getByRole('link'), + }; + } +} diff --git a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts index dc46fd157..3bd821379 100644 --- a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts @@ -54,6 +54,7 @@ import { RoutingPreviewOtherLanguageLetterTemplatePage } from 'pages/routing/let import { RoutingGetReadyToMovePage } from 'pages/routing/get-ready-to-move-page'; import { RoutingPreviewMessagePlanPage } from 'pages/routing/preview-message-plan-page'; import { TemplateMgmtDeleteErrorPage } from 'pages/template-mgmt-delete-error-page'; +import { RoutingReviewAndMoveToProductionPage } from 'pages/routing'; // Reset storage state for this file to avoid being authenticated test.use({ storageState: { cookies: [], origins: [] } }); @@ -80,6 +81,7 @@ const protectedPages = [ RoutingPreviewLargePrintLetterTemplatePage, RoutingPreviewOtherLanguageLetterTemplatePage, RoutingPreviewSmsTemplatePage, + RoutingReviewAndMoveToProductionPage, TemplateMgmtChoosePage, TemplateMgmtCopyPage, TemplateMgmtCreateEmailPage, diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts new file mode 100644 index 000000000..b0c5b1d2c --- /dev/null +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts @@ -0,0 +1,375 @@ +import { randomUUID } from 'node:crypto'; +import { Channel } from 'nhs-notify-backend-client'; +import { test, expect } from '@playwright/test'; +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; +import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; +import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { + assertFooterLinks, + assertSignOutLink, + assertHeaderLogoLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { RoutingReviewAndMoveToProductionPage } from 'pages/routing/review-and-move-to-production-page'; +import { RoutingChooseTemplatesPage } from 'pages/routing'; +import { RoutingMessagePlansPage } from 'pages/routing/message-plans-page'; + +const routingConfigStorageHelper = new RoutingConfigStorageHelper(); +const templateStorageHelper = new TemplateStorageHelper(); + +function createTemplates(user: TestUser) { + const templateIds = { + NHSAPP: randomUUID(), + EMAIL: randomUUID(), + SMS: randomUUID(), + LETTER: randomUUID(), + LARGE_PRINT_LETTER: randomUUID(), + FRENCH_LETTER: randomUUID(), + SPANISH_LETTER: randomUUID(), + }; + + return { + NHSAPP: TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + `Test NHS App template - ${templateIds.NHSAPP}`, + 'SUBMITTED' + ), + EMAIL: TemplateFactory.createEmailTemplate( + templateIds.EMAIL, + user, + `Test Email template - ${templateIds.EMAIL}`, + 'SUBMITTED' + ), + SMS: TemplateFactory.createSmsTemplate( + templateIds.SMS, + user, + `Test SMS template - ${templateIds.SMS}`, + 'SUBMITTED' + ), + LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.LETTER, + user, + `Test Letter template - ${templateIds.LETTER}`, + 'SUBMITTED' + ), + LARGE_PRINT_LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.LARGE_PRINT_LETTER, + user, + `Test Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, + 'SUBMITTED', + 'PASSED', + { letterType: 'x1' } + ), + FRENCH_LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.FRENCH_LETTER, + user, + `Test Letter template French - ${templateIds.FRENCH_LETTER}`, + 'SUBMITTED', + 'PASSED', + { language: 'fr' } + ), + SPANISH_LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.SPANISH_LETTER, + user, + `Test Spanish Letter template - ${templateIds.SPANISH_LETTER}`, + 'SUBMITTED', + 'PASSED', + { language: 'es' } + ), + }; +} + +test.describe('Routing - Review and Move to Production page', () => { + let templates: ReturnType; + + let user: TestUser; + + test.beforeAll(async () => { + user = await createAuthHelper().getTestUser(testUsers.User1.userId); + templates = createTemplates(user); + + await templateStorageHelper.seedTemplateData(Object.values(templates)); + }); + + test.afterAll(async () => { + await routingConfigStorageHelper.deleteSeeded(); + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('common page tests', async ({ page, baseURL }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const props = { + page: new RoutingReviewAndMoveToProductionPage(page).setPathParam( + 'messagePlanId', + dbEntry.id + ), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertFooterLinks(props); + await assertSignOutLink(props); + }); + + test('redirects to invalid message plan page when message plan cannot be found', async ({ + page, + baseURL, + }) => { + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', 'does-not-exist'); + + await reviewPage.loadPage(); + + await expect(page).toHaveURL(`${baseURL}/templates/message-plans/invalid`); + }); + + test('redirects to preview message plan page when message plan is not DRAFT', async ({ + page, + baseURL, + }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'COMPLETED' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await expect(page).toHaveURL( + `${baseURL}/templates/message-plans/preview-message-plan/${dbEntry.id}` + ); + }); + + test('displays message plan name in summary list', async ({ page }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await expect(reviewPage.messagePlanName).toHaveText(dbEntry.name); + }); + + test('displays preview of full routing config', async ({ page }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP', 'EMAIL', 'SMS', 'LETTER'], + { status: 'DRAFT' } + ) + .addTemplate('NHSAPP', templates.NHSAPP.id) + .addTemplate('EMAIL', templates.EMAIL.id) + .addTemplate('SMS', templates.SMS.id) + .addTemplate('LETTER', templates.LETTER.id) + .addAccessibleFormatTemplate('x1', templates.LARGE_PRINT_LETTER.id) + .addLanguageTemplate('fr', templates.FRENCH_LETTER.id) + .addLanguageTemplate('es', templates.SPANISH_LETTER.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await test.step('opens and closes all details sections', async () => { + for (const section of await reviewPage.detailsSections.all()) { + await expect(section).not.toHaveAttribute('open'); + } + + await expect(reviewPage.previewToggleButton).toHaveText( + 'Open all template previews' + ); + + await reviewPage.previewToggleButton.click(); + + for (const section of await reviewPage.detailsSections.all()) { + await expect(section).toHaveAttribute('open'); + } + + await expect(reviewPage.previewToggleButton).toHaveText( + 'Close all template previews' + ); + + await reviewPage.previewToggleButton.click(); + + for (const section of await reviewPage.detailsSections.all()) { + await expect(section).not.toHaveAttribute('open'); + } + + await expect(reviewPage.previewToggleButton).toHaveText( + 'Open all template previews' + ); + }); + + for (const [index, channel] of ( + ['NHSAPP', 'EMAIL', 'SMS'] satisfies Channel[] + ).entries()) { + await test.step(`renders ${channel} template preview and fallback blocks`, async () => { + const templateBlock = await reviewPage.getTemplateBlock(channel); + + await expect(templateBlock.number).toHaveText(`${index + 1}`); + await expect(templateBlock.defaultTemplateCard.templateName).toHaveText( + templates[channel].name + ); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateText + ).toBeHidden(); + + await templateBlock.defaultTemplateCard.previewTemplateSummary.click(); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateText + ).toBeVisible(); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateText + ).toHaveText(templates[channel].message as string); + + await expect(reviewPage.getFallbackBlock(channel)).toBeVisible(); + }); + } + + await test.step('for LETTER channel renders template links for default and accessible templates along with conditional template fallback conditions', async () => { + const templateBlock = await reviewPage.getTemplateBlock('LETTER'); + + await expect(templateBlock.number).toHaveText('4'); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateSummary + ).toBeHidden(); + + await expect(templateBlock.defaultTemplateCard.templateLink).toHaveText( + templates.LETTER.name + ); + await expect( + templateBlock.defaultTemplateCard.templateLink + ).toHaveAttribute( + 'href', + `/templates/preview-submitted-letter-template/${templates.LETTER.id}` + ); + + await expect( + templateBlock.getAccessibilityFormatCard('x1').templateLink + ).toHaveText(templates.LARGE_PRINT_LETTER.name); + + await expect( + templateBlock.getAccessibilityFormatCard('x1').templateLink + ).toHaveAttribute( + 'href', + `/templates/preview-submitted-letter-template/${templates.LARGE_PRINT_LETTER.id}` + ); + + for (const [index, language] of ( + ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< + typeof createTemplates + >)[] + ).entries()) { + const links = await templateBlock.getLanguagesCard().templateLink.all(); + await expect(links[index]).toHaveText(templates[language].name); + + await expect(links[index]).toHaveAttribute( + 'href', + `/templates/preview-submitted-letter-template/${templates[language].id}` + ); + } + }); + }); + + test('keep in draft button navigates to choose templates page', async ({ + page, + baseURL, + }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await reviewPage.keepInDraftButton.click(); + + await expect(page).toHaveURL( + `${baseURL}/templates/message-plans/choose-templates/${dbEntry.id}` + ); + + const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); + + await expect(chooseTemplatesPage.messagePlanStatus).toHaveText('Draft'); + }); + + test('move to production button submits plan and navigates to message plans page', async ({ + page, + baseURL, + }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await reviewPage.moveToProductionButton.click(); + + await expect(page).toHaveURL(`${baseURL}/templates/message-plans`); + + const messagePlansPage = new RoutingMessagePlansPage(page); + + // Verify the plan now appears in the production section + const productionIdCells = + messagePlansPage.productionMessagePlansTable.getByTestId( + 'message-plan-id-cell' + ); + + const productionCellsText = await productionIdCells.allTextContents(); + + expect(productionCellsText).toContainEqual( + expect.stringContaining(dbEntry.id) + ); + }); +}); From ad7bf1db84fc978ed91b2b73177444db6b8aeed7 Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Wed, 4 Feb 2026 17:50:00 +0000 Subject: [PATCH 06/30] terraform and test fixes --- .../module_submit_routing_config_lambda.tf | 2 + .../repository.test.ts | 109 ++-- .../routing-config-repository/repository.ts | 9 +- .../submit-routing-config.api.spec.ts | 499 +++--------------- 4 files changed, 138 insertions(+), 481 deletions(-) diff --git a/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf b/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf index 0e5613fd6..490ad0e96 100644 --- a/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf +++ b/infrastructure/terraform/modules/backend-api/module_submit_routing_config_lambda.tf @@ -55,6 +55,8 @@ data "aws_iam_policy_document" "submit_routing_config_lambda_policy" { effect = "Allow" actions = [ + "dynamodb:BatchGetItem", + "dynamodb:UpdateItem", "dynamodb:ConditionCheckItem", ] diff --git a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts index 8e9385c46..73e99fa4b 100644 --- a/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/routing-config-repository/repository.test.ts @@ -454,56 +454,6 @@ describe('RoutingConfigRepository', () => { }); }); - test('uses ConditionCheck with default lockNumber 0 for SUBMITTED template without lockNumber', async () => { - const { repo, mocks } = setup(); - - const routingConfigWithLock: RoutingConfig = { - ...routingConfig, - lockNumber: 2, - }; - - const completed: RoutingConfig = { - ...routingConfig, - status: 'COMPLETED', - lockNumber: 3, - }; - - const template = makeTemplateMock({ - id: routingConfig.cascade[0].defaultTemplateId!, - templateType: 'SMS', - templateStatus: 'SUBMITTED', - lockNumber: undefined, // intentionally omitted - }); - - mocks.dynamo - .on(GetCommand) - .resolvesOnce({ Item: routingConfigWithLock }) - .resolvesOnce({ Item: completed }); - mocks.dynamo.on(BatchGetCommand).resolvesOnce({ - Responses: { - [TEMPLATE_TABLE_NAME]: [template], - }, - }); - mocks.dynamo.on(TransactWriteCommand).resolvesOnce({}); - - const result = await repo.submit(routingConfig.id, user, 2); - - expect(result).toEqual({ data: completed }); - - expect(mocks.dynamo).toHaveReceivedCommandWith(TransactWriteCommand, { - TransactItems: expect.arrayContaining([ - expect.objectContaining({ - ConditionCheck: expect.objectContaining({ - ExpressionAttributeValues: { - ':lockNumber': 0, // defaults to 0 when lockNumber is undefined - ':submitted': 'SUBMITTED', - }, - }), - }), - ]), - }); - }); - test('returns failure on client error during initial get', async () => { const { repo, mocks } = setup(); @@ -616,6 +566,47 @@ describe('RoutingConfigRepository', () => { }); }); + test('returns failure if template from database is invalid', async () => { + const { repo, mocks } = setup(); + + const routingConfigWithLock: RoutingConfig = { + ...routingConfig, + lockNumber: 2, + }; + + // Invalid template - missing required fields like name, createdAt, etc. + const invalidTemplate = { + id: routingConfig.cascade[0].defaultTemplateId!, + owner: clientOwnerKey, + templateType: 'SMS', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + // Missing: name, message, createdAt, updatedAt + }; + + mocks.dynamo.on(GetCommand).resolvesOnce({ Item: routingConfigWithLock }); + mocks.dynamo.on(BatchGetCommand).resolvesOnce({ + Responses: { + [TEMPLATE_TABLE_NAME]: [invalidTemplate], + }, + }); + + const result = await repo.submit(routingConfig.id, user, 2); + + expect(result).toEqual({ + error: { + actualError: expect.any(Error), + errorMeta: { + code: 500, + description: 'Error parsing template from database', + details: undefined, + }, + }, + }); + + expect(mocks.dynamo).not.toHaveReceivedCommand(TransactWriteCommand); + }); + test('returns 404 failure if routing config does not exist on get', async () => { const { repo, mocks } = setup(); @@ -882,13 +873,12 @@ describe('RoutingConfigRepository', () => { mocks.dynamo.on(BatchGetCommand).resolvesOnce({ Responses: { [TEMPLATE_TABLE_NAME]: [ - { + makeTemplateMock({ id: 'deleted-template-id', - owner: clientOwnerKey, templateType: 'SMS', templateStatus: 'DELETED', lockNumber: 1, - }, + }), ], }, }); @@ -934,9 +924,22 @@ describe('RoutingConfigRepository', () => { { id: 'letter-template-id', owner: clientOwnerKey, + name: 'Test Letter Template', templateType: 'LETTER', templateStatus: 'NOT_YET_SUBMITTED', lockNumber: 1, + letterType: 'x0', + language: 'en', + letterVersion: 'PDF', + files: { + pdfTemplate: { + fileName: 'test.pdf', + currentVersion: '1', + virusScanStatus: 'PASSED', + }, + }, + createdAt: date.toISOString(), + updatedAt: date.toISOString(), }, ], }, diff --git a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts index ed6d2226c..0dbd21ea0 100644 --- a/lambdas/backend-api/src/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/infra/routing-config-repository/repository.ts @@ -284,7 +284,7 @@ export class RoutingConfigRepository { ConditionExpression: 'attribute_exists(id) AND lockNumber = :lockNumber AND templateStatus = :submitted', ExpressionAttributeValues: { - ':lockNumber': template.lockNumber ?? 0, + ':lockNumber': template.lockNumber, ':submitted': 'SUBMITTED', }, ReturnValuesOnConditionCheckFailure: @@ -300,7 +300,7 @@ export class RoutingConfigRepository { ) .setStatus('SUBMITTED') .expectStatus(template.templateStatus) - .expectLockNumber(template.lockNumber ?? 0) + .expectLockNumber(template.lockNumber) .incrementLockNumber() .setUpdatedByUserAt(this.internalUserKey(user)) .build(), @@ -346,11 +346,6 @@ export class RoutingConfigRepository { templateIds: string[], clientId: string ): Promise> { - // istanbul ignore next -- defensive check; $SubmittableCascade validation ensures at least one template - if (templateIds.length === 0) { - return success([]); - } - try { const result = await this.client.send( new BatchGetCommand({ diff --git a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts index b99b6f3c9..fd788d423 100644 --- a/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/submit-routing-config.api.spec.ts @@ -620,72 +620,80 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { }); }); - test('returns 200 if LETTER template has status PROOF_APPROVED', async ({ + test('Template statuses are updated on routing config submit', async ({ request, }) => { - const letterTemplateId = randomUUID(); + const appTemplateId = randomUUID(); + const emailTemplateId = randomUUID(); + const englishTemplateId = randomUUID(); + const frenchTemplateId = randomUUID(); - // Create a LETTER template with PROOF_APPROVED status - const letterTemplate = TemplateFactory.uploadLetterTemplate( - letterTemplateId, + const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( + appTemplateId, user1, - 'Test Letter Template', - 'PROOF_APPROVED' + 'Nhs app template' ); + nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; + nhsAppTemplate.lockNumber = 1; - await templateStorageHelper.seedTemplateData([letterTemplate]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: letterTemplateId, - }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } + const emailTemplate = TemplateFactory.createEmailTemplate( + emailTemplateId, + user1, + 'Email Template' ); + emailTemplate.templateStatus = 'SUBMITTED'; + emailTemplate.lockNumber = 2; - expect(response.status()).toBe(200); - - const responseBody = await response.json(); - expect(responseBody.data.status).toBe('COMPLETED'); - }); - - test('returns 200 if LETTER template has status SUBMITTED', async ({ - request, - }) => { - const letterTemplateId = randomUUID(); - - // Create a LETTER template with SUBMITTED status - const letterTemplate = TemplateFactory.uploadLetterTemplate( - letterTemplateId, + const englishTemplate = TemplateFactory.createAuthoringLetterTemplate( + englishTemplateId, user1, - 'Test Letter Template', - 'SUBMITTED' + 'English Template' ); + englishTemplate.templateStatus = 'PROOF_APPROVED'; + englishTemplate.lockNumber = 3; - await templateStorageHelper.seedTemplateData([letterTemplate]); + const frenchTemplate = TemplateFactory.createAuthoringLetterTemplate( + frenchTemplateId, + user1, + 'French Template' + ); + frenchTemplate.templateStatus = 'PROOF_APPROVED'; + frenchTemplate.lockNumber = 4; + + await templateStorageHelper.seedTemplateData([ + nhsAppTemplate, + emailTemplate, + englishTemplate, + frenchTemplate, + ]); + + const startingEmailTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: emailTemplateId, + }); const { dbEntry } = RoutingConfigFactory.create(user1, { cascade: [ { cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: appTemplateId, + }, + { + cascadeGroups: ['standard'], + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: emailTemplateId, + }, + { + cascadeGroups: ['translations'], channel: 'LETTER', channelType: 'primary', - defaultTemplateId: letterTemplateId, + conditionalTemplates: [ + { language: 'en', templateId: englishTemplateId }, + { language: 'fr', templateId: frenchTemplateId }, + ], }, ], }); @@ -703,389 +711,38 @@ test.describe('PATCH /v1/routing-configuration/:routingConfigId/submit', () => { ); expect(response.status()).toBe(200); - const responseBody = await response.json(); expect(responseBody.data.status).toBe('COMPLETED'); - }); - - test.describe('template status updates on submit', () => { - test('updates NHSAPP template status to SUBMITTED after routing config submit', async ({ - request, - }) => { - const templateId = randomUUID(); - - // Create a template with NOT_YET_SUBMITTED status - const template = TemplateFactory.createNhsAppTemplate( - templateId, - user1, - 'Test Template for Status Update' - ); - template.templateStatus = 'NOT_YET_SUBMITTED'; - template.lockNumber = 5; - - await templateStorageHelper.seedTemplateData([template]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: templateId, - }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - - expect(response.status()).toBe(200); - - // Verify template was updated to SUBMITTED - const updatedTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId, - }); - - expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedTemplate.lockNumber).toBe(6); - }); - - test('updates EMAIL template status to SUBMITTED after routing config submit', async ({ - request, - }) => { - const templateId = randomUUID(); - - // Create an EMAIL template with NOT_YET_SUBMITTED status - const template = TemplateFactory.createEmailTemplate( - templateId, - user1, - 'Test Email Template for Status Update' - ); - template.templateStatus = 'NOT_YET_SUBMITTED'; - template.lockNumber = 3; - - await templateStorageHelper.seedTemplateData([template]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'EMAIL', - channelType: 'primary', - defaultTemplateId: templateId, - }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - expect(response.status()).toBe(200); - - // Verify template was updated to SUBMITTED - const updatedTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId, - }); - - expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedTemplate.lockNumber).toBe(4); + const dbAppTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: appTemplateId, }); - - test('updates SMS template status to SUBMITTED after routing config submit', async ({ - request, - }) => { - const templateId = randomUUID(); - - // Create an SMS template with NOT_YET_SUBMITTED status - const template = TemplateFactory.createSmsTemplate( - templateId, - user1, - 'Test SMS Template for Status Update' - ); - template.templateStatus = 'NOT_YET_SUBMITTED'; - template.lockNumber = 1; - - await templateStorageHelper.seedTemplateData([template]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'SMS', - channelType: 'primary', - defaultTemplateId: templateId, - }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - - expect(response.status()).toBe(200); - - // Verify template was updated to SUBMITTED - const updatedTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId, - }); - - expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedTemplate.lockNumber).toBe(2); + const dbEmailTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: emailTemplateId, }); - - test('updates LETTER template lockNumber without changing SUBMITTED status', async ({ - request, - }) => { - const templateId = randomUUID(); - - // Create a LETTER template that is already SUBMITTED - const template = TemplateFactory.uploadLetterTemplate( - templateId, - user1, - 'Test Letter Template Already Submitted', - 'SUBMITTED' - ); - template.lockNumber = 10; - - await templateStorageHelper.seedTemplateData([template]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: templateId, - }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - - expect(response.status()).toBe(200); - - // Verify template lockNumber was incremented but status remains SUBMITTED - const updatedTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId, - }); - - expect(updatedTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedTemplate.lockNumber).toBe(11); + const dbEnglishTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: englishTemplateId, }); - - test('updates multiple templates to SUBMITTED after routing config submit', async ({ - request, - }) => { - const nhsAppTemplateId = randomUUID(); - const emailTemplateId = randomUUID(); - const smsTemplateId = randomUUID(); - - // Create multiple templates with NOT_YET_SUBMITTED status - const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( - nhsAppTemplateId, - user1, - 'NHS App Template' - ); - nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - nhsAppTemplate.lockNumber = 1; - - const emailTemplate = TemplateFactory.createEmailTemplate( - emailTemplateId, - user1, - 'Email Template' - ); - emailTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - emailTemplate.lockNumber = 2; - - const smsTemplate = TemplateFactory.createSmsTemplate( - smsTemplateId, - user1, - 'SMS Template' - ); - smsTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - smsTemplate.lockNumber = 3; - - await templateStorageHelper.seedTemplateData([ - nhsAppTemplate, - emailTemplate, - smsTemplate, - ]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: nhsAppTemplateId, - }, - { - cascadeGroups: ['standard'], - channel: 'EMAIL', - channelType: 'secondary', - defaultTemplateId: emailTemplateId, - }, - { - cascadeGroups: ['standard'], - channel: 'SMS', - channelType: 'secondary', - defaultTemplateId: smsTemplateId, - }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - - expect(response.status()).toBe(200); - - // Verify all templates were updated to SUBMITTED - const updatedNhsAppTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId: nhsAppTemplateId, - }); - const updatedEmailTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId: emailTemplateId, - }); - const updatedSmsTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId: smsTemplateId, - }); - - expect(updatedNhsAppTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedNhsAppTemplate.lockNumber).toBe(2); - - expect(updatedEmailTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedEmailTemplate.lockNumber).toBe(3); - - expect(updatedSmsTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedSmsTemplate.lockNumber).toBe(4); + const dbFrenchTemplate = await templateStorageHelper.getTemplate({ + clientId: user1.clientId, + templateId: frenchTemplateId, }); - test('updates conditionalTemplates to SUBMITTED after routing config submit', async ({ - request, - }) => { - const englishTemplateId = randomUUID(); - const frenchTemplateId = randomUUID(); - - // Create conditional templates - const englishTemplate = TemplateFactory.createNhsAppTemplate( - englishTemplateId, - user1, - 'English Template' - ); - englishTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - englishTemplate.lockNumber = 1; - - const frenchTemplate = TemplateFactory.createNhsAppTemplate( - frenchTemplateId, - user1, - 'French Template' - ); - frenchTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - frenchTemplate.lockNumber = 2; - - await templateStorageHelper.seedTemplateData([ - englishTemplate, - frenchTemplate, - ]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [ - { - cascadeGroups: ['translations'], - channel: 'NHSAPP', - channelType: 'primary', - conditionalTemplates: [ - { language: 'en', templateId: englishTemplateId }, - { language: 'fr', templateId: frenchTemplateId }, - ], - }, - ], - cascadeGroupOverrides: [ - { name: 'translations', language: ['en', 'fr'] }, - ], - }); - - await storageHelper.seed([dbEntry]); - - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - - expect(response.status()).toBe(200); + expect(dbAppTemplate.templateStatus).toBe('SUBMITTED'); + expect(dbAppTemplate.lockNumber).toBe(2); - // Verify both conditional templates were updated to SUBMITTED - const updatedEnglishTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId: englishTemplateId, - }); - const updatedFrenchTemplate = await templateStorageHelper.getTemplate({ - clientId: user1.clientId, - templateId: frenchTemplateId, - }); + // email was already submitted so should not have changed + expect(dbEmailTemplate.templateStatus).toBe('SUBMITTED'); + expect(dbEmailTemplate.lockNumber).toBe(2); + expect(dbEmailTemplate.updatedAt).toEqual(startingEmailTemplate.updatedAt); - expect(updatedEnglishTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedEnglishTemplate.lockNumber).toBe(2); + expect(dbEnglishTemplate.templateStatus).toBe('SUBMITTED'); + expect(dbEnglishTemplate.lockNumber).toBe(4); - expect(updatedFrenchTemplate.templateStatus).toBe('SUBMITTED'); - expect(updatedFrenchTemplate.lockNumber).toBe(3); - }); + expect(dbFrenchTemplate.templateStatus).toBe('SUBMITTED'); + expect(dbFrenchTemplate.lockNumber).toBe(5); }); }); From 0ec14c432498422de4b536c602663b3fddab9abf Mon Sep 17 00:00:00 2001 From: Clare Jones Date: Wed, 4 Feb 2026 19:36:42 +0000 Subject: [PATCH 07/30] trivvy --- package-lock.json | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index a47557758..b426ccbc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7746,9 +7746,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -16164,7 +16164,7 @@ "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { "node": "20 || >=22" @@ -17198,12 +17198,12 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { "node": "20 || >=22" @@ -23289,13 +23289,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", From 894c06750fc4f83bb5a61d645fb0e5cf2c83ffd5 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 5 Feb 2026 13:44:11 +0000 Subject: [PATCH 08/30] init --- .../config/vocabularies/words/accept.txt | 3 +- tests/test-team/config/e2e/e2e.config.ts | 2 +- .../pages/routing/choose-templates-page.ts | 6 +- tests/test-team/pages/routing/email/index.ts | 2 + tests/test-team/pages/routing/index.ts | 10 +- tests/test-team/pages/routing/letter/index.ts | 6 + .../test-team/pages/routing/nhs-app/index.ts | 2 + tests/test-team/pages/routing/sms/index.ts | 2 + ....ts => letter-file-validation.e2e.spec.ts} | 0 ...ll.e2e.spec.ts => letter-full.e2e.spec.ts} | 0 ....e2e.spec.ts => proof-polling.e2e.spec.ts} | 0 ....e2e.spec.ts => proof-request.e2e.spec.ts} | 0 .../routing.e2e.spec.ts | 203 ++++++++++++++++++ ...choose-templates.routing-component.spec.ts | 33 ++- 14 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 tests/test-team/pages/routing/email/index.ts create mode 100644 tests/test-team/pages/routing/letter/index.ts create mode 100644 tests/test-team/pages/routing/nhs-app/index.ts create mode 100644 tests/test-team/pages/routing/sms/index.ts rename tests/test-team/template-mgmt-e2e-tests/{template-mgmt-letter-file-validation.e2e.spec.ts => letter-file-validation.e2e.spec.ts} (100%) rename tests/test-team/template-mgmt-e2e-tests/{template-mgmt-letter-full.e2e.spec.ts => letter-full.e2e.spec.ts} (100%) rename tests/test-team/template-mgmt-e2e-tests/{template-mgmt-proof-polling.e2e.spec.ts => proof-polling.e2e.spec.ts} (100%) rename tests/test-team/template-mgmt-e2e-tests/{template-mgmt-proof-request.e2e.spec.ts => proof-request.e2e.spec.ts} (100%) create mode 100644 tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index fb087d41c..57d857f1c 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,4 +1,4 @@ -[A-Z]+s +AGENTS Bitwarden bot Cognito @@ -15,6 +15,7 @@ Gitleaks Grype idempotence Jira +Lambdas npm OAuth Octokit diff --git a/tests/test-team/config/e2e/e2e.config.ts b/tests/test-team/config/e2e/e2e.config.ts index 1d3deaaf4..13d9eaecf 100644 --- a/tests/test-team/config/e2e/e2e.config.ts +++ b/tests/test-team/config/e2e/e2e.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ screenshot: 'only-on-failure', baseURL: 'http://localhost:3000', ...devices['Desktop Chrome'], - headless: true, + headless: false, storageState: path.resolve(__dirname, '../.auth/user.json'), }, dependencies: ['e2e:setup'], diff --git a/tests/test-team/pages/routing/choose-templates-page.ts b/tests/test-team/pages/routing/choose-templates-page.ts index 8ef4c3bb6..b6d602158 100644 --- a/tests/test-team/pages/routing/choose-templates-page.ts +++ b/tests/test-team/pages/routing/choose-templates-page.ts @@ -90,7 +90,11 @@ export class RoutingChooseTemplatesPage extends TemplateMgmtBasePage { public readonly email = this.messagePlanChannel('EMAIL'); - public readonly letter = this.messagePlanChannel('LETTER'); + public readonly letter = { + standard: this.messagePlanChannel('LETTER'), + largePrint: this.messagePlanChannel('x1'), + language: this.messagePlanChannel('foreign-language'), + }; public alternativeLetterFormats() { const conditionalTemplates = this.page.getByTestId( diff --git a/tests/test-team/pages/routing/email/index.ts b/tests/test-team/pages/routing/email/index.ts new file mode 100644 index 000000000..55103ae1c --- /dev/null +++ b/tests/test-team/pages/routing/email/index.ts @@ -0,0 +1,2 @@ +export * from './choose-email-template-page'; +export * from './preview-email-page'; diff --git a/tests/test-team/pages/routing/index.ts b/tests/test-team/pages/routing/index.ts index d5fab41f7..dcb0d38dc 100644 --- a/tests/test-team/pages/routing/index.ts +++ b/tests/test-team/pages/routing/index.ts @@ -2,6 +2,14 @@ export * from './campaign-id-required-page'; export * from './choose-message-order-page'; export * from './choose-templates-page'; export * from './create-message-plan-page'; -export * from './message-plans-page'; +export * from './edit-message-plan-settings-page'; +export * from './get-ready-to-move-page'; export * from './invalid-message-plan-page'; +export * from './message-plans-page'; +export * from './preview-message-plan-page'; export * from './review-and-move-to-production-page'; + +export * from './email'; +export * from './letter'; +export * from './nhs-app'; +export * from './sms'; diff --git a/tests/test-team/pages/routing/letter/index.ts b/tests/test-team/pages/routing/letter/index.ts new file mode 100644 index 000000000..d545ebf34 --- /dev/null +++ b/tests/test-team/pages/routing/letter/index.ts @@ -0,0 +1,6 @@ +export * from './choose-large-print-letter-template-page'; +export * from './choose-other-language-letter-template-page'; +export * from './choose-standard-letter-template-page'; +export * from './preview-large-print-letter-template-page'; +export * from './preview-other-language-letter-template-page'; +export * from './preview-standard-letter-page'; diff --git a/tests/test-team/pages/routing/nhs-app/index.ts b/tests/test-team/pages/routing/nhs-app/index.ts new file mode 100644 index 000000000..ab6aea5fa --- /dev/null +++ b/tests/test-team/pages/routing/nhs-app/index.ts @@ -0,0 +1,2 @@ +export * from './choose-nhs-app-template-page'; +export * from './preview-nhs-app-page'; diff --git a/tests/test-team/pages/routing/sms/index.ts b/tests/test-team/pages/routing/sms/index.ts new file mode 100644 index 000000000..691ebbf71 --- /dev/null +++ b/tests/test-team/pages/routing/sms/index.ts @@ -0,0 +1,2 @@ +export * from './choose-sms-template-page'; +export * from './preview-sms-template-page'; diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/letter-file-validation.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/letter-file-validation.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/letter-full.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/letter-full.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/proof-polling.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/proof-polling.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/proof-request.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/proof-request.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts new file mode 100644 index 000000000..fd2874e21 --- /dev/null +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -0,0 +1,203 @@ +/* eslint-disable security/detect-non-literal-regexp */ +import { expect, test } from '@playwright/test'; +import { + createAuthHelper, + TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { TemplateFactory } from '../helpers/factories/template-factory'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { randomUUID } from 'node:crypto'; +import { + RoutingChooseMessageOrderPage, + RoutingChooseTemplatesPage, + RoutingCreateMessagePlanPage, + RoutingMessagePlansPage, + RoutingChooseOtherLanguageLetterTemplatePage, + RoutingChooseNhsAppTemplatePage, +} from '../pages/routing'; + +const templateStorageHelper = new TemplateStorageHelper(); + +function createTemplates(user: TestUser) { + const templateIds = { + NHSAPP: randomUUID(), + EMAIL: randomUUID(), + SMS: randomUUID(), + LETTER: randomUUID(), + LARGE_PRINT_LETTER: randomUUID(), + ARABIC_LETTER: randomUUID(), + POLISH_LETTER: randomUUID(), + }; + + return { + NHSAPP: TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + `E2E NHS App template - ${templateIds.NHSAPP}`, + 'SUBMITTED' + ), + EMAIL: TemplateFactory.createEmailTemplate( + templateIds.EMAIL, + user, + `E2E Email template - ${templateIds.EMAIL}`, + 'SUBMITTED' + ), + SMS: TemplateFactory.createSmsTemplate( + templateIds.SMS, + user, + `E2E SMS template - ${templateIds.SMS}`, + 'SUBMITTED' + ), + LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LETTER, + user, + `E2E Letter template - ${templateIds.LETTER}`, + 'PROOF_APPROVED' + ), + LARGE_PRINT_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LARGE_PRINT_LETTER, + user, + `E2E Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, + 'SUBMITTED', + { letterType: 'x1' } + ), + ARABIC_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.ARABIC_LETTER, + user, + `E2E Letter template Arabic - ${templateIds.ARABIC_LETTER}`, + 'PROOF_APPROVED', + { language: 'ar' } + ), + POLISH_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.POLISH_LETTER, + user, + `E2E Polish Letter template - ${templateIds.POLISH_LETTER}`, + 'SUBMITTED', + { language: 'pl' } + ), + }; +} + +test.describe('Routing', () => { + let templates: ReturnType; + let user: TestUser; + + test.beforeAll(async () => { + user = await createAuthHelper().getTestUser(testUsers.User1.userId); + templates = createTemplates(user); + + await templateStorageHelper.seedTemplateData(Object.values(templates)); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('templates are added to the routing config, and the routing config is completed', async ({ + page, + }) => { + const rcName = 'E2E test RC'; + + const messagePlansPage = new RoutingMessagePlansPage(page); + + await messagePlansPage.loadPage(); + + await messagePlansPage.clickNewMessagePlanButton(); + + const chooseMessageOrderPage = new RoutingChooseMessageOrderPage(page); + + await chooseMessageOrderPage.checkRadioButton( + 'NHS App, Email, Text message, Letter' + ); + + await chooseMessageOrderPage.clickContinueButton(); + + const createMessagePlanPage = new RoutingCreateMessagePlanPage(page); + + await createMessagePlanPage.nameField.fill(rcName); + + await createMessagePlanPage.clickSubmit(); + + const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); + + await chooseTemplatesPage.letter.language.chooseTemplateLink.click(); + + const chooseOtherLanguageTemplatesPage = + new RoutingChooseOtherLanguageLetterTemplatePage(page); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.ARABIC_LETTER.name, + }) + ).toBeVisible(); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.POLISH_LETTER.name, + }) + ).toBeVisible(); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.LETTER.name, + }) + ).toBeHidden(); + + const plCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( + templates.POLISH_LETTER.id + ); + + const arCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( + templates.ARABIC_LETTER.id + ); + + await arCheck.click(); + await plCheck.click(); + + await chooseOtherLanguageTemplatesPage.saveAndContinueButton.click(); + + const otherLanguageNames = + chooseTemplatesPage.letter.language.templateNames; + + await expect(otherLanguageNames).toHaveCount(2); + + await expect( + otherLanguageNames.filter({ + hasText: templates.ARABIC_LETTER.name, + }) + ).toBeVisible(); + + await expect( + otherLanguageNames.filter({ + hasText: templates.POLISH_LETTER.name, + }) + ).toBeVisible(); + + await chooseTemplatesPage.nhsApp.chooseTemplateLink.click(); + + const chooseNhsAppTemplatePage = new RoutingChooseNhsAppTemplatePage(page); + + const nhsAppRadio = chooseNhsAppTemplatePage.getRadioButton( + templates.NHSAPP.id + ); + + await nhsAppRadio.click(); + + await chooseNhsAppTemplatePage.saveAndContinueButton.click(); + + await expect(chooseTemplatesPage.nhsApp.templateName).toHaveText( + templates.NHSAPP.name + ); + + await chooseTemplatesPage.clickMoveToProduction(); + + await expect(chooseTemplatesPage.errorSummary).toContainText([ + 'There is a problem', + 'You must choose a template for each message', + 'You have not chosen a template for your second message', + 'You have not chosen a template for your third message', + 'You have not chosen a template for your fourth message', + ]); + }); +}); diff --git a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts index d2bfd3c66..a6b539bf6 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts @@ -453,8 +453,13 @@ test.describe('Routing - Choose Templates page', () => { }); await test.step('letter channel with no template selected has no name or change link', async () => { - await expect(chooseTemplatesPage.letter.templateName).toBeHidden(); - await expect(chooseTemplatesPage.letter.changeTemplateLink).toBeHidden(); + await expect( + chooseTemplatesPage.letter.standard.templateName + ).toBeHidden(); + + await expect( + chooseTemplatesPage.letter.standard.changeTemplateLink + ).toBeHidden(); }); await chooseTemplatesPage.nhsApp.clickChangeTemplateLink(); @@ -490,7 +495,9 @@ test.describe('Routing - Choose Templates page', () => { await chooseTemplatesPage.nhsApp.clickRemoveTemplateLink(); - await expect(chooseTemplatesPage.letter.removeTemplateLink).toBeHidden(); + await expect( + chooseTemplatesPage.letter.standard.removeTemplateLink + ).toBeHidden(); await expect(page).toHaveURL( `${baseURL}/templates/message-plans/choose-templates/${routingConfigIds.valid}` @@ -568,18 +575,28 @@ test.describe('Routing - Choose Templates page', () => { await chooseTemplatesPage.loadPage(); await test.step('standard letter channel with default template has template name and change link', async () => { - await expect(chooseTemplatesPage.letter.templateName).toHaveText( + await expect(chooseTemplatesPage.letter.standard.templateName).toHaveText( templates.LETTER.name ); - await expect(chooseTemplatesPage.letter.changeTemplateLink).toBeVisible(); + + await expect( + chooseTemplatesPage.letter.standard.changeTemplateLink + ).toBeVisible(); + await expect( - chooseTemplatesPage.letter.changeTemplateLink + chooseTemplatesPage.letter.standard.changeTemplateLink ).toHaveAttribute( 'href', `/templates/message-plans/choose-standard-english-letter-template/${routingConfigIds.validWithLetterTemplates}?lockNumber=${messagePlans.validWithLetterTemplates.lockNumber}` ); - await expect(chooseTemplatesPage.letter.removeTemplateLink).toBeVisible(); - await expect(chooseTemplatesPage.letter.chooseTemplateLink).toBeHidden(); + + await expect( + chooseTemplatesPage.letter.standard.removeTemplateLink + ).toBeVisible(); + + await expect( + chooseTemplatesPage.letter.standard.chooseTemplateLink + ).toBeHidden(); }); const alternativeLetterFormats = From 91b9735d6d17a5bfda6e0259dd8777a018509cd4 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 5 Feb 2026 16:54:43 +0000 Subject: [PATCH 09/30] event test --- event-subscriber.ts | 341 +++++++++++++++++ letter-prepared.ts | 35 ++ .../helpers/events/event-cache-helper.ts | 8 +- .../pages/template-mgmt-base-page.ts | 12 + .../routing.e2e.spec.ts | 346 +++++++++++++----- .../routing-config.event.spec.ts | 67 +++- 6 files changed, 717 insertions(+), 92 deletions(-) create mode 100644 event-subscriber.ts create mode 100644 letter-prepared.ts diff --git a/event-subscriber.ts b/event-subscriber.ts new file mode 100644 index 000000000..42761793f --- /dev/null +++ b/event-subscriber.ts @@ -0,0 +1,341 @@ +import { + CreateQueueCommand, + DeleteMessageBatchCommand, + DeleteQueueCommand, + ListQueuesCommand, + Message, + ReceiveMessageCommand, +} from '@aws-sdk/client-sqs'; +import { + ListSubscriptionsByTopicCommand, + SubscribeCommand, + UnsubscribeCommand, +} from '@aws-sdk/client-sns'; +import { snsClient, sqsClient } from '@comms/util-aws'; +import { sleep } from '@comms/util-retry'; +import { ZodType } from 'zod'; + +type Event = { + sentTime: Date; + record: T extends undefined ? Record : T; +}; + +/* + Class instances must be created as worker-scoped playwright fixtures. + Each worker owns its queue and subscription and runs tests in serial internally. + Each fixture should only be used in a single suite. + The cleanup static method should be called in a suite's global setup + + This util assumes that the event JSON has a unique 'id' property. Use of + non-unique IDs will lead to non-deterministic behaviour in tests. + + Receive can be called repeatedly to poll for new events. The same value for + 'since' should be used across polls since this is used to trim cached messages. + + Since by default tests can run in parallel, events triggered by other tests + may be received. Filtering should be applied or if necessary tests should be run serially. +*/ + +export class EventSubscriber { + static readonly sns = snsClient; + + static readonly sqs = sqsClient; + + static readonly rootNamespace = 'comms-e2e-es'; + + private readonly queueName: string; + + private readonly queueArn: string; + + private readonly queueUrl: string; + + private subscriptionArn: string | undefined = undefined; + + private messages = new Map< + string, + { record: Record; sentTime: Date } + >(); + + constructor( + private readonly topic: string, + private readonly account: string, + private readonly environment: string, + private readonly suite: string, + private readonly tag: string, + private readonly eventSource: string | string[], + private readonly workerIndex: number + ) { + this.queueName = `${EventSubscriber.rootNamespace}-${this.environment}-${this.suite}-${this.tag}-${this.workerIndex}`; + this.queueArn = `arn:aws:sqs:eu-west-2:${this.account}:${this.queueName}`; + this.queueUrl = `https://sqs.eu-west-2.amazonaws.com/${this.account}/${this.queueName}`; + } + + async initialise() { + await this.createQueue(); + + this.subscriptionArn = await this.createSubscription(); + } + + async receive({ + since, + match, + }: { + since: Date; + match?: ZodType; + }): Promise[]> { + this.trimCached(since); + + const received: Message[] = []; + + let polledCount = 0; + + do { + const { Messages: polled = [] } = await EventSubscriber.sqs.send( + new ReceiveMessageCommand({ + QueueUrl: this.queueUrl, + MaxNumberOfMessages: 10, + MessageSystemAttributeNames: ['SentTimestamp'], + }) + ); + + polledCount = polled.length; + + if (polledCount) { + await EventSubscriber.sqs.send( + new DeleteMessageBatchCommand({ + QueueUrl: this.queueUrl, + Entries: polled.map((msg, index) => ({ + Id: index.toString(), + ReceiptHandle: msg.ReceiptHandle!, + })), + }) + ); + } + + received.push(...polled); + } while (polledCount > 0); + + const parsed = received.flatMap(({ Body, Attributes }) => { + if (Body && Attributes?.SentTimestamp) { + const sentTime = new Date(Number(Attributes.SentTimestamp)); + const snsEvent = JSON.parse(Body); + + const record = JSON.parse(snsEvent.Message); + + const envelopeId = record.id; + + if (!envelopeId) { + throw new Error('Event record is missing id field'); + } + + return [{ sentTime, record, envelopeId }]; + } + + return []; + }); + + for (const event of parsed) { + this.messages.set(event.envelopeId, { + record: event.record, + sentTime: event.sentTime, + }); + } + + const filtered = Array.from(this.messages.values()).filter( + ({ sentTime, record }) => { + if (since && sentTime <= since) return false; + + if (match) { + return match.safeParse(record).success; + } + + return true; + } + ) as Event[]; + + return filtered.sort((a, b) => a.sentTime.getTime() - b.sentTime.getTime()); + } + + private trimCached(since: Date) { + for (const [id, { sentTime }] of this.messages) { + if (sentTime < since) { + this.messages.delete(id); + } + } + } + + async teardown() { + if (this.subscriptionArn) { + await EventSubscriber.deleteSubscription(this.subscriptionArn); + } + + await EventSubscriber.deleteQueue(this.queueUrl); + } + + private async createQueue() { + console.log(`Creating queue with ARN: ${this.queueArn}`); + + const policy = { + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowSnsSendMessage', + Effect: 'Allow', + Principal: { Service: 'sns.amazonaws.com' }, + Action: 'sqs:SendMessage', + Resource: this.queueArn, + Condition: { + ArnEquals: { + 'aws:SourceArn': this.topic, + }, + }, + }, + ], + }; + + let attempt = 0; + + while (attempt < 10) { + attempt += 1; + + try { + await EventSubscriber.sqs.send( + new CreateQueueCommand({ + QueueName: this.queueName, + Attributes: { + Policy: JSON.stringify(policy), + }, + }) + ); + break; + } catch (err) { + if ( + err instanceof Error && + // err instancecof QueueDeletedRecently does not work as expected + (err.name === 'QueueDeletedRecently' || + ('Code' in err && + err.Code === 'AWS.SimpleQueueService.QueueDeletedRecently')) + ) { + console.log( + `Queue deleted recently, retrying creation (attempt ${attempt})` + ); + await sleep(10); + } else { + throw err; + } + } + } + } + + private async createSubscription() { + console.log( + `Creating SNS subscription (topic: ${this.topic}) (queue: ${this.queueArn})` + ); + + const policy = + typeof this.eventSource === 'string' + ? `{ "source": ["${this.eventSource}"] }` + : `{ "source": ${JSON.stringify(this.eventSource)} }`; + + const subscription = await EventSubscriber.sns.send( + new SubscribeCommand({ + TopicArn: this.topic, + Protocol: 'sqs', + Endpoint: this.queueArn, + Attributes: { + FilterPolicyScope: 'MessageBody', + FilterPolicy: policy, + }, + }) + ); + + return subscription.SubscriptionArn; + } + + private static deleteQueue( + url: string, + { warn }: { warn?: boolean } = { warn: true } + ) { + console.log(`Deleting queue with URL: ${url}`); + + return EventSubscriber.sqs + .send(new DeleteQueueCommand({ QueueUrl: url })) + .catch((err) => { + if (warn) { + console.warn(`Failed to delete queue at ${url}: ${err}`); + } + }); + } + + private static deleteSubscription(arn: string) { + console.log(`Deleting SNS subscription with ARN: ${arn}`); + + return EventSubscriber.sns + .send(new UnsubscribeCommand({ SubscriptionArn: arn })) + .catch((err) => { + console.warn(`Failed to delete subscription with ARN ${arn}: ${err}`); + }); + } + + static async cleanup(suite: string, environment: string, topicArn: string) { + const namePrefix = `${EventSubscriber.rootNamespace}-${environment}-${suite}-`; + + const urls: string[] = []; + + let nextQueuesToken: string | undefined; + + do { + const queues = await EventSubscriber.sqs.send( + new ListQueuesCommand({ + QueueNamePrefix: namePrefix, + NextToken: nextQueuesToken, + }) + ); + + nextQueuesToken = queues.NextToken; + + urls.push(...(queues.QueueUrls ?? [])); + } while (nextQueuesToken); + + const subscriptionArns: string[] = []; + + let nextSubscriptionsToken: string | undefined; + + const queueArns = new Set( + urls.map((url) => + url.replace( + /^https:\/\/sqs\.eu-west-2\.amazonaws\.com\/(\d+)\/([^/]+)$/, + 'arn:aws:sqs:eu-west-2:$1:$2' + ) + ) + ); + + do { + const subscriptions = await EventSubscriber.sns.send( + new ListSubscriptionsByTopicCommand({ + TopicArn: topicArn, + NextToken: nextSubscriptionsToken, + }) + ); + + nextSubscriptionsToken = subscriptions.NextToken; + + const queueSubscriptions = + subscriptions.Subscriptions?.flatMap(({ SubscriptionArn, Endpoint }) => + SubscriptionArn && Endpoint && queueArns.has(Endpoint) + ? [SubscriptionArn] + : [] + ) ?? []; + + subscriptionArns.push(...queueSubscriptions); + } while (nextSubscriptionsToken); + + for (const arn of subscriptionArns) { + await EventSubscriber.deleteSubscription(arn); + } + + for (const url of urls) { + await EventSubscriber.deleteQueue(url); + } + } +} diff --git a/letter-prepared.ts b/letter-prepared.ts new file mode 100644 index 000000000..4ecca87dc --- /dev/null +++ b/letter-prepared.ts @@ -0,0 +1,35 @@ +import { test as base } from '@playwright/test'; +import { env, ACCOUNT_NAME } from '@comms/playwright-utils'; +import { EventSubscriber } from '../../../../helpers/event-subscriber'; +import { OUTGOING_EVENTS_TOPIC_ARN } from '../../../../constants'; + +type LetterPreparedSubscriber = { + letterPreparedSubscriber: EventSubscriber; +}; + +export const testWithLetterPreparedSubscriber = base.extend< + object, + LetterPreparedSubscriber +>({ + letterPreparedSubscriber: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, workerInfo) => { + const subscriber = new EventSubscriber( + OUTGOING_EVENTS_TOPIC_ARN, + process.env.ACCOUNT_ID, + env, + 'letters', + 'letter-prepared', + `/data-plane/letter-rendering/${ACCOUNT_NAME}/${env}`, + workerInfo.workerIndex + ); + + await subscriber.initialise(); + + await use(subscriber).finally(() => subscriber.teardown()); + }, + { scope: 'worker' }, + ], +}); + +export const { expect } = testWithLetterPreparedSubscriber; diff --git a/tests/test-team/helpers/events/event-cache-helper.ts b/tests/test-team/helpers/events/event-cache-helper.ts index e517a53f6..cf1a64854 100644 --- a/tests/test-team/helpers/events/event-cache-helper.ts +++ b/tests/test-team/helpers/events/event-cache-helper.ts @@ -49,7 +49,13 @@ export class EventCacheHelper { const results = await Promise.all(eventPromises); - return results.flat(); + // Filter events by their 'time' field (CloudEvent timestamp) + // This ensures we only return events generated after the 'from' timestamp, + // excluding events from seeded data that may have been written before 'start' + return results.flat().filter((event) => { + const eventTime = new Date(event.time); + return eventTime >= from; + }); } private async queryFileForEvents( diff --git a/tests/test-team/pages/template-mgmt-base-page.ts b/tests/test-team/pages/template-mgmt-base-page.ts index b071ab465..2acc216a1 100644 --- a/tests/test-team/pages/template-mgmt-base-page.ts +++ b/tests/test-team/pages/template-mgmt-base-page.ts @@ -121,6 +121,18 @@ export abstract class TemplateMgmtBasePage { await this.backLinkTop.click(); } + async clickTemplatesHeaderLink() { + await this.headerNavigationLinks + .getByRole('link', { name: 'Templates' }) + .click(); + } + + async clickMessagePlansHeaderLink() { + await this.headerNavigationLinks + .getByRole('link', { name: 'Message plans' }) + .click(); + } + /** * Sets the value of a path parameter which will be interpolated into the pathTemplate when calling `getUrl` or `loadPage` * @param key The name of the path parameter to set diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts index fd2874e21..b59b45363 100644 --- a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -1,5 +1,4 @@ -/* eslint-disable security/detect-non-literal-regexp */ -import { expect, test } from '@playwright/test'; +import { expect, test, Locator } from '@playwright/test'; import { createAuthHelper, TestUser, @@ -15,17 +14,89 @@ import { RoutingMessagePlansPage, RoutingChooseOtherLanguageLetterTemplatePage, RoutingChooseNhsAppTemplatePage, + RoutingChooseEmailTemplatePage, + RoutingChooseTextMessageTemplatePage, + RoutingPreviewSmsTemplatePage, + RoutingChooseStandardLetterTemplatePage, + RoutingGetReadyToMovePage, + RoutingReviewAndMoveToProductionPage, } from '../pages/routing'; +import { TemplateMgmtMessageTemplatesPage } from '../pages/template-mgmt-message-templates-page'; +import { TemplateMgmtChooseTemplateForMessagePlanBasePage } from '../pages/template-mgmt-choose-template-base-page'; +import type { Channel } from 'nhs-notify-backend-client'; const templateStorageHelper = new TemplateStorageHelper(); +type Template = { id: string; name: string }; + +async function selectTemplate( + chooseTemplateLink: Locator, + chooseTemplatePage: TemplateMgmtChooseTemplateForMessagePlanBasePage, + template: Template, + templateNameLocator: Locator +) { + return test.step(`select template: ${template.name}`, async () => { + await chooseTemplateLink.click(); + + const radio = chooseTemplatePage.getRadioButton(template.id); + + await radio.click(); + + await chooseTemplatePage.saveAndContinueButton.click(); + + await expect(templateNameLocator).toHaveText(template.name); + }); +} + +async function assertTemplateStatuses( + messageTemplatesPage: TemplateMgmtMessageTemplatesPage, + expectations: Array<{ template: Template; expectedStatus: string }> +) { + return test.step('assert template statuses', async () => { + for (const { template, expectedStatus } of expectations) { + expect( + await messageTemplatesPage.getTemplateStatus(template.id), + `Expected ${template.name} to have status "${expectedStatus}"` + ).toBe(expectedStatus); + } + }); +} + +async function assertReviewPageTemplates( + reviewPage: RoutingReviewAndMoveToProductionPage, + expectations: Array<{ channel: Channel; template: Template }> +) { + return test.step('assert review page template names', async () => { + for (const { channel, template } of expectations) { + await expect( + reviewPage.getTemplateBlock(channel).defaultTemplateCard.templateName, + `Expected ${channel} template to be "${template.name}"` + ).toHaveText(template.name); + } + }); +} + +async function assertMessagePlanInTable( + table: Locator, + messagePlanName: string +) { + return test.step(`assert message plan "${messagePlanName}" is in table`, async () => { + await table.click(); + + const row = table.getByRole('row', { name: messagePlanName }); + + await expect(row).toBeVisible(); + + await row.getByRole('link', { name: messagePlanName }).click(); + }); +} + function createTemplates(user: TestUser) { const templateIds = { NHSAPP: randomUUID(), EMAIL: randomUUID(), SMS: randomUUID(), LETTER: randomUUID(), - LARGE_PRINT_LETTER: randomUUID(), ARABIC_LETTER: randomUUID(), POLISH_LETTER: randomUUID(), }; @@ -41,13 +112,13 @@ function createTemplates(user: TestUser) { templateIds.EMAIL, user, `E2E Email template - ${templateIds.EMAIL}`, - 'SUBMITTED' + 'NOT_YET_SUBMITTED' ), SMS: TemplateFactory.createSmsTemplate( templateIds.SMS, user, `E2E SMS template - ${templateIds.SMS}`, - 'SUBMITTED' + 'NOT_YET_SUBMITTED' ), LETTER: TemplateFactory.createAuthoringLetterTemplate( templateIds.LETTER, @@ -55,13 +126,6 @@ function createTemplates(user: TestUser) { `E2E Letter template - ${templateIds.LETTER}`, 'PROOF_APPROVED' ), - LARGE_PRINT_LETTER: TemplateFactory.createAuthoringLetterTemplate( - templateIds.LARGE_PRINT_LETTER, - user, - `E2E Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, - 'SUBMITTED', - { letterType: 'x1' } - ), ARABIC_LETTER: TemplateFactory.createAuthoringLetterTemplate( templateIds.ARABIC_LETTER, user, @@ -97,107 +161,225 @@ test.describe('Routing', () => { test('templates are added to the routing config, and the routing config is completed', async ({ page, }) => { - const rcName = 'E2E test RC'; + const rcName = 'E2E TEST RC'; + const messageTemplatesPage = new TemplateMgmtMessageTemplatesPage(page); const messagePlansPage = new RoutingMessagePlansPage(page); + const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); - await messagePlansPage.loadPage(); + await test.step('check initial template statuses', async () => { + await messageTemplatesPage.loadPage(); - await messagePlansPage.clickNewMessagePlanButton(); + await expect(messageTemplatesPage.pageHeading).toBeVisible(); - const chooseMessageOrderPage = new RoutingChooseMessageOrderPage(page); + await assertTemplateStatuses(messageTemplatesPage, [ + { template: templates.NHSAPP, expectedStatus: 'Locked' }, + { template: templates.POLISH_LETTER, expectedStatus: 'Locked' }, + { template: templates.EMAIL, expectedStatus: 'Draft' }, + { template: templates.SMS, expectedStatus: 'Draft' }, + { template: templates.LETTER, expectedStatus: 'Proof approved' }, + { template: templates.ARABIC_LETTER, expectedStatus: 'Proof approved' }, + ]); + }); - await chooseMessageOrderPage.checkRadioButton( - 'NHS App, Email, Text message, Letter' - ); + await test.step('create routing config', async () => { + await messageTemplatesPage.clickMessagePlansHeaderLink(); - await chooseMessageOrderPage.clickContinueButton(); + await expect(messagePlansPage.pageHeading).toBeVisible(); - const createMessagePlanPage = new RoutingCreateMessagePlanPage(page); + await messagePlansPage.clickNewMessagePlanButton(); - await createMessagePlanPage.nameField.fill(rcName); + const chooseMessageOrderPage = new RoutingChooseMessageOrderPage(page); - await createMessagePlanPage.clickSubmit(); + await chooseMessageOrderPage.checkRadioButton( + 'NHS App, Email, Text message, Letter' + ); - const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); + await chooseMessageOrderPage.clickContinueButton(); + + const createMessagePlanPage = new RoutingCreateMessagePlanPage(page); + + await createMessagePlanPage.nameField.fill(rcName); + + await createMessagePlanPage.clickSubmit(); + }); + + await test.step('add other language letter templates', async () => { + await chooseTemplatesPage.letter.language.chooseTemplateLink.click(); + + const chooseOtherLanguageTemplatesPage = + new RoutingChooseOtherLanguageLetterTemplatePage(page); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.ARABIC_LETTER.name, + }) + ).toBeVisible(); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.POLISH_LETTER.name, + }) + ).toBeVisible(); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.LETTER.name, + }) + ).toBeHidden(); + + const plCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( + templates.POLISH_LETTER.id + ); + + const arCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( + templates.ARABIC_LETTER.id + ); + + await arCheck.click(); + await plCheck.click(); + + await chooseOtherLanguageTemplatesPage.saveAndContinueButton.click(); + + const otherLanguageNames = + chooseTemplatesPage.letter.language.templateNames; + + await expect(otherLanguageNames).toHaveCount(2); + + await expect( + otherLanguageNames.filter({ + hasText: templates.ARABIC_LETTER.name, + }) + ).toBeVisible(); + + await expect( + otherLanguageNames.filter({ + hasText: templates.POLISH_LETTER.name, + }) + ).toBeVisible(); + }); + + await test.step('check draft message plan exists', async () => { + await chooseTemplatesPage.clickMessagePlansHeaderLink(); + + await expect(messagePlansPage.pageHeading).toBeVisible(); + + await assertMessagePlanInTable( + messagePlansPage.draftMessagePlansTable, + rcName + ); + }); + + await test.step('add NHS App and Email templates', async () => { + await selectTemplate( + chooseTemplatesPage.nhsApp.chooseTemplateLink, + new RoutingChooseNhsAppTemplatePage(page), + templates.NHSAPP, + chooseTemplatesPage.nhsApp.templateName + ); + + await selectTemplate( + chooseTemplatesPage.email.chooseTemplateLink, + new RoutingChooseEmailTemplatePage(page), + templates.EMAIL, + chooseTemplatesPage.email.templateName + ); + }); + + await test.step('preview and add SMS template', async () => { + await chooseTemplatesPage.sms.chooseTemplateLink.click(); + + const chooseSmsTemplatePage = new RoutingChooseTextMessageTemplatePage( + page + ); + + const smsPreviewLink = chooseSmsTemplatePage.getPreviewLink( + templates.SMS.id + ); + + await smsPreviewLink.click(); + + const previewSmsTemplatePage = new RoutingPreviewSmsTemplatePage(page); + + await expect(previewSmsTemplatePage.templateId).toHaveText( + templates.SMS.id + ); - await chooseTemplatesPage.letter.language.chooseTemplateLink.click(); + await previewSmsTemplatePage.clickBackLinkTop(); - const chooseOtherLanguageTemplatesPage = - new RoutingChooseOtherLanguageLetterTemplatePage(page); + const smsRadio = chooseSmsTemplatePage.getRadioButton(templates.SMS.id); - await expect( - chooseOtherLanguageTemplatesPage.tableRows.filter({ - hasText: templates.ARABIC_LETTER.name, - }) - ).toBeVisible(); + await smsRadio.click(); - await expect( - chooseOtherLanguageTemplatesPage.tableRows.filter({ - hasText: templates.POLISH_LETTER.name, - }) - ).toBeVisible(); + await chooseSmsTemplatePage.saveAndContinueButton.click(); - await expect( - chooseOtherLanguageTemplatesPage.tableRows.filter({ - hasText: templates.LETTER.name, - }) - ).toBeHidden(); + await expect(chooseTemplatesPage.sms.templateName).toHaveText( + templates.SMS.name + ); + }); - const plCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( - templates.POLISH_LETTER.id - ); + await test.step('verify validation error for missing letter template', async () => { + await chooseTemplatesPage.clickMoveToProduction(); - const arCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( - templates.ARABIC_LETTER.id - ); + await expect(chooseTemplatesPage.errorSummaryList).toContainText([ + 'You have not chosen a template for your fourth message', + ]); + }); - await arCheck.click(); - await plCheck.click(); + await test.step('add standard letter template', async () => { + await selectTemplate( + chooseTemplatesPage.letter.standard.chooseTemplateLink, + new RoutingChooseStandardLetterTemplatePage(page), + templates.LETTER, + chooseTemplatesPage.letter.standard.templateName + ); + }); - await chooseOtherLanguageTemplatesPage.saveAndContinueButton.click(); + await test.step('review and move to production', async () => { + await chooseTemplatesPage.clickMoveToProduction(); - const otherLanguageNames = - chooseTemplatesPage.letter.language.templateNames; + const getReadyToMovePage = new RoutingGetReadyToMovePage(page); - await expect(otherLanguageNames).toHaveCount(2); + await expect(getReadyToMovePage.pageHeading).toBeVisible(); - await expect( - otherLanguageNames.filter({ - hasText: templates.ARABIC_LETTER.name, - }) - ).toBeVisible(); + await getReadyToMovePage.continueLink.click(); - await expect( - otherLanguageNames.filter({ - hasText: templates.POLISH_LETTER.name, - }) - ).toBeVisible(); + const reviewPage = new RoutingReviewAndMoveToProductionPage(page); - await chooseTemplatesPage.nhsApp.chooseTemplateLink.click(); + await expect(reviewPage.pageHeading).toBeVisible(); - const chooseNhsAppTemplatePage = new RoutingChooseNhsAppTemplatePage(page); + await assertReviewPageTemplates(reviewPage, [ + { channel: 'NHSAPP', template: templates.NHSAPP }, + { channel: 'EMAIL', template: templates.EMAIL }, + { channel: 'SMS', template: templates.SMS }, + { channel: 'LETTER', template: templates.LETTER }, + ]); - const nhsAppRadio = chooseNhsAppTemplatePage.getRadioButton( - templates.NHSAPP.id - ); + await reviewPage.moveToProductionButton.click(); + }); - await nhsAppRadio.click(); + await test.step('verify message plan is in production', async () => { + await expect(messagePlansPage.pageHeading).toBeVisible(); - await chooseNhsAppTemplatePage.saveAndContinueButton.click(); + await assertMessagePlanInTable( + messagePlansPage.productionMessagePlansTable, + rcName + ); + }); - await expect(chooseTemplatesPage.nhsApp.templateName).toHaveText( - templates.NHSAPP.name - ); + await test.step('verify all templates are locked', async () => { + await messagePlansPage.clickTemplatesHeaderLink(); - await chooseTemplatesPage.clickMoveToProduction(); + await expect(messageTemplatesPage.pageHeading).toBeVisible(); - await expect(chooseTemplatesPage.errorSummary).toContainText([ - 'There is a problem', - 'You must choose a template for each message', - 'You have not chosen a template for your second message', - 'You have not chosen a template for your third message', - 'You have not chosen a template for your fourth message', - ]); + await assertTemplateStatuses(messageTemplatesPage, [ + { template: templates.NHSAPP, expectedStatus: 'Locked' }, + { template: templates.EMAIL, expectedStatus: 'Locked' }, + { template: templates.SMS, expectedStatus: 'Locked' }, + { template: templates.LETTER, expectedStatus: 'Locked' }, + { template: templates.ARABIC_LETTER, expectedStatus: 'Locked' }, + { template: templates.POLISH_LETTER, expectedStatus: 'Locked' }, + ]); + }); }); }); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 75c13f23e..7a31a4c90 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -174,16 +174,33 @@ test.describe('Event publishing - Routing Config', () => { }).toPass({ timeout: 60_000 }); }); - test('Expect a draft event and a completed event', async ({ request }) => { - const templateId = randomUUID(); + test('Expect routing config and template completed events on submit', async ({ + request, + }) => { + const nhsAppTemplateId = randomUUID(); + const emailTemplateId = randomUUID(); - const template = TemplateFactory.createNhsAppTemplate( - templateId, + // NHS App template - NOT_YET_SUBMITTED, should trigger TemplateCompleted.v1 + const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( + nhsAppTemplateId, user, - 'Test Template for Submit' + 'NHS App Template for Submit' ); + nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - await templateStorageHelper.seedTemplateData([template]); + // Email template - already SUBMITTED, should NOT trigger TemplateCompleted.v1 + const emailTemplate = TemplateFactory.createEmailTemplate( + emailTemplateId, + user, + 'Email Template for Submit' + ); + emailTemplate.templateStatus = 'SUBMITTED'; + + await templateStorageHelper.seedTemplateData([nhsAppTemplate, emailTemplate]); + + // Wait for DynamoDB stream events from seeding to be processed + // This ensures any events triggered by seeding have timestamps before 'start' + await new Promise((resolve) => setTimeout(resolve, 5000)); const payload = RoutingConfigFactory.create(user, { cascade: [ @@ -191,7 +208,13 @@ test.describe('Event publishing - Routing Config', () => { cascadeGroups: ['standard'], channel: 'NHSAPP', channelType: 'primary', - defaultTemplateId: templateId, + defaultTemplateId: nhsAppTemplateId, + }, + { + cascadeGroups: ['standard'], + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: emailTemplateId, }, ], }).apiPayload; @@ -232,8 +255,13 @@ test.describe('Event publishing - Routing Config', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [id]); + const events = await eventCacheHelper.findEvents(start, [ + id, + nhsAppTemplateId, + emailTemplateId, + ]); + // Routing config events expect(events).toContainEqual( expect.objectContaining({ type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', @@ -252,7 +280,28 @@ test.describe('Event publishing - Routing Config', () => { }) ); - expect(events).toHaveLength(2); + // Template completed events - NHS App (was NOT_YET_SUBMITTED) + expect(events).toContainEqual( + expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: nhsAppTemplateId, + }), + }) + ); + + // Email template should NOT have a TemplateCompleted event (was already SUBMITTED) + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: emailTemplateId, + }), + }) + ); + + // Total: 2 routing config events + 1 template completed event = 3 + expect(events).toHaveLength(3); }).toPass({ timeout: 60_000 }); }); }); From a75e2b01596a40623e4938b1c605de40dad61255 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 5 Feb 2026 17:44:35 +0000 Subject: [PATCH 10/30] use event sub --- .../terraform/components/sandbox/README.md | 1 + .../terraform/components/sandbox/outputs.tf | 4 + letter-prepared.ts | 35 ----- package-lock.json | 10 ++ tests/test-team/config/event/event.setup.ts | 8 ++ .../fixtures/event-subscriber.fixture.ts | 35 +++++ tests/test-team/global.d.ts | 5 + .../helpers/events/event-subscriber.ts | 67 ++++----- tests/test-team/package.json | 1 + .../digital-templates.event.spec.ts | 81 +++++++---- .../letter-templates.event.spec.ts | 78 ++++++++--- .../routing-config.event.spec.ts | 132 ++++++++++++------ utils/backend-config/src/backend-config.ts | 21 +++ 13 files changed, 320 insertions(+), 158 deletions(-) delete mode 100644 letter-prepared.ts create mode 100644 tests/test-team/fixtures/event-subscriber.fixture.ts rename event-subscriber.ts => tests/test-team/helpers/events/event-subscriber.ts (84%) diff --git a/infrastructure/terraform/components/sandbox/README.md b/infrastructure/terraform/components/sandbox/README.md index ffeb07064..08f649a0a 100644 --- a/infrastructure/terraform/components/sandbox/README.md +++ b/infrastructure/terraform/components/sandbox/README.md @@ -40,6 +40,7 @@ | [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | | [download\_bucket\_name](#output\_download\_bucket\_name) | n/a | | [event\_cache\_bucket\_name](#output\_event\_cache\_bucket\_name) | n/a | +| [events\_sns\_topic\_arn](#output\_events\_sns\_topic\_arn) | n/a | | [internal\_bucket\_name](#output\_internal\_bucket\_name) | n/a | | [quarantine\_bucket\_name](#output\_quarantine\_bucket\_name) | n/a | | [request\_proof\_queue\_url](#output\_request\_proof\_queue\_url) | n/a | diff --git a/infrastructure/terraform/components/sandbox/outputs.tf b/infrastructure/terraform/components/sandbox/outputs.tf index 5288979a5..6a8929f4d 100644 --- a/infrastructure/terraform/components/sandbox/outputs.tf +++ b/infrastructure/terraform/components/sandbox/outputs.tf @@ -73,3 +73,7 @@ output "event_cache_bucket_name" { output "routing_config_table_name" { value = module.backend_api.routing_config_table_name } + +output "events_sns_topic_arn" { + value = module.eventpub.sns_topic.arn +} diff --git a/letter-prepared.ts b/letter-prepared.ts deleted file mode 100644 index 4ecca87dc..000000000 --- a/letter-prepared.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { test as base } from '@playwright/test'; -import { env, ACCOUNT_NAME } from '@comms/playwright-utils'; -import { EventSubscriber } from '../../../../helpers/event-subscriber'; -import { OUTGOING_EVENTS_TOPIC_ARN } from '../../../../constants'; - -type LetterPreparedSubscriber = { - letterPreparedSubscriber: EventSubscriber; -}; - -export const testWithLetterPreparedSubscriber = base.extend< - object, - LetterPreparedSubscriber ->({ - letterPreparedSubscriber: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use, workerInfo) => { - const subscriber = new EventSubscriber( - OUTGOING_EVENTS_TOPIC_ARN, - process.env.ACCOUNT_ID, - env, - 'letters', - 'letter-prepared', - `/data-plane/letter-rendering/${ACCOUNT_NAME}/${env}`, - workerInfo.workerIndex - ); - - await subscriber.initialise(); - - await use(subscriber).finally(() => subscriber.teardown()); - }, - { scope: 'worker' }, - ], -}); - -export const { expect } = testWithLetterPreparedSubscriber; diff --git a/package-lock.json b/package-lock.json index b426ccbc7..3fa608833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27837,6 +27837,7 @@ "@aws-sdk/client-eventbridge": "3.911.0", "@aws-sdk/client-lambda": "3.911.0", "@aws-sdk/client-s3": "3.911.0", + "@aws-sdk/client-sns": "^3.911.0", "@aws-sdk/client-sqs": "3.911.0", "@aws-sdk/client-ssm": "3.911.0", "@aws-sdk/lib-dynamodb": "3.911.0", @@ -28064,6 +28065,15 @@ "node": ">=18.0.0" } }, + "tests/test-team/node_modules/zod": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", + "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "utils/backend-config": { "name": "nhs-notify-web-template-management-util-backend-config", "version": "0.0.1", diff --git a/tests/test-team/config/event/event.setup.ts b/tests/test-team/config/event/event.setup.ts index 64294b976..ac4ccf093 100644 --- a/tests/test-team/config/event/event.setup.ts +++ b/tests/test-team/config/event/event.setup.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { test as setup } from '@playwright/test'; import { BackendConfigHelper } from 'nhs-notify-web-template-management-util-backend-config'; import { createAuthHelper } from '../../helpers/auth/cognito-auth-helper'; +import { EventSubscriber } from '../../helpers/events/event-subscriber'; setup('event test setup', async () => { const backendConfig = BackendConfigHelper.fromTerraformOutputsFile( @@ -11,4 +12,11 @@ setup('event test setup', async () => { BackendConfigHelper.toEnv(backendConfig); await createAuthHelper().setup(); + + // Cleanup stale SQS queues and SNS subscriptions from previous test runs + await EventSubscriber.cleanup( + 'event', + backendConfig.environment, + backendConfig.eventsSnsTopicArn + ); }); diff --git a/tests/test-team/fixtures/event-subscriber.fixture.ts b/tests/test-team/fixtures/event-subscriber.fixture.ts new file mode 100644 index 000000000..5c10313c7 --- /dev/null +++ b/tests/test-team/fixtures/event-subscriber.fixture.ts @@ -0,0 +1,35 @@ +import { test as base } from '@playwright/test'; +import { EventSubscriber } from '../helpers/events/event-subscriber'; + +type EventSubscriberFixture = { + eventSubscriber: EventSubscriber; +}; + +export const testWithEventSubscriber = base.extend< + object, + EventSubscriberFixture +>({ + eventSubscriber: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, workerInfo) => { + const eventSource = `//notify.nhs.uk/${process.env.COMPONENT}/${process.env.GROUP}/${process.env.ENVIRONMENT}`; + + const subscriber = new EventSubscriber( + process.env.EVENTS_SNS_TOPIC_ARN, + process.env.AWS_ACCOUNT_ID, + process.env.ENVIRONMENT, + 'event', + 'config-publishing', + eventSource, + workerInfo.workerIndex + ); + + await subscriber.initialise(); + + await use(subscriber).finally(() => subscriber.teardown()); + }, + { scope: 'worker' }, + ], +}); + +export const { expect } = testWithEventSubscriber; diff --git a/tests/test-team/global.d.ts b/tests/test-team/global.d.ts index bbfd7c3eb..051b8e41a 100644 --- a/tests/test-team/global.d.ts +++ b/tests/test-team/global.d.ts @@ -2,10 +2,15 @@ declare global { namespace NodeJS { interface ProcessEnv { API_BASE_URL: string; + AWS_ACCOUNT_ID: string; CLIENT_SSM_PATH_PREFIX: string; COGNITO_USER_POOL_CLIENT_ID: string; COGNITO_USER_POOL_ID: string; + COMPONENT: string; + ENVIRONMENT: string; EVENT_CACHE_BUCKET_NAME: string; + EVENTS_SNS_TOPIC_ARN: string; + GROUP: string; PLAYWRIGHT_RUN_ID: string; REQUEST_PROOF_QUEUE_URL: string; ROUTING_CONFIG_TABLE_NAME: string; diff --git a/event-subscriber.ts b/tests/test-team/helpers/events/event-subscriber.ts similarity index 84% rename from event-subscriber.ts rename to tests/test-team/helpers/events/event-subscriber.ts index 42761793f..42888f3b6 100644 --- a/event-subscriber.ts +++ b/tests/test-team/helpers/events/event-subscriber.ts @@ -5,16 +5,21 @@ import { ListQueuesCommand, Message, ReceiveMessageCommand, + SQSClient, } from '@aws-sdk/client-sqs'; import { ListSubscriptionsByTopicCommand, + SNSClient, SubscribeCommand, UnsubscribeCommand, } from '@aws-sdk/client-sns'; -import { snsClient, sqsClient } from '@comms/util-aws'; -import { sleep } from '@comms/util-retry'; import { ZodType } from 'zod'; +const sleep = (seconds: number) => + new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); + type Event = { sentTime: Date; record: T extends undefined ? Record : T; @@ -24,7 +29,7 @@ type Event = { Class instances must be created as worker-scoped playwright fixtures. Each worker owns its queue and subscription and runs tests in serial internally. Each fixture should only be used in a single suite. - The cleanup static method should be called in a suite's global setup + The cleanup static method should be called in a suite's global setup. This util assumes that the event JSON has a unique 'id' property. Use of non-unique IDs will lead to non-deterministic behaviour in tests. @@ -37,11 +42,11 @@ type Event = { */ export class EventSubscriber { - static readonly sns = snsClient; + static readonly sns = new SNSClient({ region: 'eu-west-2' }); - static readonly sqs = sqsClient; + static readonly sqs = new SQSClient({ region: 'eu-west-2' }); - static readonly rootNamespace = 'comms-e2e-es'; + static readonly rootNamespace = 'tm-e2e-es'; private readonly queueName: string; @@ -141,24 +146,25 @@ export class EventSubscriber { }); } - const filtered = Array.from(this.messages.values()).filter( - ({ sentTime, record }) => { - if (since && sentTime <= since) return false; - - if (match) { - return match.safeParse(record).success; - } + const filtered = [...this.messages.values()].filter(({ record }) => { + // Use the event's actual time field (CloudEvents format) for filtering + const eventTime = record.time ? new Date(record.time as string) : null; + if (since && eventTime && eventTime <= since) return false; - return true; + if (match) { + return match.safeParse(record).success; } - ) as Event[]; + + return true; + }) as Event[]; return filtered.sort((a, b) => a.sentTime.getTime() - b.sentTime.getTime()); } private trimCached(since: Date) { - for (const [id, { sentTime }] of this.messages) { - if (sentTime < since) { + for (const [id, { record }] of this.messages) { + const eventTime = record.time ? new Date(record.time as string) : null; + if (eventTime && eventTime < since) { this.messages.delete(id); } } @@ -208,20 +214,20 @@ export class EventSubscriber { }) ); break; - } catch (err) { + } catch (error) { if ( - err instanceof Error && - // err instancecof QueueDeletedRecently does not work as expected - (err.name === 'QueueDeletedRecently' || - ('Code' in err && - err.Code === 'AWS.SimpleQueueService.QueueDeletedRecently')) + error instanceof Error && + // error instanceof QueueDeletedRecently does not work as expected + (error.name === 'QueueDeletedRecently' || + ('Code' in error && + error.Code === 'AWS.SimpleQueueService.QueueDeletedRecently')) ) { console.log( `Queue deleted recently, retrying creation (attempt ${attempt})` ); await sleep(10); } else { - throw err; + throw error; } } } @@ -252,17 +258,14 @@ export class EventSubscriber { return subscription.SubscriptionArn; } - private static deleteQueue( - url: string, - { warn }: { warn?: boolean } = { warn: true } - ) { + private static deleteQueue(url: string, warn = true) { console.log(`Deleting queue with URL: ${url}`); return EventSubscriber.sqs .send(new DeleteQueueCommand({ QueueUrl: url })) - .catch((err) => { + .catch((error) => { if (warn) { - console.warn(`Failed to delete queue at ${url}: ${err}`); + console.warn(`Failed to delete queue at ${url}: ${error}`); } }); } @@ -272,8 +275,8 @@ export class EventSubscriber { return EventSubscriber.sns .send(new UnsubscribeCommand({ SubscriptionArn: arn })) - .catch((err) => { - console.warn(`Failed to delete subscription with ARN ${arn}: ${err}`); + .catch((error) => { + console.warn(`Failed to delete subscription with ARN ${arn}: ${error}`); }); } diff --git a/tests/test-team/package.json b/tests/test-team/package.json index caabff168..23b1ce22b 100644 --- a/tests/test-team/package.json +++ b/tests/test-team/package.json @@ -5,6 +5,7 @@ "@aws-sdk/client-eventbridge": "3.911.0", "@aws-sdk/client-lambda": "3.911.0", "@aws-sdk/client-s3": "3.911.0", + "@aws-sdk/client-sns": "^3.911.0", "@aws-sdk/client-sqs": "3.911.0", "@aws-sdk/client-ssm": "3.911.0", "@aws-sdk/lib-dynamodb": "3.911.0", diff --git a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts index 1fcc8f4a7..e120cda09 100644 --- a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts @@ -1,4 +1,8 @@ -import { test, expect } from '@playwright/test'; +import { z } from 'zod'; +import { + testWithEventSubscriber as test, + expect, +} from '../fixtures/event-subscriber.fixture'; import { createAuthHelper, type TestUser, @@ -6,14 +10,15 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; -import { EventCacheHelper } from '../helpers/events/event-cache-helper'; + +const eventWithDataId = (id: string) => + z.object({ data: z.object({ id: z.literal(id) }) }); const DIGITAL_CHANNELS = ['NHS_APP', 'SMS', 'EMAIL'] as const; test.describe('Event publishing - Digital', () => { const authHelper = createAuthHelper(); const templateStorageHelper = new TemplateStorageHelper(); - const eventCacheHelper = new EventCacheHelper(); let userRoutingEnabled: TestUser; let userRoutingDisabled: TestUser; @@ -31,6 +36,7 @@ test.describe('Event publishing - Digital', () => { test.describe(`${digitalChannel} template events`, () => { test('Expect Draft.v1 event When Creating And Updating templates And Completed.v1 event When submitting templates (routing disabled)', async ({ request, + eventSubscriber, }) => { const template = TemplateAPIPayloadFactory.getCreateTemplatePayload({ templateType: digitalChannel, @@ -55,7 +61,7 @@ test.describe('Event publishing - Digital', () => { } = await createResponse.json(); templateStorageHelper.addAdHocTemplateKey({ - templateId: templateId, + templateId, clientId: userRoutingDisabled.clientId, }); @@ -90,44 +96,54 @@ test.describe('Event publishing - Digital', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); expect(events).toHaveLength(3); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, - name, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + name, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, - name: 'UPDATED', + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + name: 'UPDATED', + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', - data: expect.objectContaining({ - id: templateId, - name: 'UPDATED', + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templateId, + name: 'UPDATED', + }), }), }) ); - }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); + }).toPass({ timeout: 60_000 }); }); test('Expect Deleted.v1 event When deleting templates', async ({ request, + eventSubscriber, }) => { const template = TemplateAPIPayloadFactory.getCreateTemplatePayload({ templateType: digitalChannel, @@ -152,11 +168,11 @@ test.describe('Event publishing - Digital', () => { } = await createResponse.json(); templateStorageHelper.addAdHocTemplateKey({ - templateId: templateId, + templateId, clientId: userRoutingEnabled.clientId, }); - const updateResponse = await request.delete( + const deleteResponse = await request.delete( `${process.env.API_BASE_URL}/v1/template/${templateId}`, { headers: { @@ -166,31 +182,38 @@ test.describe('Event publishing - Digital', () => { } ); - expect(updateResponse.status()).toBe(204); + expect(deleteResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); expect(events).toHaveLength(2); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); - }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); + }).toPass({ timeout: 60_000 }); }); }); } diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index bce8e5bf1..268cdc8e0 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -1,11 +1,14 @@ -import { test, expect } from '@playwright/test'; +import { z } from 'zod'; +import { + testWithEventSubscriber as test, + expect, +} from '../fixtures/event-subscriber.fixture'; import { createAuthHelper, type TestUser, testUsers, } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; -import { EventCacheHelper } from '../helpers/events/event-cache-helper'; import { randomUUID } from 'node:crypto'; import { TemplateFactory } from '../helpers/factories/template-factory'; import { readFileSync } from 'node:fs'; @@ -14,10 +17,15 @@ import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { setTimeout } from 'node:timers/promises'; import { Template } from 'helpers/types'; +const eventWithDataId = (id: string) => + z.object({ + type: z.string(), + data: z.object({ id: z.literal(id) }), + }); + test.describe('Event publishing - Letters', () => { const authHelper = createAuthHelper(); const templateStorageHelper = new TemplateStorageHelper(); - const eventCacheHelper = new EventCacheHelper(); const sftpHelper = new SftpHelper(); const lambdaClient = new LambdaClient({ region: 'eu-west-2' }); @@ -41,6 +49,7 @@ test.describe('Event publishing - Letters', () => { test('Expect no events when proofingEnabled is false', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -74,13 +83,17 @@ test.describe('Event publishing - Letters', () => { // 5 seconds seems to largest delay when testing locally await setTimeout(5000); - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); expect(events).toHaveLength(0); }); test('expect no events when deleting a letter when previous status is not publishable', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -112,13 +125,17 @@ test.describe('Event publishing - Letters', () => { // Note: not ideal - but we are expecting 0 events and there can be a delay await setTimeout(5000); - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); expect(events).toHaveLength(0); }); test('Expect Draft.v1 events When waiting for Proofs to become available And Completed.v1 event When submitting templates (routing disabled)', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -209,7 +226,10 @@ test.describe('Event publishing - Letters', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); // Note: This is weird, But sometimes the tests find all relevant events within // 6 events and can never find the 7th event before the test times out. @@ -232,17 +252,20 @@ test.describe('Event publishing - Letters', () => { const drafts = events.filter( (e) => - e.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' && - e.data.id === templateId + e.record.type === + 'uk.nhs.notify.template-management.TemplateDrafted.v1' && + e.record.data.id === templateId ); expect(drafts.length, JSON.stringify(events)).toBeGreaterThanOrEqual(5); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); @@ -253,6 +276,7 @@ test.describe('Event publishing - Letters', () => { test('Expect Draft event when routing is enabled and proof is approved', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -280,14 +304,18 @@ test.describe('Event publishing - Letters', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); expect(events).toHaveLength(2); const drafts = events.filter( (e) => - e.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' && - e.data.id === templateId + e.record.type === + 'uk.nhs.notify.template-management.TemplateDrafted.v1' && + e.record.data.id === templateId ); expect(drafts).toHaveLength(2); @@ -296,6 +324,7 @@ test.describe('Event publishing - Letters', () => { test('Expect Deleted.v1 event when deleting templates', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -328,24 +357,31 @@ test.describe('Event publishing - Letters', () => { expect(deletedResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(templateId), + }); expect(events).toHaveLength(2); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 7a31a4c90..70850ea1b 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -1,21 +1,29 @@ import { randomUUID } from 'node:crypto'; -import { test, expect } from '@playwright/test'; +import { z } from 'zod'; +import { + testWithEventSubscriber as test, + expect, +} from '../fixtures/event-subscriber.fixture'; import { createAuthHelper, type TestUser, testUsers, } from '../helpers/auth/cognito-auth-helper'; -import { EventCacheHelper } from '../helpers/events/event-cache-helper'; import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; import { TemplateFactory } from 'helpers/factories/template-factory'; import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; +const eventWithDataId = (id: string) => + z.object({ data: z.object({ id: z.literal(id) }) }); + +const eventWithDataIdIn = (ids: string[]) => + z.object({ data: z.object({ id: z.enum(ids as [string, ...string[]]) }) }); + test.describe('Event publishing - Routing Config', () => { const authHelper = createAuthHelper(); const storageHelper = new RoutingConfigStorageHelper(); const templateStorageHelper = new TemplateStorageHelper(); - const eventCacheHelper = new EventCacheHelper(); let user: TestUser; @@ -30,6 +38,7 @@ test.describe('Event publishing - Routing Config', () => { test('Expect a draft event and a deleted event when some template IDs are null', async ({ request, + eventSubscriber, }) => { const payload = RoutingConfigFactory.create(user, { cascade: [ @@ -78,22 +87,29 @@ test.describe('Event publishing - Routing Config', () => { expect(deleteResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [id]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(id), + }); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); @@ -102,7 +118,10 @@ test.describe('Event publishing - Routing Config', () => { }).toPass({ timeout: 60_000 }); }); - test('Expect a draft event and a deleted event', async ({ request }) => { + test('Expect a draft event and a deleted event', async ({ + request, + eventSubscriber, + }) => { const payload = RoutingConfigFactory.create(user, { cascade: [ { @@ -150,22 +169,29 @@ test.describe('Event publishing - Routing Config', () => { expect(deleteResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [id]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataId(id), + }); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); @@ -176,11 +202,12 @@ test.describe('Event publishing - Routing Config', () => { test('Expect routing config and template completed events on submit', async ({ request, + eventSubscriber, }) => { const nhsAppTemplateId = randomUUID(); const emailTemplateId = randomUUID(); - // NHS App template - NOT_YET_SUBMITTED, should trigger TemplateCompleted.v1 + // NHS App template - NOT_YET_SUBMITTED, should trigger TemplateCompleted.v1 on submit const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( nhsAppTemplateId, user, @@ -188,7 +215,7 @@ test.describe('Event publishing - Routing Config', () => { ); nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - // Email template - already SUBMITTED, should NOT trigger TemplateCompleted.v1 + // Email template - already SUBMITTED, should NOT trigger TemplateCompleted.v1 on submit const emailTemplate = TemplateFactory.createEmailTemplate( emailTemplateId, user, @@ -196,11 +223,22 @@ test.describe('Event publishing - Routing Config', () => { ); emailTemplate.templateStatus = 'SUBMITTED'; - await templateStorageHelper.seedTemplateData([nhsAppTemplate, emailTemplate]); + const seedStart = new Date(); + + await templateStorageHelper.seedTemplateData([ + nhsAppTemplate, + emailTemplate, + ]); - // Wait for DynamoDB stream events from seeding to be processed - // This ensures any events triggered by seeding have timestamps before 'start' - await new Promise((resolve) => setTimeout(resolve, 5000)); + // Wait for seeding events to be processed before proceeding. + // Seeding triggers: TemplateDrafted (NHS App) + TemplateCompleted (Email, since SUBMITTED) + await expect(async () => { + const seedEvents = await eventSubscriber.receive({ + since: seedStart, + match: eventWithDataIdIn([nhsAppTemplateId, emailTemplateId]), + }); + expect(seedEvents).toHaveLength(2); + }).toPass({ timeout: 60_000 }); const payload = RoutingConfigFactory.create(user, { cascade: [ @@ -254,28 +292,36 @@ test.describe('Event publishing - Routing Config', () => { expect(submitResponse.status()).toBe(200); + // After submit, expect: + // - RoutingConfigDrafted (from create) + // - RoutingConfigCompleted (from submit) + // - TemplateCompleted for NHS App (was NOT_YET_SUBMITTED, now SUBMITTED) + // - NO additional TemplateCompleted for Email (was already SUBMITTED) await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [ - id, - nhsAppTemplateId, - emailTemplateId, - ]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithDataIdIn([id, nhsAppTemplateId, emailTemplateId]), + }); // Routing config events expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); @@ -283,9 +329,11 @@ test.describe('Event publishing - Routing Config', () => { // Template completed events - NHS App (was NOT_YET_SUBMITTED) expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', - data: expect.objectContaining({ - id: nhsAppTemplateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: nhsAppTemplateId, + }), }), }) ); @@ -293,9 +341,11 @@ test.describe('Event publishing - Routing Config', () => { // Email template should NOT have a TemplateCompleted event (was already SUBMITTED) expect(events).not.toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', - data: expect.objectContaining({ - id: emailTemplateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: emailTemplateId, + }), }), }) ); diff --git a/utils/backend-config/src/backend-config.ts b/utils/backend-config/src/backend-config.ts index b7c95bcbb..4d2fd4258 100644 --- a/utils/backend-config/src/backend-config.ts +++ b/utils/backend-config/src/backend-config.ts @@ -4,8 +4,13 @@ import fs from 'node:fs'; export type BackendConfig = { apiBaseUrl: string; + awsAccountId: string; clientSsmPathPrefix: string; + component: string; + environment: string; eventCacheBucketName: string; + eventsSnsTopicArn: string; + group: string; requestProofQueueUrl: string; routingConfigTableName: string; sftpEnvironment: string; @@ -25,8 +30,13 @@ export const BackendConfigHelper = { fromEnv(): BackendConfig { return { apiBaseUrl: process.env.API_BASE_URL ?? '', + awsAccountId: process.env.AWS_ACCOUNT_ID ?? '', clientSsmPathPrefix: process.env.CLIENT_SSM_PATH_PREFIX ?? '', + component: process.env.COMPONENT ?? '', + environment: process.env.ENVIRONMENT ?? '', eventCacheBucketName: process.env.EVENT_CACHE_BUCKET_NAME ?? '', + eventsSnsTopicArn: process.env.EVENTS_SNS_TOPIC_ARN ?? '', + group: process.env.GROUP ?? '', requestProofQueueUrl: process.env.REQUEST_PROOF_QUEUE_URL ?? '', routingConfigTableName: process.env.ROUTING_CONFIG_TABLE_NAME ?? '', sftpEnvironment: process.env.SFTP_ENVIRONMENT ?? '', @@ -48,8 +58,13 @@ export const BackendConfigHelper = { toEnv(config: BackendConfig): void { process.env.API_BASE_URL = config.apiBaseUrl; + process.env.AWS_ACCOUNT_ID = config.awsAccountId; process.env.CLIENT_SSM_PATH_PREFIX = config.clientSsmPathPrefix; + process.env.COMPONENT = config.component; + process.env.ENVIRONMENT = config.environment; process.env.EVENT_CACHE_BUCKET_NAME = config.eventCacheBucketName; + process.env.EVENTS_SNS_TOPIC_ARN = config.eventsSnsTopicArn; + process.env.GROUP = config.group; process.env.COGNITO_USER_POOL_ID = config.userPoolId; process.env.COGNITO_USER_POOL_CLIENT_ID = config.userPoolClientId; process.env.TEMPLATES_TABLE_NAME = config.templatesTableName; @@ -70,13 +85,19 @@ export const BackendConfigHelper = { fromTerraformOutputsFile(filepath: string): BackendConfig { const outputsFileContent = JSON.parse(fs.readFileSync(filepath, 'utf8')); + const deployment = outputsFileContent.deployment?.value ?? {}; return { apiBaseUrl: outputsFileContent.api_base_url?.value ?? '', + awsAccountId: deployment.aws_account_id ?? '', clientSsmPathPrefix: outputsFileContent.client_ssm_path_prefix?.value ?? '', + component: deployment.component ?? '', + environment: deployment.environment ?? '', eventCacheBucketName: outputsFileContent.event_cache_bucket_name?.value ?? '', + eventsSnsTopicArn: outputsFileContent.events_sns_topic_arn?.value ?? '', + group: deployment.group ?? '', requestProofQueueUrl: outputsFileContent.request_proof_queue_url?.value ?? '', routingConfigTableName: From bebf3537c1377736c4210d030302b959532bbf7c Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 5 Feb 2026 18:16:14 +0000 Subject: [PATCH 11/30] convert all event tests --- tests/test-team/config/e2e/e2e.config.ts | 2 +- tests/test-team/config/event/event.setup.ts | 1 - .../helpers/events/event-cache-helper.ts | 147 ------------------ .../helpers/events/event-subscriber.ts | 1 - tests/test-team/helpers/events/matchers.ts | 7 + .../digital-templates.event.spec.ts | 9 +- .../letter-templates.event.spec.ts | 39 ++--- .../routing-config.event.spec.ts | 29 +--- 8 files changed, 37 insertions(+), 198 deletions(-) delete mode 100644 tests/test-team/helpers/events/event-cache-helper.ts create mode 100644 tests/test-team/helpers/events/matchers.ts diff --git a/tests/test-team/config/e2e/e2e.config.ts b/tests/test-team/config/e2e/e2e.config.ts index 13d9eaecf..1d3deaaf4 100644 --- a/tests/test-team/config/e2e/e2e.config.ts +++ b/tests/test-team/config/e2e/e2e.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ screenshot: 'only-on-failure', baseURL: 'http://localhost:3000', ...devices['Desktop Chrome'], - headless: false, + headless: true, storageState: path.resolve(__dirname, '../.auth/user.json'), }, dependencies: ['e2e:setup'], diff --git a/tests/test-team/config/event/event.setup.ts b/tests/test-team/config/event/event.setup.ts index ac4ccf093..1d3b9a0ca 100644 --- a/tests/test-team/config/event/event.setup.ts +++ b/tests/test-team/config/event/event.setup.ts @@ -13,7 +13,6 @@ setup('event test setup', async () => { await createAuthHelper().setup(); - // Cleanup stale SQS queues and SNS subscriptions from previous test runs await EventSubscriber.cleanup( 'event', backendConfig.environment, diff --git a/tests/test-team/helpers/events/event-cache-helper.ts b/tests/test-team/helpers/events/event-cache-helper.ts deleted file mode 100644 index cf1a64854..000000000 --- a/tests/test-team/helpers/events/event-cache-helper.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { z } from 'zod'; -import { SelectObjectContentEventStream } from '@aws-sdk/client-s3'; -import { - $RoutingConfigCompletedEventV1, - $RoutingConfigDeletedEventV1, - $RoutingConfigDraftedEventV1, - $TemplateCompletedEventV1, - $TemplateDeletedEventV1, - $TemplateDraftedEventV1, -} from '@nhsdigital/nhs-notify-event-schemas-template-management'; -import { - differenceInSeconds, - addHours, - startOfHour, - endOfHour, -} from 'date-fns'; -import { S3Helper } from '../s3-helper'; - -const $NHSNotifyEvent = z.discriminatedUnion('type', [ - $TemplateCompletedEventV1, - $TemplateDraftedEventV1, - $TemplateDeletedEventV1, - $RoutingConfigCompletedEventV1, - $RoutingConfigDraftedEventV1, - $RoutingConfigDeletedEventV1, -]); - -type NHSNotifyEvent = z.infer; - -export class EventCacheHelper { - private readonly bucketName = process.env.EVENT_CACHE_BUCKET_NAME; - - async findEvents(from: Date, ids: string[]): Promise { - if (ids.length === 0) { - return []; - } - - const files = await Promise.all( - this.buildFilePaths(from).map((path) => { - return S3Helper.listAll(this.bucketName, path); - }) - ); - - const filteredFiles = S3Helper.filterAndSort(files.flat(), from); - - const eventPromises = filteredFiles.map((file) => - this.queryFileForEvents(file.Key!, ids) - ); - - const results = await Promise.all(eventPromises); - - // Filter events by their 'time' field (CloudEvent timestamp) - // This ensures we only return events generated after the 'from' timestamp, - // excluding events from seeded data that may have been written before 'start' - return results.flat().filter((event) => { - const eventTime = new Date(event.time); - return eventTime >= from; - }); - } - - private async queryFileForEvents( - fileName: string, - ids: string[] - ): Promise { - const formattedIds = ids.map((r) => `'${r}'`); - - const response = await S3Helper.queryJSONLFile( - this.bucketName, - fileName, - `SELECT * FROM S3Object s WHERE s.data.id IN (${formattedIds})` - ); - - if (!response.Payload) { - return []; - } - - return await this.parse(fileName, response.Payload); - } - - private async parse( - fileName: string, - payload: AsyncIterable - ): Promise { - const events: NHSNotifyEvent[] = []; - - for await (const event of payload) { - if (!event.Records?.Payload) continue; - - const chunk = Buffer.from(event.Records.Payload).toString('utf8'); - - const parsedEvents = chunk - .split('\n') - .filter((line) => line.trim()) - .map((line) => { - const { data, success, error } = $NHSNotifyEvent.safeParse( - JSON.parse(line) - ); - - if (success) { - return data; - } - - throw new Error( - `Unrecognized event schema detected in S3 file: ${fileName}`, - { - cause: { error }, - } - ); - }); - - events.push(...parsedEvents); - } - - return events; - } - - /* - * Get files paths for the current hour - * and next hour if the difference in seconds is greater than toleranceInSeconds - * - * The way firehose stores files is yyyy/mm/dd/hh. - * On a boundary of 15:59:58 you'll find files in both 15 and 16 hour folders - */ - private buildFilePaths(start: Date, toleranceInSeconds = 30): string[] { - const paths = [this.buildPathPrefix(start)]; - - const end = addHours(startOfHour(start), 1); - - const difference = differenceInSeconds(endOfHour(start), start, { - roundingMethod: 'ceil', - }); - - if (toleranceInSeconds >= difference) { - paths.push(this.buildPathPrefix(end)); - } - - return paths; - } - - private buildPathPrefix(date: Date): string { - return date - .toISOString() - .slice(0, 13) - .replace('T', '/') - .replaceAll('-', '/'); - } -} diff --git a/tests/test-team/helpers/events/event-subscriber.ts b/tests/test-team/helpers/events/event-subscriber.ts index 42888f3b6..923bcbdb0 100644 --- a/tests/test-team/helpers/events/event-subscriber.ts +++ b/tests/test-team/helpers/events/event-subscriber.ts @@ -147,7 +147,6 @@ export class EventSubscriber { } const filtered = [...this.messages.values()].filter(({ record }) => { - // Use the event's actual time field (CloudEvents format) for filtering const eventTime = record.time ? new Date(record.time as string) : null; if (since && eventTime && eventTime <= since) return false; diff --git a/tests/test-team/helpers/events/matchers.ts b/tests/test-team/helpers/events/matchers.ts new file mode 100644 index 000000000..c31db36f5 --- /dev/null +++ b/tests/test-team/helpers/events/matchers.ts @@ -0,0 +1,7 @@ +import z from 'zod'; + +export const eventWithId = (id: string) => + z.object({ data: z.object({ id: z.literal(id) }) }); + +export const eventWithIdIn = (ids: [string, ...string[]]) => + z.object({ data: z.object({ id: z.enum(ids) }) }); diff --git a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts index e120cda09..ed22a2ded 100644 --- a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import { testWithEventSubscriber as test, expect, @@ -10,9 +9,7 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; - -const eventWithDataId = (id: string) => - z.object({ data: z.object({ id: z.literal(id) }) }); +import { eventWithId } from '../helpers/events/matchers'; const DIGITAL_CHANNELS = ['NHS_APP', 'SMS', 'EMAIL'] as const; @@ -98,7 +95,7 @@ test.describe('Event publishing - Digital', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: eventWithId(templateId), }); expect(events).toHaveLength(3); @@ -187,7 +184,7 @@ test.describe('Event publishing - Digital', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: eventWithId(templateId), }); expect(events).toHaveLength(2); diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 268cdc8e0..01cce6019 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import { testWithEventSubscriber as test, expect, @@ -16,12 +15,8 @@ import { SftpHelper } from '../helpers/sftp/sftp-helper'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { setTimeout } from 'node:timers/promises'; import { Template } from 'helpers/types'; - -const eventWithDataId = (id: string) => - z.object({ - type: z.string(), - data: z.object({ id: z.literal(id) }), - }); +import { eventWithId } from '../helpers/events/matchers'; +import z from 'zod'; test.describe('Event publishing - Letters', () => { const authHelper = createAuthHelper(); @@ -85,7 +80,7 @@ test.describe('Event publishing - Letters', () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: eventWithId(templateId), }); expect(events).toHaveLength(0); @@ -127,7 +122,7 @@ test.describe('Event publishing - Letters', () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: eventWithId(templateId), }); expect(events).toHaveLength(0); @@ -228,7 +223,12 @@ test.describe('Event publishing - Letters', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: z.object({ + data: z.object({ + type: z.string(), + id: z.literal(template.id), + }), + }), }); // Note: This is weird, But sometimes the tests find all relevant events within @@ -252,9 +252,8 @@ test.describe('Event publishing - Letters', () => { const drafts = events.filter( (e) => - e.record.type === - 'uk.nhs.notify.template-management.TemplateDrafted.v1' && - e.record.data.id === templateId + e.record.data.type === + 'uk.nhs.notify.template-management.TemplateDrafted.v1' ); expect(drafts.length, JSON.stringify(events)).toBeGreaterThanOrEqual(5); @@ -306,16 +305,20 @@ test.describe('Event publishing - Letters', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: z.object({ + data: z.object({ + type: z.string(), + id: z.literal(template.id), + }), + }), }); expect(events).toHaveLength(2); const drafts = events.filter( (e) => - e.record.type === - 'uk.nhs.notify.template-management.TemplateDrafted.v1' && - e.record.data.id === templateId + e.record.data.type === + 'uk.nhs.notify.template-management.TemplateDrafted.v1' ); expect(drafts).toHaveLength(2); @@ -359,7 +362,7 @@ test.describe('Event publishing - Letters', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(templateId), + match: eventWithId(templateId), }); expect(events).toHaveLength(2); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 70850ea1b..8830f26ce 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; import { testWithEventSubscriber as test, expect, @@ -13,12 +12,7 @@ import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-he import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; import { TemplateFactory } from 'helpers/factories/template-factory'; import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; - -const eventWithDataId = (id: string) => - z.object({ data: z.object({ id: z.literal(id) }) }); - -const eventWithDataIdIn = (ids: string[]) => - z.object({ data: z.object({ id: z.enum(ids as [string, ...string[]]) }) }); +import { eventWithId, eventWithIdIn } from '../helpers/events/matchers'; test.describe('Event publishing - Routing Config', () => { const authHelper = createAuthHelper(); @@ -89,7 +83,7 @@ test.describe('Event publishing - Routing Config', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(id), + match: eventWithId(id), }); expect(events).toContainEqual( @@ -171,7 +165,7 @@ test.describe('Event publishing - Routing Config', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataId(id), + match: eventWithId(id), }); expect(events).toContainEqual( @@ -207,7 +201,6 @@ test.describe('Event publishing - Routing Config', () => { const nhsAppTemplateId = randomUUID(); const emailTemplateId = randomUUID(); - // NHS App template - NOT_YET_SUBMITTED, should trigger TemplateCompleted.v1 on submit const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( nhsAppTemplateId, user, @@ -215,7 +208,6 @@ test.describe('Event publishing - Routing Config', () => { ); nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - // Email template - already SUBMITTED, should NOT trigger TemplateCompleted.v1 on submit const emailTemplate = TemplateFactory.createEmailTemplate( emailTemplateId, user, @@ -230,12 +222,10 @@ test.describe('Event publishing - Routing Config', () => { emailTemplate, ]); - // Wait for seeding events to be processed before proceeding. - // Seeding triggers: TemplateDrafted (NHS App) + TemplateCompleted (Email, since SUBMITTED) await expect(async () => { const seedEvents = await eventSubscriber.receive({ since: seedStart, - match: eventWithDataIdIn([nhsAppTemplateId, emailTemplateId]), + match: eventWithIdIn([nhsAppTemplateId, emailTemplateId]), }); expect(seedEvents).toHaveLength(2); }).toPass({ timeout: 60_000 }); @@ -292,18 +282,12 @@ test.describe('Event publishing - Routing Config', () => { expect(submitResponse.status()).toBe(200); - // After submit, expect: - // - RoutingConfigDrafted (from create) - // - RoutingConfigCompleted (from submit) - // - TemplateCompleted for NHS App (was NOT_YET_SUBMITTED, now SUBMITTED) - // - NO additional TemplateCompleted for Email (was already SUBMITTED) await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithDataIdIn([id, nhsAppTemplateId, emailTemplateId]), + match: eventWithIdIn([id, nhsAppTemplateId, emailTemplateId]), }); - // Routing config events expect(events).toContainEqual( expect.objectContaining({ record: expect.objectContaining({ @@ -326,7 +310,6 @@ test.describe('Event publishing - Routing Config', () => { }) ); - // Template completed events - NHS App (was NOT_YET_SUBMITTED) expect(events).toContainEqual( expect.objectContaining({ record: expect.objectContaining({ @@ -338,7 +321,6 @@ test.describe('Event publishing - Routing Config', () => { }) ); - // Email template should NOT have a TemplateCompleted event (was already SUBMITTED) expect(events).not.toContainEqual( expect.objectContaining({ record: expect.objectContaining({ @@ -350,7 +332,6 @@ test.describe('Event publishing - Routing Config', () => { }) ); - // Total: 2 routing config events + 1 template completed event = 3 expect(events).toHaveLength(3); }).toPass({ timeout: 60_000 }); }); From 96b130f7435337b5fc2aac9b8ea6204edfbb1194 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 5 Feb 2026 18:23:10 +0000 Subject: [PATCH 12/30] rm sleep fn --- tests/test-team/helpers/events/event-subscriber.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test-team/helpers/events/event-subscriber.ts b/tests/test-team/helpers/events/event-subscriber.ts index 923bcbdb0..0c2ed1037 100644 --- a/tests/test-team/helpers/events/event-subscriber.ts +++ b/tests/test-team/helpers/events/event-subscriber.ts @@ -7,6 +7,7 @@ import { ReceiveMessageCommand, SQSClient, } from '@aws-sdk/client-sqs'; +import { setTimeout } from 'node:timers/promises'; import { ListSubscriptionsByTopicCommand, SNSClient, @@ -15,11 +16,6 @@ import { } from '@aws-sdk/client-sns'; import { ZodType } from 'zod'; -const sleep = (seconds: number) => - new Promise((resolve) => { - setTimeout(resolve, seconds * 1000); - }); - type Event = { sentTime: Date; record: T extends undefined ? Record : T; @@ -224,7 +220,7 @@ export class EventSubscriber { console.log( `Queue deleted recently, retrying creation (attempt ${attempt})` ); - await sleep(10); + await setTimeout(10_000); } else { throw error; } From 52b90619d07fa5603ce9268af0f7291d8a0fa0f4 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 06:58:17 +0000 Subject: [PATCH 13/30] renaming --- ...=> template-management-event-subscriber.ts} | 4 ++-- .../digital-templates.event.spec.ts | 4 ++-- .../letter-templates.event.spec.ts | 18 ++++++++---------- .../routing-config.event.spec.ts | 4 ++-- 4 files changed, 14 insertions(+), 16 deletions(-) rename tests/test-team/fixtures/{event-subscriber.fixture.ts => template-management-event-subscriber.ts} (87%) diff --git a/tests/test-team/fixtures/event-subscriber.fixture.ts b/tests/test-team/fixtures/template-management-event-subscriber.ts similarity index 87% rename from tests/test-team/fixtures/event-subscriber.fixture.ts rename to tests/test-team/fixtures/template-management-event-subscriber.ts index 5c10313c7..d982d691b 100644 --- a/tests/test-team/fixtures/event-subscriber.fixture.ts +++ b/tests/test-team/fixtures/template-management-event-subscriber.ts @@ -5,7 +5,7 @@ type EventSubscriberFixture = { eventSubscriber: EventSubscriber; }; -export const testWithEventSubscriber = base.extend< +export const templateManagementEventSubscriber = base.extend< object, EventSubscriberFixture >({ @@ -32,4 +32,4 @@ export const testWithEventSubscriber = base.extend< ], }); -export const { expect } = testWithEventSubscriber; +export const { expect } = templateManagementEventSubscriber; diff --git a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts index ed22a2ded..cb0956d45 100644 --- a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts @@ -1,7 +1,7 @@ import { - testWithEventSubscriber as test, + templateManagementEventSubscriber as test, expect, -} from '../fixtures/event-subscriber.fixture'; +} from '../fixtures/template-management-event-subscriber'; import { createAuthHelper, type TestUser, diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 01cce6019..1920c647d 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -1,7 +1,7 @@ import { - testWithEventSubscriber as test, + templateManagementEventSubscriber as test, expect, -} from '../fixtures/event-subscriber.fixture'; +} from '../fixtures/template-management-event-subscriber'; import { createAuthHelper, type TestUser, @@ -224,8 +224,8 @@ test.describe('Event publishing - Letters', () => { const events = await eventSubscriber.receive({ since: start, match: z.object({ + type: z.string(), data: z.object({ - type: z.string(), id: z.literal(template.id), }), }), @@ -251,9 +251,8 @@ test.describe('Event publishing - Letters', () => { expect(events.length).toBeLessThanOrEqual(7); const drafts = events.filter( - (e) => - e.record.data.type === - 'uk.nhs.notify.template-management.TemplateDrafted.v1' + ({ record }) => + record.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' ); expect(drafts.length, JSON.stringify(events)).toBeGreaterThanOrEqual(5); @@ -306,8 +305,8 @@ test.describe('Event publishing - Letters', () => { const events = await eventSubscriber.receive({ since: start, match: z.object({ + type: z.string(), data: z.object({ - type: z.string(), id: z.literal(template.id), }), }), @@ -316,9 +315,8 @@ test.describe('Event publishing - Letters', () => { expect(events).toHaveLength(2); const drafts = events.filter( - (e) => - e.record.data.type === - 'uk.nhs.notify.template-management.TemplateDrafted.v1' + ({ record }) => + record.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' ); expect(drafts).toHaveLength(2); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 8830f26ce..77a50392a 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'node:crypto'; import { - testWithEventSubscriber as test, + templateManagementEventSubscriber as test, expect, -} from '../fixtures/event-subscriber.fixture'; +} from '../fixtures/template-management-event-subscriber'; import { createAuthHelper, type TestUser, From acdf2d18369d246d3d3ce5a8bf634ece0b4d6ef2 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 08:56:05 +0000 Subject: [PATCH 14/30] reviw page object --- .../__snapshots__/page.test.tsx.snap | 6 ++- .../__snapshots__/page.test.tsx.snap | 6 ++- .../MessagePlanCascadePreview.tsx | 3 +- .../components/sandbox/module_eventpub.tf | 1 - .../helpers/events/event-subscriber.ts | 22 ++++---- .../review-and-move-to-production-page.ts | 8 ++- .../routing.e2e.spec.ts | 52 +++++++++---------- ...ve-to-production.routing-component.spec.ts | 11 ++-- 8 files changed, 64 insertions(+), 45 deletions(-) diff --git a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap index 7faa73e59..8595e8c2f 100644 --- a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap @@ -713,7 +713,9 @@ exports[`full cascade plan matches snapshot 1`] = ` > Large print letter (optional) -

+

@@ -741,6 +743,7 @@ exports[`full cascade plan matches snapshot 1`] = `

Large print letter (optional) -

+

@@ -692,6 +694,7 @@ exports[`Review and move to production page matches snapshot for full cascade 1`

-

+

{ - const eventTime = record.time ? new Date(record.time as string) : null; - if (since && eventTime && eventTime <= since) return false; + const filtered = [...this.messages.values()].filter( + ({ sentTime, record }) => { + if (since && sentTime <= since) return false; - if (match) { - return match.safeParse(record).success; - } + if (match) { + return match.safeParse(record).success; + } - return true; - }) as Event[]; + return true; + } + ) as Event[]; return filtered.sort((a, b) => a.sentTime.getTime() - b.sentTime.getTime()); } private trimCached(since: Date) { - for (const [id, { record }] of this.messages) { - const eventTime = record.time ? new Date(record.time as string) : null; - if (eventTime && eventTime < since) { + for (const [id, { sentTime }] of this.messages) { + if (sentTime < since) { this.messages.delete(id); } } diff --git a/tests/test-team/pages/routing/review-and-move-to-production-page.ts b/tests/test-team/pages/routing/review-and-move-to-production-page.ts index a38437e7a..58acba98e 100644 --- a/tests/test-team/pages/routing/review-and-move-to-production-page.ts +++ b/tests/test-team/pages/routing/review-and-move-to-production-page.ts @@ -45,9 +45,15 @@ export class RoutingReviewAndMoveToProductionPage extends TemplateMgmtBasePage { ); }, getLanguagesCard: () => { - return this.getCard( + const { templateName, templateLink, ...card } = this.getCard( conditionalTemplates.getByTestId('conditional-template-languages') ); + + return { + ...card, + templateNames: templateName, + templateLinks: templateLink, + }; }, }; } diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts index b59b45363..3036fe1f9 100644 --- a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -23,13 +23,12 @@ import { } from '../pages/routing'; import { TemplateMgmtMessageTemplatesPage } from '../pages/template-mgmt-message-templates-page'; import { TemplateMgmtChooseTemplateForMessagePlanBasePage } from '../pages/template-mgmt-choose-template-base-page'; +import type { Template } from '../helpers/types'; import type { Channel } from 'nhs-notify-backend-client'; const templateStorageHelper = new TemplateStorageHelper(); -type Template = { id: string; name: string }; - -async function selectTemplate( +async function selectTemplateRadio( chooseTemplateLink: Locator, chooseTemplatePage: TemplateMgmtChooseTemplateForMessagePlanBasePage, template: Template, @@ -62,20 +61,6 @@ async function assertTemplateStatuses( }); } -async function assertReviewPageTemplates( - reviewPage: RoutingReviewAndMoveToProductionPage, - expectations: Array<{ channel: Channel; template: Template }> -) { - return test.step('assert review page template names', async () => { - for (const { channel, template } of expectations) { - await expect( - reviewPage.getTemplateBlock(channel).defaultTemplateCard.templateName, - `Expected ${channel} template to be "${template.name}"` - ).toHaveText(template.name); - } - }); -} - async function assertMessagePlanInTable( table: Locator, messagePlanName: string @@ -271,14 +256,14 @@ test.describe('Routing', () => { }); await test.step('add NHS App and Email templates', async () => { - await selectTemplate( + await selectTemplateRadio( chooseTemplatesPage.nhsApp.chooseTemplateLink, new RoutingChooseNhsAppTemplatePage(page), templates.NHSAPP, chooseTemplatesPage.nhsApp.templateName ); - await selectTemplate( + await selectTemplateRadio( chooseTemplatesPage.email.chooseTemplateLink, new RoutingChooseEmailTemplatePage(page), templates.EMAIL, @@ -327,7 +312,7 @@ test.describe('Routing', () => { }); await test.step('add standard letter template', async () => { - await selectTemplate( + await selectTemplateRadio( chooseTemplatesPage.letter.standard.chooseTemplateLink, new RoutingChooseStandardLetterTemplatePage(page), templates.LETTER, @@ -348,12 +333,27 @@ test.describe('Routing', () => { await expect(reviewPage.pageHeading).toBeVisible(); - await assertReviewPageTemplates(reviewPage, [ - { channel: 'NHSAPP', template: templates.NHSAPP }, - { channel: 'EMAIL', template: templates.EMAIL }, - { channel: 'SMS', template: templates.SMS }, - { channel: 'LETTER', template: templates.LETTER }, - ]); + const defaults: [Channel, Template][] = [ + ['NHSAPP', templates.NHSAPP], + ['EMAIL', templates.EMAIL], + ['SMS', templates.SMS], + ['LETTER', templates.LETTER], + ]; + + for (const [channel, defaultTemplate] of defaults) { + await expect( + reviewPage.getTemplateBlock(channel).defaultTemplateCard.templateName + ).toHaveText(defaultTemplate.name); + } + + const languageTemplateNames = await reviewPage + .getTemplateBlock('LETTER') + .getLanguagesCard() + .templateNames.allTextContents(); + + expect(languageTemplateNames).toHaveLength(2); + expect(languageTemplateNames).toContain(templates.ARABIC_LETTER.name); + expect(languageTemplateNames).toContain(templates.POLISH_LETTER.name); await reviewPage.moveToProductionButton.click(); }); diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts index b0c5b1d2c..0ba8c4125 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts @@ -291,15 +291,20 @@ test.describe('Routing - Review and Move to Production page', () => { `/templates/preview-submitted-letter-template/${templates.LARGE_PRINT_LETTER.id}` ); + const languagesCard = templateBlock.getLanguagesCard(); + const languageNames = await languagesCard.templateName.all(); + const languageLinks = await languagesCard.templateLink.all(); + for (const [index, language] of ( ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< typeof createTemplates >)[] ).entries()) { - const links = await templateBlock.getLanguagesCard().templateLink.all(); - await expect(links[index]).toHaveText(templates[language].name); + await expect(languageNames[index]).toHaveText( + templates[language].name + ); - await expect(links[index]).toHaveAttribute( + await expect(languageLinks[index]).toHaveAttribute( 'href', `/templates/preview-submitted-letter-template/${templates[language].id}` ); From 42a398e9b09017695f18a7fcd7877360779dce98 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 09:11:45 +0000 Subject: [PATCH 15/30] reviw page object --- .../routing.e2e.spec.ts | 46 +++++++++++++++++-- ...ve-to-production.routing-component.spec.ts | 4 +- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts index 3036fe1f9..0008e75a7 100644 --- a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -18,6 +18,7 @@ import { RoutingChooseTextMessageTemplatePage, RoutingPreviewSmsTemplatePage, RoutingChooseStandardLetterTemplatePage, + RoutingChooseLargePrintLetterTemplatePage, RoutingGetReadyToMovePage, RoutingReviewAndMoveToProductionPage, } from '../pages/routing'; @@ -82,6 +83,7 @@ function createTemplates(user: TestUser) { EMAIL: randomUUID(), SMS: randomUUID(), LETTER: randomUUID(), + LARGE_PRINT_LETTER: randomUUID(), ARABIC_LETTER: randomUUID(), POLISH_LETTER: randomUUID(), }; @@ -111,6 +113,13 @@ function createTemplates(user: TestUser) { `E2E Letter template - ${templateIds.LETTER}`, 'PROOF_APPROVED' ), + LARGE_PRINT_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LARGE_PRINT_LETTER, + user, + `E2E Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, + 'PROOF_APPROVED', + { letterType: 'x1' } + ), ARABIC_LETTER: TemplateFactory.createAuthoringLetterTemplate( templateIds.ARABIC_LETTER, user, @@ -163,6 +172,10 @@ test.describe('Routing', () => { { template: templates.EMAIL, expectedStatus: 'Draft' }, { template: templates.SMS, expectedStatus: 'Draft' }, { template: templates.LETTER, expectedStatus: 'Proof approved' }, + { + template: templates.LARGE_PRINT_LETTER, + expectedStatus: 'Proof approved', + }, { template: templates.ARABIC_LETTER, expectedStatus: 'Proof approved' }, ]); }); @@ -320,6 +333,23 @@ test.describe('Routing', () => { ); }); + await test.step('add large print letter template', async () => { + await selectTemplateRadio( + chooseTemplatesPage.letter.largePrint.chooseTemplateLink, + new RoutingChooseLargePrintLetterTemplatePage(page), + templates.LARGE_PRINT_LETTER, + chooseTemplatesPage.letter.largePrint.templateName + ); + }); + + await test.step('remove large print letter template', async () => { + await chooseTemplatesPage.letter.largePrint.removeTemplateLink.click(); + + await expect( + chooseTemplatesPage.letter.largePrint.chooseTemplateLink + ).toBeVisible(); + }); + await test.step('review and move to production', async () => { await chooseTemplatesPage.clickMoveToProduction(); @@ -346,8 +376,14 @@ test.describe('Routing', () => { ).toHaveText(defaultTemplate.name); } - const languageTemplateNames = await reviewPage - .getTemplateBlock('LETTER') + const letterBlock = reviewPage.getTemplateBlock('LETTER'); + + // this template was removed + await expect( + letterBlock.getAccessibilityFormatCard('x1').locator + ).toBeHidden(); + + const languageTemplateNames = await letterBlock .getLanguagesCard() .templateNames.allTextContents(); @@ -367,7 +403,7 @@ test.describe('Routing', () => { ); }); - await test.step('verify all templates are locked', async () => { + await test.step('verify all templates are locked (except removed large print letter)', async () => { await messagePlansPage.clickTemplatesHeaderLink(); await expect(messageTemplatesPage.pageHeading).toBeVisible(); @@ -379,6 +415,10 @@ test.describe('Routing', () => { { template: templates.LETTER, expectedStatus: 'Locked' }, { template: templates.ARABIC_LETTER, expectedStatus: 'Locked' }, { template: templates.POLISH_LETTER, expectedStatus: 'Locked' }, + { + template: templates.LARGE_PRINT_LETTER, + expectedStatus: 'Proof approved', + }, ]); }); }); diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts index 0ba8c4125..e4b537d52 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts @@ -292,8 +292,8 @@ test.describe('Routing - Review and Move to Production page', () => { ); const languagesCard = templateBlock.getLanguagesCard(); - const languageNames = await languagesCard.templateName.all(); - const languageLinks = await languagesCard.templateLink.all(); + const languageNames = await languagesCard.templateNames.all(); + const languageLinks = await languagesCard.templateLinks.all(); for (const [index, language] of ( ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< From 65028a4e18bebbbdadedfefc76968a3b6ba1c74b Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 12:19:59 +0000 Subject: [PATCH 16/30] wait for seeded --- .../helpers/events/event-subscriber.ts | 44 ++++--- .../digital-templates.event.spec.ts | 4 +- .../letter-templates.event.spec.ts | 6 +- .../routing-config.event.spec.ts | 119 +++++++++++------- 4 files changed, 106 insertions(+), 67 deletions(-) diff --git a/tests/test-team/helpers/events/event-subscriber.ts b/tests/test-team/helpers/events/event-subscriber.ts index deac27b1c..229c77361 100644 --- a/tests/test-team/helpers/events/event-subscriber.ts +++ b/tests/test-team/helpers/events/event-subscriber.ts @@ -17,7 +17,7 @@ import { import { ZodType } from 'zod'; type Event = { - sentTime: Date; + time: Date; record: T extends undefined ? Record : T; }; @@ -54,7 +54,7 @@ export class EventSubscriber { private messages = new Map< string, - { record: Record; sentTime: Date } + { record: Record; time: Date } >(); constructor( @@ -95,7 +95,6 @@ export class EventSubscriber { new ReceiveMessageCommand({ QueueUrl: this.queueUrl, MaxNumberOfMessages: 10, - MessageSystemAttributeNames: ['SentTimestamp'], }) ); @@ -116,9 +115,8 @@ export class EventSubscriber { received.push(...polled); } while (polledCount > 0); - const parsed = received.flatMap(({ Body, Attributes }) => { - if (Body && Attributes?.SentTimestamp) { - const sentTime = new Date(Number(Attributes.SentTimestamp)); + const parsed = received.flatMap(({ Body }) => { + if (Body) { const snsEvent = JSON.parse(Body); const record = JSON.parse(snsEvent.Message); @@ -129,7 +127,15 @@ export class EventSubscriber { throw new Error('Event record is missing id field'); } - return [{ sentTime, record, envelopeId }]; + const eventTime = record.time; + + if (!eventTime) { + throw new Error('Event record is missing time field'); + } + + const time = new Date(eventTime); + + return [{ time, record, envelopeId }]; } return []; @@ -138,28 +144,26 @@ export class EventSubscriber { for (const event of parsed) { this.messages.set(event.envelopeId, { record: event.record, - sentTime: event.sentTime, + time: event.time, }); } - const filtered = [...this.messages.values()].filter( - ({ sentTime, record }) => { - if (since && sentTime <= since) return false; + const filtered = [...this.messages.values()].filter(({ time, record }) => { + if (since && time <= since) return false; - if (match) { - return match.safeParse(record).success; - } - - return true; + if (match) { + return match.safeParse(record).success; } - ) as Event[]; - return filtered.sort((a, b) => a.sentTime.getTime() - b.sentTime.getTime()); + return true; + }) as Event[]; + + return filtered.sort((a, b) => a.time.getTime() - b.time.getTime()); } private trimCached(since: Date) { - for (const [id, { sentTime }] of this.messages) { - if (sentTime < since) { + for (const [id, { time }] of this.messages) { + if (time < since) { this.messages.delete(id); } } diff --git a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts index cb0956d45..66560dd64 100644 --- a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts @@ -135,7 +135,7 @@ test.describe('Event publishing - Digital', () => { }), }) ); - }).toPass({ timeout: 60_000 }); + }).toPass(); }); test('Expect Deleted.v1 event When deleting templates', async ({ @@ -210,7 +210,7 @@ test.describe('Event publishing - Digital', () => { }), }) ); - }).toPass({ timeout: 60_000 }); + }).toPass(); }); }); } diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 1920c647d..7cbfac502 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -269,7 +269,7 @@ test.describe('Event publishing - Letters', () => { ); console.log(`Events found: ${events.length}. Expected: 6 or 7`); - }).toPass({ timeout: 90_000, intervals: [1000, 3000, 5000] }); + }).toPass({ intervals: [1000, 3000, 5000] }); }); test('Expect Draft event when routing is enabled and proof is approved', async ({ @@ -320,7 +320,7 @@ test.describe('Event publishing - Letters', () => { ); expect(drafts).toHaveLength(2); - }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); + }).toPass({ intervals: [1000, 3000, 5000] }); }); test('Expect Deleted.v1 event when deleting templates', async ({ @@ -386,6 +386,6 @@ test.describe('Event publishing - Letters', () => { }), }) ); - }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); + }).toPass({ intervals: [1000, 3000, 5000] }); }); }); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 77a50392a..85b73634c 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -14,9 +14,38 @@ import { TemplateFactory } from 'helpers/factories/template-factory'; import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; import { eventWithId, eventWithIdIn } from '../helpers/events/matchers'; +function createTemplates(user: TestUser) { + const templateIds = { + NHSAPP: randomUUID(), + EMAIL: randomUUID(), + LETTER: randomUUID(), + }; + + return { + NHSAPP: TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + `Event NHS App Template - ${templateIds.NHSAPP}`, + 'NOT_YET_SUBMITTED' + ), + EMAIL: TemplateFactory.createEmailTemplate( + templateIds.EMAIL, + user, + `Event Email Template - ${templateIds.EMAIL}`, + 'SUBMITTED' + ), + LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LETTER, + user, + `Event Letter Template - ${templateIds.LETTER}`, + 'PROOF_APPROVED' + ), + }; +} + test.describe('Event publishing - Routing Config', () => { const authHelper = createAuthHelper(); - const storageHelper = new RoutingConfigStorageHelper(); + const routingConfigStorageHelper = new RoutingConfigStorageHelper(); const templateStorageHelper = new TemplateStorageHelper(); let user: TestUser; @@ -26,7 +55,7 @@ test.describe('Event publishing - Routing Config', () => { }); test.afterAll(async () => { - await storageHelper.deleteSeeded(); + await routingConfigStorageHelper.deleteSeeded(); await templateStorageHelper.deleteSeededTemplates(); }); @@ -63,7 +92,7 @@ test.describe('Event publishing - Routing Config', () => { data: { id, lockNumber }, } = await createResponse.json(); - storageHelper.addAdHocKey({ + routingConfigStorageHelper.addAdHocKey({ id, clientId: user.clientId, }); @@ -145,7 +174,7 @@ test.describe('Event publishing - Routing Config', () => { data: { id, lockNumber }, } = await createResponse.json(); - storageHelper.addAdHocKey({ + routingConfigStorageHelper.addAdHocKey({ id, clientId: user.clientId, }); @@ -198,57 +227,45 @@ test.describe('Event publishing - Routing Config', () => { request, eventSubscriber, }) => { - const nhsAppTemplateId = randomUUID(); - const emailTemplateId = randomUUID(); - - const nhsAppTemplate = TemplateFactory.createNhsAppTemplate( - nhsAppTemplateId, - user, - 'NHS App Template for Submit' - ); - nhsAppTemplate.templateStatus = 'NOT_YET_SUBMITTED'; - - const emailTemplate = TemplateFactory.createEmailTemplate( - emailTemplateId, - user, - 'Email Template for Submit' - ); - emailTemplate.templateStatus = 'SUBMITTED'; - + const templates = createTemplates(user); const seedStart = new Date(); + await templateStorageHelper.seedTemplateData(Object.values(templates)); - await templateStorageHelper.seedTemplateData([ - nhsAppTemplate, - emailTemplate, - ]); - + // Wait for seeding events to arrive before proceeding await expect(async () => { const seedEvents = await eventSubscriber.receive({ since: seedStart, - match: eventWithIdIn([nhsAppTemplateId, emailTemplateId]), + // Authoring letters don't produce events yet + match: eventWithIdIn([templates.NHSAPP.id, templates.EMAIL.id]), }); - expect(seedEvents).toHaveLength(2); + expect(seedEvents.length).toBe(2); }).toPass({ timeout: 60_000 }); + const start = new Date(); + const payload = RoutingConfigFactory.create(user, { cascade: [ { cascadeGroups: ['standard'], channel: 'NHSAPP', channelType: 'primary', - defaultTemplateId: nhsAppTemplateId, + defaultTemplateId: templates.NHSAPP.id, }, { cascadeGroups: ['standard'], channel: 'EMAIL', channelType: 'primary', - defaultTemplateId: emailTemplateId, + defaultTemplateId: templates.EMAIL.id, + }, + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: templates.LETTER.id, }, ], }).apiPayload; - const start = new Date(); - const createResponse = await request.post( `${process.env.API_BASE_URL}/v1/routing-configuration`, { @@ -262,16 +279,16 @@ test.describe('Event publishing - Routing Config', () => { expect(createResponse.status()).toBe(201); const { - data: { id, lockNumber }, + data: { id: routingConfigId, lockNumber }, } = await createResponse.json(); - storageHelper.addAdHocKey({ - id, + routingConfigStorageHelper.addAdHocKey({ + id: routingConfigId, clientId: user.clientId, }); const submitResponse = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${id}/submit`, + `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfigId}/submit`, { headers: { Authorization: await user.getAccessToken(), @@ -285,15 +302,22 @@ test.describe('Event publishing - Routing Config', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: eventWithIdIn([id, nhsAppTemplateId, emailTemplateId]), + match: eventWithIdIn([ + routingConfigId, + templates.EMAIL.id, + templates.NHSAPP.id, + templates.LETTER.id, + ]), }); + expect(events).toHaveLength(3); + expect(events).toContainEqual( expect.objectContaining({ record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', data: expect.objectContaining({ - id, + id: routingConfigId, }), }), }) @@ -304,7 +328,7 @@ test.describe('Event publishing - Routing Config', () => { record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', data: expect.objectContaining({ - id, + id: routingConfigId, }), }), }) @@ -315,24 +339,35 @@ test.describe('Event publishing - Routing Config', () => { record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', data: expect.objectContaining({ - id: nhsAppTemplateId, + id: templates.NHSAPP.id, }), }), }) ); + // This was already submitted expect(events).not.toContainEqual( expect.objectContaining({ record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', data: expect.objectContaining({ - id: emailTemplateId, + id: templates.EMAIL.id, }), }), }) ); - expect(events).toHaveLength(3); + // AUTHORING letters don't produce events yet + expect(events).not.toContainEqual( + expect.objectContaining({ + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templates.LETTER.id, + }), + }), + }) + ); }).toPass({ timeout: 60_000 }); }); }); From baad7a6c5ac92589569e66ca1a9898149fade9ce Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 13:10:32 +0000 Subject: [PATCH 17/30] fmt --- .../review-and-move-to-production.routing-component.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts index e4b537d52..5f469a617 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts @@ -300,9 +300,7 @@ test.describe('Routing - Review and Move to Production page', () => { typeof createTemplates >)[] ).entries()) { - await expect(languageNames[index]).toHaveText( - templates[language].name - ); + await expect(languageNames[index]).toHaveText(templates[language].name); await expect(languageLinks[index]).toHaveAttribute( 'href', From 1d2afebddf8dd34c5562f15bd556c3a0315be005 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 13:14:44 +0000 Subject: [PATCH 18/30] revert page object change --- .../pages/routing/review-and-move-to-production-page.ts | 8 +------- .../test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts | 2 +- ...eview-and-move-to-production.routing-component.spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/test-team/pages/routing/review-and-move-to-production-page.ts b/tests/test-team/pages/routing/review-and-move-to-production-page.ts index 58acba98e..a38437e7a 100644 --- a/tests/test-team/pages/routing/review-and-move-to-production-page.ts +++ b/tests/test-team/pages/routing/review-and-move-to-production-page.ts @@ -45,15 +45,9 @@ export class RoutingReviewAndMoveToProductionPage extends TemplateMgmtBasePage { ); }, getLanguagesCard: () => { - const { templateName, templateLink, ...card } = this.getCard( + return this.getCard( conditionalTemplates.getByTestId('conditional-template-languages') ); - - return { - ...card, - templateNames: templateName, - templateLinks: templateLink, - }; }, }; } diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts index 0008e75a7..886cd86e3 100644 --- a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -385,7 +385,7 @@ test.describe('Routing', () => { const languageTemplateNames = await letterBlock .getLanguagesCard() - .templateNames.allTextContents(); + .templateName.allTextContents(); expect(languageTemplateNames).toHaveLength(2); expect(languageTemplateNames).toContain(templates.ARABIC_LETTER.name); diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts index 5f469a617..12884316a 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts @@ -292,8 +292,8 @@ test.describe('Routing - Review and Move to Production page', () => { ); const languagesCard = templateBlock.getLanguagesCard(); - const languageNames = await languagesCard.templateNames.all(); - const languageLinks = await languagesCard.templateLinks.all(); + const languageNames = await languagesCard.templateName.all(); + const languageLinks = await languagesCard.templateLink.all(); for (const [index, language] of ( ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< From 523a9c7588efd50ae3826ea60396c9ccfe333ed6 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 13:17:02 +0000 Subject: [PATCH 19/30] restore custom timeouts --- .../template-mgmt-event-tests/letter-templates.event.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 7cbfac502..582f9defc 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -269,7 +269,7 @@ test.describe('Event publishing - Letters', () => { ); console.log(`Events found: ${events.length}. Expected: 6 or 7`); - }).toPass({ intervals: [1000, 3000, 5000] }); + }).toPass({ timeout: 90_000, intervals: [1000, 3000, 5000] }); }); test('Expect Draft event when routing is enabled and proof is approved', async ({ @@ -386,6 +386,6 @@ test.describe('Event publishing - Letters', () => { }), }) ); - }).toPass({ intervals: [1000, 3000, 5000] }); + }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); }); }); From 866265bf470483848071ee4d63119e4d87c33c5c Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 13:52:41 +0000 Subject: [PATCH 20/30] disable broken tests --- ...s => review-and-move-to-production.routing-component.spec.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/test-team/template-mgmt-routing-component-tests/{review-and-move-to-production.routing-component.spec.ts => review-and-move-to-production.routing-component.spec.txt} (100%) diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt similarity index 100% rename from tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts rename to tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt From 343875d3834c10ad45ae44b82d25d2e8000a29a5 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 13:56:06 +0000 Subject: [PATCH 21/30] disable broken tests --- ...iew-and-move-to-production.routing-component.spec.txt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt index 12884316a..b0c5b1d2c 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt @@ -291,18 +291,15 @@ test.describe('Routing - Review and Move to Production page', () => { `/templates/preview-submitted-letter-template/${templates.LARGE_PRINT_LETTER.id}` ); - const languagesCard = templateBlock.getLanguagesCard(); - const languageNames = await languagesCard.templateName.all(); - const languageLinks = await languagesCard.templateLink.all(); - for (const [index, language] of ( ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< typeof createTemplates >)[] ).entries()) { - await expect(languageNames[index]).toHaveText(templates[language].name); + const links = await templateBlock.getLanguagesCard().templateLink.all(); + await expect(links[index]).toHaveText(templates[language].name); - await expect(languageLinks[index]).toHaveAttribute( + await expect(links[index]).toHaveAttribute( 'href', `/templates/preview-submitted-letter-template/${templates[language].id}` ); From acb179f839cacb829fc1aa18d5f55dc5c514f5d9 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 14:17:47 +0000 Subject: [PATCH 22/30] reorder helpers --- .../routing.e2e.spec.ts | 101 +++++++++--------- .../digital-templates.event.spec.ts | 4 +- .../letter-templates.event.spec.ts | 2 +- 3 files changed, 55 insertions(+), 52 deletions(-) diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts index 886cd86e3..ee2e0a6de 100644 --- a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -29,54 +29,6 @@ import type { Channel } from 'nhs-notify-backend-client'; const templateStorageHelper = new TemplateStorageHelper(); -async function selectTemplateRadio( - chooseTemplateLink: Locator, - chooseTemplatePage: TemplateMgmtChooseTemplateForMessagePlanBasePage, - template: Template, - templateNameLocator: Locator -) { - return test.step(`select template: ${template.name}`, async () => { - await chooseTemplateLink.click(); - - const radio = chooseTemplatePage.getRadioButton(template.id); - - await radio.click(); - - await chooseTemplatePage.saveAndContinueButton.click(); - - await expect(templateNameLocator).toHaveText(template.name); - }); -} - -async function assertTemplateStatuses( - messageTemplatesPage: TemplateMgmtMessageTemplatesPage, - expectations: Array<{ template: Template; expectedStatus: string }> -) { - return test.step('assert template statuses', async () => { - for (const { template, expectedStatus } of expectations) { - expect( - await messageTemplatesPage.getTemplateStatus(template.id), - `Expected ${template.name} to have status "${expectedStatus}"` - ).toBe(expectedStatus); - } - }); -} - -async function assertMessagePlanInTable( - table: Locator, - messagePlanName: string -) { - return test.step(`assert message plan "${messagePlanName}" is in table`, async () => { - await table.click(); - - const row = table.getByRole('row', { name: messagePlanName }); - - await expect(row).toBeVisible(); - - await row.getByRole('link', { name: messagePlanName }).click(); - }); -} - function createTemplates(user: TestUser) { const templateIds = { NHSAPP: randomUUID(), @@ -137,6 +89,54 @@ function createTemplates(user: TestUser) { }; } +async function selectTemplateRadio( + chooseTemplateLink: Locator, + chooseTemplatePage: TemplateMgmtChooseTemplateForMessagePlanBasePage, + template: Template, + templateNameLocator: Locator +) { + return test.step(`select template: ${template.name}`, async () => { + await chooseTemplateLink.click(); + + const radio = chooseTemplatePage.getRadioButton(template.id); + + await radio.click(); + + await chooseTemplatePage.saveAndContinueButton.click(); + + await expect(templateNameLocator).toHaveText(template.name); + }); +} + +async function assertTemplateStatuses( + messageTemplatesPage: TemplateMgmtMessageTemplatesPage, + expectations: Array<{ template: Template; expectedStatus: string }> +) { + return test.step('assert template statuses', async () => { + for (const { template, expectedStatus } of expectations) { + expect( + await messageTemplatesPage.getTemplateStatus(template.id), + `Expected ${template.name} to have status "${expectedStatus}"` + ).toBe(expectedStatus); + } + }); +} + +async function assertMessagePlanInTable( + table: Locator, + messagePlanName: string +) { + return test.step(`assert message plan "${messagePlanName}" is in table`, async () => { + await table.click(); + + const row = table.getByRole('row', { name: messagePlanName }); + + await expect(row).toBeVisible(); + + await row.getByRole('link', { name: messagePlanName }).click(); + }); +} + test.describe('Routing', () => { let templates: ReturnType; let user: TestUser; @@ -268,14 +268,16 @@ test.describe('Routing', () => { ); }); - await test.step('add NHS App and Email templates', async () => { + await test.step('add NHS App template', async () => { await selectTemplateRadio( chooseTemplatesPage.nhsApp.chooseTemplateLink, new RoutingChooseNhsAppTemplatePage(page), templates.NHSAPP, chooseTemplatesPage.nhsApp.templateName ); + }); + await test.step('add Email template', async () => { await selectTemplateRadio( chooseTemplatesPage.email.chooseTemplateLink, new RoutingChooseEmailTemplatePage(page), @@ -417,6 +419,7 @@ test.describe('Routing', () => { { template: templates.POLISH_LETTER, expectedStatus: 'Locked' }, { template: templates.LARGE_PRINT_LETTER, + // this was removed before going to production expectedStatus: 'Proof approved', }, ]); diff --git a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts index 66560dd64..9684a4ec6 100644 --- a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts @@ -135,7 +135,7 @@ test.describe('Event publishing - Digital', () => { }), }) ); - }).toPass(); + }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); }); test('Expect Deleted.v1 event When deleting templates', async ({ @@ -210,7 +210,7 @@ test.describe('Event publishing - Digital', () => { }), }) ); - }).toPass(); + }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); }); }); } diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 582f9defc..1920c647d 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -320,7 +320,7 @@ test.describe('Event publishing - Letters', () => { ); expect(drafts).toHaveLength(2); - }).toPass({ intervals: [1000, 3000, 5000] }); + }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); }); test('Expect Deleted.v1 event when deleting templates', async ({ From eb2367b205a2fa29cc527f51b6bf301f8b524826 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 15:29:36 +0000 Subject: [PATCH 23/30] fix authoring letter template factory --- tests/test-team/helpers/factories/template-factory.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test-team/helpers/factories/template-factory.ts b/tests/test-team/helpers/factories/template-factory.ts index 23502cec7..123b46a6a 100644 --- a/tests/test-team/helpers/factories/template-factory.ts +++ b/tests/test-team/helpers/factories/template-factory.ts @@ -128,7 +128,6 @@ export const TemplateFactory = { owner: `CLIENT#${user.clientId}`, templateStatus, templateType: 'LETTER', - proofingEnabled: true, sidesCount: options?.sidesCount ?? 2, letterVariantId: options?.letterVariantId, }); From 932cea5dc2acbfa9b631faa3cc6e29de196d4e84 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 15:55:31 +0000 Subject: [PATCH 24/30] add type to matcher helpers --- tests/test-team/helpers/events/matchers.ts | 8 ++++++-- .../letter-templates.event.spec.ts | 15 ++------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/test-team/helpers/events/matchers.ts b/tests/test-team/helpers/events/matchers.ts index c31db36f5..0ef181582 100644 --- a/tests/test-team/helpers/events/matchers.ts +++ b/tests/test-team/helpers/events/matchers.ts @@ -1,7 +1,11 @@ import z from 'zod'; +const $PartialCloudEvent = z.object({ type: z.string() }); + export const eventWithId = (id: string) => - z.object({ data: z.object({ id: z.literal(id) }) }); + $PartialCloudEvent.extend( + z.object({ data: z.object({ id: z.literal(id) }) }) + ); export const eventWithIdIn = (ids: [string, ...string[]]) => - z.object({ data: z.object({ id: z.enum(ids) }) }); + $PartialCloudEvent.extend(z.object({ data: z.object({ id: z.enum(ids) }) })); diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 1920c647d..d4b9d99b0 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -16,7 +16,6 @@ import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { setTimeout } from 'node:timers/promises'; import { Template } from 'helpers/types'; import { eventWithId } from '../helpers/events/matchers'; -import z from 'zod'; test.describe('Event publishing - Letters', () => { const authHelper = createAuthHelper(); @@ -223,12 +222,7 @@ test.describe('Event publishing - Letters', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: z.object({ - type: z.string(), - data: z.object({ - id: z.literal(template.id), - }), - }), + match: eventWithId(templateId), }); // Note: This is weird, But sometimes the tests find all relevant events within @@ -304,12 +298,7 @@ test.describe('Event publishing - Letters', () => { await expect(async () => { const events = await eventSubscriber.receive({ since: start, - match: z.object({ - type: z.string(), - data: z.object({ - id: z.literal(template.id), - }), - }), + match: eventWithId(templateId), }); expect(events).toHaveLength(2); From fe77d0b327a4a3c92f6e84996cdc30a60b16d7cb Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 16:09:19 +0000 Subject: [PATCH 25/30] hardcode group and component --- .../fixtures/template-management-event-subscriber.ts | 2 +- tests/test-team/global.d.ts | 2 -- utils/backend-config/src/backend-config.ts | 8 -------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/test-team/fixtures/template-management-event-subscriber.ts b/tests/test-team/fixtures/template-management-event-subscriber.ts index d982d691b..20ceedbe9 100644 --- a/tests/test-team/fixtures/template-management-event-subscriber.ts +++ b/tests/test-team/fixtures/template-management-event-subscriber.ts @@ -12,7 +12,7 @@ export const templateManagementEventSubscriber = base.extend< eventSubscriber: [ // eslint-disable-next-line no-empty-pattern async ({}, use, workerInfo) => { - const eventSource = `//notify.nhs.uk/${process.env.COMPONENT}/${process.env.GROUP}/${process.env.ENVIRONMENT}`; + const eventSource = `//notify.nhs.uk/sbx/nhs-notify-template-management-dev/${process.env.ENVIRONMENT}`; const subscriber = new EventSubscriber( process.env.EVENTS_SNS_TOPIC_ARN, diff --git a/tests/test-team/global.d.ts b/tests/test-team/global.d.ts index 051b8e41a..caad2f920 100644 --- a/tests/test-team/global.d.ts +++ b/tests/test-team/global.d.ts @@ -6,11 +6,9 @@ declare global { CLIENT_SSM_PATH_PREFIX: string; COGNITO_USER_POOL_CLIENT_ID: string; COGNITO_USER_POOL_ID: string; - COMPONENT: string; ENVIRONMENT: string; EVENT_CACHE_BUCKET_NAME: string; EVENTS_SNS_TOPIC_ARN: string; - GROUP: string; PLAYWRIGHT_RUN_ID: string; REQUEST_PROOF_QUEUE_URL: string; ROUTING_CONFIG_TABLE_NAME: string; diff --git a/utils/backend-config/src/backend-config.ts b/utils/backend-config/src/backend-config.ts index 4d2fd4258..5166a51d3 100644 --- a/utils/backend-config/src/backend-config.ts +++ b/utils/backend-config/src/backend-config.ts @@ -6,11 +6,9 @@ export type BackendConfig = { apiBaseUrl: string; awsAccountId: string; clientSsmPathPrefix: string; - component: string; environment: string; eventCacheBucketName: string; eventsSnsTopicArn: string; - group: string; requestProofQueueUrl: string; routingConfigTableName: string; sftpEnvironment: string; @@ -32,11 +30,9 @@ export const BackendConfigHelper = { apiBaseUrl: process.env.API_BASE_URL ?? '', awsAccountId: process.env.AWS_ACCOUNT_ID ?? '', clientSsmPathPrefix: process.env.CLIENT_SSM_PATH_PREFIX ?? '', - component: process.env.COMPONENT ?? '', environment: process.env.ENVIRONMENT ?? '', eventCacheBucketName: process.env.EVENT_CACHE_BUCKET_NAME ?? '', eventsSnsTopicArn: process.env.EVENTS_SNS_TOPIC_ARN ?? '', - group: process.env.GROUP ?? '', requestProofQueueUrl: process.env.REQUEST_PROOF_QUEUE_URL ?? '', routingConfigTableName: process.env.ROUTING_CONFIG_TABLE_NAME ?? '', sftpEnvironment: process.env.SFTP_ENVIRONMENT ?? '', @@ -60,11 +56,9 @@ export const BackendConfigHelper = { process.env.API_BASE_URL = config.apiBaseUrl; process.env.AWS_ACCOUNT_ID = config.awsAccountId; process.env.CLIENT_SSM_PATH_PREFIX = config.clientSsmPathPrefix; - process.env.COMPONENT = config.component; process.env.ENVIRONMENT = config.environment; process.env.EVENT_CACHE_BUCKET_NAME = config.eventCacheBucketName; process.env.EVENTS_SNS_TOPIC_ARN = config.eventsSnsTopicArn; - process.env.GROUP = config.group; process.env.COGNITO_USER_POOL_ID = config.userPoolId; process.env.COGNITO_USER_POOL_CLIENT_ID = config.userPoolClientId; process.env.TEMPLATES_TABLE_NAME = config.templatesTableName; @@ -92,12 +86,10 @@ export const BackendConfigHelper = { awsAccountId: deployment.aws_account_id ?? '', clientSsmPathPrefix: outputsFileContent.client_ssm_path_prefix?.value ?? '', - component: deployment.component ?? '', environment: deployment.environment ?? '', eventCacheBucketName: outputsFileContent.event_cache_bucket_name?.value ?? '', eventsSnsTopicArn: outputsFileContent.events_sns_topic_arn?.value ?? '', - group: deployment.group ?? '', requestProofQueueUrl: outputsFileContent.request_proof_queue_url?.value ?? '', routingConfigTableName: From 32a87f1f1db396ac08a9cc4e2c88db2080a4e023 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 6 Feb 2026 17:22:32 +0000 Subject: [PATCH 26/30] fix zod extend --- infrastructure/terraform/components/sandbox/README.md | 1 - infrastructure/terraform/components/sandbox/outputs.tf | 4 ---- tests/test-team/global.d.ts | 1 - tests/test-team/helpers/events/matchers.ts | 6 ++---- utils/backend-config/src/backend-config.ts | 5 ----- 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/infrastructure/terraform/components/sandbox/README.md b/infrastructure/terraform/components/sandbox/README.md index 08f649a0a..e9394ac0a 100644 --- a/infrastructure/terraform/components/sandbox/README.md +++ b/infrastructure/terraform/components/sandbox/README.md @@ -39,7 +39,6 @@ | [cognito\_user\_pool\_id](#output\_cognito\_user\_pool\_id) | n/a | | [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | | [download\_bucket\_name](#output\_download\_bucket\_name) | n/a | -| [event\_cache\_bucket\_name](#output\_event\_cache\_bucket\_name) | n/a | | [events\_sns\_topic\_arn](#output\_events\_sns\_topic\_arn) | n/a | | [internal\_bucket\_name](#output\_internal\_bucket\_name) | n/a | | [quarantine\_bucket\_name](#output\_quarantine\_bucket\_name) | n/a | diff --git a/infrastructure/terraform/components/sandbox/outputs.tf b/infrastructure/terraform/components/sandbox/outputs.tf index 6a8929f4d..cab82d420 100644 --- a/infrastructure/terraform/components/sandbox/outputs.tf +++ b/infrastructure/terraform/components/sandbox/outputs.tf @@ -66,10 +66,6 @@ output "test_email_bucket_prefix" { value = "emails-${var.environment}" } -output "event_cache_bucket_name" { - value = module.eventpub.s3_bucket_event_cache.bucket -} - output "routing_config_table_name" { value = module.backend_api.routing_config_table_name } diff --git a/tests/test-team/global.d.ts b/tests/test-team/global.d.ts index caad2f920..6654bfe88 100644 --- a/tests/test-team/global.d.ts +++ b/tests/test-team/global.d.ts @@ -7,7 +7,6 @@ declare global { COGNITO_USER_POOL_CLIENT_ID: string; COGNITO_USER_POOL_ID: string; ENVIRONMENT: string; - EVENT_CACHE_BUCKET_NAME: string; EVENTS_SNS_TOPIC_ARN: string; PLAYWRIGHT_RUN_ID: string; REQUEST_PROOF_QUEUE_URL: string; diff --git a/tests/test-team/helpers/events/matchers.ts b/tests/test-team/helpers/events/matchers.ts index 0ef181582..6435d615c 100644 --- a/tests/test-team/helpers/events/matchers.ts +++ b/tests/test-team/helpers/events/matchers.ts @@ -3,9 +3,7 @@ import z from 'zod'; const $PartialCloudEvent = z.object({ type: z.string() }); export const eventWithId = (id: string) => - $PartialCloudEvent.extend( - z.object({ data: z.object({ id: z.literal(id) }) }) - ); + $PartialCloudEvent.extend({ data: z.object({ id: z.literal(id) }) }); export const eventWithIdIn = (ids: [string, ...string[]]) => - $PartialCloudEvent.extend(z.object({ data: z.object({ id: z.enum(ids) }) })); + $PartialCloudEvent.extend({ data: z.object({ id: z.enum(ids) }) }); diff --git a/utils/backend-config/src/backend-config.ts b/utils/backend-config/src/backend-config.ts index 5166a51d3..6dace6a49 100644 --- a/utils/backend-config/src/backend-config.ts +++ b/utils/backend-config/src/backend-config.ts @@ -7,7 +7,6 @@ export type BackendConfig = { awsAccountId: string; clientSsmPathPrefix: string; environment: string; - eventCacheBucketName: string; eventsSnsTopicArn: string; requestProofQueueUrl: string; routingConfigTableName: string; @@ -31,7 +30,6 @@ export const BackendConfigHelper = { awsAccountId: process.env.AWS_ACCOUNT_ID ?? '', clientSsmPathPrefix: process.env.CLIENT_SSM_PATH_PREFIX ?? '', environment: process.env.ENVIRONMENT ?? '', - eventCacheBucketName: process.env.EVENT_CACHE_BUCKET_NAME ?? '', eventsSnsTopicArn: process.env.EVENTS_SNS_TOPIC_ARN ?? '', requestProofQueueUrl: process.env.REQUEST_PROOF_QUEUE_URL ?? '', routingConfigTableName: process.env.ROUTING_CONFIG_TABLE_NAME ?? '', @@ -57,7 +55,6 @@ export const BackendConfigHelper = { process.env.AWS_ACCOUNT_ID = config.awsAccountId; process.env.CLIENT_SSM_PATH_PREFIX = config.clientSsmPathPrefix; process.env.ENVIRONMENT = config.environment; - process.env.EVENT_CACHE_BUCKET_NAME = config.eventCacheBucketName; process.env.EVENTS_SNS_TOPIC_ARN = config.eventsSnsTopicArn; process.env.COGNITO_USER_POOL_ID = config.userPoolId; process.env.COGNITO_USER_POOL_CLIENT_ID = config.userPoolClientId; @@ -87,8 +84,6 @@ export const BackendConfigHelper = { clientSsmPathPrefix: outputsFileContent.client_ssm_path_prefix?.value ?? '', environment: deployment.environment ?? '', - eventCacheBucketName: - outputsFileContent.event_cache_bucket_name?.value ?? '', eventsSnsTopicArn: outputsFileContent.events_sns_topic_arn?.value ?? '', requestProofQueueUrl: outputsFileContent.request_proof_queue_url?.value ?? '', From d7f14666f45c60f9899271373f9c1f5c48062528 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Mon, 9 Feb 2026 12:03:19 +0000 Subject: [PATCH 27/30] merge cleanup --- .../__snapshots__/page.test.tsx.snap | 6 +- .../__snapshots__/page.test.tsx.snap | 38 +- .../[routingConfigId]/page.test.tsx | 2 +- ...e-to-production.routing-component.spec.txt | 375 ------------------ 4 files changed, 31 insertions(+), 390 deletions(-) delete mode 100644 tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt diff --git a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap index 8595e8c2f..7faa73e59 100644 --- a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap @@ -713,9 +713,7 @@ exports[`full cascade plan matches snapshot 1`] = ` > Large print letter (optional) -

+

@@ -743,7 +741,6 @@ exports[`full cascade plan matches snapshot 1`] = `

letter template name @@ -664,11 +664,9 @@ exports[`Review and move to production page matches snapshot for full cascade 1` > Large print letter (optional) -

+

letter template name @@ -694,20 +692,18 @@ exports[`Review and move to production page matches snapshot for full cascade 1`

letter template name

letter template name @@ -724,6 +720,30 @@ exports[`Review and move to production page matches snapshot for full cascade 1`

+ + + + -

-
    -
  • - -

    - First message -

    -
    -
    -

    - NHS App -

    -

    - app template name -

    -
    - - - Preview - - NHS App - - template - - -
    -
    -

    - message -

    - - -
    -
    -
    -
    -
    -
  • -
  • - -
    - - - Fallback conditions - - -
    -
      -
    • - - If first message read within 24 hours, no further messages sent. -
    • -
    • - - If first message not read within 24 hours, second message sent. -
    • -
    -
    -
    -
  • -
  • - -

    - Second message -

    -
    -
    -

    - Email -

    -

    - email template name -

    -
    - - - Preview - - Email - - template - - -
    -
    -

    - message -

    - - -
    -
    -
    -
    -
    -
  • -
  • - -
    - - - Fallback conditions - - -
    -
      -
    • - - If second message delivered within 72 hours, no further messages sent. -
    • -
    • - - If second message not delivered within 72 hours, third message sent. -
    • -
    -
    -
    -
  • -
  • - -

    - Third message -

    -
    -
    -

    - Text message (SMS) -

    -

    - sms template name -

    -
    - - - Preview - - Text message (SMS) - - template - - -
    -
    -

    - message -

    - - -
    -
    -
    -
    -
    -
  • -
  • - -
    - - - Fallback conditions - - -
    -
      -
    • - - If third message delivered within 72 hours, no further messages sent. -
    • -
    • - - If third message not delivered within 72 hours, fourth message sent. -
    • -
    -
    -
    -
  • -
  • - -

    - Fourth message -

    -
    -
    -

    - Standard English letter -

    -

    - - letter template name - -

    -
    -
    -
      -
    • - -
      - - - Conditions for accessible and language letters - - -
      -
        -
      • - - The relevant accessible or language letter will be sent instead of the standard English letter if, both: -
          -
        • - the recipient has requested an accessible or language letter in PDS -
        • -
        • - you've included the relevant template in this message plan -
        • -
        -
      • -
      -
      -
      -
    • -
    • -
      -
      -

      - Large print letter (optional) -

      -

      - - letter template name - -

      -
      -
      -
    • -
    • -
      -
      -

      - Other language letters (optional) -

      -

      - - letter template name - -

      -

      - - letter template name - -

      -
      -
      -
    • -
    -
  • -
-
- - - - - - - - - Keep in draft - -
-
-
- - -`; diff --git a/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx b/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx deleted file mode 100644 index 2e33bd35c..000000000 --- a/frontend/src/__tests__/app/review-and-move-to-production/[routingConfigId]/page.test.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { redirect, RedirectType } from 'next/navigation'; -import { render, screen, within } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { RoutingConfig } from 'nhs-notify-backend-client'; -import { - EMAIL_TEMPLATE, - PDF_LETTER_TEMPLATE, - NHS_APP_TEMPLATE, - SMS_TEMPLATE, -} from '@testhelpers/helpers'; -import { RoutingConfigFactory } from '@testhelpers/routing-config-factory'; -import { - getMessagePlanTemplates, - getRoutingConfig, -} from '@utils/message-plans'; -import type { MessagePlanTemplates } from '@utils/routing-utils'; - -import ReviewAndMoveMessagePlanPage, { - metadata, -} from '@app/message-plans/review-and-move-to-production/[routingConfigId]/page'; - -jest.mock('next/navigation'); -jest.mock('@utils/message-plans'); -jest.mock( - '@app/message-plans/review-and-move-to-production/[routingConfigId]/actions', - () => ({ - moveToProduction: jest.fn(), - }) -); - -function createRoutingConfig(data?: Partial) { - return RoutingConfigFactory.create({ - id: 'rc-123', - campaignId: 'cmp-1', - status: 'DRAFT', - lockNumber: 5, - ...data, - }); -} - -async function renderPage(routingConfig?: RoutingConfig, id?: string) { - jest.mocked(getRoutingConfig).mockResolvedValue(routingConfig); - - const page = await ReviewAndMoveMessagePlanPage({ - params: Promise.resolve({ routingConfigId: id ?? routingConfig?.id ?? '' }), - }); - - return render(page); -} - -const appTemplateId = '8f9df705-fa06-4882-a7ca-02a257fbeb60'; -const emailTemplateId = 'e1095ace-6c32-476b-9467-89e60323c7c4'; -const smsTemplateId = '920f7ad7-8cf6-4dfb-a08d-a7782860375e'; -const letterTemplateId = '278e1a92-353f-42a3-b08d-565ea1c9d763'; -const kuTemplateId = '31399023-08a2-4dc7-81c7-e25b284b2aab'; -const sqTemplateId = '35746144-cac4-4e1f-b92b-4f58e9f1154f'; -const largePrintTemplateId = '72ebc15c-d950-4e2e-99d4-3de7f174fba6'; - -const templates: MessagePlanTemplates = { - [appTemplateId]: { ...NHS_APP_TEMPLATE, id: appTemplateId }, - [emailTemplateId]: { ...EMAIL_TEMPLATE, id: emailTemplateId }, - [smsTemplateId]: { ...SMS_TEMPLATE, id: smsTemplateId }, - [letterTemplateId]: { ...PDF_LETTER_TEMPLATE, id: letterTemplateId }, - [kuTemplateId]: { ...PDF_LETTER_TEMPLATE, id: kuTemplateId }, - [sqTemplateId]: { ...PDF_LETTER_TEMPLATE, id: sqTemplateId }, - [largePrintTemplateId]: { ...PDF_LETTER_TEMPLATE, id: largePrintTemplateId }, -}; - -beforeEach(() => { - jest.mocked(getMessagePlanTemplates).mockResolvedValue(templates); -}); - -afterEach(() => { - jest.resetAllMocks(); -}); - -describe('Review and move to production page', () => { - it('has correct page metadata', () => { - expect(metadata).toEqual({ - title: 'Review and move message plan to production - NHS Notify', - }); - }); - - it('redirects to invalid when message plan not found', async () => { - await renderPage(undefined, 'rc-unknown'); - - expect(getRoutingConfig).toHaveBeenCalledWith('rc-unknown'); - expect(redirect).toHaveBeenCalledWith( - '/message-plans/invalid', - RedirectType.replace - ); - }); - - it('redirects to message plans if status is not DRAFT', async () => { - const routingConfig = createRoutingConfig({ status: 'COMPLETED' }); - - await renderPage(routingConfig); - - expect(redirect).toHaveBeenCalledWith( - '/message-plans', - RedirectType.replace - ); - }); - - it('renders the page heading and step counter', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - expect(screen.getByText('Step 2 of 2')).toBeInTheDocument(); - expect( - screen.getByRole('heading', { - level: 1, - name: 'Review and move message plan to production', - }) - ).toBeInTheDocument(); - }); - - it('renders summary list with message plan name only', async () => { - const routingConfig = createRoutingConfig({ - name: 'My Test Plan', - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - const summaryList = screen.getByTestId('message-plan-details'); - expect(within(summaryList).getByTestId('plan-name')).toHaveTextContent( - 'My Test Plan' - ); - - // Should only have name row, not ID, campaign or status - expect( - within(summaryList).queryByTestId('plan-id') - ).not.toBeInTheDocument(); - expect( - within(summaryList).queryByTestId('campaign-id') - ).not.toBeInTheDocument(); - expect(within(summaryList).queryByTestId('status')).not.toBeInTheDocument(); - }); - - it('renders move to production button with warning style', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - const moveButton = screen.getByTestId('move-to-production-button'); - expect(moveButton).toBeInTheDocument(); - expect(moveButton).toHaveTextContent('Move to production'); - expect(moveButton).toHaveClass('nhsuk-button--warning'); - }); - - it('renders keep in draft link with correct href', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - const keepInDraftLink = screen.getByTestId('keep-in-draft-link'); - expect(keepInDraftLink).toHaveTextContent('Keep in draft'); - expect(keepInDraftLink).toHaveAttribute( - 'href', - '/message-plans/choose-templates/rc-123' - ); - }); - - describe('cascade channel display', () => { - it('renders cascade channel list', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - { - channel: 'EMAIL', - channelType: 'primary', - defaultTemplateId: emailTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - const channelList = screen.getByTestId('cascade-channel-list'); - expect(channelList).toBeInTheDocument(); - - expect( - within(channelList).getByTestId('message-plan-block-NHSAPP') - ).toBeInTheDocument(); - expect( - within(channelList).getByTestId('message-plan-block-EMAIL') - ).toBeInTheDocument(); - }); - - it('shows open/close all button for digital channels', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - expect( - screen.getByRole('button', { name: /open all template previews/i }) - ).toBeInTheDocument(); - }); - - it('does not show open/close all button when only letters in cascade', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: letterTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - expect( - screen.queryByRole('button', { name: /open all template previews/i }) - ).not.toBeInTheDocument(); - }); - - it('renders template preview details for digital channels', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - const block = screen.getByTestId('message-plan-block-NHSAPP'); - expect(within(block).getByTestId('template-name')).toHaveTextContent( - templates[appTemplateId].name - ); - expect( - within(block).getByTestId('preview-template-summary') - ).toBeInTheDocument(); - }); - - it('renders letter template as link', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: letterTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - const block = screen.getByTestId('message-plan-block-LETTER'); - const templateName = within(block).getByTestId('template-name'); - const link = within(templateName).getByRole('link'); - - expect(link).toHaveTextContent(templates[letterTemplateId].name); - expect(link).toHaveAttribute( - 'href', - `/preview-letter-template/${letterTemplateId}` - ); - }); - - it('renders fallback conditions between channels', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - { - channel: 'EMAIL', - channelType: 'primary', - defaultTemplateId: emailTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - expect( - screen.getByTestId('message-plan-fallback-conditions-NHSAPP') - ).toBeInTheDocument(); - }); - }); - - describe('conditional templates', () => { - it('renders accessible format templates', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: letterTemplateId, - cascadeGroups: ['standard', 'accessible'], - conditionalTemplates: [ - { templateId: largePrintTemplateId, accessibleFormat: 'x1' }, - ], - }, - ], - }); - - await renderPage(routingConfig); - - expect(screen.getByTestId('conditional-template-x1')).toBeInTheDocument(); - }); - - it('renders language templates', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: letterTemplateId, - cascadeGroups: ['standard', 'translations'], - conditionalTemplates: [ - { templateId: kuTemplateId, language: 'ku' }, - { templateId: sqTemplateId, language: 'sq' }, - ], - }, - ], - }); - - await renderPage(routingConfig); - - expect( - screen.getByTestId('conditional-template-languages') - ).toBeInTheDocument(); - }); - }); - - it('does not render back to all message plans link', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - ], - }); - - await renderPage(routingConfig); - - expect( - screen.queryByText('Back to all message plans') - ).not.toBeInTheDocument(); - expect(screen.queryByTestId('back-link-top')).not.toBeInTheDocument(); - expect(screen.queryByTestId('back-link-bottom')).not.toBeInTheDocument(); - }); - - it('matches snapshot for full cascade', async () => { - const routingConfig = createRoutingConfig({ - cascade: [ - { - channel: 'NHSAPP', - channelType: 'primary', - defaultTemplateId: appTemplateId, - cascadeGroups: ['standard'], - }, - { - channel: 'EMAIL', - channelType: 'primary', - defaultTemplateId: emailTemplateId, - cascadeGroups: ['standard'], - }, - { - channel: 'SMS', - channelType: 'primary', - defaultTemplateId: smsTemplateId, - cascadeGroups: ['standard'], - }, - { - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: letterTemplateId, - cascadeGroups: ['standard', 'accessible', 'translations'], - conditionalTemplates: [ - { templateId: kuTemplateId, language: 'ku' }, - { templateId: sqTemplateId, language: 'sq' }, - { templateId: largePrintTemplateId, accessibleFormat: 'x1' }, - ], - }, - ], - }); - - const { asFragment } = await renderPage(routingConfig); - - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts deleted file mode 100644 index 428b22e17..000000000 --- a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { submitRoutingConfig } from '../../../../utils/message-plans'; -import { moveToProduction } from './actions'; -import { redirect, RedirectType } from 'next/navigation'; - -jest.mock('../../../../utils/message-plans', () => ({ - submitRoutingConfig: jest.fn(), -})); - -type RedirectFn = typeof redirect & { url?: string; type?: RedirectType }; -jest.mock('next/navigation', () => ({ - redirect: ((url?: string, type?: RedirectType) => { - (redirect as RedirectFn).url = url; - (redirect as RedirectFn).type = type; - throw Object.assign(new Error('NEXT_REDIRECT'), { url, type }); - }) as RedirectFn, - RedirectType: { replace: 'replace' }, -})); - -describe('actions: moveToProduction', () => { - it('submits with routingConfigId and lockNumber and redirects to message plans', async () => { - await expect(moveToProduction('rc-123', 5)).rejects.toMatchObject({ - url: '/message-plans', - }); - expect(submitRoutingConfig).toHaveBeenCalledWith('rc-123', 5); - }); -}); diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts deleted file mode 100644 index c472664c7..000000000 --- a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -'use server'; - -import { redirect, RedirectType } from 'next/navigation'; -import { submitRoutingConfig } from '@utils/message-plans'; - -export async function moveToProduction( - routingConfigId: string, - lockNumber: number -) { - await submitRoutingConfig(routingConfigId, lockNumber); - redirect('/message-plans', RedirectType.replace); -} diff --git a/lambdas/AGENTS.md b/lambdas/AGENTS.md deleted file mode 100644 index 91b9064fa..000000000 --- a/lambdas/AGENTS.md +++ /dev/null @@ -1,365 +0,0 @@ -# AGENTS.md - Lambdas - - -## Scope - -This file provides guidance for AI agents working on Lambda functions in this repository. For general repository guidance, see the root `AGENTS.md`. - -## Directory Structure - -Each lambda project follows this structure: - -```text -lambdas/{name}/ -├── package.json # Workspace package -├── jest.config.ts # Jest configuration -├── tsconfig.json # TypeScript configuration -├── build.sh # Build script (if custom build needed) -└── src/ - ├── __tests__/ # Test files mirroring src structure - │ ├── app/ # Client/business logic tests - │ ├── infra/ # Repository tests - │ └── fixtures/ # Test data factories - ├── api/ # API request/response handling - ├── app/ # Business logic clients - ├── container/ # Dependency injection - ├── domain/ # Domain types and schemas - ├── infra/ # Data access (repositories) - ├── utils/ # Shared utilities - └── *.ts # Lambda entry points -``` - -## Architecture Patterns - -### Layered Architecture - -The `backend-api` lambda follows a layered architecture: - -1. **Entry Points** (`src/*.ts`) - Lambda handlers, minimal logic -2. **API Layer** (`src/api/`) - Request parsing, response formatting -3. **App Layer** (`src/app/`) - Business logic clients -4. **Infra Layer** (`src/infra/`) - Data access repositories - -### Repository Pattern - -Repositories handle all DynamoDB operations. Key patterns: - -```typescript -// Repository methods return ApplicationResult -async get(id: string, clientId: string): Promise> -async create(input: CreateEntity, user: User): Promise> -async update(id: string, data: UpdateEntity, user: User, lockNumber: number): Promise> -async submit(id: string, user: User, lockNumber: number): Promise> -async delete(id: string, user: User, lockNumber: number): Promise> -``` - -### Result Type Pattern - -All repository and client methods return `ApplicationResult`: - -```typescript -type ApplicationResult = SuccessResult | FailureResult; - -// Usage -const result = await repository.get(id, clientId); -if (result.error) { - return result; // Propagate failure -} -const data = result.data; // Access success data -``` - -Use `success()` and `failure()` helpers from `@backend-api/utils/result`: - -```typescript -return success(data); -return failure(ErrorCase.NOT_FOUND, 'Entity not found'); -return failure(ErrorCase.VALIDATION_FAILED, 'Invalid input', error, { details: 'extra info' }); -``` - -### Optimistic Locking - -Entities use `lockNumber` for optimistic concurrency control: - -- Client must provide current `lockNumber` for update/delete/submit operations -- Repository increments `lockNumber` on successful write -- Returns `409 CONFLICT` if lock number mismatch - -### DynamoDB Transaction Pattern - -For operations requiring atomic writes with validation, use `TransactWriteCommand`: - -```typescript -await this.client.send( - new TransactWriteCommand({ - TransactItems: [ - { - Update: updateCommand, // Main entity update - }, - // ConditionChecks for related entities - ...relatedIds.map((id) => ({ - ConditionCheck: { - TableName: this.relatedTableName, - Key: { id, owner: this.clientOwnerKey(clientId) }, - ConditionExpression: 'attribute_exists(id) AND someCondition', - ExpressionAttributeValues: { ... }, - }, - })), - ], - }) -); -``` - -**Important:** DynamoDB ConditionChecks can only validate: - -- Existence of items (`attribute_exists`) -- Simple attribute comparisons -- Status checks (`attribute IN (:val1, :val2)`) - -They **cannot** iterate over arrays or validate complex nested structures. For array/document validation, fetch the data first and validate in application code. - -### Error Handling in Transactions - -Handle `TransactionCanceledException` by inspecting `CancellationReasons`: - -```typescript -private handleTransactionError( - err: unknown, - lockNumber: number, - relatedIds: string[] -): ApplicationResult { - if (!(err instanceof TransactionCanceledException)) { - return this.handleUpdateError(err, lockNumber); - } - - // First item is always the main update - const [updateReason, ...relatedReasons] = err.CancellationReasons ?? []; - - if (updateReason && updateReason.Code !== 'None') { - // Main update failed - handle status/lock errors - return this.handleUpdateError( - new ConditionalCheckFailedException({ - message: updateReason.Message!, - Item: updateReason.Item, - $metadata: err.$metadata, - }), - lockNumber - ); - } - - // Check which related items failed - const failedIds = relatedReasons - .map((reason, index) => - reason.Code === 'ConditionalCheckFailed' ? relatedIds[index] : null - ) - .filter((id): id is string => id != null); - - if (failedIds.length > 0) { - return failure(ErrorCase.VALIDATION_FAILED, 'Related items validation failed', err, { - ids: failedIds.join(','), - }); - } - - return this.handleUpdateError(err, lockNumber); -} -``` - -### Validation Pattern - -For complex validation that can't be done in DynamoDB: - -1. **Fetch** the entity first -2. **Validate** in application code -3. **Execute** the transaction - -```typescript -async submit(id: string, user: User, lockNumber: number): Promise> { - // 1. Fetch for validation - const existing = await this.get(id, user.clientId); - if (existing.error) return existing; - - // 2. Validate in application code - const validationError = this.validateForSubmit(existing.data); - if (validationError) return validationError; - - // 3. Execute transaction with DynamoDB-level checks - try { - await this.client.send(new TransactWriteCommand({ ... })); - // ... - } catch (error) { - return this.handleTransactionError(error, lockNumber, ids); - } -} -``` - -## Testing Patterns - -### Repository Tests - -Located in `src/__tests__/infra/{repository-name}/`. Use `aws-sdk-client-mock`: - -```typescript -import { mockClient } from 'aws-sdk-client-mock'; -import { DynamoDBDocumentClient, GetCommand, TransactWriteCommand } from '@aws-sdk/lib-dynamodb'; - -const dynamo = mockClient(DynamoDBDocumentClient); - -function setup() { - dynamo.reset(); // Reset mock state between tests - // ... -} -``` - -**Chaining mock responses** for multiple calls to the same command: - -```typescript -// Correct: chain resolvesOnce calls -mocks.dynamo - .on(GetCommand) - .resolvesOnce({ Item: firstResponse }) - .resolvesOnce({ Item: secondResponse }); - -// Wrong: separate calls don't queue -mocks.dynamo.on(GetCommand).resolvesOnce({ Item: firstResponse }); -mocks.dynamo.on(GetCommand).resolvesOnce({ Item: secondResponse }); // Overwrites! -``` - -### Client Tests - -Located in `src/__tests__/app/`. Mock repositories using `jest-mock-extended`: - -```typescript -import { mock } from 'jest-mock-extended'; -import type { Repository } from '../../infra/repository'; - -const repository = mock(); -``` - -### Test Fixtures - -Use factory functions in `src/__tests__/fixtures/`: - -```typescript -export const entity: Entity = { /* default values */ }; - -export const makeEntity = (overrides: Partial = {}): Entity => ({ - ...entity, - id: randomUUID(), - ...overrides, -}); -``` - -## Running Tests - -```bash -# From lambda directory -npm run test:unit - -# Specific test file -npx jest src/__tests__/infra/repository.test.ts --no-coverage - -# Specific test pattern -npx jest --testPathPattern="repository" --testNamePattern="submit" -``` - -## Common Error Cases - -From `nhs-notify-backend-client`: - -| ErrorCase | HTTP Code | Use For | -| ------------------------------------- | --------- | ------------------------------------ | -| `NOT_FOUND` | 404 | Entity doesn't exist or is deleted | -| `VALIDATION_FAILED` | 400 | Input validation failures | -| `ALREADY_SUBMITTED` | 400 | Entity already in final state | -| `CONFLICT` | 409 | Lock number mismatch | -| `INTERNAL` | 500 | Unexpected errors | -| `ROUTING_CONFIG_TEMPLATES_NOT_FOUND` | 400 | Referenced templates missing | - -## Agent Checklist - -When modifying lambda code: - -- [ ] Follow the layered architecture (entry → api → app → infra) -- [ ] Return `ApplicationResult` from repository/client methods -- [ ] Use `TransactWriteCommand` for atomic operations with related entity checks -- [ ] Handle `TransactionCanceledException` by inspecting `CancellationReasons` -- [ ] Add/update tests mirroring the source structure -- [ ] Run `npm run test:unit` in the lambda directory -- [ ] Run `npm run typecheck` to verify types - -## Schema and Type System - -### Type Generation Pipeline - -Types are generated from OpenAPI specifications. The pipeline flows: - -```text -spec.tmpl.json → (Terraform templating) → spec.json → (openapi-typescript) → generated.d.ts -``` - -**Key files:** - -- `infrastructure/terraform/modules/backend-api/spec.tmpl.json` - Source OpenAPI spec (with Terraform template vars) -- `lambdas/backend-client/src/types/generated.d.ts` - Generated TypeScript types - -**To regenerate types after spec changes:** - -```bash -cd lambdas/backend-client -npm run generate-dependencies -``` - -### Zod Schemas with `schemaFor` - -Zod schemas in `backend-client` use the `schemaFor` helper for type-safety: - -```typescript -import { z } from 'zod/v4'; -import type { MyType } from '../types/generated'; -import { schemaFor } from './schema-for'; - -// This ensures the Zod schema matches the generated TypeScript type -const $MyType = schemaFor()( - z.object({ - field: z.string(), - }) -); -``` - -**Why `schemaFor`?** It provides compile-time verification that the Zod schema produces the same shape as the generated type. If the schema doesn't match the type, TypeScript will error. - -### Validation Schemas (Submittable Pattern) - -For workflows that require stricter validation than the base schema (e.g., "submit" operations), create extended schemas: - -```typescript -// Base schema allows optional/nullable fields for draft state -const $CascadeItem = schemaFor()( - z.object({ - defaultTemplateId: z.string().nonempty().nullable(), // Can be null in drafts - // ... - }) -); - -// Submittable schema enforces fields required for submission -export const $SubmittableCascadeItem = $CascadeItem.and( - z.object({ - // Override: defaultTemplateId can be missing, but can't be null - defaultTemplateId: z.string().nonempty().optional(), - }) -); - -export const $SubmittableCascade = z.array($SubmittableCascadeItem).nonempty(); -``` - -**Usage in repository:** - -```typescript -const parseResult = $SubmittableCascade.safeParse(existing.data.cascade); -if (!parseResult.success) { - return failure( - ErrorCase.VALIDATION_FAILED, - 'Routing config is not ready for submission', - new Error(parseResult.error.message) - ); -} -``` diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 57d857f1c..fb087d41c 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,4 +1,4 @@ -AGENTS +[A-Z]+s Bitwarden bot Cognito @@ -15,7 +15,6 @@ Gitleaks Grype idempotence Jira -Lambdas npm OAuth Octokit diff --git a/tests/test-team/AGENTS.md b/tests/test-team/AGENTS.md deleted file mode 100644 index e3da42f53..000000000 --- a/tests/test-team/AGENTS.md +++ /dev/null @@ -1,257 +0,0 @@ -# AGENTS.md - Test Team - - -## Scope - -This file provides guidance for AI agents working on automated tests in this repository. For general repository guidance, see the root `AGENTS.md`. - -## Directory Structure - -```text -tests/test-team/ -├── template-mgmt-api-tests/ # API integration tests -├── template-mgmt-component-tests/ # Component tests (page-level) -├── template-mgmt-e2e-tests/ # End-to-end user journey tests -├── template-mgmt-event-tests/ # Event-driven tests -├── template-mgmt-routing-component-tests/ # Routing config component tests -├── helpers/ -│ ├── auth/ # Cognito authentication helpers -│ ├── db/ # Database storage helpers -│ ├── factories/ # Test data factories -│ ├── client/ # API client helpers -│ └── ... -├── fixtures/ # Shared test fixtures -└── config/ # Playwright configuration -``` - -## API Tests - -API tests are located in `template-mgmt-api-tests/` and use Playwright's request API to test backend endpoints directly. - -### Test File Structure - -```typescript -import { test, expect } from '@playwright/test'; -import { randomUUID } from 'node:crypto'; -import { - createAuthHelper, - type TestUser, - testUsers, -} from '../helpers/auth/cognito-auth-helper'; -import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; -import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; -import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; -import { TemplateFactory } from '../helpers/factories/template-factory'; - -test.describe('PATCH /v1/routing-configuration/:id/submit', () => { - const authHelper = createAuthHelper(); - const storageHelper = new RoutingConfigStorageHelper(); - const templateStorageHelper = new TemplateStorageHelper(); - let user1: TestUser; - - test.beforeAll(async () => { - user1 = await authHelper.getTestUser(testUsers.User1.userId); - }); - - test.afterAll(async () => { - await storageHelper.deleteSeeded(); - await templateStorageHelper.deleteSeededTemplates(); - }); - - test('returns 200 and the updated data', async ({ request }) => { - // 1. Create test data using factories - const templateId = randomUUID(); - const template = TemplateFactory.createNhsAppTemplate(templateId, user1, 'Test'); - await templateStorageHelper.seedTemplateData([template]); - - const { dbEntry } = RoutingConfigFactory.create(user1, { /* overrides */ }); - await storageHelper.seed([dbEntry]); - - // 2. Make the API request - const response = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${dbEntry.id}/submit`, - { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - } - ); - - // 3. Assert response - expect(response.status()).toBe(200); - const body = await response.json(); - expect(body.data.status).toBe('COMPLETED'); - }); -}); -``` - -### Key Helpers - -#### Authentication (`helpers/auth/cognito-auth-helper.ts`) - -```typescript -const authHelper = createAuthHelper(); -const user = await authHelper.getTestUser(testUsers.User1.userId); - -// Get access token for API requests -const token = await user.getAccessToken(); - -// User properties -user.clientId; // Client ID for ownership checks -user.campaignId; // Default campaign ID -``` - -Pre-configured test users with different permissions: - -- `testUsers.User1` - Standard user -- `testUsers.User2` - User with routing disabled -- `testUsers.User7` - User sharing same client as User1 -- `testUsers.UserRoutingEnabled` - User with different client - -#### Storage Helpers (`helpers/db/`) - -Seed test data directly into DynamoDB: - -```typescript -const storageHelper = new RoutingConfigStorageHelper(); -const templateStorageHelper = new TemplateStorageHelper(); - -// Seed data -await storageHelper.seed([dbEntry1, dbEntry2]); -await templateStorageHelper.seedTemplateData([template1, template2]); - -// Clean up in afterAll -await storageHelper.deleteSeeded(); -await templateStorageHelper.deleteSeededTemplates(); -``` - -#### Factories (`helpers/factories/`) - -Create test data with sensible defaults: - -```typescript -// Routing configs -const { dbEntry, apiResponse } = RoutingConfigFactory.create(user, { - status: 'DRAFT', - cascade: [{ /* cascade item */ }], -}); - -// Templates -const nhsAppTemplate = TemplateFactory.createNhsAppTemplate(id, user, 'Name'); -const emailTemplate = TemplateFactory.createEmailTemplate(id, user, 'Name', 'NOT_YET_SUBMITTED'); -const smsTemplate = TemplateFactory.createSmsTemplate(id, user, 'Name'); -const letterTemplate = TemplateFactory.uploadLetterTemplate( - id, - user, - 'Name', - 'PROOF_APPROVED', // templateStatus - 'PASSED', // virusScanStatus - { language: 'fr', letterType: 'x0' } // options -); -``` - -### Test Patterns - -#### Testing Validation Errors - -```typescript -test('returns 400 if validation fails', async ({ request }) => { - const { dbEntry } = RoutingConfigFactory.create(user1, { - cascade: [{ /* invalid cascade */ }], - }); - await storageHelper.seed([dbEntry]); - - const response = await request.patch(`${url}/${dbEntry.id}/submit`, { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - }); - - expect(response.status()).toBe(400); - expect(await response.json()).toEqual({ - statusCode: 400, - technicalMessage: 'Expected error message', - }); -}); -``` - -#### Testing Authorization - -```typescript -test('returns 401 if no auth token', async ({ request }) => { - const response = await request.patch(url, { - headers: { 'X-Lock-Number': '0' }, // No Authorization header - }); - expect(response.status()).toBe(401); -}); - -test('returns 404 if owned by different client', async ({ request }) => { - const { dbEntry } = RoutingConfigFactory.create(user1); - await storageHelper.seed([dbEntry]); - - const response = await request.patch(`${url}/${dbEntry.id}`, { - headers: { - Authorization: await userDifferentClient.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber), - }, - }); - expect(response.status()).toBe(404); // Not 403 - don't leak existence -}); -``` - -#### Testing Optimistic Locking - -```typescript -test('returns 409 if lock number mismatch', async ({ request }) => { - const { dbEntry } = RoutingConfigFactory.create(user1); - await storageHelper.seed([dbEntry]); - - const response = await request.patch(`${url}/${dbEntry.id}`, { - headers: { - Authorization: await user1.getAccessToken(), - 'X-Lock-Number': String(dbEntry.lockNumber + 1), // Wrong lock number - }, - }); - expect(response.status()).toBe(409); -}); -``` - -### Avoiding Redundant Tests - -When adding tests, consider whether the scenario is already covered: - -1. **Happy path with full response validation** - One comprehensive test is usually enough -2. **Each distinct error type** - One test per unique error message/code -3. **Boundary conditions** - Test the boundaries, not every value - -For example, if testing template status validation: - -- ✅ One test for invalid status (e.g., `NOT_YET_SUBMITTED`) -- ✅ One test for valid status (e.g., `PROOF_APPROVED`) -- ❌ Don't need separate tests for every valid status (`PROOF_APPROVED` AND `SUBMITTED`) - -### Running API Tests - -```bash -# From tests/test-team directory -npm run test:api - -# Run specific test file -npx playwright test submit-routing-config.api.spec.ts - -# Run with UI mode for debugging -npx playwright test --ui -``` - -## Quality Checklist - -Before submitting API test changes: - -- [ ] Tests clean up seeded data in `afterAll` -- [ ] Use `randomUUID()` for IDs to avoid collisions -- [ ] Test both success and failure paths -- [ ] Assert on response status AND body -- [ ] No redundant tests covering same scenario -- [ ] Factory methods used (don't construct raw objects) diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt new file mode 100644 index 000000000..b0c5b1d2c --- /dev/null +++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt @@ -0,0 +1,375 @@ +import { randomUUID } from 'node:crypto'; +import { Channel } from 'nhs-notify-backend-client'; +import { test, expect } from '@playwright/test'; +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; +import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; +import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { + assertFooterLinks, + assertSignOutLink, + assertHeaderLogoLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { RoutingReviewAndMoveToProductionPage } from 'pages/routing/review-and-move-to-production-page'; +import { RoutingChooseTemplatesPage } from 'pages/routing'; +import { RoutingMessagePlansPage } from 'pages/routing/message-plans-page'; + +const routingConfigStorageHelper = new RoutingConfigStorageHelper(); +const templateStorageHelper = new TemplateStorageHelper(); + +function createTemplates(user: TestUser) { + const templateIds = { + NHSAPP: randomUUID(), + EMAIL: randomUUID(), + SMS: randomUUID(), + LETTER: randomUUID(), + LARGE_PRINT_LETTER: randomUUID(), + FRENCH_LETTER: randomUUID(), + SPANISH_LETTER: randomUUID(), + }; + + return { + NHSAPP: TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + `Test NHS App template - ${templateIds.NHSAPP}`, + 'SUBMITTED' + ), + EMAIL: TemplateFactory.createEmailTemplate( + templateIds.EMAIL, + user, + `Test Email template - ${templateIds.EMAIL}`, + 'SUBMITTED' + ), + SMS: TemplateFactory.createSmsTemplate( + templateIds.SMS, + user, + `Test SMS template - ${templateIds.SMS}`, + 'SUBMITTED' + ), + LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.LETTER, + user, + `Test Letter template - ${templateIds.LETTER}`, + 'SUBMITTED' + ), + LARGE_PRINT_LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.LARGE_PRINT_LETTER, + user, + `Test Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, + 'SUBMITTED', + 'PASSED', + { letterType: 'x1' } + ), + FRENCH_LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.FRENCH_LETTER, + user, + `Test Letter template French - ${templateIds.FRENCH_LETTER}`, + 'SUBMITTED', + 'PASSED', + { language: 'fr' } + ), + SPANISH_LETTER: TemplateFactory.uploadLetterTemplate( + templateIds.SPANISH_LETTER, + user, + `Test Spanish Letter template - ${templateIds.SPANISH_LETTER}`, + 'SUBMITTED', + 'PASSED', + { language: 'es' } + ), + }; +} + +test.describe('Routing - Review and Move to Production page', () => { + let templates: ReturnType; + + let user: TestUser; + + test.beforeAll(async () => { + user = await createAuthHelper().getTestUser(testUsers.User1.userId); + templates = createTemplates(user); + + await templateStorageHelper.seedTemplateData(Object.values(templates)); + }); + + test.afterAll(async () => { + await routingConfigStorageHelper.deleteSeeded(); + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('common page tests', async ({ page, baseURL }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const props = { + page: new RoutingReviewAndMoveToProductionPage(page).setPathParam( + 'messagePlanId', + dbEntry.id + ), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertFooterLinks(props); + await assertSignOutLink(props); + }); + + test('redirects to invalid message plan page when message plan cannot be found', async ({ + page, + baseURL, + }) => { + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', 'does-not-exist'); + + await reviewPage.loadPage(); + + await expect(page).toHaveURL(`${baseURL}/templates/message-plans/invalid`); + }); + + test('redirects to preview message plan page when message plan is not DRAFT', async ({ + page, + baseURL, + }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'COMPLETED' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await expect(page).toHaveURL( + `${baseURL}/templates/message-plans/preview-message-plan/${dbEntry.id}` + ); + }); + + test('displays message plan name in summary list', async ({ page }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await expect(reviewPage.messagePlanName).toHaveText(dbEntry.name); + }); + + test('displays preview of full routing config', async ({ page }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP', 'EMAIL', 'SMS', 'LETTER'], + { status: 'DRAFT' } + ) + .addTemplate('NHSAPP', templates.NHSAPP.id) + .addTemplate('EMAIL', templates.EMAIL.id) + .addTemplate('SMS', templates.SMS.id) + .addTemplate('LETTER', templates.LETTER.id) + .addAccessibleFormatTemplate('x1', templates.LARGE_PRINT_LETTER.id) + .addLanguageTemplate('fr', templates.FRENCH_LETTER.id) + .addLanguageTemplate('es', templates.SPANISH_LETTER.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await test.step('opens and closes all details sections', async () => { + for (const section of await reviewPage.detailsSections.all()) { + await expect(section).not.toHaveAttribute('open'); + } + + await expect(reviewPage.previewToggleButton).toHaveText( + 'Open all template previews' + ); + + await reviewPage.previewToggleButton.click(); + + for (const section of await reviewPage.detailsSections.all()) { + await expect(section).toHaveAttribute('open'); + } + + await expect(reviewPage.previewToggleButton).toHaveText( + 'Close all template previews' + ); + + await reviewPage.previewToggleButton.click(); + + for (const section of await reviewPage.detailsSections.all()) { + await expect(section).not.toHaveAttribute('open'); + } + + await expect(reviewPage.previewToggleButton).toHaveText( + 'Open all template previews' + ); + }); + + for (const [index, channel] of ( + ['NHSAPP', 'EMAIL', 'SMS'] satisfies Channel[] + ).entries()) { + await test.step(`renders ${channel} template preview and fallback blocks`, async () => { + const templateBlock = await reviewPage.getTemplateBlock(channel); + + await expect(templateBlock.number).toHaveText(`${index + 1}`); + await expect(templateBlock.defaultTemplateCard.templateName).toHaveText( + templates[channel].name + ); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateText + ).toBeHidden(); + + await templateBlock.defaultTemplateCard.previewTemplateSummary.click(); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateText + ).toBeVisible(); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateText + ).toHaveText(templates[channel].message as string); + + await expect(reviewPage.getFallbackBlock(channel)).toBeVisible(); + }); + } + + await test.step('for LETTER channel renders template links for default and accessible templates along with conditional template fallback conditions', async () => { + const templateBlock = await reviewPage.getTemplateBlock('LETTER'); + + await expect(templateBlock.number).toHaveText('4'); + + await expect( + templateBlock.defaultTemplateCard.previewTemplateSummary + ).toBeHidden(); + + await expect(templateBlock.defaultTemplateCard.templateLink).toHaveText( + templates.LETTER.name + ); + await expect( + templateBlock.defaultTemplateCard.templateLink + ).toHaveAttribute( + 'href', + `/templates/preview-submitted-letter-template/${templates.LETTER.id}` + ); + + await expect( + templateBlock.getAccessibilityFormatCard('x1').templateLink + ).toHaveText(templates.LARGE_PRINT_LETTER.name); + + await expect( + templateBlock.getAccessibilityFormatCard('x1').templateLink + ).toHaveAttribute( + 'href', + `/templates/preview-submitted-letter-template/${templates.LARGE_PRINT_LETTER.id}` + ); + + for (const [index, language] of ( + ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< + typeof createTemplates + >)[] + ).entries()) { + const links = await templateBlock.getLanguagesCard().templateLink.all(); + await expect(links[index]).toHaveText(templates[language].name); + + await expect(links[index]).toHaveAttribute( + 'href', + `/templates/preview-submitted-letter-template/${templates[language].id}` + ); + } + }); + }); + + test('keep in draft button navigates to choose templates page', async ({ + page, + baseURL, + }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await reviewPage.keepInDraftButton.click(); + + await expect(page).toHaveURL( + `${baseURL}/templates/message-plans/choose-templates/${dbEntry.id}` + ); + + const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); + + await expect(chooseTemplatesPage.messagePlanStatus).toHaveText('Draft'); + }); + + test('move to production button submits plan and navigates to message plans page', async ({ + page, + baseURL, + }) => { + const { dbEntry } = RoutingConfigFactory.createWithChannels( + user, + ['NHSAPP'], + { status: 'DRAFT' } + ).addTemplate('NHSAPP', templates.NHSAPP.id); + + await routingConfigStorageHelper.seed([dbEntry]); + + const reviewPage = new RoutingReviewAndMoveToProductionPage( + page + ).setPathParam('messagePlanId', dbEntry.id); + + await reviewPage.loadPage(); + + await reviewPage.moveToProductionButton.click(); + + await expect(page).toHaveURL(`${baseURL}/templates/message-plans`); + + const messagePlansPage = new RoutingMessagePlansPage(page); + + // Verify the plan now appears in the production section + const productionIdCells = + messagePlansPage.productionMessagePlansTable.getByTestId( + 'message-plan-id-cell' + ); + + const productionCellsText = await productionIdCells.allTextContents(); + + expect(productionCellsText).toContainEqual( + expect.stringContaining(dbEntry.id) + ); + }); +}); From 36b2947488aeea9fab8c4d68873fa6e5330cb6ce Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Mon, 9 Feb 2026 12:19:52 +0000 Subject: [PATCH 29/30] delete disabled test file --- ...e-to-production.routing-component.spec.txt | 375 ------------------ 1 file changed, 375 deletions(-) delete mode 100644 tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt deleted file mode 100644 index b0c5b1d2c..000000000 --- a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.txt +++ /dev/null @@ -1,375 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { Channel } from 'nhs-notify-backend-client'; -import { test, expect } from '@playwright/test'; -import { - createAuthHelper, - TestUser, - testUsers, -} from 'helpers/auth/cognito-auth-helper'; -import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; -import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; -import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; -import { TemplateFactory } from 'helpers/factories/template-factory'; -import { - assertFooterLinks, - assertSignOutLink, - assertHeaderLogoLink, - assertSkipToMainContent, -} from 'helpers/template-mgmt-common.steps'; -import { RoutingReviewAndMoveToProductionPage } from 'pages/routing/review-and-move-to-production-page'; -import { RoutingChooseTemplatesPage } from 'pages/routing'; -import { RoutingMessagePlansPage } from 'pages/routing/message-plans-page'; - -const routingConfigStorageHelper = new RoutingConfigStorageHelper(); -const templateStorageHelper = new TemplateStorageHelper(); - -function createTemplates(user: TestUser) { - const templateIds = { - NHSAPP: randomUUID(), - EMAIL: randomUUID(), - SMS: randomUUID(), - LETTER: randomUUID(), - LARGE_PRINT_LETTER: randomUUID(), - FRENCH_LETTER: randomUUID(), - SPANISH_LETTER: randomUUID(), - }; - - return { - NHSAPP: TemplateFactory.createNhsAppTemplate( - templateIds.NHSAPP, - user, - `Test NHS App template - ${templateIds.NHSAPP}`, - 'SUBMITTED' - ), - EMAIL: TemplateFactory.createEmailTemplate( - templateIds.EMAIL, - user, - `Test Email template - ${templateIds.EMAIL}`, - 'SUBMITTED' - ), - SMS: TemplateFactory.createSmsTemplate( - templateIds.SMS, - user, - `Test SMS template - ${templateIds.SMS}`, - 'SUBMITTED' - ), - LETTER: TemplateFactory.uploadLetterTemplate( - templateIds.LETTER, - user, - `Test Letter template - ${templateIds.LETTER}`, - 'SUBMITTED' - ), - LARGE_PRINT_LETTER: TemplateFactory.uploadLetterTemplate( - templateIds.LARGE_PRINT_LETTER, - user, - `Test Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, - 'SUBMITTED', - 'PASSED', - { letterType: 'x1' } - ), - FRENCH_LETTER: TemplateFactory.uploadLetterTemplate( - templateIds.FRENCH_LETTER, - user, - `Test Letter template French - ${templateIds.FRENCH_LETTER}`, - 'SUBMITTED', - 'PASSED', - { language: 'fr' } - ), - SPANISH_LETTER: TemplateFactory.uploadLetterTemplate( - templateIds.SPANISH_LETTER, - user, - `Test Spanish Letter template - ${templateIds.SPANISH_LETTER}`, - 'SUBMITTED', - 'PASSED', - { language: 'es' } - ), - }; -} - -test.describe('Routing - Review and Move to Production page', () => { - let templates: ReturnType; - - let user: TestUser; - - test.beforeAll(async () => { - user = await createAuthHelper().getTestUser(testUsers.User1.userId); - templates = createTemplates(user); - - await templateStorageHelper.seedTemplateData(Object.values(templates)); - }); - - test.afterAll(async () => { - await routingConfigStorageHelper.deleteSeeded(); - await templateStorageHelper.deleteSeededTemplates(); - }); - - test('common page tests', async ({ page, baseURL }) => { - const { dbEntry } = RoutingConfigFactory.createWithChannels( - user, - ['NHSAPP'], - { status: 'DRAFT' } - ).addTemplate('NHSAPP', templates.NHSAPP.id); - - await routingConfigStorageHelper.seed([dbEntry]); - - const props = { - page: new RoutingReviewAndMoveToProductionPage(page).setPathParam( - 'messagePlanId', - dbEntry.id - ), - baseURL, - }; - - await assertSkipToMainContent(props); - await assertHeaderLogoLink(props); - await assertFooterLinks(props); - await assertSignOutLink(props); - }); - - test('redirects to invalid message plan page when message plan cannot be found', async ({ - page, - baseURL, - }) => { - const reviewPage = new RoutingReviewAndMoveToProductionPage( - page - ).setPathParam('messagePlanId', 'does-not-exist'); - - await reviewPage.loadPage(); - - await expect(page).toHaveURL(`${baseURL}/templates/message-plans/invalid`); - }); - - test('redirects to preview message plan page when message plan is not DRAFT', async ({ - page, - baseURL, - }) => { - const { dbEntry } = RoutingConfigFactory.createWithChannels( - user, - ['NHSAPP'], - { status: 'COMPLETED' } - ).addTemplate('NHSAPP', templates.NHSAPP.id); - - await routingConfigStorageHelper.seed([dbEntry]); - - const reviewPage = new RoutingReviewAndMoveToProductionPage( - page - ).setPathParam('messagePlanId', dbEntry.id); - - await reviewPage.loadPage(); - - await expect(page).toHaveURL( - `${baseURL}/templates/message-plans/preview-message-plan/${dbEntry.id}` - ); - }); - - test('displays message plan name in summary list', async ({ page }) => { - const { dbEntry } = RoutingConfigFactory.createWithChannels( - user, - ['NHSAPP'], - { status: 'DRAFT' } - ).addTemplate('NHSAPP', templates.NHSAPP.id); - - await routingConfigStorageHelper.seed([dbEntry]); - - const reviewPage = new RoutingReviewAndMoveToProductionPage( - page - ).setPathParam('messagePlanId', dbEntry.id); - - await reviewPage.loadPage(); - - await expect(reviewPage.messagePlanName).toHaveText(dbEntry.name); - }); - - test('displays preview of full routing config', async ({ page }) => { - const { dbEntry } = RoutingConfigFactory.createWithChannels( - user, - ['NHSAPP', 'EMAIL', 'SMS', 'LETTER'], - { status: 'DRAFT' } - ) - .addTemplate('NHSAPP', templates.NHSAPP.id) - .addTemplate('EMAIL', templates.EMAIL.id) - .addTemplate('SMS', templates.SMS.id) - .addTemplate('LETTER', templates.LETTER.id) - .addAccessibleFormatTemplate('x1', templates.LARGE_PRINT_LETTER.id) - .addLanguageTemplate('fr', templates.FRENCH_LETTER.id) - .addLanguageTemplate('es', templates.SPANISH_LETTER.id); - - await routingConfigStorageHelper.seed([dbEntry]); - - const reviewPage = new RoutingReviewAndMoveToProductionPage( - page - ).setPathParam('messagePlanId', dbEntry.id); - - await reviewPage.loadPage(); - - await test.step('opens and closes all details sections', async () => { - for (const section of await reviewPage.detailsSections.all()) { - await expect(section).not.toHaveAttribute('open'); - } - - await expect(reviewPage.previewToggleButton).toHaveText( - 'Open all template previews' - ); - - await reviewPage.previewToggleButton.click(); - - for (const section of await reviewPage.detailsSections.all()) { - await expect(section).toHaveAttribute('open'); - } - - await expect(reviewPage.previewToggleButton).toHaveText( - 'Close all template previews' - ); - - await reviewPage.previewToggleButton.click(); - - for (const section of await reviewPage.detailsSections.all()) { - await expect(section).not.toHaveAttribute('open'); - } - - await expect(reviewPage.previewToggleButton).toHaveText( - 'Open all template previews' - ); - }); - - for (const [index, channel] of ( - ['NHSAPP', 'EMAIL', 'SMS'] satisfies Channel[] - ).entries()) { - await test.step(`renders ${channel} template preview and fallback blocks`, async () => { - const templateBlock = await reviewPage.getTemplateBlock(channel); - - await expect(templateBlock.number).toHaveText(`${index + 1}`); - await expect(templateBlock.defaultTemplateCard.templateName).toHaveText( - templates[channel].name - ); - - await expect( - templateBlock.defaultTemplateCard.previewTemplateText - ).toBeHidden(); - - await templateBlock.defaultTemplateCard.previewTemplateSummary.click(); - - await expect( - templateBlock.defaultTemplateCard.previewTemplateText - ).toBeVisible(); - - await expect( - templateBlock.defaultTemplateCard.previewTemplateText - ).toHaveText(templates[channel].message as string); - - await expect(reviewPage.getFallbackBlock(channel)).toBeVisible(); - }); - } - - await test.step('for LETTER channel renders template links for default and accessible templates along with conditional template fallback conditions', async () => { - const templateBlock = await reviewPage.getTemplateBlock('LETTER'); - - await expect(templateBlock.number).toHaveText('4'); - - await expect( - templateBlock.defaultTemplateCard.previewTemplateSummary - ).toBeHidden(); - - await expect(templateBlock.defaultTemplateCard.templateLink).toHaveText( - templates.LETTER.name - ); - await expect( - templateBlock.defaultTemplateCard.templateLink - ).toHaveAttribute( - 'href', - `/templates/preview-submitted-letter-template/${templates.LETTER.id}` - ); - - await expect( - templateBlock.getAccessibilityFormatCard('x1').templateLink - ).toHaveText(templates.LARGE_PRINT_LETTER.name); - - await expect( - templateBlock.getAccessibilityFormatCard('x1').templateLink - ).toHaveAttribute( - 'href', - `/templates/preview-submitted-letter-template/${templates.LARGE_PRINT_LETTER.id}` - ); - - for (const [index, language] of ( - ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType< - typeof createTemplates - >)[] - ).entries()) { - const links = await templateBlock.getLanguagesCard().templateLink.all(); - await expect(links[index]).toHaveText(templates[language].name); - - await expect(links[index]).toHaveAttribute( - 'href', - `/templates/preview-submitted-letter-template/${templates[language].id}` - ); - } - }); - }); - - test('keep in draft button navigates to choose templates page', async ({ - page, - baseURL, - }) => { - const { dbEntry } = RoutingConfigFactory.createWithChannels( - user, - ['NHSAPP'], - { status: 'DRAFT' } - ).addTemplate('NHSAPP', templates.NHSAPP.id); - - await routingConfigStorageHelper.seed([dbEntry]); - - const reviewPage = new RoutingReviewAndMoveToProductionPage( - page - ).setPathParam('messagePlanId', dbEntry.id); - - await reviewPage.loadPage(); - - await reviewPage.keepInDraftButton.click(); - - await expect(page).toHaveURL( - `${baseURL}/templates/message-plans/choose-templates/${dbEntry.id}` - ); - - const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); - - await expect(chooseTemplatesPage.messagePlanStatus).toHaveText('Draft'); - }); - - test('move to production button submits plan and navigates to message plans page', async ({ - page, - baseURL, - }) => { - const { dbEntry } = RoutingConfigFactory.createWithChannels( - user, - ['NHSAPP'], - { status: 'DRAFT' } - ).addTemplate('NHSAPP', templates.NHSAPP.id); - - await routingConfigStorageHelper.seed([dbEntry]); - - const reviewPage = new RoutingReviewAndMoveToProductionPage( - page - ).setPathParam('messagePlanId', dbEntry.id); - - await reviewPage.loadPage(); - - await reviewPage.moveToProductionButton.click(); - - await expect(page).toHaveURL(`${baseURL}/templates/message-plans`); - - const messagePlansPage = new RoutingMessagePlansPage(page); - - // Verify the plan now appears in the production section - const productionIdCells = - messagePlansPage.productionMessagePlansTable.getByTestId( - 'message-plan-id-cell' - ); - - const productionCellsText = await productionIdCells.allTextContents(); - - expect(productionCellsText).toContainEqual( - expect.stringContaining(dbEntry.id) - ); - }); -}); From c8487e68d270d4765a7ca98f863bf512fc93b4bd Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Mon, 9 Feb 2026 12:42:25 +0000 Subject: [PATCH 30/30] testid fix --- .../preview-message-plan/__snapshots__/page.test.tsx.snap | 6 +++++- .../__snapshots__/page.test.tsx.snap | 6 +++++- .../MessagePlanCascadePreview/MessagePlanCascadePreview.tsx | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap index 7faa73e59..8595e8c2f 100644 --- a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap @@ -713,7 +713,9 @@ exports[`full cascade plan matches snapshot 1`] = ` > Large print letter (optional) -

+

@@ -741,6 +743,7 @@ exports[`full cascade plan matches snapshot 1`] = `

Large print letter (optional) -

+

@@ -692,6 +694,7 @@ exports[`Review and move to production page matches snapshot for full cascade 1`

-

+

{template.name} @@ -179,6 +179,7 @@ export function MessagePlanCascadePreview({