From 8b32f7581117c2a14de56e6eb0eea03cbbc449f1 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 14 May 2026 14:49:38 +0100 Subject: [PATCH 01/11] CCM-17640: rename nhsapp-status-handler to core-status-handler --- .../c4/notifhir/viewer/callback/index.md | 2 +- ...ndler.md => c4code-core-status-handler.md} | 4 +- .../terraform/components/dl/README.md | 4 +- ...tch_event_rule_channel_status_published.tf | 4 +- ...vent_source_mapping_core_status_handler.tf | 10 + ...nt_source_mapping_nhsapp_status_handler.tf | 10 - ...f => module_lambda_core_status_handler.tf} | 16 +- ...r.tf => module_sqs_core_status_handler.tf} | 10 +- .../jest.config.ts | 0 .../package.json | 2 +- .../__tests__/apis/sqs-trigger-lambda.test.ts | 8 +- .../src/__tests__/app/ttl-actions.test.ts | 18 +- .../src/__tests__/container.test.ts | 0 .../src/__tests__/data.ts | 2 +- .../src/__tests__/index.test.ts | 0 .../src/__tests__/infra/config.test.ts | 0 .../__tests__/infra/ttl-repository.test.ts | 10 +- .../src/apis/sqs-trigger-lambda.ts | 0 .../src/app/ttl-actions.ts | 0 .../src/container.ts | 0 .../src/index.ts | 0 .../src/infra/config.ts | 0 .../src/infra/ttl-repository.ts | 0 .../src/types/types.ts | 0 .../tsconfig.json | 0 package-lock.json | 300 +++++++++--------- package.json | 2 +- ...nel-status-published.consumer.pact.test.ts | 2 +- .../playwright/constants/backend-constants.ts | 4 +- ... => core-status-handler.component.spec.ts} | 20 +- 30 files changed, 214 insertions(+), 214 deletions(-) rename docs/collections/_diagrams/{c4code-nhsapp-status-handler.md => c4code-core-status-handler.md} (89%) create mode 100644 infrastructure/terraform/components/dl/lambda_event_source_mapping_core_status_handler.tf delete mode 100644 infrastructure/terraform/components/dl/lambda_event_source_mapping_nhsapp_status_handler.tf rename infrastructure/terraform/components/dl/{module_lambda_nhsapp_status_handler.tf => module_lambda_core_status_handler.tf} (84%) rename infrastructure/terraform/components/dl/{module_sqs_nhsapp_status_handler.tf => module_sqs_core_status_handler.tf} (83%) rename lambdas/{nhsapp-status-handler => core-status-handler}/jest.config.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/package.json (92%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/apis/sqs-trigger-lambda.test.ts (97%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/app/ttl-actions.test.ts (75%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/container.test.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/data.ts (95%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/index.test.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/infra/config.test.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/__tests__/infra/ttl-repository.test.ts (83%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/apis/sqs-trigger-lambda.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/app/ttl-actions.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/container.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/index.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/infra/config.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/infra/ttl-repository.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/src/types/types.ts (100%) rename lambdas/{nhsapp-status-handler => core-status-handler}/tsconfig.json (100%) rename tests/playwright/digital-letters-component-tests/{nhsapp-status-handler.component.spec.ts => core-status-handler.component.spec.ts} (93%) diff --git a/docs/architecture/c4/notifhir/viewer/callback/index.md b/docs/architecture/c4/notifhir/viewer/callback/index.md index 26dfc7f69..5a1ce5645 100644 --- a/docs/architecture/c4/notifhir/viewer/callback/index.md +++ b/docs/architecture/c4/notifhir/viewer/callback/index.md @@ -8,7 +8,7 @@ is_not_draft: false last_modified_date: 2026-03-26 owner: Ross Buggins author: Tom D'Roza -diagrams: [c4code-nhsapp-status-handler] +diagrams: [c4code-core-status-handler] events-raised: [viewer-digital-letter-read] events-consumed: [] c4type: code diff --git a/docs/collections/_diagrams/c4code-nhsapp-status-handler.md b/docs/collections/_diagrams/c4code-core-status-handler.md similarity index 89% rename from docs/collections/_diagrams/c4code-nhsapp-status-handler.md rename to docs/collections/_diagrams/c4code-core-status-handler.md index adb158b8e..7ae348413 100644 --- a/docs/collections/_diagrams/c4code-nhsapp-status-handler.md +++ b/docs/collections/_diagrams/c4code-core-status-handler.md @@ -1,12 +1,12 @@ --- -title: c4code-nhsapp-status-handler +title: c4code-core-status-handler --- ```mermaid architecture-beta - group AppStatusHandler(cloud)[NHSAppStatusHandler] + group AppStatusHandler(cloud)[CoreStatusHandler] service optedOutEvent(aws:res-amazon-eventbridge-event)[channel status PUBLISHED v1 Event] service lambda(logos:aws-lambda)[App Status Handler] in AppStatusHandler service sqs(logos:aws-sqs)[App Status Queue] in AppStatusHandler diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 8e169b050..173603601 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -67,6 +67,7 @@ No requirements. | Name | Source | Version | |------|--------|---------| | [core\_notifier](#module\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [core\_status\_handler](#module\_core\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-eventpub.zip | n/a | | [file\_scanner](#module\_file\_scanner) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip | n/a | @@ -76,7 +77,6 @@ No requirements. | [mesh\_download](#module\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [move\_scanned\_files](#module\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | -| [nhsapp\_status\_handler](#module\_nhsapp\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | @@ -95,11 +95,11 @@ No requirements. | [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | | [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | | [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | +| [sqs\_core\_status\_handler](#module\_sqs\_core\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_mesh\_acknowledge](#module\_sqs\_mesh\_acknowledge) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_move\_scanned\_files](#module\_sqs\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | -| [sqs\_nhsapp\_status\_handler](#module\_sqs\_nhsapp\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | | [sqs\_print\_analyser](#module\_sqs\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf index 38203115f..691ebbef0 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf @@ -12,8 +12,8 @@ resource "aws_cloudwatch_event_rule" "channel_status_published" { }) } -resource "aws_cloudwatch_event_target" "sqs_nhsapp_status_handler_target" { +resource "aws_cloudwatch_event_target" "sqs_core_status_handler_target" { rule = aws_cloudwatch_event_rule.channel_status_published.name - arn = module.sqs_nhsapp_status_handler.sqs_queue_arn + arn = module.sqs_core_status_handler.sqs_queue_arn event_bus_name = aws_cloudwatch_event_bus.main.name } diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_status_handler.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_status_handler.tf new file mode 100644 index 000000000..cfd90e31e --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_status_handler.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "core_status_handler" { + event_source_arn = module.sqs_core_status_handler.sqs_queue_arn + function_name = module.core_status_handler.function_name + batch_size = var.queue_batch_size + maximum_batching_window_in_seconds = var.queue_batch_window_seconds + + function_response_types = [ + "ReportBatchItemFailures" + ] +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_nhsapp_status_handler.tf deleted file mode 100644 index c001c6757..000000000 --- a/infrastructure/terraform/components/dl/lambda_event_source_mapping_nhsapp_status_handler.tf +++ /dev/null @@ -1,10 +0,0 @@ -resource "aws_lambda_event_source_mapping" "nhsapp_status_handler" { - event_source_arn = module.sqs_nhsapp_status_handler.sqs_queue_arn - function_name = module.nhsapp_status_handler.function_name - batch_size = var.queue_batch_size - maximum_batching_window_in_seconds = var.queue_batch_window_seconds - - function_response_types = [ - "ReportBatchItemFailures" - ] -} diff --git a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf similarity index 84% rename from infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf rename to infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf index a4e1d6b26..0fbd4ec68 100644 --- a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf @@ -1,8 +1,8 @@ -module "nhsapp_status_handler" { +module "core_status_handler" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip" - function_name = "nhsapp-status-handler" - description = "A function for handling NHS app status" + function_name = "core-status-handler" + description = "A function for handling core status" aws_account_id = var.aws_account_id component = local.component @@ -15,12 +15,12 @@ module "nhsapp_status_handler" { kms_key_arn = module.kms.key_arn iam_policy_document = { - body = data.aws_iam_policy_document.nhsapp_status_handler.json + body = data.aws_iam_policy_document.core_status_handler.json } function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] function_code_base_path = local.aws_lambda_functions_dir_path - function_code_dir = "nhsapp-status-handler/dist" + function_code_dir = "core-status-handler/dist" function_include_common = true handler_function_name = "handler" runtime = "nodejs22.x" @@ -41,7 +41,7 @@ module "nhsapp_status_handler" { } } -data "aws_iam_policy_document" "nhsapp_status_handler" { +data "aws_iam_policy_document" "core_status_handler" { statement { sid = "AllowTtlDynamoAccess" effect = "Allow" @@ -70,7 +70,7 @@ data "aws_iam_policy_document" "nhsapp_status_handler" { } statement { - sid = "SQSPermissionsNhsappStatusHandlerQueue" + sid = "SQSPermissionsCoreStatusHandlerQueue" effect = "Allow" actions = [ @@ -81,7 +81,7 @@ data "aws_iam_policy_document" "nhsapp_status_handler" { ] resources = [ - module.sqs_nhsapp_status_handler.sqs_queue_arn, + module.sqs_core_status_handler.sqs_queue_arn, ] } diff --git a/infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf similarity index 83% rename from infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf rename to infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf index 00136cac7..e547801f8 100644 --- a/infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf @@ -1,4 +1,4 @@ -module "sqs_nhsapp_status_handler" { +module "sqs_core_status_handler" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.4/terraform-sqs.zip" aws_account_id = var.aws_account_id @@ -6,15 +6,15 @@ module "sqs_nhsapp_status_handler" { environment = var.environment project = var.project region = var.region - name = "nhsapp-status-handler" + name = "core-status-handler" sqs_kms_key_arn = module.kms.key_arn visibility_timeout_seconds = var.sqs_visibility_timeout_seconds create_dlq = true max_receive_count = var.sqs_max_receive_count - sqs_policy_overload = data.aws_iam_policy_document.sqs_nhsapp_status_handler.json + sqs_policy_overload = data.aws_iam_policy_document.sqs_core_status_handler.json } -data "aws_iam_policy_document" "sqs_nhsapp_status_handler" { +data "aws_iam_policy_document" "sqs_core_status_handler" { statement { sid = "AllowEventBridgeToSendMessage" effect = "Allow" @@ -29,7 +29,7 @@ data "aws_iam_policy_document" "sqs_nhsapp_status_handler" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-nhsapp-status-handler-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-core-status-handler-queue" ] condition { diff --git a/lambdas/nhsapp-status-handler/jest.config.ts b/lambdas/core-status-handler/jest.config.ts similarity index 100% rename from lambdas/nhsapp-status-handler/jest.config.ts rename to lambdas/core-status-handler/jest.config.ts diff --git a/lambdas/nhsapp-status-handler/package.json b/lambdas/core-status-handler/package.json similarity index 92% rename from lambdas/nhsapp-status-handler/package.json rename to lambdas/core-status-handler/package.json index a9827e47f..3ab7d6550 100644 --- a/lambdas/nhsapp-status-handler/package.json +++ b/lambdas/core-status-handler/package.json @@ -12,7 +12,7 @@ "jest": "^29.7.0", "typescript": "^5.9.3" }, - "name": "nhs-notify-digital-letters-nhsapp-status-handler", + "name": "nhs-notify-digital-letters-core-status-handler", "private": true, "scripts": { "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts similarity index 97% rename from lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts rename to lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index f5d3584e4..bfb0b8222 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,4 +1,4 @@ -import { messageDownloadedEvent, nhsAppStatusEvent } from '__tests__/data'; +import { coreStatusEvent, messageDownloadedEvent } from '__tests__/data'; import { createHandler } from 'apis/sqs-trigger-lambda'; import type { SQSEvent } from 'aws-lambda'; import { @@ -24,7 +24,7 @@ describe('createHandler', () => { let handler: any; const eventBusEvent = { - detail: nhsAppStatusEvent, + detail: coreStatusEvent, }; const digitalLetterReadEvent: DigitalLetterRead = { @@ -61,7 +61,7 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(nhsAppStatusEvent); + expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(coreStatusEvent); expect(eventPublisher.sendEvents).toHaveBeenCalledWith( [digitalLetterReadEvent], validateDigitalLetterRead, @@ -139,7 +139,7 @@ describe('createHandler', () => { const res = await handler(event); - expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(nhsAppStatusEvent); + expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(coreStatusEvent); expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(logger.info).toHaveBeenCalledWith({ diff --git a/lambdas/nhsapp-status-handler/src/__tests__/app/ttl-actions.test.ts b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts similarity index 75% rename from lambdas/nhsapp-status-handler/src/__tests__/app/ttl-actions.test.ts rename to lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts index b39e5dc7b..2b8dfb0cc 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/app/ttl-actions.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts @@ -1,4 +1,4 @@ -import { messageDownloadedEvent, nhsAppStatusEvent } from '__tests__/data'; +import { coreStatusEvent, messageDownloadedEvent } from '__tests__/data'; import { TtlActions } from 'app/ttl-actions'; import { TtlRepository } from 'infra/ttl-repository'; @@ -16,7 +16,7 @@ describe('TtlActions', () => { it('returns success when markWithdrawn succeeds', async () => { repo.markWithdrawn.mockResolvedValue({ event: messageDownloadedEvent }); - const result = await ttlActions.markWithdrawn(nhsAppStatusEvent); + const result = await ttlActions.markWithdrawn(coreStatusEvent); expect(result).toEqual({ result: 'success', @@ -25,11 +25,11 @@ describe('TtlActions', () => { expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('TTL record marked as withdrawn'), - messageReference: nhsAppStatusEvent.data.messageReference, + messageReference: coreStatusEvent.data.messageReference, }), ); expect(repo.markWithdrawn).toHaveBeenCalledWith( - nhsAppStatusEvent.data.messageReference, + coreStatusEvent.data.messageReference, ); }); @@ -37,18 +37,18 @@ describe('TtlActions', () => { // eslint-disable-next-line unicorn/no-useless-undefined repo.markWithdrawn.mockResolvedValue(undefined); - const result = await ttlActions.markWithdrawn(nhsAppStatusEvent); + const result = await ttlActions.markWithdrawn(coreStatusEvent); expect(result).toEqual({ result: 'success' }); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('TTL record not found'), - messageReference: nhsAppStatusEvent.data.messageReference, + messageReference: coreStatusEvent.data.messageReference, }), ); expect(repo.markWithdrawn).toHaveBeenCalledWith( - nhsAppStatusEvent.data.messageReference, + coreStatusEvent.data.messageReference, ); }); @@ -56,13 +56,13 @@ describe('TtlActions', () => { const error = new Error('fail'); repo.markWithdrawn.mockRejectedValue(error); - const result = await ttlActions.markWithdrawn(nhsAppStatusEvent); + const result = await ttlActions.markWithdrawn(coreStatusEvent); expect(result).toEqual({ result: 'failed' }); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('Error marking TTL withdrawn'), - messageReference: nhsAppStatusEvent.data.messageReference, + messageReference: coreStatusEvent.data.messageReference, err: error, }), ); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts b/lambdas/core-status-handler/src/__tests__/container.test.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/__tests__/container.test.ts rename to lambdas/core-status-handler/src/__tests__/container.test.ts diff --git a/lambdas/nhsapp-status-handler/src/__tests__/data.ts b/lambdas/core-status-handler/src/__tests__/data.ts similarity index 95% rename from lambdas/nhsapp-status-handler/src/__tests__/data.ts rename to lambdas/core-status-handler/src/__tests__/data.ts index 6235d612b..674754b63 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/data.ts +++ b/lambdas/core-status-handler/src/__tests__/data.ts @@ -26,7 +26,7 @@ export const messageDownloadedEvent: MESHInboxMessageDownloaded = { }, }; -export const nhsAppStatusEvent: ChannelStatusPublishedEvent = { +export const coreStatusEvent: ChannelStatusPublishedEvent = { data: { messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, supplierStatus: 'paper_letter_opted_out', diff --git a/lambdas/nhsapp-status-handler/src/__tests__/index.test.ts b/lambdas/core-status-handler/src/__tests__/index.test.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/__tests__/index.test.ts rename to lambdas/core-status-handler/src/__tests__/index.test.ts diff --git a/lambdas/nhsapp-status-handler/src/__tests__/infra/config.test.ts b/lambdas/core-status-handler/src/__tests__/infra/config.test.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/__tests__/infra/config.test.ts rename to lambdas/core-status-handler/src/__tests__/infra/config.test.ts diff --git a/lambdas/nhsapp-status-handler/src/__tests__/infra/ttl-repository.test.ts b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts similarity index 83% rename from lambdas/nhsapp-status-handler/src/__tests__/infra/ttl-repository.test.ts rename to lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts index 6fdf14a9a..f9a3f82b2 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/infra/ttl-repository.test.ts +++ b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts @@ -1,6 +1,6 @@ import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { UpdateCommand } from '@aws-sdk/lib-dynamodb'; -import { nhsAppStatusEvent } from '__tests__/data'; +import { coreStatusEvent } from '__tests__/data'; import { TtlRepository } from 'infra/ttl-repository'; describe('TtlRepository', () => { @@ -14,14 +14,14 @@ describe('TtlRepository', () => { }); it('marks item as withdrawn', async () => { - await repo.markWithdrawn(nhsAppStatusEvent.data.messageReference); + await repo.markWithdrawn(coreStatusEvent.data.messageReference); const updateCommand: UpdateCommand = dynamoDocumentClient.send.mock.calls[0][0]; expect(updateCommand.input).toStrictEqual({ TableName: tableName, Key: { - PK: nhsAppStatusEvent.data.messageReference, + PK: coreStatusEvent.data.messageReference, SK: 'TTL', }, ConditionExpression: 'attribute_exists(PK)', @@ -41,7 +41,7 @@ describe('TtlRepository', () => { dynamoDocumentClient.send.mockRejectedValue(error); const result = await repo.markWithdrawn( - nhsAppStatusEvent.data.messageReference, + coreStatusEvent.data.messageReference, ); expect(result).toBeUndefined(); @@ -52,7 +52,7 @@ describe('TtlRepository', () => { dynamoDocumentClient.send.mockRejectedValue(error); await expect( - repo.markWithdrawn(nhsAppStatusEvent.data.messageReference), + repo.markWithdrawn(coreStatusEvent.data.messageReference), ).rejects.toThrow(error); }); }); diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts rename to lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts diff --git a/lambdas/nhsapp-status-handler/src/app/ttl-actions.ts b/lambdas/core-status-handler/src/app/ttl-actions.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/app/ttl-actions.ts rename to lambdas/core-status-handler/src/app/ttl-actions.ts diff --git a/lambdas/nhsapp-status-handler/src/container.ts b/lambdas/core-status-handler/src/container.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/container.ts rename to lambdas/core-status-handler/src/container.ts diff --git a/lambdas/nhsapp-status-handler/src/index.ts b/lambdas/core-status-handler/src/index.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/index.ts rename to lambdas/core-status-handler/src/index.ts diff --git a/lambdas/nhsapp-status-handler/src/infra/config.ts b/lambdas/core-status-handler/src/infra/config.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/infra/config.ts rename to lambdas/core-status-handler/src/infra/config.ts diff --git a/lambdas/nhsapp-status-handler/src/infra/ttl-repository.ts b/lambdas/core-status-handler/src/infra/ttl-repository.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/infra/ttl-repository.ts rename to lambdas/core-status-handler/src/infra/ttl-repository.ts diff --git a/lambdas/nhsapp-status-handler/src/types/types.ts b/lambdas/core-status-handler/src/types/types.ts similarity index 100% rename from lambdas/nhsapp-status-handler/src/types/types.ts rename to lambdas/core-status-handler/src/types/types.ts diff --git a/lambdas/nhsapp-status-handler/tsconfig.json b/lambdas/core-status-handler/tsconfig.json similarity index 100% rename from lambdas/nhsapp-status-handler/tsconfig.json rename to lambdas/core-status-handler/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 2fa724e5f..991e9e3a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "lambdas/report-event-transformer", "lambdas/move-scanned-files-lambda", "lambdas/report-generator", - "lambdas/nhsapp-status-handler", + "lambdas/core-status-handler", "utils/utils", "utils/sender-management", "scripts", @@ -398,11 +398,12 @@ "dev": true, "license": "MIT" }, - "lambdas/file-scanner-lambda": { - "name": "nhs-notify-digital-letters-file-scanner-lambda", + "lambdas/core-status-handler": { + "name": "nhs-notify-digital-letters-core-status-handler", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-s3": "^3.908.0", + "@aws-sdk/client-dynamodb": "^3.981.0", + "@aws-sdk/lib-dynamodb": "^3.908.0", "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, @@ -411,12 +412,13 @@ "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", "jest": "^29.7.0", - "jest-mock-extended": "^3.0.7", "typescript": "^5.9.3" } }, - "lambdas/file-scanner-lambda/node_modules/@jest/core": { + "lambdas/core-status-handler/node_modules/@jest/core": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, "license": "MIT", "dependencies": { @@ -461,8 +463,10 @@ } } }, - "lambdas/file-scanner-lambda/node_modules/@jest/schemas": { + "lambdas/core-status-handler/node_modules/@jest/schemas": { "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", "dependencies": { @@ -472,8 +476,10 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/@jest/types": { + "lambdas/core-status-handler/node_modules/@jest/types": { "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "license": "MIT", "dependencies": { @@ -488,15 +494,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/@sinclair/typebox": { + "lambdas/core-status-handler/node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, - "lambdas/file-scanner-lambda/node_modules/@types/jest": { + "lambdas/core-status-handler/node_modules/@types/jest": { "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -504,8 +512,10 @@ "pretty-format": "^29.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/expect": { + "lambdas/core-status-handler/node_modules/expect": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", "dependencies": { @@ -519,11 +529,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/jest": { + "lambdas/core-status-handler/node_modules/jest": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -545,8 +556,10 @@ } } }, - "lambdas/file-scanner-lambda/node_modules/jest-cli": { + "lambdas/core-status-handler/node_modules/jest-cli": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "license": "MIT", "dependencies": { @@ -577,8 +590,10 @@ } } }, - "lambdas/file-scanner-lambda/node_modules/jest-message-util": { + "lambdas/core-status-handler/node_modules/jest-message-util": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, "license": "MIT", "dependencies": { @@ -596,28 +611,20 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/jest-mock-extended": { - "version": "3.0.7", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-essentials": "^10.0.0" - }, - "peerDependencies": { - "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", - "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" - } - }, - "lambdas/file-scanner-lambda/node_modules/jest-regex-util": { + "lambdas/core-status-handler/node_modules/jest-regex-util": { "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/jest-snapshot": { + "lambdas/core-status-handler/node_modules/jest-snapshot": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, "license": "MIT", "dependencies": { @@ -646,8 +653,10 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/jest-util": { + "lambdas/core-status-handler/node_modules/jest-util": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "license": "MIT", "dependencies": { @@ -662,7 +671,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/picomatch": { + "lambdas/core-status-handler/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", @@ -675,8 +684,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "lambdas/file-scanner-lambda/node_modules/pretty-format": { + "lambdas/core-status-handler/node_modules/pretty-format": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -688,32 +699,31 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/file-scanner-lambda/node_modules/react-is": { + "lambdas/core-status-handler/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, - "lambdas/key-generation": { + "lambdas/file-scanner-lambda": { + "name": "nhs-notify-digital-letters-file-scanner-lambda", "version": "0.0.1", "dependencies": { - "date-fns": "^4.1.0", - "esbuild": "^0.25.9", - "jose": "^5.10.0", - "utils": "*" + "@aws-sdk/client-s3": "^3.908.0", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", - "@types/aws-lambda": "^8.10.148", + "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", - "@types/node": "^24.0.10", "jest": "^29.7.0", "jest-mock-extended": "^3.0.7", - "typescript": "^5.8.2" + "typescript": "^5.9.3" } }, - "lambdas/key-generation/node_modules/@jest/core": { + "lambdas/file-scanner-lambda/node_modules/@jest/core": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -759,7 +769,7 @@ } } }, - "lambdas/key-generation/node_modules/@jest/schemas": { + "lambdas/file-scanner-lambda/node_modules/@jest/schemas": { "version": "29.6.3", "dev": true, "license": "MIT", @@ -770,7 +780,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/@jest/types": { + "lambdas/file-scanner-lambda/node_modules/@jest/types": { "version": "29.6.3", "dev": true, "license": "MIT", @@ -786,14 +796,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/@sinclair/typebox": { + "lambdas/file-scanner-lambda/node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, - "lambdas/key-generation/node_modules/@types/jest": { + "lambdas/file-scanner-lambda/node_modules/@types/jest": { "version": "29.5.14", "dev": true, "license": "MIT", @@ -802,17 +812,7 @@ "pretty-format": "^29.0.0" } }, - "lambdas/key-generation/node_modules/@types/node": { - "version": "24.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", - "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "lambdas/key-generation/node_modules/expect": { + "lambdas/file-scanner-lambda/node_modules/expect": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -827,7 +827,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/jest": { + "lambdas/file-scanner-lambda/node_modules/jest": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -853,7 +853,7 @@ } } }, - "lambdas/key-generation/node_modules/jest-cli": { + "lambdas/file-scanner-lambda/node_modules/jest-cli": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -885,7 +885,7 @@ } } }, - "lambdas/key-generation/node_modules/jest-message-util": { + "lambdas/file-scanner-lambda/node_modules/jest-message-util": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -904,7 +904,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/jest-mock-extended": { + "lambdas/file-scanner-lambda/node_modules/jest-mock-extended": { "version": "3.0.7", "dev": true, "license": "MIT", @@ -916,7 +916,7 @@ "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, - "lambdas/key-generation/node_modules/jest-regex-util": { + "lambdas/file-scanner-lambda/node_modules/jest-regex-util": { "version": "29.6.3", "dev": true, "license": "MIT", @@ -924,7 +924,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/jest-snapshot": { + "lambdas/file-scanner-lambda/node_modules/jest-snapshot": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -954,7 +954,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/jest-util": { + "lambdas/file-scanner-lambda/node_modules/jest-util": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -970,16 +970,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "lambdas/key-generation/node_modules/picomatch": { + "lambdas/file-scanner-lambda/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", @@ -992,7 +983,7 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "lambdas/key-generation/node_modules/pretty-format": { + "lambdas/file-scanner-lambda/node_modules/pretty-format": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1005,31 +996,32 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/react-is": { + "lambdas/file-scanner-lambda/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, - "lambdas/move-scanned-files-lambda": { - "name": "nhs-notify-digital-move-scanned-files-lambda", + "lambdas/key-generation": { "version": "0.0.1", "dependencies": { - "axios": "^1.15.0", - "digital-letters-events": "^0.0.1", - "utils": "^0.0.1" + "date-fns": "^4.1.0", + "esbuild": "^0.25.9", + "jose": "^5.10.0", + "utils": "*" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", - "@types/aws-lambda": "^8.10.155", + "@types/aws-lambda": "^8.10.148", "@types/jest": "^29.5.14", + "@types/node": "^24.0.10", "jest": "^29.7.0", "jest-mock-extended": "^3.0.7", - "typescript": "^5.9.3" + "typescript": "^5.8.2" } }, - "lambdas/move-scanned-files-lambda/node_modules/@jest/core": { + "lambdas/key-generation/node_modules/@jest/core": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1075,7 +1067,7 @@ } } }, - "lambdas/move-scanned-files-lambda/node_modules/@jest/schemas": { + "lambdas/key-generation/node_modules/@jest/schemas": { "version": "29.6.3", "dev": true, "license": "MIT", @@ -1086,7 +1078,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/@jest/types": { + "lambdas/key-generation/node_modules/@jest/types": { "version": "29.6.3", "dev": true, "license": "MIT", @@ -1102,12 +1094,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/@sinclair/typebox": { + "lambdas/key-generation/node_modules/@sinclair/typebox": { "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, - "lambdas/move-scanned-files-lambda/node_modules/@types/jest": { + "lambdas/key-generation/node_modules/@types/jest": { "version": "29.5.14", "dev": true, "license": "MIT", @@ -1116,7 +1110,17 @@ "pretty-format": "^29.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/expect": { + "lambdas/key-generation/node_modules/@types/node": { + "version": "24.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "lambdas/key-generation/node_modules/expect": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1131,7 +1135,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/jest": { + "lambdas/key-generation/node_modules/jest": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1157,7 +1161,7 @@ } } }, - "lambdas/move-scanned-files-lambda/node_modules/jest-cli": { + "lambdas/key-generation/node_modules/jest-cli": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1189,7 +1193,7 @@ } } }, - "lambdas/move-scanned-files-lambda/node_modules/jest-message-util": { + "lambdas/key-generation/node_modules/jest-message-util": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1208,7 +1212,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/jest-mock-extended": { + "lambdas/key-generation/node_modules/jest-mock-extended": { "version": "3.0.7", "dev": true, "license": "MIT", @@ -1220,7 +1224,7 @@ "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/jest-regex-util": { + "lambdas/key-generation/node_modules/jest-regex-util": { "version": "29.6.3", "dev": true, "license": "MIT", @@ -1228,7 +1232,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/jest-snapshot": { + "lambdas/key-generation/node_modules/jest-snapshot": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1258,7 +1262,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/jest-util": { + "lambdas/key-generation/node_modules/jest-util": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1274,7 +1278,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/picomatch": { + "lambdas/key-generation/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "lambdas/key-generation/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", @@ -1287,7 +1300,7 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "lambdas/move-scanned-files-lambda/node_modules/pretty-format": { + "lambdas/key-generation/node_modules/pretty-format": { "version": "29.7.0", "dev": true, "license": "MIT", @@ -1300,17 +1313,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/move-scanned-files-lambda/node_modules/react-is": { + "lambdas/key-generation/node_modules/react-is": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, - "lambdas/nhsapp-status-handler": { - "name": "nhs-notify-digital-letters-nhsapp-status-handler", + "lambdas/move-scanned-files-lambda": { + "name": "nhs-notify-digital-move-scanned-files-lambda", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-dynamodb": "^3.981.0", - "@aws-sdk/lib-dynamodb": "^3.908.0", + "axios": "^1.15.0", "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, @@ -1319,13 +1333,12 @@ "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", "typescript": "^5.9.3" } }, - "lambdas/nhsapp-status-handler/node_modules/@jest/core": { + "lambdas/move-scanned-files-lambda/node_modules/@jest/core": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, "license": "MIT", "dependencies": { @@ -1370,10 +1383,8 @@ } } }, - "lambdas/nhsapp-status-handler/node_modules/@jest/schemas": { + "lambdas/move-scanned-files-lambda/node_modules/@jest/schemas": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", "dependencies": { @@ -1383,10 +1394,8 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/@jest/types": { + "lambdas/move-scanned-files-lambda/node_modules/@jest/types": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "license": "MIT", "dependencies": { @@ -1401,17 +1410,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/@sinclair/typebox": { + "lambdas/move-scanned-files-lambda/node_modules/@sinclair/typebox": { "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, - "lambdas/nhsapp-status-handler/node_modules/@types/jest": { + "lambdas/move-scanned-files-lambda/node_modules/@types/jest": { "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1419,10 +1424,8 @@ "pretty-format": "^29.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/expect": { + "lambdas/move-scanned-files-lambda/node_modules/expect": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", "dependencies": { @@ -1436,12 +1439,11 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/jest": { + "lambdas/move-scanned-files-lambda/node_modules/jest": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -1463,10 +1465,8 @@ } } }, - "lambdas/nhsapp-status-handler/node_modules/jest-cli": { + "lambdas/move-scanned-files-lambda/node_modules/jest-cli": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "license": "MIT", "dependencies": { @@ -1497,10 +1497,8 @@ } } }, - "lambdas/nhsapp-status-handler/node_modules/jest-message-util": { + "lambdas/move-scanned-files-lambda/node_modules/jest-message-util": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1518,20 +1516,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/jest-regex-util": { + "lambdas/move-scanned-files-lambda/node_modules/jest-mock-extended": { + "version": "3.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-regex-util": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/jest-snapshot": { + "lambdas/move-scanned-files-lambda/node_modules/jest-snapshot": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, "license": "MIT", "dependencies": { @@ -1560,10 +1566,8 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/jest-util": { + "lambdas/move-scanned-files-lambda/node_modules/jest-util": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "license": "MIT", "dependencies": { @@ -1578,7 +1582,7 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/picomatch": { + "lambdas/move-scanned-files-lambda/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", @@ -1591,10 +1595,8 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "lambdas/nhsapp-status-handler/node_modules/pretty-format": { + "lambdas/move-scanned-files-lambda/node_modules/pretty-format": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1606,10 +1608,8 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/nhsapp-status-handler/node_modules/react-is": { + "lambdas/move-scanned-files-lambda/node_modules/react-is": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, @@ -21840,6 +21840,10 @@ "resolved": "lambdas/core-notifier-lambda", "link": true }, + "node_modules/nhs-notify-digital-letters-core-status-handler": { + "resolved": "lambdas/core-status-handler", + "link": true + }, "node_modules/nhs-notify-digital-letters-file-scanner-lambda": { "resolved": "lambdas/file-scanner-lambda", "link": true @@ -21848,10 +21852,6 @@ "resolved": "tests/playwright", "link": true }, - "node_modules/nhs-notify-digital-letters-nhsapp-status-handler": { - "resolved": "lambdas/nhsapp-status-handler", - "link": true - }, "node_modules/nhs-notify-digital-letters-pact-tests": { "resolved": "tests/pact-tests", "link": true diff --git a/package.json b/package.json index 9a2b25239..1b79a51f7 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "lambdas/report-event-transformer", "lambdas/move-scanned-files-lambda", "lambdas/report-generator", - "lambdas/nhsapp-status-handler", + "lambdas/core-status-handler", "utils/utils", "utils/sender-management", "scripts", diff --git a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts index e85f905d7..4ba183c98 100644 --- a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts +++ b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts @@ -13,7 +13,7 @@ import { import { getPathFromProvider } from '../utils/path-utils'; async function handle(event: unknown) { - // The schema used by the nhsapp-status-handler to validate the event. + // The schema used by the core-status-handler to validate the event. $ChannelStatusPublishedEvent.parse(event); } diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index 280192bd7..cd3cfff6d 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -38,7 +38,7 @@ export const PRINT_SENDER_DLQ_NAME = `${CSI}-print-sender-dlq`; export const MOVE_SCANNED_FILES_NAME = `${CSI}-move-scanned-files-queue`; export const MOVE_SCANNED_FILES_DLQ_NAME = `${CSI}-move-scanned-files-dlq`; export const REPORT_SENDER_DLQ_NAME = `${CSI}-report-sender-dlq`; -export const NHSAPP_STATUS_HANDLER_DLQ_NAME = `${CSI}-nhsapp-status-handler-dlq`; +export const CORE_STATUS_HANDLER_DLQ_NAME = `${CSI}-core-status-handler-dlq`; // Queue Url Prefix export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`; @@ -78,7 +78,7 @@ export const PRINT_SENDER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-send export const MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-move-scanned-files`; export const MESH_DOWNLOAD_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-mesh-download`; export const CREATE_TTL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-ttl-create`; -export const NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-nhsapp-status-handler`; +export const CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-status-handler`; // Data Firehose export const FIREHOSE_STREAM_NAME = `${CSI}-to-s3-reporting`; diff --git a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts similarity index 93% rename from tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts rename to tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts index eb9f65a60..d0c7a949a 100644 --- a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts @@ -1,8 +1,8 @@ import { expect, test } from '@playwright/test'; import { + CORE_STATUS_HANDLER_DLQ_NAME, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, ENV, - NHSAPP_STATUS_HANDLER_DLQ_NAME, - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, } from 'constants/backend-constants'; import { SENDER_ID_VALID_FOR_NOTIFY_SANDBOX } from 'constants/tests-constants'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; @@ -13,9 +13,9 @@ import expectToPassEventually from 'helpers/expectations'; import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; -test.describe('Digital Letters - NHSApp Status Handler', () => { +test.describe('Digital Letters - Core Status Handler', () => { test.beforeAll(async () => { - await purgeQueue(NHSAPP_STATUS_HANDLER_DLQ_NAME); + await purgeQueue(CORE_STATUS_HANDLER_DLQ_NAME); }); const baseEvent: MESHInboxMessageDownloaded = { @@ -105,7 +105,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, [ '$.message.description = "TTL record marked as withdrawn"', `$.message.messageReference = "${concatedReference}"`, @@ -179,7 +179,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, [ '$.message.description = "TTL record marked as withdrawn"', `$.message.messageReference = "${concatedReference}"`, @@ -210,7 +210,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, [ '$.message.description = "TTL record not found"', `$.message.messageReference = "${concatedReference}"`, @@ -221,7 +221,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { }, 150); }); - test('should send invalid event to nhsapp status handler dlq', async () => { + test('should send invalid event to core status handler dlq', async () => { test.setTimeout(160_000); const concatedReference = `${uuidv4()}_${uuidv4()}`; @@ -243,7 +243,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await Promise.all([ expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, [ '$.message.description = "Error parsing sqs record"', `$.message.messageReference = "${concatedReference}"`, @@ -256,7 +256,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { }, 150), expectMessageContainingString( - NHSAPP_STATUS_HANDLER_DLQ_NAME, + CORE_STATUS_HANDLER_DLQ_NAME, concatedReference, 150, ), From 8a61afa190e0a12fde6e02bdb64c0c74d5cecce7 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 22 May 2026 09:07:18 +0100 Subject: [PATCH 02/11] CCM-17640: add handling of extra event statuses --- ...tch_event_rule_channel_status_published.tf | 2 +- ...tch_event_rule_message_status_published.tf | 19 + .../dl/module_lambda_core_status_handler.tf | 1 + .../dl/module_sqs_core_status_handler.tf | 5 +- .../__tests__/apis/sqs-trigger-lambda.test.ts | 82 ++- .../app/status-action-resolver.test.ts | 214 ++++++ .../src/__tests__/app/ttl-actions.test.ts | 163 +++-- .../core-status-handler/src/__tests__/data.ts | 20 +- .../__tests__/infra/ttl-repository.test.ts | 98 ++- .../src/apis/sqs-trigger-lambda.ts | 60 +- .../src/app/status-action-resolver.ts | 68 ++ .../src/app/ttl-actions.ts | 45 +- lambdas/core-status-handler/src/container.ts | 5 +- .../src/infra/ttl-repository.ts | 31 +- .../core-status-handler/src/types/types.ts | 2 + ...queue-digital-letter-read-data.schema.yaml | 3 + .../2025-10-draft/defs/core.schema.yaml | 5 + .../core-status-handler.component.spec.ts | 657 ++++++++++++++---- .../file-scanner.component.spec.ts | 254 +++---- .../mesh-acknowledge.component.spec.ts | 7 +- .../print-sender.component.spec.ts | 7 +- tests/playwright/helpers/event-builders.ts | 2 + turbo.json | 5 + .../types/channel-status-published-event.ts | 12 - .../src/types/core-status-published-event.ts | 46 ++ utils/utils/src/types/index.ts | 2 +- 26 files changed, 1408 insertions(+), 407 deletions(-) create mode 100644 infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf create mode 100644 lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts create mode 100644 lambdas/core-status-handler/src/app/status-action-resolver.ts delete mode 100644 utils/utils/src/types/channel-status-published-event.ts create mode 100644 utils/utils/src/types/core-status-published-event.ts diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf index 691ebbef0..29f67e5bd 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_channel_status_published.tf @@ -12,7 +12,7 @@ resource "aws_cloudwatch_event_rule" "channel_status_published" { }) } -resource "aws_cloudwatch_event_target" "sqs_core_status_handler_target" { +resource "aws_cloudwatch_event_target" "channel_status_published_core_status_handler" { rule = aws_cloudwatch_event_rule.channel_status_published.name arn = module.sqs_core_status_handler.sqs_queue_arn event_bus_name = aws_cloudwatch_event_bus.main.name diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf new file mode 100644 index 000000000..80a532895 --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf @@ -0,0 +1,19 @@ +resource "aws_cloudwatch_event_rule" "message_status_published" { + name = "${local.csi}-message-status-published" + description = "message status PUBLISHED event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.message.status.PUBLISHED.v1" + ], + } + }) +} + +resource "aws_cloudwatch_event_target" "message_status_published_core_status_handler" { + rule = aws_cloudwatch_event_rule.message_status_published.name + arn = module.sqs_core_status_handler.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf index bb6253514..428a4e276 100644 --- a/infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_core_status_handler.tf @@ -50,6 +50,7 @@ data "aws_iam_policy_document" "core_status_handler" { actions = [ "dynamodb:UpdateItem", + "dynamodb:DeleteItem", ] resources = [ diff --git a/infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf b/infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf index e547801f8..c11a46640 100644 --- a/infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_sqs_core_status_handler.tf @@ -35,7 +35,10 @@ data "aws_iam_policy_document" "sqs_core_status_handler" { condition { test = "ArnLike" variable = "aws:SourceArn" - values = [aws_cloudwatch_event_rule.channel_status_published.arn] + values = [ + aws_cloudwatch_event_rule.channel_status_published.arn, + aws_cloudwatch_event_rule.message_status_published.arn + ] } } } diff --git a/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index bfb0b8222..73a1f08bc 100644 --- a/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,4 +1,4 @@ -import { coreStatusEvent, messageDownloadedEvent } from '__tests__/data'; +import { channelStatusEvent, messageDownloadedEvent } from '__tests__/data'; import { createHandler } from 'apis/sqs-trigger-lambda'; import type { SQSEvent } from 'aws-lambda'; import { @@ -18,13 +18,13 @@ mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001'); mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); describe('createHandler', () => { - let ttlActions: any; + let statusActionResolver: any; let eventPublisher: any; let logger: any; let handler: any; const eventBusEvent = { - detail: coreStatusEvent, + detail: channelStatusEvent, }; const digitalLetterReadEvent: DigitalLetterRead = { @@ -39,18 +39,20 @@ describe('createHandler', () => { data: { messageReference: messageDownloadedEvent.data.messageReference, senderId: messageDownloadedEvent.data.senderId, + supplierStatus: 'paper_letter_opted_in', }, }; beforeEach(() => { - ttlActions = { markWithdrawn: jest.fn() }; + statusActionResolver = { resolve: jest.fn() }; eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; logger = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; - handler = createHandler({ ttlActions, eventPublisher, logger }); + handler = createHandler({ eventPublisher, logger, statusActionResolver }); }); it('processes a valid SQS event and returns success', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ + statusActionResolver.resolve.mockResolvedValue({ + publish: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); @@ -61,7 +63,9 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(coreStatusEvent); + expect(statusActionResolver.resolve).toHaveBeenCalledWith( + channelStatusEvent, + ); expect(eventPublisher.sendEvents).toHaveBeenCalledWith( [digitalLetterReadEvent], validateDigitalLetterRead, @@ -77,6 +81,7 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 0, retrieved: 1, + skipped: 0, success: 1, }); }); @@ -99,6 +104,7 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 1, retrieved: 1, + skipped: 0, success: 0, }); }); @@ -127,25 +133,29 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 1, retrieved: 1, + skipped: 0, success: 0, }); }); - it('handles ttlActions.markWithdrawn failure', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ result: 'failed' }); + it('handles statusActionResolver.resolve failure', async () => { + statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], } as any; const res = await handler(event); - expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(coreStatusEvent); + expect(statusActionResolver.resolve).toHaveBeenCalledWith( + channelStatusEvent, + ); expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 1, retrieved: 1, + skipped: 0, success: 0, }); }); @@ -172,6 +182,7 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 1, retrieved: 1, + skipped: 0, success: 0, }); }); @@ -197,6 +208,7 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 1, retrieved: 1, + skipped: 0, success: 0, }); @@ -204,7 +216,8 @@ describe('createHandler', () => { }); it('processes multiple successful events and sends them as a batch', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ + statusActionResolver.resolve.mockResolvedValue({ + publish: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); @@ -219,7 +232,7 @@ describe('createHandler', () => { const res = await handler(sqsEvent); expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).toHaveBeenCalledTimes(3); + expect(statusActionResolver.resolve).toHaveBeenCalledTimes(3); expect(eventPublisher.sendEvents).toHaveBeenCalledWith( [digitalLetterReadEvent, digitalLetterReadEvent, digitalLetterReadEvent], validateDigitalLetterRead, @@ -228,12 +241,14 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 0, retrieved: 3, + skipped: 0, success: 3, }); }); it('handles partial event publishing failures and logs warning', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ + statusActionResolver.resolve.mockResolvedValue({ + publish: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); @@ -262,7 +277,8 @@ describe('createHandler', () => { }); it('handles event publishing exception and logs warning', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ + statusActionResolver.resolve.mockResolvedValue({ + publish: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); @@ -288,7 +304,7 @@ describe('createHandler', () => { }); it('does not call eventPublisher when no successful events', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ result: 'failed' }); + statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], @@ -302,12 +318,33 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 1, retrieved: 1, + skipped: 0, + success: 0, + }); + }); + + it('does not call eventPublisher for skipped events', async () => { + statusActionResolver.resolve.mockResolvedValue({ result: 'skipped' }); + + const event: SQSEvent = { + Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + skipped: 1, success: 0, }); }); it('does not call eventPublisher when no TTL record is found', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ result: 'success' }); + statusActionResolver.resolve.mockResolvedValue({ result: 'success' }); const event: SQSEvent = { Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], @@ -321,23 +358,27 @@ describe('createHandler', () => { description: 'Processed SQS Event.', failed: 0, retrieved: 1, + skipped: 0, success: 1, }); }); - it('handles mixed success and failure scenarios', async () => { - ttlActions.markWithdrawn + it('handles mixed success, failure, and skipped scenarios', async () => { + statusActionResolver.resolve .mockResolvedValueOnce({ + publish: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }) - .mockResolvedValueOnce({ result: 'failed' }); + .mockResolvedValueOnce({ result: 'failed' }) + .mockResolvedValueOnce({ result: 'skipped' }); const event: SQSEvent = { Records: [ { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, { body: '{}', messageId: 'msg2' }, { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg4' }, ], } as any; @@ -354,7 +395,8 @@ describe('createHandler', () => { expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 2, - retrieved: 3, + retrieved: 4, + skipped: 1, success: 1, }); }); diff --git a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts new file mode 100644 index 000000000..4d16f806e --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts @@ -0,0 +1,214 @@ +import { + channelStatusEvent, + messageDownloadedEvent, + messagesStatusEvent, +} from '__tests__/data'; +import { StatusActionResolver } from 'app/status-action-resolver'; +import { TtlActions } from 'app/ttl-actions'; +import { + ChannelStatusPublishedEvent, + MessageStatusPublishedEvent, +} from 'utils'; + +const successOutcome = { + result: 'success' as const, + ttlItem: { event: messageDownloadedEvent }, +}; + +describe('StatusActionResolver', () => { + let ttlActions: jest.Mocked>; + let logger: { warn: jest.Mock; info: jest.Mock }; + let resolver: StatusActionResolver; + + beforeEach(() => { + ttlActions = { markWithdrawn: jest.fn(), delete: jest.fn() } as any; + logger = { warn: jest.fn(), info: jest.fn() }; + resolver = new StatusActionResolver(ttlActions as any, logger as any); + }); + + describe('ChannelStatusPublishedEvent', () => { + it('when supplierStatus is paper_letter_opted_out - calls markWithdrawn and publishes DigitalLetterRead event', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusEvent, + data: { + ...channelStatusEvent.data, + supplierStatus: 'paper_letter_opted_out', + }, + }; + ttlActions.markWithdrawn.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(result).toEqual({ + ...successOutcome, + publish: { supplierStatus: 'paper_letter_opted_out' }, + }); + }); + + it('when supplierStatus is paper_letter_opted_in - calls delete and publishes DigitalLetterRead event', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusEvent, + data: { + ...channelStatusEvent.data, + supplierStatus: 'paper_letter_opted_in', + }, + }; + ttlActions.delete.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.delete).toHaveBeenCalledWith(event); + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(result).toEqual({ + ...successOutcome, + publish: { supplierStatus: 'paper_letter_opted_in' }, + }); + }); + + it('when supplierStatus is rejected, channelStatus is failed - calls delete', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusEvent, + data: { + ...channelStatusEvent.data, + supplierStatus: 'rejected', + channelStatus: 'failed', + }, + }; + ttlActions.delete.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.delete).toHaveBeenCalledWith(event); + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(result).toEqual({ ...successOutcome }); + }); + + it('when supplierStatus is rejected, but channelStatus is not failed - skips', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusEvent, + data: { + ...channelStatusEvent.data, + supplierStatus: 'rejected', + channelStatus: 'retry', + } as unknown as ChannelStatusPublishedEvent['data'], + }; + ttlActions.delete.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped' }); + }); + + it('when supplierStatus is unrecognised - skips', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusEvent, + data: { + ...channelStatusEvent.data, + supplierStatus: 'some_other_status', + } as unknown as ChannelStatusPublishedEvent['data'], + }; + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped' }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Event skipped', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: event.data, + }), + ); + }); + }); + + describe('MessageStatusPublishedEvent', () => { + it('when messageStatus is failed and resolvedChannels is empty - calls markWithdrawn', async () => { + const event: MessageStatusPublishedEvent = { + ...messagesStatusEvent, + data: { + ...messagesStatusEvent.data, + messageStatus: 'failed', + resolvedChannels: [], + }, + }; + ttlActions.markWithdrawn.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(result).toEqual({ ...successOutcome }); + }); + + it('when messageStatus is failed and resolvedChannels is absent - calls markWithdrawn', async () => { + const event: MessageStatusPublishedEvent = { + ...messagesStatusEvent, + data: { + ...messagesStatusEvent.data, + messageStatus: 'failed', + resolvedChannels: undefined, + }, + }; + ttlActions.markWithdrawn.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); + expect(result).toEqual({ ...successOutcome }); + }); + + it('when messageStatus is failed but resolvedChannels is non-empty - skips', async () => { + const event: MessageStatusPublishedEvent = { + ...messagesStatusEvent, + data: { + ...messagesStatusEvent.data, + messageStatus: 'failed', + resolvedChannels: ['letter'], + }, + }; + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped' }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Event skipped', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: event.data, + }), + ); + }); + + it('when messageStatus is not failed - skips', async () => { + const event: MessageStatusPublishedEvent = { + ...messagesStatusEvent, + data: { + ...messagesStatusEvent.data, + messageStatus: 'delivered', + resolvedChannels: [], + } as unknown as MessageStatusPublishedEvent['data'], + }; + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped' }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Event skipped', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: event.data, + }), + ); + }); + }); +}); diff --git a/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts index 2b8dfb0cc..ec37a0901 100644 --- a/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts @@ -1,4 +1,4 @@ -import { coreStatusEvent, messageDownloadedEvent } from '__tests__/data'; +import { channelStatusEvent, messageDownloadedEvent } from '__tests__/data'; import { TtlActions } from 'app/ttl-actions'; import { TtlRepository } from 'infra/ttl-repository'; @@ -7,64 +7,131 @@ describe('TtlActions', () => { let logger: any; let ttlActions: TtlActions; - beforeEach(() => { - repo = { markWithdrawn: jest.fn() } as any; - logger = { warn: jest.fn(), info: jest.fn() }; - ttlActions = new TtlActions(repo, logger); - }); + describe('markWithdrawn', () => { + beforeEach(() => { + repo = { markWithdrawn: jest.fn() } as any; + logger = { warn: jest.fn(), info: jest.fn() }; + ttlActions = new TtlActions(repo, logger); + }); - it('returns success when markWithdrawn succeeds', async () => { - repo.markWithdrawn.mockResolvedValue({ event: messageDownloadedEvent }); + it('returns success when markWithdrawn succeeds', async () => { + repo.markWithdrawn.mockResolvedValue({ event: messageDownloadedEvent }); - const result = await ttlActions.markWithdrawn(coreStatusEvent); + const result = await ttlActions.markWithdrawn(channelStatusEvent); - expect(result).toEqual({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, + expect(result).toEqual({ + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining( + 'TTL record marked as withdrawn', + ), + messageReference: channelStatusEvent.data.messageReference, + }), + ); + expect(repo.markWithdrawn).toHaveBeenCalledWith( + channelStatusEvent.data.messageReference, + ); }); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining('TTL record marked as withdrawn'), - messageReference: coreStatusEvent.data.messageReference, - }), - ); - expect(repo.markWithdrawn).toHaveBeenCalledWith( - coreStatusEvent.data.messageReference, - ); - }); - it('returns success when TTL record not found', async () => { - // eslint-disable-next-line unicorn/no-useless-undefined - repo.markWithdrawn.mockResolvedValue(undefined); + it('returns success when TTL record not found', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + repo.markWithdrawn.mockResolvedValue(undefined); + + const result = await ttlActions.markWithdrawn(channelStatusEvent); + + expect(result).toEqual({ result: 'success' }); - const result = await ttlActions.markWithdrawn(coreStatusEvent); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('TTL record not found'), + messageReference: channelStatusEvent.data.messageReference, + }), + ); + expect(repo.markWithdrawn).toHaveBeenCalledWith( + channelStatusEvent.data.messageReference, + ); + }); + + it('returns failed and logs error when markWithdrawn throws', async () => { + const error = new Error('fail'); + repo.markWithdrawn.mockRejectedValue(error); - expect(result).toEqual({ result: 'success' }); + const result = await ttlActions.markWithdrawn(channelStatusEvent); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining('TTL record not found'), - messageReference: coreStatusEvent.data.messageReference, - }), - ); - expect(repo.markWithdrawn).toHaveBeenCalledWith( - coreStatusEvent.data.messageReference, - ); + expect(result).toEqual({ result: 'failed' }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('Error marking TTL withdrawn'), + messageReference: channelStatusEvent.data.messageReference, + err: error, + }), + ); + }); }); - it('returns failed and logs error when markWithdrawn throws', async () => { - const error = new Error('fail'); - repo.markWithdrawn.mockRejectedValue(error); + describe('delete', () => { + beforeEach(() => { + repo = { delete: jest.fn() } as any; + logger = { warn: jest.fn(), info: jest.fn() }; + ttlActions = new TtlActions(repo, logger); + }); + + it('returns success when delete succeeds', async () => { + repo.delete.mockResolvedValue({ event: messageDownloadedEvent }); - const result = await ttlActions.markWithdrawn(coreStatusEvent); + const result = await ttlActions.delete(channelStatusEvent); - expect(result).toEqual({ result: 'failed' }); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining('Error marking TTL withdrawn'), - messageReference: coreStatusEvent.data.messageReference, - err: error, - }), - ); + expect(result).toEqual({ + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('TTL record deleted'), + messageReference: channelStatusEvent.data.messageReference, + }), + ); + expect(repo.delete).toHaveBeenCalledWith( + channelStatusEvent.data.messageReference, + ); + }); + + it('returns success when TTL record not found', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + repo.delete.mockResolvedValue(undefined); + + const result = await ttlActions.delete(channelStatusEvent); + + expect(result).toEqual({ result: 'success' }); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('TTL record not found'), + messageReference: channelStatusEvent.data.messageReference, + }), + ); + expect(repo.delete).toHaveBeenCalledWith( + channelStatusEvent.data.messageReference, + ); + }); + + it('returns failed and logs error when delete throws', async () => { + const error = new Error('fail'); + repo.delete.mockRejectedValue(error); + + const result = await ttlActions.delete(channelStatusEvent); + + expect(result).toEqual({ result: 'failed' }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('Error deleting TTL record'), + messageReference: channelStatusEvent.data.messageReference, + err: error, + }), + ); + }); }); }); diff --git a/lambdas/core-status-handler/src/__tests__/data.ts b/lambdas/core-status-handler/src/__tests__/data.ts index 674754b63..30f4d8f28 100644 --- a/lambdas/core-status-handler/src/__tests__/data.ts +++ b/lambdas/core-status-handler/src/__tests__/data.ts @@ -1,5 +1,8 @@ import { MESHInboxMessageDownloaded } from 'digital-letters-events'; -import { ChannelStatusPublishedEvent } from 'utils'; +import { + ChannelStatusPublishedEvent, + MessageStatusPublishedEvent, +} from 'utils'; export const messageDownloadedEvent: MESHInboxMessageDownloaded = { id: '550e8400-e29b-41d4-a716-446655440001', @@ -26,9 +29,22 @@ export const messageDownloadedEvent: MESHInboxMessageDownloaded = { }, }; -export const coreStatusEvent: ChannelStatusPublishedEvent = { +export const channelStatusEvent: ChannelStatusPublishedEvent = { + source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { + channelStatus: 'delivered', messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, supplierStatus: 'paper_letter_opted_out', }, }; + +export const messagesStatusEvent: MessageStatusPublishedEvent = { + source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: { + messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, + messageStatus: 'failed', + resolvedChannels: ['letter'], + }, +}; diff --git a/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts index f9a3f82b2..ad87f367a 100644 --- a/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts +++ b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts @@ -1,6 +1,6 @@ import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; -import { UpdateCommand } from '@aws-sdk/lib-dynamodb'; -import { coreStatusEvent } from '__tests__/data'; +import { DeleteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { channelStatusEvent } from '__tests__/data'; import { TtlRepository } from 'infra/ttl-repository'; describe('TtlRepository', () => { @@ -13,46 +13,74 @@ describe('TtlRepository', () => { repo = new TtlRepository(tableName, dynamoDocumentClient); }); - it('marks item as withdrawn', async () => { - await repo.markWithdrawn(coreStatusEvent.data.messageReference); - - const updateCommand: UpdateCommand = - dynamoDocumentClient.send.mock.calls[0][0]; - expect(updateCommand.input).toStrictEqual({ - TableName: tableName, - Key: { - PK: coreStatusEvent.data.messageReference, - SK: 'TTL', - }, - ConditionExpression: 'attribute_exists(PK)', - UpdateExpression: 'set withdrawn = :val1', - ExpressionAttributeValues: { - ':val1': true, - }, - ReturnValues: 'ALL_NEW', + describe('markWithdrawn', () => { + it('marks item as withdrawn', async () => { + await repo.markWithdrawn(channelStatusEvent.data.messageReference); + + const updateCommand: UpdateCommand = + dynamoDocumentClient.send.mock.calls[0][0]; + expect(updateCommand.input).toStrictEqual({ + TableName: tableName, + Key: { + PK: channelStatusEvent.data.messageReference, + SK: 'TTL', + }, + ConditionExpression: 'attribute_exists(PK)', + UpdateExpression: 'set withdrawn = :val1', + ExpressionAttributeValues: { + ':val1': true, + }, + ReturnValues: 'ALL_NEW', + }); }); - }); - it('returns undefined on ConditionalCheckFailedException', async () => { - const error = new ConditionalCheckFailedException({ - message: 'ConditionalCheckFailedException', - $metadata: {}, + it('returns undefined on ConditionalCheckFailedException', async () => { + const error = new ConditionalCheckFailedException({ + message: 'ConditionalCheckFailedException', + $metadata: {}, + }); + dynamoDocumentClient.send.mockRejectedValue(error); + + const result = await repo.markWithdrawn( + channelStatusEvent.data.messageReference, + ); + + expect(result).toBeUndefined(); }); - dynamoDocumentClient.send.mockRejectedValue(error); - const result = await repo.markWithdrawn( - coreStatusEvent.data.messageReference, - ); + it('errors on dynamo error', async () => { + const error = new Error('fail'); + dynamoDocumentClient.send.mockRejectedValue(error); - expect(result).toBeUndefined(); + await expect( + repo.markWithdrawn(channelStatusEvent.data.messageReference), + ).rejects.toThrow(error); + }); }); - it('errors on dynamo error', async () => { - const error = new Error('fail'); - dynamoDocumentClient.send.mockRejectedValue(error); + describe('delete', () => { + it('deletes item', async () => { + await repo.delete(channelStatusEvent.data.messageReference); - await expect( - repo.markWithdrawn(coreStatusEvent.data.messageReference), - ).rejects.toThrow(error); + const deleteCommand: DeleteCommand = + dynamoDocumentClient.send.mock.calls[0][0]; + expect(deleteCommand.input).toStrictEqual({ + TableName: tableName, + Key: { + PK: channelStatusEvent.data.messageReference, + SK: 'TTL', + }, + ReturnValues: 'ALL_OLD', + }); + }); + + it('errors on dynamo error', async () => { + const error = new Error('fail'); + dynamoDocumentClient.send.mockRejectedValue(error); + + await expect( + repo.delete(channelStatusEvent.data.messageReference), + ).rejects.toThrow(error); + }); }); }); diff --git a/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts index 93b4d3a74..d9a3c9b70 100644 --- a/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts @@ -4,20 +4,29 @@ import type { SQSEvent, } from 'aws-lambda'; import { randomUUID } from 'node:crypto'; -import type { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; -import { $ChannelStatusPublishedEvent, EventPublisher, Logger } from 'utils'; +import { + StatusActionResolver, + StatusActionResolverOutcome, +} from 'app/status-action-resolver'; import { DigitalLetterRead, MESHInboxMessageDownloaded, validateDigitalLetterRead, } from 'digital-letters-events'; +import { + $StatusPublishedEvent, + EventPublisher, + Logger, + StatusPublishedEvent, +} from 'utils'; interface ProcessingResult { - outcome: TtlActionOutcome; + outcome: StatusActionResolverOutcome; + item?: StatusPublishedEvent; } interface CreateHandlerDependencies { - ttlActions: TtlActions; + statusActionResolver: StatusActionResolver; eventPublisher: EventPublisher; logger: Logger; } @@ -25,7 +34,7 @@ interface CreateHandlerDependencies { export const createHandler = ({ eventPublisher, logger, - ttlActions, + statusActionResolver, }: CreateHandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { const batchItemFailures: SQSBatchItemFailure[] = []; @@ -35,12 +44,11 @@ export const createHandler = ({ try { const sqsEventBody = JSON.parse(body); const sqsEventDetail = sqsEventBody.detail; - const { data: item, error: parseError, success: parseSuccess, - } = $ChannelStatusPublishedEvent.safeParse(sqsEventDetail); + } = $StatusPublishedEvent.safeParse(sqsEventDetail); if (!parseSuccess) { logger.warn({ @@ -54,7 +62,7 @@ export const createHandler = ({ return { outcome: { result: 'failed' } }; } - const result = await ttlActions.markWithdrawn(item); + const result = await statusActionResolver.resolve(item); if (result.result === 'failed') { batchItemFailures.push({ itemIdentifier: messageId }); @@ -77,22 +85,35 @@ export const createHandler = ({ const results = await Promise.allSettled(promises); - const processed: Record = - { - retrieved: results.length, - success: 0, - failed: 0, - }; - - const successfulEvents: MESHInboxMessageDownloaded[] = []; + const processed: Record< + StatusActionResolverOutcome['result'] | 'retrieved', + number + > = { + retrieved: results.length, + success: 0, + failed: 0, + skipped: 0, + }; + + const successfulEvents: { + event: MESHInboxMessageDownloaded; + supplierStatus: string; + }[] = []; for (const result of results) { if (result.status === 'fulfilled') { const { outcome } = result.value; processed[outcome.result] += 1; - if (outcome.result === 'success' && outcome.ttlItem) { - successfulEvents.push(outcome.ttlItem.event); + if ( + outcome.result === 'success' && + outcome.ttlItem && + outcome.publish + ) { + successfulEvents.push({ + event: outcome.ttlItem.event, + supplierStatus: outcome.publish.supplierStatus, + }); } } else { logger.warn({ err: result.reason }); @@ -103,7 +124,7 @@ export const createHandler = ({ if (successfulEvents.length > 0) { try { const failedEvents = await eventPublisher.sendEvents( - successfulEvents.map((event) => ({ + successfulEvents.map(({ event, supplierStatus }) => ({ ...event, id: randomUUID(), time: new Date().toISOString(), @@ -115,6 +136,7 @@ export const createHandler = ({ data: { messageReference: event.data.messageReference, senderId: event.data.senderId, + supplierStatus, }, })), validateDigitalLetterRead, diff --git a/lambdas/core-status-handler/src/app/status-action-resolver.ts b/lambdas/core-status-handler/src/app/status-action-resolver.ts new file mode 100644 index 000000000..0700d6483 --- /dev/null +++ b/lambdas/core-status-handler/src/app/status-action-resolver.ts @@ -0,0 +1,68 @@ +import { Logger, StatusPublishedEvent } from 'utils'; +import { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; + +export type StatusActionResolverOutcome = TtlActionOutcome & { + publish?: { supplierStatus: string }; +}; + +type Action = + | { kind: 'markWithdrawn' | 'delete'; publish?: { supplierStatus: string } } + | { kind: 'skip' }; + +function resolveAction(item: StatusPublishedEvent): Action { + if (item.type === 'uk.nhs.notify.channel.status.PUBLISHED.v1') { + if (item.data.supplierStatus === 'paper_letter_opted_out') { + return { + kind: 'markWithdrawn', + publish: { supplierStatus: item.data.supplierStatus }, + }; + } + if (item.data.supplierStatus === 'paper_letter_opted_in') { + return { + kind: 'delete', + publish: { supplierStatus: item.data.supplierStatus }, + }; + } + if ( + item.data.supplierStatus === 'rejected' && + item.data.channelStatus === 'failed' + ) { + return { kind: 'delete' }; + } + } + + if ( + item.type === 'uk.nhs.notify.message.status.PUBLISHED.v1' && + item.data.messageStatus === 'failed' && + !item.data.resolvedChannels?.length + ) { + return { kind: 'markWithdrawn' }; + } + + return { kind: 'skip' }; +} + +export class StatusActionResolver { + constructor( + private readonly ttlActions: TtlActions, + private readonly logger: Logger, + ) {} + + async resolve( + item: StatusPublishedEvent, + ): Promise { + const action = resolveAction(item); + + if (action.kind === 'skip') { + this.logger.info({ + description: 'Event skipped', + type: item.type, + data: item.data, + }); + return { result: 'skipped' }; + } + + const outcome = await this.ttlActions[action.kind](item); + return { ...outcome, publish: action.publish }; + } +} diff --git a/lambdas/core-status-handler/src/app/ttl-actions.ts b/lambdas/core-status-handler/src/app/ttl-actions.ts index ae6b0f22f..a0c628964 100644 --- a/lambdas/core-status-handler/src/app/ttl-actions.ts +++ b/lambdas/core-status-handler/src/app/ttl-actions.ts @@ -1,12 +1,11 @@ -import { ChannelStatusPublishedEvent, Logger } from 'utils'; +import { Logger, StatusPublishedEvent } from 'utils'; import { TtlRepository } from 'infra/ttl-repository'; -import { TtlRecord } from 'types/types'; - -export type TtlItem = TtlRecord | undefined; +import { TtlItem } from 'types/types'; export type TtlActionOutcome = | { result: 'success'; ttlItem: TtlItem } - | { result: 'failed' }; + | { result: 'failed' } + | { result: 'skipped' }; export class TtlActions { constructor( @@ -14,9 +13,7 @@ export class TtlActions { private readonly logger: Logger, ) {} - async markWithdrawn( - item: ChannelStatusPublishedEvent, - ): Promise { + async markWithdrawn(item: StatusPublishedEvent): Promise { const { messageReference } = item.data; let ttlItem: TtlItem; @@ -47,4 +44,36 @@ export class TtlActions { return { result: 'success', ttlItem }; } + + async delete(item: StatusPublishedEvent): Promise { + const { messageReference } = item.data; + + let ttlItem: TtlItem; + + try { + ttlItem = await this.ttlRepository.delete(messageReference); + } catch (error) { + this.logger.warn({ + description: 'Error deleting TTL record', + messageReference, + err: error, + }); + + return { result: 'failed' }; + } + + if (ttlItem) { + this.logger.info({ + description: 'TTL record deleted', + messageReference, + }); + } else { + this.logger.info({ + description: 'TTL record not found', + messageReference, + }); + } + + return { result: 'success', ttlItem }; + } } diff --git a/lambdas/core-status-handler/src/container.ts b/lambdas/core-status-handler/src/container.ts index 4f829ccf1..c406ee35d 100644 --- a/lambdas/core-status-handler/src/container.ts +++ b/lambdas/core-status-handler/src/container.ts @@ -8,6 +8,7 @@ import { } from 'utils'; import { loadConfig } from 'infra/config'; import { TtlRepository } from 'infra/ttl-repository'; +import { StatusActionResolver } from 'app/status-action-resolver'; import { TtlActions } from 'app/ttl-actions'; export const createContainer = () => { @@ -26,6 +27,8 @@ export const createContainer = () => { const ttlActions = new TtlActions(requestTtlRepository, logger); + const statusActionResolver = new StatusActionResolver(ttlActions, logger); + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, @@ -38,9 +41,9 @@ export const createContainer = () => { }); return { - ttlActions, eventPublisher, logger, + statusActionResolver, }; }; diff --git a/lambdas/core-status-handler/src/infra/ttl-repository.ts b/lambdas/core-status-handler/src/infra/ttl-repository.ts index f37753996..06bac389d 100644 --- a/lambdas/core-status-handler/src/infra/ttl-repository.ts +++ b/lambdas/core-status-handler/src/infra/ttl-repository.ts @@ -1,9 +1,14 @@ import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; -import { UpdateCommand, UpdateCommandOutput } from '@aws-sdk/lib-dynamodb'; +import { + DeleteCommand, + DeleteCommandOutput, + UpdateCommand, + UpdateCommandOutput, +} from '@aws-sdk/lib-dynamodb'; import { TtlRecord } from 'types/types'; interface IDynamoCaller { - send: (command: UpdateCommand) => Promise; + send(command: UpdateCommand | DeleteCommand): Promise; } export class TtlRepository { @@ -30,7 +35,8 @@ export class TtlRepository { }; const request = new UpdateCommand(params); try { - const output = await this.dynamoDocumentClient.send(request); + const output = + await this.dynamoDocumentClient.send(request); return output.Attributes as TtlRecord; } catch (error) { @@ -40,6 +46,25 @@ export class TtlRepository { throw error; } } + + public async delete( + messageReference: string, + ): Promise { + const params = { + TableName: this.tableName, + Key: { + PK: messageReference, + SK: 'TTL', + }, + ReturnValues: 'ALL_OLD' as const, + }; + + const output = await this.dynamoDocumentClient.send( + new DeleteCommand(params), + ); + + return output.Attributes as TtlRecord; + } } export default TtlRepository; diff --git a/lambdas/core-status-handler/src/types/types.ts b/lambdas/core-status-handler/src/types/types.ts index 7e4bab326..24872109a 100644 --- a/lambdas/core-status-handler/src/types/types.ts +++ b/lambdas/core-status-handler/src/types/types.ts @@ -3,3 +3,5 @@ import { MESHInboxMessageDownloaded } from 'digital-letters-events'; export type TtlRecord = { event: MESHInboxMessageDownloaded; }; + +export type TtlItem = TtlRecord | undefined; diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml index dc7823190..fca4f08a3 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml @@ -8,6 +8,9 @@ properties: $ref: ../defs/requests.schema.yaml#/properties/messageReference senderId: $ref: ../defs/requests.schema.yaml#/properties/senderId + supplierStatus: + $ref: ../defs/core.schema.yaml#/properties/supplierStatus required: - messageReference - senderId + - supplierStatus diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml index f0b211191..43314912b 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml @@ -21,6 +21,11 @@ properties: description: "A human readable desription of the reason for failure" examples: - "Failed reason: Not registered with NHS App" + supplierStatus: + type: string + description: "Status returned by the supplier for this channel" + examples: + - "paper_letter_opted_out" time: title: "Event Time" description: "Timestamp when the event occurred (RFC 3339)." diff --git a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts index d0c7a949a..93160c91d 100644 --- a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { CORE_STATUS_HANDLER_DLQ_NAME, CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - ENV, + EVENT_BUS_LOG_GROUP_NAME, } from 'constants/backend-constants'; import { SENDER_ID_VALID_FOR_NOTIFY_SANDBOX } from 'constants/tests-constants'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; @@ -11,6 +11,10 @@ import { getTtl, putTtl } from 'helpers/dynamodb-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; +import { + ChannelStatusPublishedEvent, + MessageStatusPublishedEvent, +} from 'utils'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - Core Status Handler', () => { @@ -43,141 +47,519 @@ test.describe('Digital Letters - Core Status Handler', () => { }, }; - test('should mark TTL withdrawn and publish digital.letter.read event', async () => { - const event = { - ...baseEvent, + test.describe('channel.status.PUBLISHED', () => { + const channelFailedEvent: ChannelStatusPublishedEvent = { + source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { - ...baseEvent.data, - messageReference: uuidv4(), + channelStatus: 'failed', + supplierStatus: 'rejected', + messageReference: '', }, }; - const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; + test('when supplierStatus is rejected and channelStatus is failed - delete TTL', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), + }, + }; - const ttlItem = { - PK: concatedReference, - SK: 'TTL', - dateOfExpiry: '2023-12-31#0', - event, - ttl: Date.now() / 1000 + 3600, - }; + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; - const putResponseCode = await putTtl(ttlItem); - expect(putResponseCode).toBe(200); + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; - await eventPublisher.sendEvents( - [ - { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: concatedReference, - supplierStatus: 'paper_letter_opted_out', - }, - }, - ], - () => true, - ); + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); - await expectToPassEventually(async () => { - const ttl = await getTtl( - event.data.senderId, - event.data.messageReference, + await eventPublisher.sendEvents( + [ + { + ...channelFailedEvent, + data: { + ...channelFailedEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => true, ); - expect(ttl.length).toBe(1); - expect(ttl[0]).toHaveProperty('withdrawn', true); - }); + await expectToPassEventually(async () => { + const ttl = await getTtl( + event.data.senderId, + event.data.messageReference, + ); - await Promise.all([ - expectToPassEventually(async () => { + expect(ttl.length).toBe(0); + }); + + await expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( - `/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, [ - '$.message_type = "EVENT_RECEIPT"', - '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.read.v1"', - `$.details.event_detail = "*\\"messageReference\\":\\"${event.data.messageReference}\\"*"`, + '$.message.description = "TTL record deleted"', + `$.message.messageReference = "${concatedReference}"`, ], ); expect(eventLogEntry.length).toEqual(1); - }), + }, 150); + }); - expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + test.describe('paper_letter_opted_out', () => { + const optedOutEvent: ChannelStatusPublishedEvent = { + source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + channelStatus: 'delivered', + supplierStatus: 'paper_letter_opted_out', + messageReference: '', + }, + }; + + test('when supplierStatus is paper_letter_opted_out - mark TTL withdrawn and publish digital.letter.read event', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), + }, + }; + + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; + + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; + + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); + + await eventPublisher.sendEvents( [ - '$.message.description = "TTL record marked as withdrawn"', - `$.message.messageReference = "${concatedReference}"`, + { + ...optedOutEvent, + data: { + ...optedOutEvent.data, + messageReference: concatedReference, + }, + }, ], + () => true, ); - expect(eventLogEntry.length).toEqual(1); - }, 150), - ]); - }); + await expectToPassEventually(async () => { + const ttl = await getTtl( + event.data.senderId, + event.data.messageReference, + ); + + expect(ttl.length).toBe(1); + expect(ttl[0]).toHaveProperty('withdrawn', true); + }); + + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.read.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${event.data.messageReference}\\"*"`, + `$.details.event_detail = "*\\"supplierStatus\\":\\"${optedOutEvent.data.supplierStatus}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }), + + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record marked as withdrawn"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + ]); + }); + + test('when duplicate event is received for the same TTL record - process them both', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), + }, + }; - test('should handle duplicate event for the same TTL record', async () => { - const event = { - ...baseEvent, - data: { - ...baseEvent.data, - messageReference: uuidv4(), - }, - }; + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; - const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; - const ttlItem = { - PK: concatedReference, - SK: 'TTL', - dateOfExpiry: '2023-12-31#0', - event, - ttl: Date.now() / 1000 + 3600, - }; + const channelStatusPublishedEvent = { + ...optedOutEvent, + data: { + ...optedOutEvent.data, + messageReference: concatedReference, + }, + }; + + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); + + await eventPublisher.sendEvents( + [channelStatusPublishedEvent, channelStatusPublishedEvent], + () => true, + ); + + await expectToPassEventually(async () => { + const ttl = await getTtl( + event.data.senderId, + event.data.messageReference, + ); + + expect(ttl.length).toBe(1); + expect(ttl[0]).toHaveProperty('withdrawn', true); + }); + + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.read.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${event.data.messageReference}\\"*"`, + `$.details.event_detail = "*\\"supplierStatus\\":\\"${optedOutEvent.data.supplierStatus}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(2); + }), + + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record marked as withdrawn"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(2); + }, 150), + ]); + }); + + test('when TTL record is not found - perform no operations', async () => { + const concatedReference = `${uuidv4()}_${uuidv4()}`; + + await eventPublisher.sendEvents( + [ + { + ...optedOutEvent, + data: { + ...optedOutEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record not found"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150); + }); + }); + + test.describe('paper_letter_opted_in', () => { + const optedInEvent: ChannelStatusPublishedEvent = { + source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + channelStatus: 'delivered', + supplierStatus: 'paper_letter_opted_in', + messageReference: '', + }, + }; + + test('when supplierStatus is paper_letter_opted_in - delete TTL and publish digital.letter.read event', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), + }, + }; + + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; + + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; + + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); - const channelStatusPublishedEvent = { + await eventPublisher.sendEvents( + [ + { + ...optedInEvent, + data: { + ...optedInEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const ttl = await getTtl( + event.data.senderId, + event.data.messageReference, + ); + + expect(ttl.length).toBe(0); + }); + + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.read.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${event.data.messageReference}\\"*"`, + `$.details.event_detail = "*\\"supplierStatus\\":\\"${optedInEvent.data.supplierStatus}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }), + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record deleted"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + ]); + }); + + test('when duplicate event is received for the same TTL record - process them both1', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), + }, + }; + + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; + + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; + + const channelStatusPublishedEvent = { + ...optedInEvent, + data: { + ...optedInEvent.data, + messageReference: concatedReference, + }, + }; + + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); + + await eventPublisher.sendEvents( + [channelStatusPublishedEvent, channelStatusPublishedEvent], + () => true, + ); + + await expectToPassEventually(async () => { + const ttl = await getTtl( + event.data.senderId, + event.data.messageReference, + ); + + expect(ttl.length).toBe(0); + }); + + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.read.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${event.data.messageReference}\\"*"`, + `$.details.event_detail = "*\\"supplierStatus\\":\\"${optedInEvent.data.supplierStatus}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }), + + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record deleted"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record not found"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + ]); + }); + + test('when TTL record is not found - perform no operations', async () => { + const concatedReference = `${uuidv4()}_${uuidv4()}`; + + await eventPublisher.sendEvents( + [ + { + ...optedInEvent, + data: { + ...optedInEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record not found"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150); + }); + }); + }); + + test.describe('message.status.PUBLISHED', () => { + const messageEvent: MessageStatusPublishedEvent = { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { - messageReference: concatedReference, - supplierStatus: 'paper_letter_opted_out', + messageStatus: 'failed', + messageReference: '', + resolvedChannels: [], }, }; - const putResponseCode = await putTtl(ttlItem); - expect(putResponseCode).toBe(200); + test('when messageStatus is failed and resolvedChannels is empty - mark TTL withdrawn', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), + }, + }; - await eventPublisher.sendEvents( - [channelStatusPublishedEvent, channelStatusPublishedEvent], - () => true, - ); + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; - await expectToPassEventually(async () => { - const ttl = await getTtl( - event.data.senderId, - event.data.messageReference, - ); + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; - expect(ttl.length).toBe(1); - expect(ttl[0]).toHaveProperty('withdrawn', true); - }); + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); - await Promise.all([ - expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - `/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`, - [ - '$.message_type = "EVENT_RECEIPT"', - '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.read.v1"', - `$.details.event_detail = "*\\"messageReference\\":\\"${event.data.messageReference}\\"*"`, - ], + await eventPublisher.sendEvents( + [ + { + ...messageEvent, + data: { + ...messageEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const ttl = await getTtl( + event.data.senderId, + event.data.messageReference, ); - expect(eventLogEntry.length).toEqual(2); - }), + expect(ttl.length).toBe(1); + expect(ttl[0]).toHaveProperty('withdrawn', true); + }); - expectToPassEventually(async () => { + await expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, [ @@ -186,42 +568,61 @@ test.describe('Digital Letters - Core Status Handler', () => { ], ); - expect(eventLogEntry.length).toEqual(2); - }, 150), - ]); - }); - - test('should handle missing TTL record', async () => { - const concatedReference = `${uuidv4()}_${uuidv4()}`; + expect(eventLogEntry.length).toEqual(1); + }, 150); + }); - await eventPublisher.sendEvents( - [ - { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: concatedReference, - supplierStatus: 'paper_letter_opted_out', - }, + test('when messageStatus is failed and resolvedChannels is NOT empty - skip', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + messageReference: uuidv4(), }, - ], - () => true, - ); + }; - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + const concatedReference = `${event.data.senderId}_${event.data.messageReference}`; + + const ttlItem = { + PK: concatedReference, + SK: 'TTL', + dateOfExpiry: '2023-12-31#0', + event, + ttl: Date.now() / 1000 + 3600, + }; + + const putResponseCode = await putTtl(ttlItem); + expect(putResponseCode).toBe(200); + + await eventPublisher.sendEvents( [ - '$.message.description = "TTL record not found"', - `$.message.messageReference = "${concatedReference}"`, + { + ...messageEvent, + data: { + ...messageEvent.data, + messageReference: concatedReference, + resolvedChannels: ['LETTER'], + }, + }, ], + () => true, ); - expect(eventLogEntry.length).toEqual(1); - }, 150); + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "Event skipped"', + `$.message.data.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150); + }); }); - test('should send invalid event to core status handler dlq', async () => { + test('when event is invalid - send to dlq', async () => { test.setTimeout(160_000); const concatedReference = `${uuidv4()}_${uuidv4()}`; @@ -230,10 +631,10 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageReference: concatedReference, - supplierStatus: 'I am not valid', + messageStatus: 'I am not valid', }, }, ], @@ -248,7 +649,7 @@ test.describe('Digital Letters - Core Status Handler', () => { '$.message.description = "Error parsing sqs record"', `$.message.messageReference = "${concatedReference}"`, String.raw`$.message.err.message = "*\"invalid_value\"*"`, - String.raw`$.message.err.message = "*\"supplierStatus\"*"`, + String.raw`$.message.err.message = "*\"messageStatus\"*"`, ], ); diff --git a/tests/playwright/digital-letters-component-tests/file-scanner.component.spec.ts b/tests/playwright/digital-letters-component-tests/file-scanner.component.spec.ts index e96cbb604..2dc09427d 100644 --- a/tests/playwright/digital-letters-component-tests/file-scanner.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/file-scanner.component.spec.ts @@ -24,138 +24,140 @@ test.describe('File Scanner', () => { await purgeQueue(FILE_SCANNER_DLQ_NAME); test.setTimeout(250_000); }); -}); -test('should extract PDF from DocumentReference and store in unscanned bucket with metadata', async () => { - const messageReference = uuidv4(); - const senderId = 'TEST_SENDER_001'; - const documentReferenceKey = `${PREFIX_DL_FILES}${messageReference}`; - - const pdfContent = Buffer.from('Sample PDF content for test'); - const documentReference = { - resourceType: 'DocumentReference', - id: messageReference, - content: [ - { - attachment: { - contentType: 'application/pdf', - data: pdfContent.toString('base64'), + test('should extract PDF from DocumentReference and store in unscanned bucket with metadata', async () => { + const messageReference = uuidv4(); + const senderId = 'TEST_SENDER_001'; + const documentReferenceKey = `${PREFIX_DL_FILES}${messageReference}`; + + const pdfContent = Buffer.from('Sample PDF content for test'); + const documentReference = { + resourceType: 'DocumentReference', + id: messageReference, + content: [ + { + attachment: { + contentType: 'application/pdf', + data: pdfContent.toString('base64'), + }, }, - }, - ], - }; - - await putDataS3(documentReference, { - Bucket: DOCUMENT_REFERENCE_BUCKET, - Key: documentReferenceKey, - }); + ], + }; - const eventId = uuidv4(); - const messageUri = `s3://${DOCUMENT_REFERENCE_BUCKET}/${documentReferenceKey}`; - const eventTime = new Date().toISOString(); - - await eventPublisher.sendEvents( - [ - { - id: eventId, - plane: 'data', - dataschemaversion: '1.0.0', - specversion: '1.0', - source: `/nhs/england/notify/development/dev-1/digitalletters/queue`, - subject: `message/${messageReference}`, - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - time: eventTime, - recordedtime: eventTime, - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', - severitytext: 'INFO', - data: { - messageReference, - senderId, - messageUri, - }, - }, - ], - validateItemDequeued, - ); - - await expectToPassEventually(async () => { - const expectedKey = `${PREFIX_DL_FILES}${messageReference}.pdf`; - const expectedUri = `s3://${UNSCANNED_FILES_BUCKET}/${expectedKey}`; - - const storedPdf = await getS3ObjectBufferFromUri(expectedUri); - expect(storedPdf).toBeDefined(); - expect(storedPdf.toString()).toEqual(pdfContent.toString()); - - const metadata = await getS3ObjectMetadata({ - Bucket: UNSCANNED_FILES_BUCKET, - Key: expectedKey, + await putDataS3(documentReference, { + Bucket: DOCUMENT_REFERENCE_BUCKET, + Key: documentReferenceKey, }); - expect(metadata).toBeDefined(); - expect(metadata?.messagereference).toEqual(messageReference); - expect(metadata?.senderid).toEqual(senderId); - expect(metadata?.createdat).toBeDefined(); - }, 120); -}); -test('should handle validation errors by sending messages to DLQ', async () => { - test.setTimeout(160_000); - const messageReference = uuidv4(); - const senderId = 'TEST_SENDER_002'; - const documentReferenceKey = `document-reference/${messageReference}`; - - const documentReference = { - resourceType: 'DocumentReference', - id: messageReference, - content: [], - }; - - await putDataS3(documentReference, { - Bucket: DOCUMENT_REFERENCE_BUCKET, - Key: documentReferenceKey, + const eventId = uuidv4(); + const messageUri = `s3://${DOCUMENT_REFERENCE_BUCKET}/${documentReferenceKey}`; + const eventTime = new Date().toISOString(); + + await eventPublisher.sendEvents( + [ + { + id: eventId, + plane: 'data', + dataschemaversion: '1.0.0', + specversion: '1.0', + source: `/nhs/england/notify/development/dev-1/digitalletters/queue`, + subject: `message/${messageReference}`, + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + time: eventTime, + recordedtime: eventTime, + severitynumber: 2, + traceparent: + '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + severitytext: 'INFO', + data: { + messageReference, + senderId, + messageUri, + }, + }, + ], + validateItemDequeued, + ); + + await expectToPassEventually(async () => { + const expectedKey = `${PREFIX_DL_FILES}${messageReference}.pdf`; + const expectedUri = `s3://${UNSCANNED_FILES_BUCKET}/${expectedKey}`; + + const storedPdf = await getS3ObjectBufferFromUri(expectedUri); + expect(storedPdf).toBeDefined(); + expect(storedPdf.toString()).toEqual(pdfContent.toString()); + + const metadata = await getS3ObjectMetadata({ + Bucket: UNSCANNED_FILES_BUCKET, + Key: expectedKey, + }); + expect(metadata).toBeDefined(); + expect(metadata?.messagereference).toEqual(messageReference); + expect(metadata?.senderid).toEqual(senderId); + expect(metadata?.createdat).toBeDefined(); + }, 120); }); - const eventId = uuidv4(); - const messageUri = `s3://${DOCUMENT_REFERENCE_BUCKET}/${documentReferenceKey}`; - const eventTime = new Date().toISOString(); - - await eventPublisher.sendEvents( - [ - { - id: eventId, - specversion: '1.0', - plane: 'data', - dataschemaversion: '1.0.0', - source: `/nhs/england/notify/development/dev-1/digitalletters/queue`, - subject: `message/${messageReference}`, - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - time: eventTime, - recordedtime: eventTime, - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', - severitytext: 'INFO', - data: { - messageReference, - senderId, - messageUri, + test('should handle validation errors by sending messages to DLQ', async () => { + test.setTimeout(160_000); + const messageReference = uuidv4(); + const senderId = 'TEST_SENDER_002'; + const documentReferenceKey = `document-reference/${messageReference}`; + + const documentReference = { + resourceType: 'DocumentReference', + id: messageReference, + content: [], + }; + + await putDataS3(documentReference, { + Bucket: DOCUMENT_REFERENCE_BUCKET, + Key: documentReferenceKey, + }); + + const eventId = uuidv4(); + const messageUri = `s3://${DOCUMENT_REFERENCE_BUCKET}/${documentReferenceKey}`; + const eventTime = new Date().toISOString(); + + await eventPublisher.sendEvents( + [ + { + id: eventId, + specversion: '1.0', + plane: 'data', + dataschemaversion: '1.0.0', + source: `/nhs/england/notify/development/dev-1/digitalletters/queue`, + subject: `message/${messageReference}`, + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + time: eventTime, + recordedtime: eventTime, + severitynumber: 2, + traceparent: + '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + severitytext: 'INFO', + data: { + messageReference, + senderId, + messageUri, + }, }, - }, - ], - validateItemDequeued, - ); - - // Verify the file was NOT processed successfully - await expectToPassEventually(async () => { - const expectedKey = `${ENV}/${messageReference}.pdf`; - const expectedUri = `s3://${UNSCANNED_FILES_BUCKET}/${expectedKey}`; - await expect(getS3ObjectBufferFromUri(expectedUri)).rejects.toThrow(); - }, 150); - // Verify there is a message in the DLQ - await expectMessageContainingString(FILE_SCANNER_DLQ_NAME, eventId, 150); + ], + validateItemDequeued, + ); + + // Verify the file was NOT processed successfully + await expectToPassEventually(async () => { + const expectedKey = `${ENV}/${messageReference}.pdf`; + const expectedUri = `s3://${UNSCANNED_FILES_BUCKET}/${expectedKey}`; + await expect(getS3ObjectBufferFromUri(expectedUri)).rejects.toThrow(); + }, 150); + // Verify there is a message in the DLQ + await expectMessageContainingString(FILE_SCANNER_DLQ_NAME, eventId, 150); + }); }); diff --git a/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts b/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts index c65a1ed37..259316ad8 100644 --- a/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts @@ -15,7 +15,7 @@ import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; import { downloadFromS3 } from 'helpers/s3-helpers'; -import { expectMessageContainingString } from 'helpers/sqs-helpers'; +import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - Mesh Acknowledger', () => { @@ -55,6 +55,11 @@ test.describe('Digital Letters - Mesh Acknowledger', () => { }, }; + test.beforeAll(async () => { + await purgeQueue(MESH_ACKNOWLEDGE_DLQ_NAME); + test.setTimeout(250_000); + }); + test('should send MESH acknowledgement and publish message acknowledged event following message downloaded event', async () => { const letterId = uuidv4(); const messageReference = uuidv4(); diff --git a/tests/playwright/digital-letters-component-tests/print-sender.component.spec.ts b/tests/playwright/digital-letters-component-tests/print-sender.component.spec.ts index fc02e0995..23b5634cd 100644 --- a/tests/playwright/digital-letters-component-tests/print-sender.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/print-sender.component.spec.ts @@ -8,10 +8,15 @@ import { PDFAnalysed, validatePDFAnalysed } from 'digital-letters-events'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; -import { expectMessageContainingString } from 'helpers/sqs-helpers'; +import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - Print Sender', () => { + test.beforeAll(async () => { + await purgeQueue(PRINT_SENDER_DLQ_NAME); + test.setTimeout(250_000); + }); + test('should send Letter Prepared event from PDF Analysed event', async () => { test.setTimeout(120_000); diff --git a/tests/playwright/helpers/event-builders.ts b/tests/playwright/helpers/event-builders.ts index a156bb92b..290a135f6 100644 --- a/tests/playwright/helpers/event-builders.ts +++ b/tests/playwright/helpers/event-builders.ts @@ -31,6 +31,7 @@ export function buildDigitalLetterReadEvent( time: string, messageReference: string, senderId: string, + supplierStatus = 'paper_letter_opted_out', ): DigitalLetterRead { const baseEvent = buildBaseEvent('queue', time); return { @@ -42,6 +43,7 @@ export function buildDigitalLetterReadEvent( data: { messageReference, senderId, + supplierStatus, }, } as DigitalLetterRead; } diff --git a/turbo.json b/turbo.json index 75822f803..eab76cc64 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,11 @@ "generate-dependencies": { "dependsOn": [ "^generate-dependencies" + ], + "inputs": [ + "src/**", + "../../schemas/**", + "../../src/cloudevents/**" ] }, "lint": { diff --git a/utils/utils/src/types/channel-status-published-event.ts b/utils/utils/src/types/channel-status-published-event.ts deleted file mode 100644 index c34873551..000000000 --- a/utils/utils/src/types/channel-status-published-event.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod'; - -export const $ChannelStatusPublishedEvent = z.object({ - data: z.object({ - messageReference: z.string(), - supplierStatus: z.literal('paper_letter_opted_out'), - }), -}); - -export type ChannelStatusPublishedEvent = z.infer< - typeof $ChannelStatusPublishedEvent ->; diff --git a/utils/utils/src/types/core-status-published-event.ts b/utils/utils/src/types/core-status-published-event.ts new file mode 100644 index 000000000..2418bf8ea --- /dev/null +++ b/utils/utils/src/types/core-status-published-event.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const $ChannelStatusPublishedEvent = z.object({ + source: z.string(), + type: z.literal('uk.nhs.notify.channel.status.PUBLISHED.v1'), + data: z.union([ + z.object({ + channelStatus: z.literal('failed'), + messageReference: z.string(), + supplierStatus: z.literal('rejected'), + }), + z.object({ + channelStatus: z.string(), + messageReference: z.string(), + supplierStatus: z.enum([ + 'paper_letter_opted_in', + 'paper_letter_opted_out', + ]), + }), + ]), +}); + +export type ChannelStatusPublishedEvent = z.infer< + typeof $ChannelStatusPublishedEvent +>; + +export const $MessageStatusPublishedEvent = z.object({ + source: z.string(), + type: z.literal('uk.nhs.notify.message.status.PUBLISHED.v1'), + data: z.object({ + messageReference: z.string(), + messageStatus: z.string('failed'), + resolvedChannels: z.array(z.unknown()).optional(), + }), +}); + +export type MessageStatusPublishedEvent = z.infer< + typeof $MessageStatusPublishedEvent +>; + +export const $StatusPublishedEvent = z.discriminatedUnion('type', [ + $ChannelStatusPublishedEvent, + $MessageStatusPublishedEvent, +]); + +export type StatusPublishedEvent = z.infer; diff --git a/utils/utils/src/types/index.ts b/utils/utils/src/types/index.ts index c5272f269..1c270e0b5 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -1,4 +1,4 @@ -export * from './channel-status-published-event'; +export * from './core-status-published-event'; export * from './pdm-types'; export * from './sender'; export * from './supplier-api-letter-event'; From c611fa58209aebc146c411edde1c146a56a4a57a Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 26 May 2026 11:04:50 +0100 Subject: [PATCH 03/11] CCM-17640: updates for pact tests --- lambdas/print-status-handler/package.json | 2 +- package-lock.json | 6 +- ...nel-status-published.consumer.pact.test.ts | 65 +++++++++++++++---- ...age-status-published.consumer.pact.test.ts | 48 ++++++++++++++ tests/pact-tests/package.json | 2 +- ...nel-status-published.provider.pact.test.ts | 30 --------- .../status-published.provider.pact.test.ts | 55 ++++++++++++++++ tests/pact-tests/utils/pact-config.ts | 11 +++- tests/playwright/package.json | 2 +- 9 files changed, 172 insertions(+), 49 deletions(-) create mode 100644 tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts delete mode 100644 tests/pact-tests/pact-verification/channel-status-published.provider.pact.test.ts create mode 100644 tests/pact-tests/pact-verification/status-published.provider.pact.test.ts diff --git a/lambdas/print-status-handler/package.json b/lambdas/print-status-handler/package.json index b3782739f..2761b57a3 100644 --- a/lambdas/print-status-handler/package.json +++ b/lambdas/print-status-handler/package.json @@ -5,7 +5,7 @@ "zod": "^4.1.12" }, "devDependencies": { - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.19", "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", diff --git a/package-lock.json b/package-lock.json index 67dcb835a..11fb11055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3089,7 +3089,7 @@ "zod": "^4.1.12" }, "devDependencies": { - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.19", "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", @@ -27871,7 +27871,7 @@ "version": "0.0.1", "devDependencies": { "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.1.0", - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.19", "@pact-foundation/pact": "^16.3.0", "@types/jest": "^29.5.14", "jest": "^29.7.0", @@ -28250,7 +28250,7 @@ "@aws-sdk/lib-dynamodb": "^3.900.0", "@aws-sdk/util-dynamodb": "^3.933.0", "@faker-js/faker": "^9.6.0", - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.19", "@playwright/test": "^1.51.1", "csv-parse": "^6.1.0", "digital-letters-events": "^0.0.1", diff --git a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts index 4ba183c98..84bab186d 100644 --- a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts +++ b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts @@ -1,4 +1,3 @@ -import ChannelStatusPublishedEventPaperLetterOptedOut from '@nhsdigital/nhs-notify-event-schemas-status-published/examples/ChannelStatusPublishedEvent/v1/paper_letter_opted_out.json'; import { MatchersV3, MessageConsumerPact, @@ -6,14 +5,15 @@ import { } from '@pact-foundation/pact'; import { $ChannelStatusPublishedEvent } from 'utils'; import { + PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION, + PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION, + PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, PACT_CONSUMER, - PACT_MESSAGE_DESCRIPTION, PACT_STATUS_PUBLISHED_PROVIDER, } from '../utils/pact-config'; import { getPathFromProvider } from '../utils/path-utils'; -async function handle(event: unknown) { - // The schema used by the core-status-handler to validate the event. +async function handleChannelStatus(event: unknown) { $ChannelStatusPublishedEvent.parse(event); } @@ -28,22 +28,63 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { pactfileWriteMode: 'update', }); - it('validates a channel status published event', async () => { + it('validates a channel status published event with supplierStatus paper_letter_opted_out', async () => { await expect( messagePact - .expectsToReceive(PACT_MESSAGE_DESCRIPTION) + .expectsToReceive(PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION) .withContent({ + source: + '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: MatchersV3.string( - ChannelStatusPublishedEventPaperLetterOptedOut.data - .messageReference, + 'fa5a36ce-20e2-4d72-889e-eec4ac06d8d0_53189467-8375-4c50-8c49-d53483a6d5e9', ), - supplierStatus: - ChannelStatusPublishedEventPaperLetterOptedOut.data - .supplierStatus, + channelStatus: MatchersV3.string('delivered'), + supplierStatus: 'paper_letter_opted_out', }, }) - .verify(asynchronousBodyHandler(handle)), + .verify(asynchronousBodyHandler(handleChannelStatus)), + ).resolves.not.toThrow(); + }); + + it('validates a channel status published event with supplierStatus paper_letter_opted_in', async () => { + await expect( + messagePact + .expectsToReceive(PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION) + .withContent({ + source: + '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + messageReference: MatchersV3.string( + '96dfed4f-cc43-4c70-99e5-caf98bfe3910_a273ecc5-afa0-4327-95b1-160827ccb665', + ), + channelStatus: MatchersV3.string('delivered'), + supplierStatus: 'paper_letter_opted_in', + }, + }) + .verify(asynchronousBodyHandler(handleChannelStatus)), + ).resolves.not.toThrow(); + }); + + it('validates a channel status published event with supplierStatus rejected and channelStatus failed', async () => { + await expect( + messagePact + .expectsToReceive(PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION) + .withContent({ + source: + '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + messageReference: MatchersV3.string( + '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', + ), + channelStatus: 'failed', + supplierStatus: 'rejected', + }, + }) + .verify(asynchronousBodyHandler(handleChannelStatus)), ).resolves.not.toThrow(); }); }); diff --git a/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts new file mode 100644 index 000000000..2307539cd --- /dev/null +++ b/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts @@ -0,0 +1,48 @@ +import { + MatchersV3, + MessageConsumerPact, + asynchronousBodyHandler, +} from '@pact-foundation/pact'; +import { $MessageStatusPublishedEvent } from 'utils'; +import { + PACT_CONSUMER, + PACT_MESSAGE_STATUS_FAILED_DESCRIPTION, + PACT_STATUS_PUBLISHED_PROVIDER, +} from '../utils/pact-config'; +import { getPathFromProvider } from '../utils/path-utils'; + +async function handleMessageStatus(event: unknown) { + $MessageStatusPublishedEvent.parse(event); +} + +const PACT_DIRECTORY = getPathFromProvider(PACT_STATUS_PUBLISHED_PROVIDER); + +describe('Pact message consumer - ChannelStatusPublished event', () => { + const messagePact = new MessageConsumerPact({ + consumer: PACT_CONSUMER, + provider: PACT_STATUS_PUBLISHED_PROVIDER, + dir: PACT_DIRECTORY, + logLevel: 'error', + pactfileWriteMode: 'update', + }); + + it('validates a message status published event with messageStatus failed and empty resolvedChannels', async () => { + await expect( + messagePact + .expectsToReceive(PACT_MESSAGE_STATUS_FAILED_DESCRIPTION) + .withContent({ + source: + '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: { + messageReference: MatchersV3.string( + '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', + ), + messageStatus: 'failed', + resolvedChannels: [], + }, + }) + .verify(asynchronousBodyHandler(handleMessageStatus)), + ).resolves.not.toThrow(); + }); +}); diff --git a/tests/pact-tests/package.json b/tests/pact-tests/package.json index cef06603e..2298a3f9c 100644 --- a/tests/pact-tests/package.json +++ b/tests/pact-tests/package.json @@ -1,7 +1,7 @@ { "devDependencies": { "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.1.0", - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.19", "@pact-foundation/pact": "^16.3.0", "@types/jest": "^29.5.14", "jest": "^29.7.0", diff --git a/tests/pact-tests/pact-verification/channel-status-published.provider.pact.test.ts b/tests/pact-tests/pact-verification/channel-status-published.provider.pact.test.ts deleted file mode 100644 index 5a70ae49a..000000000 --- a/tests/pact-tests/pact-verification/channel-status-published.provider.pact.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MessageProviderPact } from '@pact-foundation/pact'; - -import ChannelStatusPublishedEventPaperLetterOptedOut from '@nhsdigital/nhs-notify-event-schemas-status-published/examples/ChannelStatusPublishedEvent/v1/paper_letter_opted_out.json'; -import { getPactFilePath } from '../utils/path-utils'; -import { - PACT_CONSUMER, - PACT_MESSAGE_DESCRIPTION, - PACT_STATUS_PUBLISHED_PROVIDER, -} from '../utils/pact-config'; - -const PACT_FILE = getPactFilePath( - PACT_CONSUMER, - PACT_STATUS_PUBLISHED_PROVIDER, -); - -describe('Channel status published provider tests', () => { - test('verify pacts', async () => { - const p = new MessageProviderPact({ - provider: PACT_STATUS_PUBLISHED_PROVIDER, - pactUrls: [PACT_FILE], - messageProviders: { - [PACT_MESSAGE_DESCRIPTION]: () => - ChannelStatusPublishedEventPaperLetterOptedOut, - }, - logLevel: 'error', - }); - - await expect(p.verify()).resolves.not.toThrow(); - }); -}); diff --git a/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts b/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts new file mode 100644 index 000000000..1f8f471e1 --- /dev/null +++ b/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts @@ -0,0 +1,55 @@ +import { MessageProviderPact } from '@pact-foundation/pact'; +import ChannelStatusPublishedEventPaperLetterOptedOut from '@nhsdigital/nhs-notify-event-schemas-status-published/examples/ChannelStatusPublishedEvent/v1/paper_letter_opted_out.json'; +import MessageStatusPublishedEventFailedEarly from '@nhsdigital/nhs-notify-event-schemas-status-published/examples/MessageStatusPublishedEvent/v1/failed_early.json'; +import { getPactFilePath } from '../utils/path-utils'; +import { + PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION, + PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION, + PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, + PACT_CONSUMER, + PACT_MESSAGE_STATUS_FAILED_DESCRIPTION, + PACT_STATUS_PUBLISHED_PROVIDER, +} from '../utils/pact-config'; + +const PACT_FILE = getPactFilePath( + PACT_CONSUMER, + PACT_STATUS_PUBLISHED_PROVIDER, +); + +describe('Channel status published provider tests', () => { + test('verify pacts', async () => { + const p = new MessageProviderPact({ + provider: PACT_STATUS_PUBLISHED_PROVIDER, + pactUrls: [PACT_FILE], + messageProviders: { + [PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION]: () => + ChannelStatusPublishedEventPaperLetterOptedOut, + [PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION]: () => ({ + ...ChannelStatusPublishedEventPaperLetterOptedOut, + data: { + ...ChannelStatusPublishedEventPaperLetterOptedOut.data, + supplierStatus: 'paper_letter_opted_in', + }, + }), + [PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION]: () => ({ + ...ChannelStatusPublishedEventPaperLetterOptedOut, + data: { + ...ChannelStatusPublishedEventPaperLetterOptedOut.data, + channelStatus: 'failed', + supplierStatus: 'rejected', + }, + }), + [PACT_MESSAGE_STATUS_FAILED_DESCRIPTION]: () => ({ + ...MessageStatusPublishedEventFailedEarly, + data: { + ...MessageStatusPublishedEventFailedEarly.data, + resolvedChannels: [], + }, + }), + }, + logLevel: 'error', + }); + + await expect(p.verify()).resolves.not.toThrow(); + }); +}); diff --git a/tests/pact-tests/utils/pact-config.ts b/tests/pact-tests/utils/pact-config.ts index 1188dc28c..9fa401c11 100644 --- a/tests/pact-tests/utils/pact-config.ts +++ b/tests/pact-tests/utils/pact-config.ts @@ -2,5 +2,14 @@ export const PACT_CONSUMER = 'digital-letters'; export const PACT_STATUS_PUBLISHED_PROVIDER = 'status-published'; export const PACT_SUPPLIER_API_PROVIDER = 'supplier-api'; -export const PACT_MESSAGE_DESCRIPTION = +export const PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION = 'ChannelStatusPublishedEvent-paper_letter_opted_out'; + +export const PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION = + 'ChannelStatusPublishedEvent-paper_letter_opted_in'; + +export const PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION = + 'ChannelStatusPublishedEvent-supplier_rejected'; + +export const PACT_MESSAGE_STATUS_FAILED_DESCRIPTION = + 'MessageStatusPublishedEvent-failed'; diff --git a/tests/playwright/package.json b/tests/playwright/package.json index d8838f818..56e1356a0 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -10,7 +10,7 @@ "@aws-sdk/lib-dynamodb": "^3.900.0", "@aws-sdk/util-dynamodb": "^3.933.0", "@faker-js/faker": "^9.6.0", - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.19", "@playwright/test": "^1.51.1", "csv-parse": "^6.1.0", "digital-letters-events": "^0.0.1", From 9b17f1a8a17d2e19c7859a0b6b7b1a71f94bb9ea Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 27 May 2026 08:16:24 +0100 Subject: [PATCH 04/11] CCM-17640: fix message validation bug --- .../__tests__/apis/sqs-trigger-lambda.test.ts | 136 +++++++++++++++--- .../app/status-action-resolver.test.ts | 18 +-- .../core-status-handler/src/__tests__/data.ts | 2 +- .../src/types/core-status-published-event.ts | 2 +- 4 files changed, 128 insertions(+), 30 deletions(-) diff --git a/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index 73a1f08bc..a2dcee1e8 100644 --- a/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,4 +1,8 @@ -import { channelStatusEvent, messageDownloadedEvent } from '__tests__/data'; +import { + channelStatusEvent, + messageDownloadedEvent, + messageStatusEvent, +} from '__tests__/data'; import { createHandler } from 'apis/sqs-trigger-lambda'; import type { SQSEvent } from 'aws-lambda'; import { @@ -23,10 +27,14 @@ describe('createHandler', () => { let logger: any; let handler: any; - const eventBusEvent = { + const channelStatusBusEvent = { detail: channelStatusEvent, }; + const messageStatusBusEvent = { + detail: messageStatusEvent, + }; + const digitalLetterReadEvent: DigitalLetterRead = { ...messageDownloadedEvent, id: '550e8400-e29b-41d4-a716-446655440001', @@ -50,14 +58,16 @@ describe('createHandler', () => { handler = createHandler({ eventPublisher, logger, statusActionResolver }); }); - it('processes a valid SQS event and returns success', async () => { + it('processes a valid channel status SQS event and returns success', async () => { statusActionResolver.resolve.mockResolvedValue({ publish: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], } as any; const res = await handler(event); @@ -86,6 +96,33 @@ describe('createHandler', () => { }); }); + it('processes a valid message status SQS event and returns success', async () => { + statusActionResolver.resolve.mockResolvedValue({ + result: 'success', + }); + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(messageStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(statusActionResolver.resolve).toHaveBeenCalledWith( + messageStatusEvent, + ); + expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + skipped: 0, + success: 1, + }); + }); + it('handles event validation failure and logs error', async () => { const event: SQSEvent = { Records: [{ body: '{}', messageId: 'msg1' }], @@ -109,12 +146,63 @@ describe('createHandler', () => { }); }); - it('handles event validation failure and logs error with message reference if present', async () => { + it('handles channel event validation failure and logs error with message reference if present', async () => { + const messageReference = randomUUID(); + const invalidchannelStatusBusEvent = { + ...channelStatusBusEvent, + detail: { + ...channelStatusBusEvent.detail, + data: { + ...channelStatusBusEvent.detail.data, + supplierStatus: 'I am not valid', + messageReference, + }, + }, + }; + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify(invalidchannelStatusBusEvent), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('Error parsing sqs record'), + messageReference, + }), + ); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 1, + retrieved: 1, + skipped: 0, + success: 0, + }); + }); + + it('handles message event validation failure and logs error with message reference if present', async () => { const messageReference = randomUUID(); + const invalidMessageStatusBusEvent = { + ...messageStatusBusEvent, + detail: { + ...messageStatusBusEvent.detail, + data: { + ...messageStatusBusEvent.detail.data, + messageStatus: 'I am not valid', + messageReference, + }, + }, + }; const event: SQSEvent = { Records: [ { - body: `{ "detail": { "data": { "messageReference": "${messageReference}" } } }`, + body: JSON.stringify(invalidMessageStatusBusEvent), messageId: 'msg1', }, ], @@ -141,7 +229,9 @@ describe('createHandler', () => { it('handles statusActionResolver.resolve failure', async () => { statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], } as any; const res = await handler(event); @@ -223,9 +313,9 @@ describe('createHandler', () => { }); const sqsEvent: SQSEvent = { Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg2' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg2' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg3' }, ], } as any; @@ -257,8 +347,8 @@ describe('createHandler', () => { const event: SQSEvent = { Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg2' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg2' }, ], } as any; @@ -286,7 +376,9 @@ describe('createHandler', () => { eventPublisher.sendEvents.mockRejectedValue(publishError); const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], } as any; const res = await handler(event); @@ -307,7 +399,9 @@ describe('createHandler', () => { statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], } as any; const res = await handler(event); @@ -327,7 +421,9 @@ describe('createHandler', () => { statusActionResolver.resolve.mockResolvedValue({ result: 'skipped' }); const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], } as any; const res = await handler(event); @@ -347,7 +443,9 @@ describe('createHandler', () => { statusActionResolver.resolve.mockResolvedValue({ result: 'success' }); const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], } as any; const res = await handler(event); @@ -375,10 +473,10 @@ describe('createHandler', () => { const event: SQSEvent = { Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, { body: '{}', messageId: 'msg2' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg4' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg3' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg4' }, ], } as any; diff --git a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts index 4d16f806e..4c9c0e40b 100644 --- a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts @@ -1,7 +1,7 @@ import { channelStatusEvent, messageDownloadedEvent, - messagesStatusEvent, + messageStatusEvent, } from '__tests__/data'; import { StatusActionResolver } from 'app/status-action-resolver'; import { TtlActions } from 'app/ttl-actions'; @@ -130,9 +130,9 @@ describe('StatusActionResolver', () => { describe('MessageStatusPublishedEvent', () => { it('when messageStatus is failed and resolvedChannels is empty - calls markWithdrawn', async () => { const event: MessageStatusPublishedEvent = { - ...messagesStatusEvent, + ...messageStatusEvent, data: { - ...messagesStatusEvent.data, + ...messageStatusEvent.data, messageStatus: 'failed', resolvedChannels: [], }, @@ -148,9 +148,9 @@ describe('StatusActionResolver', () => { it('when messageStatus is failed and resolvedChannels is absent - calls markWithdrawn', async () => { const event: MessageStatusPublishedEvent = { - ...messagesStatusEvent, + ...messageStatusEvent, data: { - ...messagesStatusEvent.data, + ...messageStatusEvent.data, messageStatus: 'failed', resolvedChannels: undefined, }, @@ -165,9 +165,9 @@ describe('StatusActionResolver', () => { it('when messageStatus is failed but resolvedChannels is non-empty - skips', async () => { const event: MessageStatusPublishedEvent = { - ...messagesStatusEvent, + ...messageStatusEvent, data: { - ...messagesStatusEvent.data, + ...messageStatusEvent.data, messageStatus: 'failed', resolvedChannels: ['letter'], }, @@ -189,9 +189,9 @@ describe('StatusActionResolver', () => { it('when messageStatus is not failed - skips', async () => { const event: MessageStatusPublishedEvent = { - ...messagesStatusEvent, + ...messageStatusEvent, data: { - ...messagesStatusEvent.data, + ...messageStatusEvent.data, messageStatus: 'delivered', resolvedChannels: [], } as unknown as MessageStatusPublishedEvent['data'], diff --git a/lambdas/core-status-handler/src/__tests__/data.ts b/lambdas/core-status-handler/src/__tests__/data.ts index 30f4d8f28..8220faa2c 100644 --- a/lambdas/core-status-handler/src/__tests__/data.ts +++ b/lambdas/core-status-handler/src/__tests__/data.ts @@ -39,7 +39,7 @@ export const channelStatusEvent: ChannelStatusPublishedEvent = { }, }; -export const messagesStatusEvent: MessageStatusPublishedEvent = { +export const messageStatusEvent: MessageStatusPublishedEvent = { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { diff --git a/utils/utils/src/types/core-status-published-event.ts b/utils/utils/src/types/core-status-published-event.ts index 2418bf8ea..94f00969b 100644 --- a/utils/utils/src/types/core-status-published-event.ts +++ b/utils/utils/src/types/core-status-published-event.ts @@ -29,7 +29,7 @@ export const $MessageStatusPublishedEvent = z.object({ type: z.literal('uk.nhs.notify.message.status.PUBLISHED.v1'), data: z.object({ messageReference: z.string(), - messageStatus: z.string('failed'), + messageStatus: z.literal('failed'), resolvedChannels: z.array(z.unknown()).optional(), }), }); From 890767804cad8e631c12bdf07a6721d6456c0d7a Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 28 May 2026 09:16:04 +0100 Subject: [PATCH 05/11] CCM-17640: add changes from CCM-17639 --- .../__tests__/apis/sqs-trigger-lambda.test.ts | 186 +++++++----------- .../src/__tests__/app/event-sender.test.ts | 164 +++++++++++++++ .../__tests__/app/results-aggregator.test.ts | 153 ++++++++++++++ .../app/status-action-resolver.test.ts | 68 ++++--- .../src/__tests__/app/ttl-actions.test.ts | 43 ++-- .../core-status-handler/src/__tests__/data.ts | 16 +- .../__tests__/infra/ttl-repository.test.ts | 18 +- .../src/apis/sqs-trigger-lambda.ts | 109 ++-------- .../src/app/event-sender.ts | 108 ++++++++++ .../src/app/results-aggregator.ts | 76 +++++++ .../src/app/status-action-resolver.ts | 35 +++- lambdas/core-status-handler/src/container.ts | 5 +- .../core-status-handler/src/infra/config.ts | 4 +- ...nel-status-published.consumer.pact.test.ts | 4 +- ...age-status-published.consumer.pact.test.ts | 2 + .../status-published.provider.pact.test.ts | 4 + .../core-status-handler.component.spec.ts | 97 ++++++--- .../mesh-acknowledge.component.spec.ts | 2 +- .../src/types/core-status-published-event.ts | 4 + 19 files changed, 794 insertions(+), 304 deletions(-) create mode 100644 lambdas/core-status-handler/src/__tests__/app/event-sender.test.ts create mode 100644 lambdas/core-status-handler/src/__tests__/app/results-aggregator.test.ts create mode 100644 lambdas/core-status-handler/src/app/event-sender.ts create mode 100644 lambdas/core-status-handler/src/app/results-aggregator.ts diff --git a/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index a2dcee1e8..cf8c34169 100644 --- a/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,14 +1,10 @@ import { - channelStatusEvent, + channelStatusDeliveredEvent, messageDownloadedEvent, messageStatusEvent, } from '__tests__/data'; import { createHandler } from 'apis/sqs-trigger-lambda'; import type { SQSEvent } from 'aws-lambda'; -import { - DigitalLetterRead, - validateDigitalLetterRead, -} from 'digital-letters-events'; import { randomUUID } from 'node:crypto'; jest.mock('node:crypto', () => ({ @@ -23,44 +19,31 @@ mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); describe('createHandler', () => { let statusActionResolver: any; - let eventPublisher: any; + let eventSender: any; let logger: any; let handler: any; const channelStatusBusEvent = { - detail: channelStatusEvent, + detail: channelStatusDeliveredEvent, }; const messageStatusBusEvent = { detail: messageStatusEvent, }; - const digitalLetterReadEvent: DigitalLetterRead = { - ...messageDownloadedEvent, - id: '550e8400-e29b-41d4-a716-446655440001', - source: '/nhs/england/notify/production/primary/digitalletters/queue', - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', - data: { - messageReference: messageDownloadedEvent.data.messageReference, - senderId: messageDownloadedEvent.data.senderId, - supplierStatus: 'paper_letter_opted_in', - }, - }; - beforeEach(() => { statusActionResolver = { resolve: jest.fn() }; - eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; + eventSender = { + digitalLetterRead: jest.fn(), + digitalLetterUnsuccessful: jest.fn(), + }; logger = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; - handler = createHandler({ eventPublisher, logger, statusActionResolver }); + handler = createHandler({ eventSender, logger, statusActionResolver }); }); it('processes a valid channel status SQS event and returns success', async () => { statusActionResolver.resolve.mockResolvedValue({ - publish: { supplierStatus: 'paper_letter_opted_in' }, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); @@ -74,19 +57,14 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([]); expect(statusActionResolver.resolve).toHaveBeenCalledWith( - channelStatusEvent, - ); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent], - validateDigitalLetterRead, + channelStatusDeliveredEvent, ); - - const publishedEvent = eventPublisher.sendEvents.mock.lastCall?.[0]; - expect(publishedEvent).toHaveLength(1); - expect(() => - validateDigitalLetterRead(publishedEvent?.[0], logger), - ).not.toThrow(); - + expect(eventSender.digitalLetterRead).toHaveBeenCalledWith([ + expect.objectContaining({ + event: messageDownloadedEvent, + supplierStatus: 'paper_letter_opted_in', + }), + ]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -112,8 +90,8 @@ describe('createHandler', () => { expect(statusActionResolver.resolve).toHaveBeenCalledWith( messageStatusEvent, ); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); - + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -237,9 +215,10 @@ describe('createHandler', () => { const res = await handler(event); expect(statusActionResolver.resolve).toHaveBeenCalledWith( - channelStatusEvent, + channelStatusDeliveredEvent, ); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', @@ -279,9 +258,7 @@ describe('createHandler', () => { it('handles rejected promises from event.Records.map', async () => { // Very unlikely that event.Records.map will reject as all the logic is inside a try/catch. - const event = { Records: [] } as any; - // Spy on Promise.allSettled to return a rejected result const originalAllSettled = Promise.allSettled; Promise.allSettled = jest .fn() @@ -307,7 +284,7 @@ describe('createHandler', () => { it('processes multiple successful events and sends them as a batch', async () => { statusActionResolver.resolve.mockResolvedValue({ - publish: { supplierStatus: 'paper_letter_opted_in' }, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }); @@ -323,10 +300,12 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([]); expect(statusActionResolver.resolve).toHaveBeenCalledTimes(3); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent, digitalLetterReadEvent, digitalLetterReadEvent], - validateDigitalLetterRead, + expect(eventSender.digitalLetterRead).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ supplierStatus: 'paper_letter_opted_in' }), + ]), ); + expect(eventSender.digitalLetterRead.mock.calls[0][0]).toHaveLength(3); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -336,66 +315,7 @@ describe('createHandler', () => { }); }); - it('handles partial event publishing failures and logs warning', async () => { - statusActionResolver.resolve.mockResolvedValue({ - publish: { supplierStatus: 'paper_letter_opted_in' }, - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - const failedEvents = [messageDownloadedEvent]; - eventPublisher.sendEvents.mockResolvedValue(failedEvents); - - const event: SQSEvent = { - Records: [ - { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, - { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg2' }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent, digitalLetterReadEvent], - validateDigitalLetterRead, - ); - expect(logger.warn).toHaveBeenCalledWith({ - description: 'Some events failed to publish', - failedCount: 1, - totalAttempted: 2, - }); - }); - - it('handles event publishing exception and logs warning', async () => { - statusActionResolver.resolve.mockResolvedValue({ - publish: { supplierStatus: 'paper_letter_opted_in' }, - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - const publishError = new Error('EventBridge error'); - eventPublisher.sendEvents.mockRejectedValue(publishError); - - const event: SQSEvent = { - Records: [ - { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent], - validateDigitalLetterRead, - ); - expect(logger.warn).toHaveBeenCalledWith({ - err: publishError, - description: 'Failed to send events to EventBridge', - eventCount: 1, - }); - }); - - it('does not call eventPublisher when no successful events', async () => { + it('does not call eventSender when no successful events', async () => { statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { @@ -407,7 +327,8 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 1, @@ -417,7 +338,7 @@ describe('createHandler', () => { }); }); - it('does not call eventPublisher for skipped events', async () => { + it('does not call eventSender for skipped events', async () => { statusActionResolver.resolve.mockResolvedValue({ result: 'skipped' }); const event: SQSEvent = { @@ -429,7 +350,8 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -439,7 +361,7 @@ describe('createHandler', () => { }); }); - it('does not call eventPublisher when no TTL record is found', async () => { + it('does not call eventSender when no TTL record is found', async () => { statusActionResolver.resolve.mockResolvedValue({ result: 'success' }); const event: SQSEvent = { @@ -451,7 +373,8 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -464,7 +387,7 @@ describe('createHandler', () => { it('handles mixed success, failure, and skipped scenarios', async () => { statusActionResolver.resolve .mockResolvedValueOnce({ - publish: { supplierStatus: 'paper_letter_opted_in' }, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, result: 'success', ttlItem: { event: messageDownloadedEvent }, }) @@ -486,10 +409,9 @@ describe('createHandler', () => { { itemIdentifier: 'msg2' }, { itemIdentifier: 'msg3' }, ]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent], - validateDigitalLetterRead, - ); + expect(eventSender.digitalLetterRead).toHaveBeenCalledWith([ + expect.objectContaining({ supplierStatus: 'paper_letter_opted_in' }), + ]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 2, @@ -498,4 +420,32 @@ describe('createHandler', () => { success: 1, }); }); + + it('calls eventSender.digitalLetterUnsuccessful when publishUnsuccessful is set', async () => { + statusActionResolver.resolve.mockResolvedValueOnce({ + publishUnsuccessful: { + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + await handler(event); + + expect(eventSender.digitalLetterUnsuccessful).toHaveBeenCalledWith([ + expect.objectContaining({ + event: messageDownloadedEvent, + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }), + ]); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + }); }); diff --git a/lambdas/core-status-handler/src/__tests__/app/event-sender.test.ts b/lambdas/core-status-handler/src/__tests__/app/event-sender.test.ts new file mode 100644 index 000000000..bc189fbea --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/app/event-sender.test.ts @@ -0,0 +1,164 @@ +import { messageDownloadedEvent } from '__tests__/data'; +import { EventSender } from 'app/event-sender'; + +describe('EventSender', () => { + let eventPublisher: any; + let logger: any; + let sender: EventSender; + + beforeEach(() => { + eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; + logger = { warn: jest.fn() }; + sender = new EventSender(eventPublisher, logger); + }); + + describe('digitalLetterRead', () => { + it('calls eventPublisher.sendEvents with mapped events', async () => { + await sender.digitalLetterRead([ + { + event: messageDownloadedEvent, + supplierStatus: 'paper_letter_opted_in', + }, + ]); + + expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(1); + const [events] = eventPublisher.sendEvents.mock.calls[0]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', + source: '/nhs/england/notify/production/primary/digitalletters/queue', + data: { + messageReference: messageDownloadedEvent.data.messageReference, + senderId: messageDownloadedEvent.data.senderId, + supplierStatus: 'paper_letter_opted_in', + }, + }); + }); + + it('logs a warning when some events fail to publish', async () => { + const failedEvent = { id: 'failed-id', reason: 'error' }; + eventPublisher.sendEvents.mockResolvedValue([failedEvent]); + + await sender.digitalLetterRead([ + { + event: messageDownloadedEvent, + supplierStatus: 'paper_letter_opted_in', + }, + ]); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Some events failed to publish', + failedCount: 1, + totalAttempted: 1, + }), + ); + }); + + it('logs a warning when sendEvents throws', async () => { + const error = new Error('EventBridge failure'); + eventPublisher.sendEvents.mockRejectedValue(error); + + await sender.digitalLetterRead([ + { + event: messageDownloadedEvent, + supplierStatus: 'paper_letter_opted_in', + }, + ]); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: error, + description: 'Failed to send events to EventBridge', + eventCount: 1, + }), + ); + }); + + it('does nothing when given an empty array', async () => { + await sender.digitalLetterRead([]); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [], + expect.any(Function), + ); + }); + }); + + describe('digitalLetterUnsuccessful', () => { + it('calls eventPublisher.sendEvents with mapped events', async () => { + await sender.digitalLetterUnsuccessful([ + { + event: messageDownloadedEvent, + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + ]); + + expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(1); + const [events] = eventPublisher.sendEvents.mock.calls[0]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + source: '/nhs/england/notify/production/primary/digitalletters/queue', + data: { + messageReference: messageDownloadedEvent.data.messageReference, + senderId: messageDownloadedEvent.data.senderId, + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + }); + }); + + it('logs a warning when some events fail to publish', async () => { + const failedEvent = { id: 'failed-id', reason: 'error' }; + eventPublisher.sendEvents.mockResolvedValue([failedEvent]); + + await sender.digitalLetterUnsuccessful([ + { + event: messageDownloadedEvent, + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + ]); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Some events failed to publish', + failedCount: 1, + totalAttempted: 1, + }), + ); + }); + + it('logs a warning when sendEvents throws', async () => { + const error = new Error('EventBridge failure'); + eventPublisher.sendEvents.mockRejectedValue(error); + + await sender.digitalLetterUnsuccessful([ + { + event: messageDownloadedEvent, + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + ]); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: error, + description: 'Failed to send events to EventBridge', + eventCount: 1, + }), + ); + }); + + it('does nothing when given an empty array', async () => { + await sender.digitalLetterUnsuccessful([]); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [], + expect.any(Function), + ); + }); + }); +}); diff --git a/lambdas/core-status-handler/src/__tests__/app/results-aggregator.test.ts b/lambdas/core-status-handler/src/__tests__/app/results-aggregator.test.ts new file mode 100644 index 000000000..4692fdc85 --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/app/results-aggregator.test.ts @@ -0,0 +1,153 @@ +import { messageDownloadedEvent } from '__tests__/data'; +import { ProcessingResult, aggregateResults } from 'app/results-aggregator'; + +describe('aggregateResults', () => { + let logger: any; + + const successOutcome = { + result: 'success' as const, + ttlItem: { event: messageDownloadedEvent }, + }; + + beforeEach(() => { + logger = { warn: jest.fn() }; + }); + + it('counts retrieved from total results length', () => { + const results: PromiseSettledResult[] = [ + { status: 'fulfilled', value: { outcome: successOutcome } }, + { status: 'fulfilled', value: { outcome: { result: 'skipped' } } }, + ]; + + const { processed } = aggregateResults(results, logger); + + expect(processed.retrieved).toBe(2); + }); + + it('counts fulfilled outcomes by result type', () => { + const results: PromiseSettledResult[] = [ + { status: 'fulfilled', value: { outcome: successOutcome } }, + { status: 'fulfilled', value: { outcome: successOutcome } }, + { status: 'fulfilled', value: { outcome: { result: 'failed' } } }, + { status: 'fulfilled', value: { outcome: { result: 'skipped' } } }, + ]; + + const { processed } = aggregateResults(results, logger); + + expect(processed.success).toBe(2); + expect(processed.failed).toBe(1); + expect(processed.skipped).toBe(1); + }); + + it('counts rejected promises as failed and logs a warning', () => { + const error = new Error('forced rejection'); + const results: PromiseSettledResult[] = [ + { status: 'rejected', reason: error }, + ]; + + const { processed } = aggregateResults(results, logger); + + expect(processed.failed).toBe(1); + expect(logger.warn).toHaveBeenCalledWith({ err: error }); + }); + + it('collects digitalLetterRead events from publishRead outcomes', () => { + const results: PromiseSettledResult[] = [ + { + status: 'fulfilled', + value: { + outcome: { + ...successOutcome, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, + }, + }, + }, + ]; + + const { digitalLetterReadEvents } = aggregateResults(results, logger); + + expect(digitalLetterReadEvents).toHaveLength(1); + expect(digitalLetterReadEvents[0]).toEqual({ + event: messageDownloadedEvent, + supplierStatus: 'paper_letter_opted_in', + }); + }); + + it('collects digitalLetterUnsuccessful events from publishUnsuccessful outcomes', () => { + const results: PromiseSettledResult[] = [ + { + status: 'fulfilled', + value: { + outcome: { + ...successOutcome, + publishUnsuccessful: { + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + }, + }, + }, + ]; + + const { digitalLetterUnsuccessfulEvents } = aggregateResults( + results, + logger, + ); + + expect(digitalLetterUnsuccessfulEvents).toHaveLength(1); + expect(digitalLetterUnsuccessfulEvents[0]).toEqual({ + event: messageDownloadedEvent, + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }); + }); + + it('does not collect events when result is not success', () => { + const results: PromiseSettledResult[] = [ + { status: 'fulfilled', value: { outcome: { result: 'skipped' } } }, + { status: 'fulfilled', value: { outcome: { result: 'failed' } } }, + ]; + + const { digitalLetterReadEvents, digitalLetterUnsuccessfulEvents } = + aggregateResults(results, logger); + + expect(digitalLetterReadEvents).toHaveLength(0); + expect(digitalLetterUnsuccessfulEvents).toHaveLength(0); + }); + + it('does not collect events when success but no ttlItem', () => { + const results: PromiseSettledResult[] = [ + { + status: 'fulfilled', + value: { + outcome: { + result: 'success', + ttlItem: undefined, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, + }, + }, + }, + ]; + + const { digitalLetterReadEvents } = aggregateResults(results, logger); + + expect(digitalLetterReadEvents).toHaveLength(0); + }); + + it('returns empty event arrays for an empty results list', () => { + const { + digitalLetterReadEvents, + digitalLetterUnsuccessfulEvents, + processed, + } = aggregateResults([], logger); + + expect(processed).toEqual({ + retrieved: 0, + success: 0, + failed: 0, + skipped: 0, + }); + expect(digitalLetterReadEvents).toHaveLength(0); + expect(digitalLetterUnsuccessfulEvents).toHaveLength(0); + }); +}); diff --git a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts index 4c9c0e40b..3b770d630 100644 --- a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts @@ -1,5 +1,6 @@ import { - channelStatusEvent, + channelStatusDeliveredEvent, + channelStatusFailedEvent, messageDownloadedEvent, messageStatusEvent, } from '__tests__/data'; @@ -29,9 +30,9 @@ describe('StatusActionResolver', () => { describe('ChannelStatusPublishedEvent', () => { it('when supplierStatus is paper_letter_opted_out - calls markWithdrawn and publishes DigitalLetterRead event', async () => { const event: ChannelStatusPublishedEvent = { - ...channelStatusEvent, + ...channelStatusDeliveredEvent, data: { - ...channelStatusEvent.data, + ...channelStatusDeliveredEvent.data, supplierStatus: 'paper_letter_opted_out', }, }; @@ -43,15 +44,15 @@ describe('StatusActionResolver', () => { expect(ttlActions.delete).not.toHaveBeenCalled(); expect(result).toEqual({ ...successOutcome, - publish: { supplierStatus: 'paper_letter_opted_out' }, + publishRead: { supplierStatus: 'paper_letter_opted_out' }, }); }); it('when supplierStatus is paper_letter_opted_in - calls delete and publishes DigitalLetterRead event', async () => { const event: ChannelStatusPublishedEvent = { - ...channelStatusEvent, + ...channelStatusDeliveredEvent, data: { - ...channelStatusEvent.data, + ...channelStatusDeliveredEvent.data, supplierStatus: 'paper_letter_opted_in', }, }; @@ -63,33 +64,31 @@ describe('StatusActionResolver', () => { expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); expect(result).toEqual({ ...successOutcome, - publish: { supplierStatus: 'paper_letter_opted_in' }, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, }); }); - it('when supplierStatus is rejected, channelStatus is failed - calls delete', async () => { - const event: ChannelStatusPublishedEvent = { - ...channelStatusEvent, - data: { - ...channelStatusEvent.data, - supplierStatus: 'rejected', - channelStatus: 'failed', - }, - }; + it('when supplierStatus is rejected, channelStatus is failed - calls delete and flags for DigitalLetterUnsuccessful event', async () => { ttlActions.delete.mockResolvedValue(successOutcome); - const result = await resolver.resolve(event); + const result = await resolver.resolve(channelStatusFailedEvent); - expect(ttlActions.delete).toHaveBeenCalledWith(event); + expect(ttlActions.delete).toHaveBeenCalledWith(channelStatusFailedEvent); expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); - expect(result).toEqual({ ...successOutcome }); + expect(result).toEqual({ + ...successOutcome, + publishUnsuccessful: { + reasonCode: 'CFR_CNSN_0001', + reasonText: 'Could not send notification', + }, + }); }); it('when supplierStatus is rejected, but channelStatus is not failed - skips', async () => { const event: ChannelStatusPublishedEvent = { - ...channelStatusEvent, + ...channelStatusDeliveredEvent, data: { - ...channelStatusEvent.data, + ...channelStatusDeliveredEvent.data, supplierStatus: 'rejected', channelStatus: 'retry', } as unknown as ChannelStatusPublishedEvent['data'], @@ -105,9 +104,9 @@ describe('StatusActionResolver', () => { it('when supplierStatus is unrecognised - skips', async () => { const event: ChannelStatusPublishedEvent = { - ...channelStatusEvent, + ...channelStatusDeliveredEvent, data: { - ...channelStatusEvent.data, + ...channelStatusDeliveredEvent.data, supplierStatus: 'some_other_status', } as unknown as ChannelStatusPublishedEvent['data'], }; @@ -128,12 +127,11 @@ describe('StatusActionResolver', () => { }); describe('MessageStatusPublishedEvent', () => { - it('when messageStatus is failed and resolvedChannels is empty - calls markWithdrawn', async () => { + it('when messageStatus is failed and resolvedChannels is empty - calls markWithdrawn and flags for DigitalLetterUnsuccessful event', async () => { const event: MessageStatusPublishedEvent = { ...messageStatusEvent, data: { ...messageStatusEvent.data, - messageStatus: 'failed', resolvedChannels: [], }, }; @@ -143,15 +141,20 @@ describe('StatusActionResolver', () => { expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); expect(ttlActions.delete).not.toHaveBeenCalled(); - expect(result).toEqual({ ...successOutcome }); + expect(result).toEqual({ + ...successOutcome, + publishUnsuccessful: { + reasonCode: 'MFR_CFGV_0005', + reasonText: 'Failed reason: contact detail missing', + }, + }); }); - it('when messageStatus is failed and resolvedChannels is absent - calls markWithdrawn', async () => { + it('when messageStatus is failed and resolvedChannels is absent - calls markWithdrawn and flags for DigitalLetterUnsuccessful event', async () => { const event: MessageStatusPublishedEvent = { ...messageStatusEvent, data: { ...messageStatusEvent.data, - messageStatus: 'failed', resolvedChannels: undefined, }, }; @@ -160,7 +163,13 @@ describe('StatusActionResolver', () => { const result = await resolver.resolve(event); expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); - expect(result).toEqual({ ...successOutcome }); + expect(result).toEqual({ + ...successOutcome, + publishUnsuccessful: { + reasonCode: 'MFR_CFGV_0005', + reasonText: 'Failed reason: contact detail missing', + }, + }); }); it('when messageStatus is failed but resolvedChannels is non-empty - skips', async () => { @@ -168,7 +177,6 @@ describe('StatusActionResolver', () => { ...messageStatusEvent, data: { ...messageStatusEvent.data, - messageStatus: 'failed', resolvedChannels: ['letter'], }, }; diff --git a/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts index ec37a0901..1d30af12a 100644 --- a/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts @@ -1,4 +1,7 @@ -import { channelStatusEvent, messageDownloadedEvent } from '__tests__/data'; +import { + channelStatusDeliveredEvent, + messageDownloadedEvent, +} from '__tests__/data'; import { TtlActions } from 'app/ttl-actions'; import { TtlRepository } from 'infra/ttl-repository'; @@ -17,7 +20,9 @@ describe('TtlActions', () => { it('returns success when markWithdrawn succeeds', async () => { repo.markWithdrawn.mockResolvedValue({ event: messageDownloadedEvent }); - const result = await ttlActions.markWithdrawn(channelStatusEvent); + const result = await ttlActions.markWithdrawn( + channelStatusDeliveredEvent, + ); expect(result).toEqual({ result: 'success', @@ -28,11 +33,11 @@ describe('TtlActions', () => { description: expect.stringContaining( 'TTL record marked as withdrawn', ), - messageReference: channelStatusEvent.data.messageReference, + messageReference: channelStatusDeliveredEvent.data.messageReference, }), ); expect(repo.markWithdrawn).toHaveBeenCalledWith( - channelStatusEvent.data.messageReference, + channelStatusDeliveredEvent.data.messageReference, ); }); @@ -40,18 +45,20 @@ describe('TtlActions', () => { // eslint-disable-next-line unicorn/no-useless-undefined repo.markWithdrawn.mockResolvedValue(undefined); - const result = await ttlActions.markWithdrawn(channelStatusEvent); + const result = await ttlActions.markWithdrawn( + channelStatusDeliveredEvent, + ); expect(result).toEqual({ result: 'success' }); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('TTL record not found'), - messageReference: channelStatusEvent.data.messageReference, + messageReference: channelStatusDeliveredEvent.data.messageReference, }), ); expect(repo.markWithdrawn).toHaveBeenCalledWith( - channelStatusEvent.data.messageReference, + channelStatusDeliveredEvent.data.messageReference, ); }); @@ -59,13 +66,15 @@ describe('TtlActions', () => { const error = new Error('fail'); repo.markWithdrawn.mockRejectedValue(error); - const result = await ttlActions.markWithdrawn(channelStatusEvent); + const result = await ttlActions.markWithdrawn( + channelStatusDeliveredEvent, + ); expect(result).toEqual({ result: 'failed' }); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('Error marking TTL withdrawn'), - messageReference: channelStatusEvent.data.messageReference, + messageReference: channelStatusDeliveredEvent.data.messageReference, err: error, }), ); @@ -82,7 +91,7 @@ describe('TtlActions', () => { it('returns success when delete succeeds', async () => { repo.delete.mockResolvedValue({ event: messageDownloadedEvent }); - const result = await ttlActions.delete(channelStatusEvent); + const result = await ttlActions.delete(channelStatusDeliveredEvent); expect(result).toEqual({ result: 'success', @@ -91,11 +100,11 @@ describe('TtlActions', () => { expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('TTL record deleted'), - messageReference: channelStatusEvent.data.messageReference, + messageReference: channelStatusDeliveredEvent.data.messageReference, }), ); expect(repo.delete).toHaveBeenCalledWith( - channelStatusEvent.data.messageReference, + channelStatusDeliveredEvent.data.messageReference, ); }); @@ -103,18 +112,18 @@ describe('TtlActions', () => { // eslint-disable-next-line unicorn/no-useless-undefined repo.delete.mockResolvedValue(undefined); - const result = await ttlActions.delete(channelStatusEvent); + const result = await ttlActions.delete(channelStatusDeliveredEvent); expect(result).toEqual({ result: 'success' }); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('TTL record not found'), - messageReference: channelStatusEvent.data.messageReference, + messageReference: channelStatusDeliveredEvent.data.messageReference, }), ); expect(repo.delete).toHaveBeenCalledWith( - channelStatusEvent.data.messageReference, + channelStatusDeliveredEvent.data.messageReference, ); }); @@ -122,13 +131,13 @@ describe('TtlActions', () => { const error = new Error('fail'); repo.delete.mockRejectedValue(error); - const result = await ttlActions.delete(channelStatusEvent); + const result = await ttlActions.delete(channelStatusDeliveredEvent); expect(result).toEqual({ result: 'failed' }); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining('Error deleting TTL record'), - messageReference: channelStatusEvent.data.messageReference, + messageReference: channelStatusDeliveredEvent.data.messageReference, err: error, }), ); diff --git a/lambdas/core-status-handler/src/__tests__/data.ts b/lambdas/core-status-handler/src/__tests__/data.ts index 8220faa2c..a56de1906 100644 --- a/lambdas/core-status-handler/src/__tests__/data.ts +++ b/lambdas/core-status-handler/src/__tests__/data.ts @@ -29,7 +29,7 @@ export const messageDownloadedEvent: MESHInboxMessageDownloaded = { }, }; -export const channelStatusEvent: ChannelStatusPublishedEvent = { +export const channelStatusDeliveredEvent: ChannelStatusPublishedEvent = { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { @@ -39,10 +39,24 @@ export const channelStatusEvent: ChannelStatusPublishedEvent = { }, }; +export const channelStatusFailedEvent: ChannelStatusPublishedEvent = { + source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', + channelStatus: 'failed', + messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, + supplierStatus: 'rejected', + }, +}; + export const messageStatusEvent: MessageStatusPublishedEvent = { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, messageStatus: 'failed', resolvedChannels: ['letter'], diff --git a/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts index ad87f367a..0fe627eb0 100644 --- a/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts +++ b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts @@ -1,6 +1,6 @@ import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { DeleteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; -import { channelStatusEvent } from '__tests__/data'; +import { channelStatusDeliveredEvent } from '__tests__/data'; import { TtlRepository } from 'infra/ttl-repository'; describe('TtlRepository', () => { @@ -15,14 +15,16 @@ describe('TtlRepository', () => { describe('markWithdrawn', () => { it('marks item as withdrawn', async () => { - await repo.markWithdrawn(channelStatusEvent.data.messageReference); + await repo.markWithdrawn( + channelStatusDeliveredEvent.data.messageReference, + ); const updateCommand: UpdateCommand = dynamoDocumentClient.send.mock.calls[0][0]; expect(updateCommand.input).toStrictEqual({ TableName: tableName, Key: { - PK: channelStatusEvent.data.messageReference, + PK: channelStatusDeliveredEvent.data.messageReference, SK: 'TTL', }, ConditionExpression: 'attribute_exists(PK)', @@ -42,7 +44,7 @@ describe('TtlRepository', () => { dynamoDocumentClient.send.mockRejectedValue(error); const result = await repo.markWithdrawn( - channelStatusEvent.data.messageReference, + channelStatusDeliveredEvent.data.messageReference, ); expect(result).toBeUndefined(); @@ -53,21 +55,21 @@ describe('TtlRepository', () => { dynamoDocumentClient.send.mockRejectedValue(error); await expect( - repo.markWithdrawn(channelStatusEvent.data.messageReference), + repo.markWithdrawn(channelStatusDeliveredEvent.data.messageReference), ).rejects.toThrow(error); }); }); describe('delete', () => { it('deletes item', async () => { - await repo.delete(channelStatusEvent.data.messageReference); + await repo.delete(channelStatusDeliveredEvent.data.messageReference); const deleteCommand: DeleteCommand = dynamoDocumentClient.send.mock.calls[0][0]; expect(deleteCommand.input).toStrictEqual({ TableName: tableName, Key: { - PK: channelStatusEvent.data.messageReference, + PK: channelStatusDeliveredEvent.data.messageReference, SK: 'TTL', }, ReturnValues: 'ALL_OLD', @@ -79,7 +81,7 @@ describe('TtlRepository', () => { dynamoDocumentClient.send.mockRejectedValue(error); await expect( - repo.delete(channelStatusEvent.data.messageReference), + repo.delete(channelStatusDeliveredEvent.data.messageReference), ).rejects.toThrow(error); }); }); diff --git a/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts index d9a3c9b70..ef892053d 100644 --- a/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts @@ -3,36 +3,19 @@ import type { SQSBatchResponse, SQSEvent, } from 'aws-lambda'; -import { randomUUID } from 'node:crypto'; -import { - StatusActionResolver, - StatusActionResolverOutcome, -} from 'app/status-action-resolver'; -import { - DigitalLetterRead, - MESHInboxMessageDownloaded, - validateDigitalLetterRead, -} from 'digital-letters-events'; -import { - $StatusPublishedEvent, - EventPublisher, - Logger, - StatusPublishedEvent, -} from 'utils'; - -interface ProcessingResult { - outcome: StatusActionResolverOutcome; - item?: StatusPublishedEvent; -} +import { StatusActionResolver } from 'app/status-action-resolver'; +import { $StatusPublishedEvent, Logger } from 'utils'; +import { EventSender } from 'app/event-sender'; +import { ProcessingResult, aggregateResults } from 'app/results-aggregator'; interface CreateHandlerDependencies { + eventSender: EventSender; statusActionResolver: StatusActionResolver; - eventPublisher: EventPublisher; logger: Logger; } export const createHandler = ({ - eventPublisher, + eventSender, logger, statusActionResolver, }: CreateHandlerDependencies) => @@ -85,76 +68,20 @@ export const createHandler = ({ const results = await Promise.allSettled(promises); - const processed: Record< - StatusActionResolverOutcome['result'] | 'retrieved', - number - > = { - retrieved: results.length, - success: 0, - failed: 0, - skipped: 0, - }; - - const successfulEvents: { - event: MESHInboxMessageDownloaded; - supplierStatus: string; - }[] = []; - - for (const result of results) { - if (result.status === 'fulfilled') { - const { outcome } = result.value; - processed[outcome.result] += 1; - - if ( - outcome.result === 'success' && - outcome.ttlItem && - outcome.publish - ) { - successfulEvents.push({ - event: outcome.ttlItem.event, - supplierStatus: outcome.publish.supplierStatus, - }); - } - } else { - logger.warn({ err: result.reason }); - processed.failed += 1; - } + const { + digitalLetterReadEvents, + digitalLetterUnsuccessfulEvents, + processed, + } = aggregateResults(results, logger); + + if (digitalLetterReadEvents.length > 0) { + await eventSender.digitalLetterRead(digitalLetterReadEvents); } - if (successfulEvents.length > 0) { - try { - const failedEvents = await eventPublisher.sendEvents( - successfulEvents.map(({ event, supplierStatus }) => ({ - ...event, - id: randomUUID(), - time: new Date().toISOString(), - recordedtime: new Date().toISOString(), - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', - source: event.source.replace(/\/mesh$/, '/queue'), - data: { - messageReference: event.data.messageReference, - senderId: event.data.senderId, - supplierStatus, - }, - })), - validateDigitalLetterRead, - ); - if (failedEvents.length > 0) { - logger.warn({ - description: 'Some events failed to publish', - failedCount: failedEvents.length, - totalAttempted: successfulEvents.length, - }); - } - } catch (error) { - logger.warn({ - err: error, - description: 'Failed to send events to EventBridge', - eventCount: successfulEvents.length, - }); - } + if (digitalLetterUnsuccessfulEvents.length > 0) { + await eventSender.digitalLetterUnsuccessful( + digitalLetterUnsuccessfulEvents, + ); } logger.info({ diff --git a/lambdas/core-status-handler/src/app/event-sender.ts b/lambdas/core-status-handler/src/app/event-sender.ts new file mode 100644 index 000000000..4ad48cf1b --- /dev/null +++ b/lambdas/core-status-handler/src/app/event-sender.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto'; +import { + DigitalLetterRead, + DigitalLetterUnsuccessful, + MESHInboxMessageDownloaded, + validateDigitalLetterRead, + validateDigitalLetterUnsuccessful, +} from 'digital-letters-events'; +import { EventPublisher, Logger } from 'utils'; + +export type DigitalLetterReadEvents = { + event: MESHInboxMessageDownloaded; + supplierStatus: string; +}[]; + +export type DigitalLetterUnsuccessfulEvents = { + event: MESHInboxMessageDownloaded; + reasonCode: string; + reasonText: string; +}[]; + +export class EventSender { + constructor( + private readonly eventPublisher: EventPublisher, + private readonly logger: Logger, + ) {} + + async digitalLetterRead( + digitalLetterReadEvents: DigitalLetterReadEvents, + ): Promise { + try { + const failedEvents = + await this.eventPublisher.sendEvents( + digitalLetterReadEvents.map(({ event, supplierStatus }) => ({ + ...event, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', + source: event.source.replace(/\/mesh$/, '/queue'), + data: { + messageReference: event.data.messageReference, + senderId: event.data.senderId, + supplierStatus, + }, + })), + validateDigitalLetterRead, + ); + if (failedEvents.length > 0) { + this.logger.warn({ + description: 'Some events failed to publish', + failedCount: failedEvents.length, + totalAttempted: digitalLetterReadEvents.length, + }); + } + } catch (error) { + this.logger.warn({ + err: error, + description: 'Failed to send events to EventBridge', + eventCount: digitalLetterReadEvents.length, + }); + } + } + + async digitalLetterUnsuccessful( + digitalLetterUnsuccessfulEvents: DigitalLetterUnsuccessfulEvents, + ): Promise { + try { + const failedEvents = + await this.eventPublisher.sendEvents( + digitalLetterUnsuccessfulEvents.map( + ({ event, reasonCode, reasonText }) => ({ + ...event, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.json', + source: event.source.replace(/\/mesh$/, '/queue'), + data: { + messageReference: event.data.messageReference, + senderId: event.data.senderId, + reasonCode, + reasonText, + }, + }), + ), + validateDigitalLetterUnsuccessful, + ); + if (failedEvents.length > 0) { + this.logger.warn({ + description: 'Some events failed to publish', + failedCount: failedEvents.length, + totalAttempted: digitalLetterUnsuccessfulEvents.length, + }); + } + } catch (error) { + this.logger.warn({ + err: error, + description: 'Failed to send events to EventBridge', + eventCount: digitalLetterUnsuccessfulEvents.length, + }); + } + } +} diff --git a/lambdas/core-status-handler/src/app/results-aggregator.ts b/lambdas/core-status-handler/src/app/results-aggregator.ts new file mode 100644 index 000000000..2e7606430 --- /dev/null +++ b/lambdas/core-status-handler/src/app/results-aggregator.ts @@ -0,0 +1,76 @@ +import { Logger } from 'utils'; +import { + DigitalLetterReadEvents, + DigitalLetterUnsuccessfulEvents, +} from 'app/event-sender'; +import { StatusActionResolverOutcome } from 'app/status-action-resolver'; + +export type ProcessingResult = { + outcome: StatusActionResolverOutcome; +}; + +export type AggregatedResults = { + processed: Record< + StatusActionResolverOutcome['result'] | 'retrieved', + number + >; + digitalLetterReadEvents: DigitalLetterReadEvents; + digitalLetterUnsuccessfulEvents: DigitalLetterUnsuccessfulEvents; +}; + +const collectSuccessEvents = ( + outcome: StatusActionResolverOutcome, + digitalLetterReadEvents: DigitalLetterReadEvents, + digitalLetterUnsuccessfulEvents: DigitalLetterUnsuccessfulEvents, +) => { + if (outcome.result !== 'success' || !outcome.ttlItem) return; + + if (outcome.publishRead) { + digitalLetterReadEvents.push({ + event: outcome.ttlItem.event, + supplierStatus: outcome.publishRead.supplierStatus, + }); + } + + if (outcome.publishUnsuccessful) { + digitalLetterUnsuccessfulEvents.push({ + event: outcome.ttlItem.event, + ...outcome.publishUnsuccessful, + }); + } +}; + +export const aggregateResults = ( + results: PromiseSettledResult[], + logger: Logger, +): AggregatedResults => { + const processed: AggregatedResults['processed'] = { + retrieved: results.length, + success: 0, + failed: 0, + skipped: 0, + }; + const digitalLetterReadEvents: DigitalLetterReadEvents = []; + const digitalLetterUnsuccessfulEvents: DigitalLetterUnsuccessfulEvents = []; + + for (const result of results) { + if (result.status === 'rejected') { + logger.warn({ err: result.reason }); + processed.failed += 1; + } else { + const { outcome } = result.value; + processed[outcome.result] += 1; + collectSuccessEvents( + outcome, + digitalLetterReadEvents, + digitalLetterUnsuccessfulEvents, + ); + } + } + + return { + processed, + digitalLetterReadEvents, + digitalLetterUnsuccessfulEvents, + }; +}; diff --git a/lambdas/core-status-handler/src/app/status-action-resolver.ts b/lambdas/core-status-handler/src/app/status-action-resolver.ts index 0700d6483..ce731ff87 100644 --- a/lambdas/core-status-handler/src/app/status-action-resolver.ts +++ b/lambdas/core-status-handler/src/app/status-action-resolver.ts @@ -2,11 +2,16 @@ import { Logger, StatusPublishedEvent } from 'utils'; import { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; export type StatusActionResolverOutcome = TtlActionOutcome & { - publish?: { supplierStatus: string }; + publishRead?: { supplierStatus: string }; + publishUnsuccessful?: { reasonCode: string; reasonText: string }; }; type Action = - | { kind: 'markWithdrawn' | 'delete'; publish?: { supplierStatus: string } } + | { + kind: 'markWithdrawn' | 'delete'; + publishRead?: { supplierStatus: string }; + publishUnsuccessful?: { reasonCode: string; reasonText: string }; + } | { kind: 'skip' }; function resolveAction(item: StatusPublishedEvent): Action { @@ -14,20 +19,26 @@ function resolveAction(item: StatusPublishedEvent): Action { if (item.data.supplierStatus === 'paper_letter_opted_out') { return { kind: 'markWithdrawn', - publish: { supplierStatus: item.data.supplierStatus }, + publishRead: { supplierStatus: item.data.supplierStatus }, }; } if (item.data.supplierStatus === 'paper_letter_opted_in') { return { kind: 'delete', - publish: { supplierStatus: item.data.supplierStatus }, + publishRead: { supplierStatus: item.data.supplierStatus }, }; } if ( item.data.supplierStatus === 'rejected' && item.data.channelStatus === 'failed' ) { - return { kind: 'delete' }; + return { + kind: 'delete', + publishUnsuccessful: { + reasonCode: item.data.channelFailureReasonCode, + reasonText: item.data.channelFailedReason, + }, + }; } } @@ -36,7 +47,13 @@ function resolveAction(item: StatusPublishedEvent): Action { item.data.messageStatus === 'failed' && !item.data.resolvedChannels?.length ) { - return { kind: 'markWithdrawn' }; + return { + kind: 'markWithdrawn', + publishUnsuccessful: { + reasonCode: item.data.messageFailureReasonCode, + reasonText: item.data.messageFailedReason, + }, + }; } return { kind: 'skip' }; @@ -63,6 +80,10 @@ export class StatusActionResolver { } const outcome = await this.ttlActions[action.kind](item); - return { ...outcome, publish: action.publish }; + return { + ...outcome, + publishRead: action.publishRead, + publishUnsuccessful: action.publishUnsuccessful, + }; } } diff --git a/lambdas/core-status-handler/src/container.ts b/lambdas/core-status-handler/src/container.ts index c406ee35d..f56b6655b 100644 --- a/lambdas/core-status-handler/src/container.ts +++ b/lambdas/core-status-handler/src/container.ts @@ -10,6 +10,7 @@ import { loadConfig } from 'infra/config'; import { TtlRepository } from 'infra/ttl-repository'; import { StatusActionResolver } from 'app/status-action-resolver'; import { TtlActions } from 'app/ttl-actions'; +import { EventSender } from 'app/event-sender'; export const createContainer = () => { const { @@ -40,8 +41,10 @@ export const createContainer = () => { ]), }); + const eventSender = new EventSender(eventPublisher, logger); + return { - eventPublisher, + eventSender, logger, statusActionResolver, }; diff --git a/lambdas/core-status-handler/src/infra/config.ts b/lambdas/core-status-handler/src/infra/config.ts index 3f81612a2..60043f398 100644 --- a/lambdas/core-status-handler/src/infra/config.ts +++ b/lambdas/core-status-handler/src/infra/config.ts @@ -1,6 +1,6 @@ import { defaultConfigReader } from 'utils'; -export type TtlCreateConfig = { +export type CoreStatusHandlerConfig = { environment: string; ttlTableName: string; eventPublisherEventBusArn: string; @@ -8,7 +8,7 @@ export type TtlCreateConfig = { dlMetricsNamespace: string; }; -export function loadConfig(): TtlCreateConfig { +export function loadConfig(): CoreStatusHandlerConfig { return { environment: defaultConfigReader.getValue('ENVIRONMENT'), ttlTableName: defaultConfigReader.getValue('TTL_TABLE_NAME'), diff --git a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts index 84bab186d..5e29ac017 100644 --- a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts +++ b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts @@ -77,10 +77,12 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', + channelStatus: 'failed', messageReference: MatchersV3.string( '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', ), - channelStatus: 'failed', supplierStatus: 'rejected', }, }) diff --git a/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts index 2307539cd..89a419d2c 100644 --- a/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts +++ b/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts @@ -35,6 +35,8 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', messageReference: MatchersV3.string( '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', ), diff --git a/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts b/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts index 1f8f471e1..ac34ddd66 100644 --- a/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts +++ b/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts @@ -35,6 +35,8 @@ describe('Channel status published provider tests', () => { ...ChannelStatusPublishedEventPaperLetterOptedOut, data: { ...ChannelStatusPublishedEventPaperLetterOptedOut.data, + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', channelStatus: 'failed', supplierStatus: 'rejected', }, @@ -43,6 +45,8 @@ describe('Channel status published provider tests', () => { ...MessageStatusPublishedEventFailedEarly, data: { ...MessageStatusPublishedEventFailedEarly.data, + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', resolvedChannels: [], }, }), diff --git a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts index 93160c91d..605f5e997 100644 --- a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts @@ -52,17 +52,21 @@ test.describe('Digital Letters - Core Status Handler', () => { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', channelStatus: 'failed', supplierStatus: 'rejected', messageReference: '', }, }; - test('when supplierStatus is rejected and channelStatus is failed - delete TTL', async () => { + test('when supplierStatus is rejected and channelStatus is failed - delete TTL and publish digital.letter.unsuccessful event', async () => { const event = { ...baseEvent, data: { ...baseEvent.data, + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', messageReference: uuidv4(), }, }; @@ -102,17 +106,35 @@ test.describe('Digital Letters - Core Status Handler', () => { expect(ttl.length).toBe(0); }); - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - [ - '$.message.description = "TTL record deleted"', - `$.message.messageReference = "${concatedReference}"`, - ], - ); + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1"', + String.raw`$.details.event_detail = "*\"messageReference\":\"${event.data.messageReference}\"*"`, + String.raw`$.details.event_detail = "*\"senderId\":\"${event.data.senderId}\"*"`, + String.raw`$.details.event_detail = "*\"reasonCode\":\"CFR_CNSN_0001\"*"`, + String.raw`$.details.event_detail = "*\"reasonText\":\"Could not send notification\"*"`, + ], + ); - expect(eventLogEntry.length).toEqual(1); - }, 150); + expect(eventLogEntry.length).toEqual(1); + }, 150), + + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record deleted"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + ]); }); test.describe('paper_letter_opted_out', () => { @@ -184,7 +206,7 @@ test.describe('Digital Letters - Core Status Handler', () => { ); expect(eventLogEntry.length).toEqual(1); - }), + }, 150), expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( @@ -258,7 +280,7 @@ test.describe('Digital Letters - Core Status Handler', () => { ); expect(eventLogEntry.length).toEqual(2); - }), + }, 150), expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( @@ -372,7 +394,8 @@ test.describe('Digital Letters - Core Status Handler', () => { ); expect(eventLogEntry.length).toEqual(1); - }), + }, 150), + expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, @@ -387,7 +410,7 @@ test.describe('Digital Letters - Core Status Handler', () => { ]); }); - test('when duplicate event is received for the same TTL record - process them both1', async () => { + test('when duplicate event is received for the same TTL record - process them both', async () => { const event = { ...baseEvent, data: { @@ -444,7 +467,7 @@ test.describe('Digital Letters - Core Status Handler', () => { ); expect(eventLogEntry.length).toEqual(1); - }), + }, 150), expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( @@ -508,13 +531,15 @@ test.describe('Digital Letters - Core Status Handler', () => { source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', messageStatus: 'failed', messageReference: '', resolvedChannels: [], }, }; - test('when messageStatus is failed and resolvedChannels is empty - mark TTL withdrawn', async () => { + test('when messageStatus is failed and resolvedChannels is empty - mark TTL withdrawn and publish digital.letter.unsuccessful event', async () => { const event = { ...baseEvent, data: { @@ -559,17 +584,35 @@ test.describe('Digital Letters - Core Status Handler', () => { expect(ttl[0]).toHaveProperty('withdrawn', true); }); - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - [ - '$.message.description = "TTL record marked as withdrawn"', - `$.message.messageReference = "${concatedReference}"`, - ], - ); + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1"', + String.raw`$.details.event_detail = "*\"messageReference\":\"${event.data.messageReference}\"*"`, + String.raw`$.details.event_detail = "*\"senderId\":\"${event.data.senderId}\"*"`, + String.raw`$.details.event_detail = "*\"reasonCode\":\"MFR_CFGV_0005\"*"`, + String.raw`$.details.event_detail = "*\"reasonText\":\"Failed reason: contact detail missing\"*"`, + ], + ); - expect(eventLogEntry.length).toEqual(1); - }, 150); + expect(eventLogEntry.length).toEqual(1); + }, 150), + + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "TTL record marked as withdrawn"', + `$.message.messageReference = "${concatedReference}"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + ]); }); test('when messageStatus is failed and resolvedChannels is NOT empty - skip', async () => { diff --git a/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts b/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts index 259316ad8..b2f0cae24 100644 --- a/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts @@ -107,7 +107,7 @@ test.describe('Digital Letters - Mesh Acknowledger', () => { ); sentMeshMessageId = eventDetail.data.sentMeshMessageId; expect(sentMeshMessageId).toBeTruthy(); - }); + }, 150); // Verify MESH acknowledgement message was sent. await expectToPassEventually(async () => { diff --git a/utils/utils/src/types/core-status-published-event.ts b/utils/utils/src/types/core-status-published-event.ts index 94f00969b..59b42a9e4 100644 --- a/utils/utils/src/types/core-status-published-event.ts +++ b/utils/utils/src/types/core-status-published-event.ts @@ -5,6 +5,8 @@ export const $ChannelStatusPublishedEvent = z.object({ type: z.literal('uk.nhs.notify.channel.status.PUBLISHED.v1'), data: z.union([ z.object({ + channelFailedReason: z.string(), + channelFailureReasonCode: z.string(), channelStatus: z.literal('failed'), messageReference: z.string(), supplierStatus: z.literal('rejected'), @@ -30,6 +32,8 @@ export const $MessageStatusPublishedEvent = z.object({ data: z.object({ messageReference: z.string(), messageStatus: z.literal('failed'), + messageFailedReason: z.string(), + messageFailureReasonCode: z.string(), resolvedChannels: z.array(z.unknown()).optional(), }), }); From 7a64f92dffcefa9603a32cf5d58dec1b1de8b48e Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 28 May 2026 15:34:09 +0100 Subject: [PATCH 06/11] CCM-17640: fix for flaky component tests --- .../__tests__/test_report_sender_processor.py | 6 ++- .../report_sender/report_sender_processor.py | 8 ++-- .../playwright/constants/backend-constants.ts | 1 + .../mesh-acknowledge.component.spec.ts | 2 +- .../send-reports-trust.component.spec.ts | 48 ++++++++++++------- 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/lambdas/report-sender/report_sender/__tests__/test_report_sender_processor.py b/lambdas/report-sender/report_sender/__tests__/test_report_sender_processor.py index a53733f4a..1be7846e2 100644 --- a/lambdas/report-sender/report_sender/__tests__/test_report_sender_processor.py +++ b/lambdas/report-sender/report_sender/__tests__/test_report_sender_processor.py @@ -231,8 +231,9 @@ def test_publish_report_sent_event_success( mesh_mailbox_reports_id = "MAILBOX001" report_reference = "report-reference-123" sent_mesh_message_id = "mesh-msg-id-abc123" + report_uri = "s3://bucket/report-reference-123" - processor._publish_report_sent_event(SENDER_ID, mesh_mailbox_reports_id, report_reference, sent_mesh_message_id) + processor._publish_report_sent_event(SENDER_ID, mesh_mailbox_reports_id, report_reference, sent_mesh_message_id, report_uri) # Verify event was published mock_event_publisher.send_events.assert_called_once() @@ -268,9 +269,10 @@ def test_publish_report_sent_event_failure( mock_event_publisher.send_events.return_value = [{'id': 'failed-event'}] report_reference = "report-reference-123" sent_mesh_message_id = "mesh-msg-id-abc123" + report_uri = "s3://bucket/report-reference-123" with pytest.raises(RuntimeError) as exc_info: - processor._publish_report_sent_event(SENDER_ID, mesh_mailbox_reports_id, report_reference, sent_mesh_message_id) + processor._publish_report_sent_event(SENDER_ID, mesh_mailbox_reports_id, report_reference, sent_mesh_message_id, report_uri) assert "Failed to publish ReportingReportSent event" in str(exc_info.value) mock_logger.error.assert_called() diff --git a/lambdas/report-sender/report_sender/report_sender_processor.py b/lambdas/report-sender/report_sender/report_sender_processor.py index 76b1313d8..a029d1c41 100644 --- a/lambdas/report-sender/report_sender/report_sender_processor.py +++ b/lambdas/report-sender/report_sender/report_sender_processor.py @@ -78,10 +78,10 @@ def process_sqs_message(self, sqs_record): ) self.__log.info(f'Publishing ReportEventSent for the sender: {sender_id} using mailbox: {reporting_mailbox} for date: {report_date}') - self._publish_report_sent_event(sender_id, reporting_mailbox, report_reference, sent_mesh_message_id) + self._publish_report_sent_event(sender_id, reporting_mailbox, report_reference, sent_mesh_message_id, report_uri) self.__send_metric.record(1) - def _publish_report_sent_event(self, sender_id, mesh_mailbox_reports_id, report_reference, sent_mesh_message_id): + def _publish_report_sent_event(self, sender_id, mesh_mailbox_reports_id, report_reference, sent_mesh_message_id, report_uri): """ Publishes a ReportSent event """ @@ -121,5 +121,7 @@ def _publish_report_sent_event(self, sender_id, mesh_mailbox_reports_id, report_ "Published ReportingReportSent event", sender_id=sender_id, mesh_mailbox_reports_id=mesh_mailbox_reports_id, - report_reference=report_reference + report_reference=report_reference, + report_uri=report_uri, + sent_mesh_message_id=sent_mesh_message_id, ) diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index cd3cfff6d..44d316a5d 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -79,6 +79,7 @@ export const MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-move export const MESH_DOWNLOAD_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-mesh-download`; export const CREATE_TTL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-ttl-create`; export const CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-status-handler`; +export const REPORT_SENDER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-report-sender`; // Data Firehose export const FIREHOSE_STREAM_NAME = `${CSI}-to-s3-reporting`; diff --git a/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts b/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts index b2f0cae24..78ed1a9f7 100644 --- a/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/mesh-acknowledge.component.spec.ts @@ -156,7 +156,7 @@ test.describe('Digital Letters - Mesh Acknowledger', () => { }); test('should send invalid event to dlq', async () => { - test.setTimeout(160_000); + test.setTimeout(200_000); const letterId = uuidv4(); diff --git a/tests/playwright/digital-letters-component-tests/send-reports-trust.component.spec.ts b/tests/playwright/digital-letters-component-tests/send-reports-trust.component.spec.ts index 2e958527b..5f028be24 100644 --- a/tests/playwright/digital-letters-component-tests/send-reports-trust.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/send-reports-trust.component.spec.ts @@ -4,6 +4,7 @@ import { NON_PII_S3_BUCKET_NAME, REPORTING_S3_BUCKET_NAME, REPORT_SENDER_DLQ_NAME, + REPORT_SENDER_LAMBDA_LOG_GROUP_NAME, } from 'constants/backend-constants'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; @@ -14,6 +15,21 @@ import { v4 as uuidv4 } from 'uuid'; import { SENDER_ID_SKIPS_NOTIFY } from 'constants/tests-constants'; import { validateReportGenerated } from 'digital-letters-events'; +async function getSentMeshMessageId(reportUri: string): Promise { + const logEntries = await getLogsFromCloudwatch( + REPORT_SENDER_LAMBDA_LOG_GROUP_NAME, + [ + '$.event = "Published ReportingReportSent event"', + `$.report_uri = "${reportUri}"`, + ], + ); + + expect(logEntries.length).toEqual(1); + const entry = logEntries[0] as any; + expect(entry.sent_mesh_message_id).toBeTruthy(); + return entry.sent_mesh_message_id; +} + test.describe('Digital Letters - Send reports to Trust', () => { const senderId = SENDER_ID_SKIPS_NOTIFY; const trustMeshMailboxReportsId = 'test-mesh-reports-1'; @@ -56,6 +72,7 @@ test.describe('Digital Letters - Send reports to Trust', () => { async function expectReportSentEventAndMeshMessageSent( meshMailboxReportsId: string, + sentMeshMessageId: string, ): Promise { await expectToPassEventually(async () => { const eventLogEntry = await getLogsFromCloudwatch( @@ -65,26 +82,19 @@ test.describe('Digital Letters - Send reports to Trust', () => { '$.details.detail_type = "uk.nhs.notify.digital.letters.reporting.report.sent.v1"', `$.details.event_detail = "*\\"meshMailboxReportsId\\":\\"${meshMailboxReportsId}\\"*"`, `$.details.event_detail = "*\\"senderId\\":\\"${senderId}\\"*"`, + `$.details.event_detail = "*\\"sentMeshMessageId\\":\\"${sentMeshMessageId}\\"*"`, ], ); - expect(eventLogEntry.length).toBeGreaterThanOrEqual(1); + expect(eventLogEntry.length).toEqual(1); - const parsedEvents = eventLogEntry.map((entry: any) => - JSON.parse(entry.details.event_detail), + // Mock MESH uses NON_PII_S3_BUCKET_NAME bucket, the object key is the sentMeshMessageId. + const storedMessage = await downloadFromS3( + NON_PII_S3_BUCKET_NAME, + `mock-mesh/mock-mailbox/out/${trustMeshMailboxReportsId}/${sentMeshMessageId}`, ); - for (const event of parsedEvents) { - const { sentMeshMessageId } = event.data; - expect(sentMeshMessageId).toBeTruthy(); - // Mock MESH uses NON_PII_S3_BUCKET_NAME bucket, the object key is the sentMeshMessageId. - const storedMessage = await downloadFromS3( - NON_PII_S3_BUCKET_NAME, - `mock-mesh/mock-mailbox/out/${trustMeshMailboxReportsId}/${sentMeshMessageId}`, - ); - - expect(storedMessage.body).toContain(messageContent); - } + expect(storedMessage.body).toContain(messageContent); }, 120_000); } @@ -100,12 +110,18 @@ test.describe('Digital Letters - Send reports to Trust', () => { await publishReportGeneratedEvent(reportKey); await expectToPassEventually(async () => { - await expectReportSentEventAndMeshMessageSent(trustMeshMailboxReportsId); + const sentMeshMessageId = await getSentMeshMessageId( + `s3://${REPORTING_S3_BUCKET_NAME}/${reportKey}`, + ); + await expectReportSentEventAndMeshMessageSent( + trustMeshMailboxReportsId, + sentMeshMessageId, + ); }, 120_000); }); test('should send message to report-sender DLQ when file does not exists', async () => { - test.setTimeout(160_000); + test.setTimeout(200_000); const missingReportFileName = 'report-does-not-exist.csv'; From 479a22b40f921bf964aecb561df1ad2d8f8e87bf Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 29 May 2026 12:00:03 +0100 Subject: [PATCH 07/11] CCM-17640: update pact contract package version --- pact-contracts/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pact-contracts/package.json b/pact-contracts/package.json index 4a33a2404..9437452aa 100644 --- a/pact-contracts/package.json +++ b/pact-contracts/package.json @@ -17,5 +17,5 @@ "test:unit:coverage": "echo Unit tests not required", "typecheck": "echo Typecheck not required" }, - "version": "1.0.1" + "version": "1.0.2" } From da4337b94b2599b8ebc3f59a6357bddaf6697fdd Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 29 May 2026 12:09:05 +0100 Subject: [PATCH 08/11] CCM-17640: update core notify pact test layout --- ...t.ts => core-notify.consumer.pact.test.ts} | 54 +++++++++++++++---- ...age-status-published.consumer.pact.test.ts | 50 ----------------- ...t.ts => core-notify.provider.pact.test.ts} | 46 ++++++++-------- tests/pact-tests/utils/pact-config.ts | 18 +++---- 4 files changed, 74 insertions(+), 94 deletions(-) rename tests/pact-tests/consumer/{channel-status-published.consumer.pact.test.ts => core-notify.consumer.pact.test.ts} (60%) delete mode 100644 tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts rename tests/pact-tests/pact-verification/{status-published.provider.pact.test.ts => core-notify.provider.pact.test.ts} (54%) diff --git a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts similarity index 60% rename from tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts rename to tests/pact-tests/consumer/core-notify.consumer.pact.test.ts index 5e29ac017..ebfba2260 100644 --- a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts +++ b/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts @@ -3,13 +3,17 @@ import { MessageConsumerPact, asynchronousBodyHandler, } from '@pact-foundation/pact'; -import { $ChannelStatusPublishedEvent } from 'utils'; import { - PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION, - PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION, - PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, + $ChannelStatusPublishedEvent, + $MessageStatusPublishedEvent, +} from 'utils'; +import { PACT_CONSUMER, - PACT_STATUS_PUBLISHED_PROVIDER, + PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION, + PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION, + PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, + PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION, + PACT_CORE_NOTIFY_PROVIDER, } from '../utils/pact-config'; import { getPathFromProvider } from '../utils/path-utils'; @@ -17,12 +21,16 @@ async function handleChannelStatus(event: unknown) { $ChannelStatusPublishedEvent.parse(event); } -const PACT_DIRECTORY = getPathFromProvider(PACT_STATUS_PUBLISHED_PROVIDER); +async function handleMessageStatus(event: unknown) { + $MessageStatusPublishedEvent.parse(event); +} + +const PACT_DIRECTORY = getPathFromProvider(PACT_CORE_NOTIFY_PROVIDER); -describe('Pact message consumer - ChannelStatusPublished event', () => { +describe('Pact message consumer - core notify events', () => { const messagePact = new MessageConsumerPact({ consumer: PACT_CONSUMER, - provider: PACT_STATUS_PUBLISHED_PROVIDER, + provider: PACT_CORE_NOTIFY_PROVIDER, dir: PACT_DIRECTORY, logLevel: 'error', pactfileWriteMode: 'update', @@ -31,7 +39,7 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { it('validates a channel status published event with supplierStatus paper_letter_opted_out', async () => { await expect( messagePact - .expectsToReceive(PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION) + .expectsToReceive(PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION) .withContent({ source: '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', @@ -51,7 +59,7 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { it('validates a channel status published event with supplierStatus paper_letter_opted_in', async () => { await expect( messagePact - .expectsToReceive(PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION) + .expectsToReceive(PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION) .withContent({ source: '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', @@ -71,7 +79,9 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { it('validates a channel status published event with supplierStatus rejected and channelStatus failed', async () => { await expect( messagePact - .expectsToReceive(PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION) + .expectsToReceive( + PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, + ) .withContent({ source: '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', @@ -89,4 +99,26 @@ describe('Pact message consumer - ChannelStatusPublished event', () => { .verify(asynchronousBodyHandler(handleChannelStatus)), ).resolves.not.toThrow(); }); + + it('validates a message status published event with messageStatus failed and empty resolvedChannels', async () => { + await expect( + messagePact + .expectsToReceive(PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION) + .withContent({ + source: + '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: { + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', + messageReference: MatchersV3.string( + '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', + ), + messageStatus: 'failed', + resolvedChannels: [], + }, + }) + .verify(asynchronousBodyHandler(handleMessageStatus)), + ).resolves.not.toThrow(); + }); }); diff --git a/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts deleted file mode 100644 index 89a419d2c..000000000 --- a/tests/pact-tests/consumer/message-status-published.consumer.pact.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - MatchersV3, - MessageConsumerPact, - asynchronousBodyHandler, -} from '@pact-foundation/pact'; -import { $MessageStatusPublishedEvent } from 'utils'; -import { - PACT_CONSUMER, - PACT_MESSAGE_STATUS_FAILED_DESCRIPTION, - PACT_STATUS_PUBLISHED_PROVIDER, -} from '../utils/pact-config'; -import { getPathFromProvider } from '../utils/path-utils'; - -async function handleMessageStatus(event: unknown) { - $MessageStatusPublishedEvent.parse(event); -} - -const PACT_DIRECTORY = getPathFromProvider(PACT_STATUS_PUBLISHED_PROVIDER); - -describe('Pact message consumer - ChannelStatusPublished event', () => { - const messagePact = new MessageConsumerPact({ - consumer: PACT_CONSUMER, - provider: PACT_STATUS_PUBLISHED_PROVIDER, - dir: PACT_DIRECTORY, - logLevel: 'error', - pactfileWriteMode: 'update', - }); - - it('validates a message status published event with messageStatus failed and empty resolvedChannels', async () => { - await expect( - messagePact - .expectsToReceive(PACT_MESSAGE_STATUS_FAILED_DESCRIPTION) - .withContent({ - source: - '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', - type: 'uk.nhs.notify.message.status.PUBLISHED.v1', - data: { - messageFailedReason: 'Failed reason: contact detail missing', - messageFailureReasonCode: 'MFR_CFGV_0005', - messageReference: MatchersV3.string( - '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', - ), - messageStatus: 'failed', - resolvedChannels: [], - }, - }) - .verify(asynchronousBodyHandler(handleMessageStatus)), - ).resolves.not.toThrow(); - }); -}); diff --git a/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts b/tests/pact-tests/pact-verification/core-notify.provider.pact.test.ts similarity index 54% rename from tests/pact-tests/pact-verification/status-published.provider.pact.test.ts rename to tests/pact-tests/pact-verification/core-notify.provider.pact.test.ts index ac34ddd66..f9bd13eb7 100644 --- a/tests/pact-tests/pact-verification/status-published.provider.pact.test.ts +++ b/tests/pact-tests/pact-verification/core-notify.provider.pact.test.ts @@ -3,45 +3,43 @@ import ChannelStatusPublishedEventPaperLetterOptedOut from '@nhsdigital/nhs-noti import MessageStatusPublishedEventFailedEarly from '@nhsdigital/nhs-notify-event-schemas-status-published/examples/MessageStatusPublishedEvent/v1/failed_early.json'; import { getPactFilePath } from '../utils/path-utils'; import { - PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION, - PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION, - PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, PACT_CONSUMER, - PACT_MESSAGE_STATUS_FAILED_DESCRIPTION, - PACT_STATUS_PUBLISHED_PROVIDER, + PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION, + PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION, + PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, + PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION, + PACT_CORE_NOTIFY_PROVIDER, } from '../utils/pact-config'; -const PACT_FILE = getPactFilePath( - PACT_CONSUMER, - PACT_STATUS_PUBLISHED_PROVIDER, -); +const PACT_FILE = getPactFilePath(PACT_CONSUMER, PACT_CORE_NOTIFY_PROVIDER); -describe('Channel status published provider tests', () => { +describe('Core notify provider tests', () => { test('verify pacts', async () => { const p = new MessageProviderPact({ - provider: PACT_STATUS_PUBLISHED_PROVIDER, + provider: PACT_CORE_NOTIFY_PROVIDER, pactUrls: [PACT_FILE], messageProviders: { - [PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION]: () => + [PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION]: () => ChannelStatusPublishedEventPaperLetterOptedOut, - [PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION]: () => ({ + [PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION]: () => ({ ...ChannelStatusPublishedEventPaperLetterOptedOut, data: { ...ChannelStatusPublishedEventPaperLetterOptedOut.data, supplierStatus: 'paper_letter_opted_in', }, }), - [PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION]: () => ({ - ...ChannelStatusPublishedEventPaperLetterOptedOut, - data: { - ...ChannelStatusPublishedEventPaperLetterOptedOut.data, - channelFailedReason: 'Could not send notification', - channelFailureReasonCode: 'CFR_CNSN_0001', - channelStatus: 'failed', - supplierStatus: 'rejected', - }, - }), - [PACT_MESSAGE_STATUS_FAILED_DESCRIPTION]: () => ({ + [PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION]: + () => ({ + ...ChannelStatusPublishedEventPaperLetterOptedOut, + data: { + ...ChannelStatusPublishedEventPaperLetterOptedOut.data, + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', + channelStatus: 'failed', + supplierStatus: 'rejected', + }, + }), + [PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION]: () => ({ ...MessageStatusPublishedEventFailedEarly, data: { ...MessageStatusPublishedEventFailedEarly.data, diff --git a/tests/pact-tests/utils/pact-config.ts b/tests/pact-tests/utils/pact-config.ts index 9fa401c11..55d9b3e51 100644 --- a/tests/pact-tests/utils/pact-config.ts +++ b/tests/pact-tests/utils/pact-config.ts @@ -1,15 +1,15 @@ export const PACT_CONSUMER = 'digital-letters'; -export const PACT_STATUS_PUBLISHED_PROVIDER = 'status-published'; +export const PACT_CORE_NOTIFY_PROVIDER = 'core-notify'; export const PACT_SUPPLIER_API_PROVIDER = 'supplier-api'; -export const PACT_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION = - 'ChannelStatusPublishedEvent-paper_letter_opted_out'; +export const PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION = + 'CoreNotify-ChannelStatusPublishedEvent-paper_letter_opted_out'; -export const PACT_CHANNEL_STATUS_OPTED_IN_DESCRIPTION = - 'ChannelStatusPublishedEvent-paper_letter_opted_in'; +export const PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION = + 'CoreNotify-ChannelStatusPublishedEvent-paper_letter_opted_in'; -export const PACT_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION = - 'ChannelStatusPublishedEvent-supplier_rejected'; +export const PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION = + 'CoreNotify-ChannelStatusPublishedEvent-supplier_rejected'; -export const PACT_MESSAGE_STATUS_FAILED_DESCRIPTION = - 'MessageStatusPublishedEvent-failed'; +export const PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION = + 'CoreNotify-MessageStatusPublishedEvent-failed'; From 320c11861caf6a2842f8fc6d89fafb6766728bc2 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 1 Jun 2026 13:30:51 +0100 Subject: [PATCH 09/11] CCM-17640: clean up schemas and types --- .../src/__tests__/app/ttl-actions.test.ts | 18 +++++---------- .../core-status-handler/src/__tests__/data.ts | 4 ---- .../src/app/status-action-resolver.ts | 10 +++++---- .../src/app/ttl-actions.ts | 3 +-- .../core-notify.consumer.pact.test.ts | 22 +++++++------------ .../core-status-handler.component.spec.ts | 7 ------ .../src/types/core-status-published-event.ts | 3 --- 7 files changed, 21 insertions(+), 46 deletions(-) diff --git a/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts index 1d30af12a..89d58320e 100644 --- a/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts @@ -10,13 +10,13 @@ describe('TtlActions', () => { let logger: any; let ttlActions: TtlActions; - describe('markWithdrawn', () => { - beforeEach(() => { - repo = { markWithdrawn: jest.fn() } as any; - logger = { warn: jest.fn(), info: jest.fn() }; - ttlActions = new TtlActions(repo, logger); - }); + beforeEach(() => { + repo = { delete: jest.fn(), markWithdrawn: jest.fn() } as any; + logger = { warn: jest.fn(), info: jest.fn() }; + ttlActions = new TtlActions(repo, logger); + }); + describe('markWithdrawn', () => { it('returns success when markWithdrawn succeeds', async () => { repo.markWithdrawn.mockResolvedValue({ event: messageDownloadedEvent }); @@ -82,12 +82,6 @@ describe('TtlActions', () => { }); describe('delete', () => { - beforeEach(() => { - repo = { delete: jest.fn() } as any; - logger = { warn: jest.fn(), info: jest.fn() }; - ttlActions = new TtlActions(repo, logger); - }); - it('returns success when delete succeeds', async () => { repo.delete.mockResolvedValue({ event: messageDownloadedEvent }); diff --git a/lambdas/core-status-handler/src/__tests__/data.ts b/lambdas/core-status-handler/src/__tests__/data.ts index a56de1906..b8356bfad 100644 --- a/lambdas/core-status-handler/src/__tests__/data.ts +++ b/lambdas/core-status-handler/src/__tests__/data.ts @@ -30,17 +30,14 @@ export const messageDownloadedEvent: MESHInboxMessageDownloaded = { }; export const channelStatusDeliveredEvent: ChannelStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { - channelStatus: 'delivered', messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, supplierStatus: 'paper_letter_opted_out', }, }; export const channelStatusFailedEvent: ChannelStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { channelFailedReason: 'Could not send notification', @@ -52,7 +49,6 @@ export const channelStatusFailedEvent: ChannelStatusPublishedEvent = { }; export const messageStatusEvent: MessageStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageFailedReason: 'Failed reason: contact detail missing', diff --git a/lambdas/core-status-handler/src/app/status-action-resolver.ts b/lambdas/core-status-handler/src/app/status-action-resolver.ts index ce731ff87..a50991676 100644 --- a/lambdas/core-status-handler/src/app/status-action-resolver.ts +++ b/lambdas/core-status-handler/src/app/status-action-resolver.ts @@ -1,10 +1,12 @@ import { Logger, StatusPublishedEvent } from 'utils'; import { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; -export type StatusActionResolverOutcome = TtlActionOutcome & { - publishRead?: { supplierStatus: string }; - publishUnsuccessful?: { reasonCode: string; reasonText: string }; -}; +export type StatusActionResolverOutcome = + | { result: 'skipped' } + | (TtlActionOutcome & { + publishRead?: { supplierStatus: string }; + publishUnsuccessful?: { reasonCode: string; reasonText: string }; + }); type Action = | { diff --git a/lambdas/core-status-handler/src/app/ttl-actions.ts b/lambdas/core-status-handler/src/app/ttl-actions.ts index a0c628964..3f4abae35 100644 --- a/lambdas/core-status-handler/src/app/ttl-actions.ts +++ b/lambdas/core-status-handler/src/app/ttl-actions.ts @@ -4,8 +4,7 @@ import { TtlItem } from 'types/types'; export type TtlActionOutcome = | { result: 'success'; ttlItem: TtlItem } - | { result: 'failed' } - | { result: 'skipped' }; + | { result: 'failed' }; export class TtlActions { constructor( diff --git a/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts b/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts index ebfba2260..2be9b9a74 100644 --- a/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts +++ b/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts @@ -41,14 +41,11 @@ describe('Pact message consumer - core notify events', () => { messagePact .expectsToReceive(PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION) .withContent({ - source: - '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: MatchersV3.string( 'fa5a36ce-20e2-4d72-889e-eec4ac06d8d0_53189467-8375-4c50-8c49-d53483a6d5e9', ), - channelStatus: MatchersV3.string('delivered'), supplierStatus: 'paper_letter_opted_out', }, }) @@ -61,14 +58,11 @@ describe('Pact message consumer - core notify events', () => { messagePact .expectsToReceive(PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION) .withContent({ - source: - '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: MatchersV3.string( '96dfed4f-cc43-4c70-99e5-caf98bfe3910_a273ecc5-afa0-4327-95b1-160827ccb665', ), - channelStatus: MatchersV3.string('delivered'), supplierStatus: 'paper_letter_opted_in', }, }) @@ -83,12 +77,12 @@ describe('Pact message consumer - core notify events', () => { PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, ) .withContent({ - source: - '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { - channelFailedReason: 'Could not send notification', - channelFailureReasonCode: 'CFR_CNSN_0001', + channelFailedReason: MatchersV3.string( + 'Could not send notification', + ), + channelFailureReasonCode: MatchersV3.string('CFR_CNSN_0001'), channelStatus: 'failed', messageReference: MatchersV3.string( '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', @@ -105,12 +99,12 @@ describe('Pact message consumer - core notify events', () => { messagePact .expectsToReceive(PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION) .withContent({ - source: - '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { - messageFailedReason: 'Failed reason: contact detail missing', - messageFailureReasonCode: 'MFR_CFGV_0005', + messageFailedReason: MatchersV3.string( + 'Failed reason: contact detail missing', + ), + messageFailureReasonCode: MatchersV3.string('MFR_CFGV_0005'), messageReference: MatchersV3.string( '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', ), diff --git a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts index 605f5e997..d7295679a 100644 --- a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts @@ -49,7 +49,6 @@ test.describe('Digital Letters - Core Status Handler', () => { test.describe('channel.status.PUBLISHED', () => { const channelFailedEvent: ChannelStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { channelFailedReason: 'Could not send notification', @@ -139,10 +138,8 @@ test.describe('Digital Letters - Core Status Handler', () => { test.describe('paper_letter_opted_out', () => { const optedOutEvent: ChannelStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { - channelStatus: 'delivered', supplierStatus: 'paper_letter_opted_out', messageReference: '', }, @@ -328,10 +325,8 @@ test.describe('Digital Letters - Core Status Handler', () => { test.describe('paper_letter_opted_in', () => { const optedInEvent: ChannelStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { - channelStatus: 'delivered', supplierStatus: 'paper_letter_opted_in', messageReference: '', }, @@ -528,7 +523,6 @@ test.describe('Digital Letters - Core Status Handler', () => { test.describe('message.status.PUBLISHED', () => { const messageEvent: MessageStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageFailedReason: 'Failed reason: contact detail missing', @@ -673,7 +667,6 @@ test.describe('Digital Letters - Core Status Handler', () => { await eventPublisher.sendEvents( [ { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageReference: concatedReference, diff --git a/utils/utils/src/types/core-status-published-event.ts b/utils/utils/src/types/core-status-published-event.ts index 59b42a9e4..a63770f54 100644 --- a/utils/utils/src/types/core-status-published-event.ts +++ b/utils/utils/src/types/core-status-published-event.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; export const $ChannelStatusPublishedEvent = z.object({ - source: z.string(), type: z.literal('uk.nhs.notify.channel.status.PUBLISHED.v1'), data: z.union([ z.object({ @@ -12,7 +11,6 @@ export const $ChannelStatusPublishedEvent = z.object({ supplierStatus: z.literal('rejected'), }), z.object({ - channelStatus: z.string(), messageReference: z.string(), supplierStatus: z.enum([ 'paper_letter_opted_in', @@ -27,7 +25,6 @@ export type ChannelStatusPublishedEvent = z.infer< >; export const $MessageStatusPublishedEvent = z.object({ - source: z.string(), type: z.literal('uk.nhs.notify.message.status.PUBLISHED.v1'), data: z.object({ messageReference: z.string(), From 7381074f61ccf2bdb3504a034549b422501682b2 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 1 Jun 2026 17:14:15 +0100 Subject: [PATCH 10/11] CCM-17640: fix component tests --- ...-digital-letters-consumer-contracts-1.0.2.tgz | Bin 0 -> 1130 bytes .../core-status-handler.component.spec.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 nhsdigital-notify-digital-letters-consumer-contracts-1.0.2.tgz diff --git a/nhsdigital-notify-digital-letters-consumer-contracts-1.0.2.tgz b/nhsdigital-notify-digital-letters-consumer-contracts-1.0.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..dc6efac65dff4b2b452aeef6a5a57d8fb488decd GIT binary patch literal 1130 zcmV-w1eNKfH|NB76mSx4ux}<26!S^DI=6yVocOS{GVEmrVVN@_~^hn6LG%Ujz7z3ro zJTC#O+{pC7z0yu+1 zP)4_4b*o^8?X~PMt{{aH6t4$NS;&0K0gnlcaUh~Gj^hL{01p@^RK$WzL*I*&fJQj> zqYwu?Bsk_lf<@>DvCpUoA#qN+L+Pq0G706iVWzU7IRz`AoHpMz%?kr!npehYoz^>l z$2=5uaMiu57TVnu1Ya0W;R_2Y-@_wUZ!mA8dyK^y^^m$EQ2R~G3sFX5tw z!KV#O6WL`o%cNd_c((#&@IM6EqAr=?3#sN;RR+BYpXK7AKjA;Qb{X6Q^WN=Nuiy#bS2F zIkPi9vfS0K$*=?Vjj}eOA_WnonDfBL0gnhy5(qJ8X%f#;@Dq3ekHog~@ z&*21kg%x!2bnyM`dm~?PWUp_q%1$h|ulk%G(2TtZ0Joy6Nn!pwyd6@u=&&#Oab>?QvCU({)KxX}c3Qbp1}jZ4xx))`>lH$;9za#h5H~ryyaJ2uo6+8XUK3|~nJyZW9 zo=5z?{^x~(chvvBoBr3^;xFD`qh^aoXN~@cJ~sLvy0j-O%b%syi)*%WQkivhz?z$C zp|D}iOma}VIbnUcX*{-k^Rrs$jlwu;Xa_%5pc`ot_XaIWxnjnmq%70Wv^7VN(_E!; zzGFAWIp{_`u^sq^W1SM_Qk&AAJ)DIxg`SK?s#v7!IvdbtQYpEE{;Z@ywpl;zFyZ+MN;ZeD w_IxOSFM!{-4maDD{r>G7bhkH`xMOFy9v(^bXh%ER(cYl_4ND7e!T=rs03<~@IsgCw literal 0 HcmV?d00001 diff --git a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts index d7295679a..e4d5c6a76 100644 --- a/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts @@ -47,6 +47,9 @@ test.describe('Digital Letters - Core Status Handler', () => { }, }; + const statusEventSource = + '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging'; + test.describe('channel.status.PUBLISHED', () => { const channelFailedEvent: ChannelStatusPublishedEvent = { type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', @@ -87,6 +90,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...channelFailedEvent, + source: statusEventSource, data: { ...channelFailedEvent.data, messageReference: concatedReference, @@ -171,6 +175,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...optedOutEvent, + source: statusEventSource, data: { ...optedOutEvent.data, messageReference: concatedReference, @@ -240,6 +245,7 @@ test.describe('Digital Letters - Core Status Handler', () => { const channelStatusPublishedEvent = { ...optedOutEvent, + source: statusEventSource, data: { ...optedOutEvent.data, messageReference: concatedReference, @@ -300,6 +306,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...optedOutEvent, + source: statusEventSource, data: { ...optedOutEvent.data, messageReference: concatedReference, @@ -358,6 +365,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...optedInEvent, + source: statusEventSource, data: { ...optedInEvent.data, messageReference: concatedReference, @@ -426,6 +434,7 @@ test.describe('Digital Letters - Core Status Handler', () => { const channelStatusPublishedEvent = { ...optedInEvent, + source: statusEventSource, data: { ...optedInEvent.data, messageReference: concatedReference, @@ -497,6 +506,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...optedInEvent, + source: statusEventSource, data: { ...optedInEvent.data, messageReference: concatedReference, @@ -559,6 +569,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...messageEvent, + source: statusEventSource, data: { ...messageEvent.data, messageReference: concatedReference, @@ -635,6 +646,7 @@ test.describe('Digital Letters - Core Status Handler', () => { [ { ...messageEvent, + source: statusEventSource, data: { ...messageEvent.data, messageReference: concatedReference, @@ -667,6 +679,7 @@ test.describe('Digital Letters - Core Status Handler', () => { await eventPublisher.sendEvents( [ { + source: statusEventSource, type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageReference: concatedReference, From 0fd3939c7ae2962609b23cb2dc5603e605fad532 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 1 Jun 2026 17:33:15 +0100 Subject: [PATCH 11/11] CCM-17640: additions to unit tests --- .../app/status-action-resolver.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts index 3b770d630..65ad9aebd 100644 --- a/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts +++ b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts @@ -163,6 +163,7 @@ describe('StatusActionResolver', () => { const result = await resolver.resolve(event); expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); + expect(ttlActions.delete).not.toHaveBeenCalled(); expect(result).toEqual({ ...successOutcome, publishUnsuccessful: { @@ -219,4 +220,29 @@ describe('StatusActionResolver', () => { ); }); }); + + describe('UnexpectedStatusPublishedEvent', () => { + it('when neither MessageStatusPublishedEvent nor ChannelStatusPublishedEvent - skips', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusDeliveredEvent, + type: 'I.am.not.expected.here.v1' as unknown as ChannelStatusPublishedEvent['type'], + data: { + ...channelStatusDeliveredEvent.data, + }, + }; + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(ttlActions.delete).not.toHaveBeenCalled(); + expect(result).toEqual({ result: 'skipped' }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Event skipped', + type: 'I.am.not.expected.here.v1', + data: event.data, + }), + ); + }); + }); });