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 218f0930a..e7b4e6d5e 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/4.0.2/terraform-lambda.zip | n/a | +| [core\_status\_handler](#module\_core\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/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/4.0.2/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/4.0.2/terraform-lambda.zip | n/a | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a | | [move\_scanned\_files](#module\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a | -| [nhsapp\_status\_handler](#module\_nhsapp\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a | | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a | | [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/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..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,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" "channel_status_published_core_status_handler" { 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/cloudwatch_event_rule_message_status_published.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf index b78fe4bae..80a532895 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf @@ -8,17 +8,12 @@ resource "aws_cloudwatch_event_rule" "message_status_published" { "type" : [ "uk.nhs.notify.message.status.PUBLISHED.v1" ], - "data" : { - "messageStatus" : [ - "failed" - ] - } } }) } -resource "aws_cloudwatch_event_target" "sqs_nhsapp_status_handler_message_status_target" { +resource "aws_cloudwatch_event_target" "message_status_published_core_status_handler" { rule = aws_cloudwatch_event_rule.message_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 bed77eeb8..74fd9b968 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/4.0.2/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" @@ -43,13 +43,14 @@ 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" actions = [ "dynamodb:UpdateItem", + "dynamodb:DeleteItem", ] resources = [ @@ -72,7 +73,7 @@ data "aws_iam_policy_document" "nhsapp_status_handler" { } statement { - sid = "SQSPermissionsNhsappStatusHandlerQueue" + sid = "SQSPermissionsCoreStatusHandlerQueue" effect = "Allow" actions = [ @@ -83,7 +84,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 84% 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 5141d4375..034a77105 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/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts new file mode 100644 index 000000000..cf8c34169 --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -0,0 +1,451 @@ +import { + channelStatusDeliveredEvent, + messageDownloadedEvent, + messageStatusEvent, +} from '__tests__/data'; +import { createHandler } from 'apis/sqs-trigger-lambda'; +import type { SQSEvent } from 'aws-lambda'; +import { randomUUID } from 'node:crypto'; + +jest.mock('node:crypto', () => ({ + ...jest.requireActual('node:crypto'), + randomUUID: jest.fn(), +})); + +const mockRandomUUID = randomUUID as jest.MockedFunction; +const mockDate = jest.spyOn(Date.prototype, 'toISOString'); +mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001'); +mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); + +describe('createHandler', () => { + let statusActionResolver: any; + let eventSender: any; + let logger: any; + let handler: any; + + const channelStatusBusEvent = { + detail: channelStatusDeliveredEvent, + }; + + const messageStatusBusEvent = { + detail: messageStatusEvent, + }; + + beforeEach(() => { + statusActionResolver = { resolve: jest.fn() }; + eventSender = { + digitalLetterRead: jest.fn(), + digitalLetterUnsuccessful: jest.fn(), + }; + logger = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; + handler = createHandler({ eventSender, logger, statusActionResolver }); + }); + + it('processes a valid channel status SQS event and returns success', async () => { + statusActionResolver.resolve.mockResolvedValue({ + publishRead: { supplierStatus: 'paper_letter_opted_in' }, + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(statusActionResolver.resolve).toHaveBeenCalledWith( + channelStatusDeliveredEvent, + ); + expect(eventSender.digitalLetterRead).toHaveBeenCalledWith([ + expect.objectContaining({ + event: messageDownloadedEvent, + supplierStatus: 'paper_letter_opted_in', + }), + ]); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + skipped: 0, + success: 1, + }); + }); + + 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(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).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' }], + } 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: 'not present', + }), + ); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 1, + retrieved: 1, + skipped: 0, + success: 0, + }); + }); + + 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: JSON.stringify(invalidMessageStatusBusEvent), + 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 statusActionResolver.resolve failure', async () => { + statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + const res = await handler(event); + + expect(statusActionResolver.resolve).toHaveBeenCalledWith( + channelStatusDeliveredEvent, + ); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 1, + retrieved: 1, + skipped: 0, + success: 0, + }); + }); + + it('handles thrown error and logs', async () => { + const event: SQSEvent = { + Records: [{ body: 'I am not json', 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 during SQS trigger handler', + ), + err: expect.objectContaining({ + message: expect.stringContaining('is not valid JSON'), + }), + }), + ); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 1, + retrieved: 1, + skipped: 0, + success: 0, + }); + }); + + 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; + const originalAllSettled = Promise.allSettled; + Promise.allSettled = jest + .fn() + .mockResolvedValue([ + { status: 'rejected', reason: new Error('forced rejection') }, + ]); + + await handler(event); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + ); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 1, + retrieved: 1, + skipped: 0, + success: 0, + }); + + Promise.allSettled = originalAllSettled; + }); + + it('processes multiple successful events and sends them as a batch', async () => { + statusActionResolver.resolve.mockResolvedValue({ + publishRead: { supplierStatus: 'paper_letter_opted_in' }, + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + const sqsEvent: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg2' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg3' }, + ], + } as any; + + const res = await handler(sqsEvent); + + expect(res.batchItemFailures).toEqual([]); + expect(statusActionResolver.resolve).toHaveBeenCalledTimes(3); + 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, + retrieved: 3, + skipped: 0, + success: 3, + }); + }); + + it('does not call eventSender when no successful events', async () => { + statusActionResolver.resolve.mockResolvedValue({ result: 'failed' }); + + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 1, + retrieved: 1, + skipped: 0, + success: 0, + }); + }); + + it('does not call eventSender for skipped events', async () => { + statusActionResolver.resolve.mockResolvedValue({ result: 'skipped' }); + + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + skipped: 1, + success: 0, + }); + }); + + it('does not call eventSender when no TTL record is found', async () => { + statusActionResolver.resolve.mockResolvedValue({ result: 'success' }); + + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(eventSender.digitalLetterRead).not.toHaveBeenCalled(); + expect(eventSender.digitalLetterUnsuccessful).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + skipped: 0, + success: 1, + }); + }); + + it('handles mixed success, failure, and skipped scenarios', async () => { + statusActionResolver.resolve + .mockResolvedValueOnce({ + publishRead: { supplierStatus: 'paper_letter_opted_in' }, + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }) + .mockResolvedValueOnce({ result: 'failed' }) + .mockResolvedValueOnce({ result: 'skipped' }); + + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg1' }, + { body: '{}', messageId: 'msg2' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg3' }, + { body: JSON.stringify(channelStatusBusEvent), messageId: 'msg4' }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([ + { itemIdentifier: 'msg2' }, + { itemIdentifier: 'msg3' }, + ]); + expect(eventSender.digitalLetterRead).toHaveBeenCalledWith([ + expect.objectContaining({ supplierStatus: 'paper_letter_opted_in' }), + ]); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 2, + retrieved: 4, + skipped: 1, + 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 new file mode 100644 index 000000000..65ad9aebd --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/app/status-action-resolver.test.ts @@ -0,0 +1,248 @@ +import { + channelStatusDeliveredEvent, + channelStatusFailedEvent, + messageDownloadedEvent, + messageStatusEvent, +} 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 = { + ...channelStatusDeliveredEvent, + data: { + ...channelStatusDeliveredEvent.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, + publishRead: { supplierStatus: 'paper_letter_opted_out' }, + }); + }); + + it('when supplierStatus is paper_letter_opted_in - calls delete and publishes DigitalLetterRead event', async () => { + const event: ChannelStatusPublishedEvent = { + ...channelStatusDeliveredEvent, + data: { + ...channelStatusDeliveredEvent.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, + publishRead: { supplierStatus: 'paper_letter_opted_in' }, + }); + }); + + 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(channelStatusFailedEvent); + + expect(ttlActions.delete).toHaveBeenCalledWith(channelStatusFailedEvent); + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + 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 = { + ...channelStatusDeliveredEvent, + data: { + ...channelStatusDeliveredEvent.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 = { + ...channelStatusDeliveredEvent, + data: { + ...channelStatusDeliveredEvent.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 and flags for DigitalLetterUnsuccessful event', async () => { + const event: MessageStatusPublishedEvent = { + ...messageStatusEvent, + data: { + ...messageStatusEvent.data, + 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, + publishUnsuccessful: { + reasonCode: 'MFR_CFGV_0005', + reasonText: 'Failed reason: contact detail missing', + }, + }); + }); + + it('when messageStatus is failed and resolvedChannels is absent - calls markWithdrawn and flags for DigitalLetterUnsuccessful event', async () => { + const event: MessageStatusPublishedEvent = { + ...messageStatusEvent, + data: { + ...messageStatusEvent.data, + resolvedChannels: undefined, + }, + }; + ttlActions.markWithdrawn.mockResolvedValue(successOutcome); + + const result = await resolver.resolve(event); + + expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(event); + expect(ttlActions.delete).not.toHaveBeenCalled(); + 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 () => { + const event: MessageStatusPublishedEvent = { + ...messageStatusEvent, + data: { + ...messageStatusEvent.data, + 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 = { + ...messageStatusEvent, + data: { + ...messageStatusEvent.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, + }), + ); + }); + }); + + 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, + }), + ); + }); + }); +}); 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 new file mode 100644 index 000000000..89d58320e --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/app/ttl-actions.test.ts @@ -0,0 +1,140 @@ +import { + channelStatusDeliveredEvent, + messageDownloadedEvent, +} from '__tests__/data'; +import { TtlActions } from 'app/ttl-actions'; +import { TtlRepository } from 'infra/ttl-repository'; + +describe('TtlActions', () => { + let repo: jest.Mocked; + let logger: any; + let ttlActions: TtlActions; + + 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 }); + + const result = await ttlActions.markWithdrawn( + channelStatusDeliveredEvent, + ); + + expect(result).toEqual({ + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining( + 'TTL record marked as withdrawn', + ), + messageReference: channelStatusDeliveredEvent.data.messageReference, + }), + ); + expect(repo.markWithdrawn).toHaveBeenCalledWith( + channelStatusDeliveredEvent.data.messageReference, + ); + }); + + 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( + channelStatusDeliveredEvent, + ); + + expect(result).toEqual({ result: 'success' }); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('TTL record not found'), + messageReference: channelStatusDeliveredEvent.data.messageReference, + }), + ); + expect(repo.markWithdrawn).toHaveBeenCalledWith( + channelStatusDeliveredEvent.data.messageReference, + ); + }); + + it('returns failed and logs error when markWithdrawn throws', async () => { + const error = new Error('fail'); + repo.markWithdrawn.mockRejectedValue(error); + + 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: channelStatusDeliveredEvent.data.messageReference, + err: error, + }), + ); + }); + }); + + describe('delete', () => { + it('returns success when delete succeeds', async () => { + repo.delete.mockResolvedValue({ event: messageDownloadedEvent }); + + const result = await ttlActions.delete(channelStatusDeliveredEvent); + + expect(result).toEqual({ + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('TTL record deleted'), + messageReference: channelStatusDeliveredEvent.data.messageReference, + }), + ); + expect(repo.delete).toHaveBeenCalledWith( + channelStatusDeliveredEvent.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(channelStatusDeliveredEvent); + + expect(result).toEqual({ result: 'success' }); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('TTL record not found'), + messageReference: channelStatusDeliveredEvent.data.messageReference, + }), + ); + expect(repo.delete).toHaveBeenCalledWith( + channelStatusDeliveredEvent.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(channelStatusDeliveredEvent); + + expect(result).toEqual({ result: 'failed' }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: expect.stringContaining('Error deleting TTL record'), + messageReference: channelStatusDeliveredEvent.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 61% rename from lambdas/nhsapp-status-handler/src/__tests__/data.ts rename to lambdas/core-status-handler/src/__tests__/data.ts index 15c2ac05d..b8356bfad 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/data.ts +++ b/lambdas/core-status-handler/src/__tests__/data.ts @@ -1,8 +1,7 @@ import { MESHInboxMessageDownloaded } from 'digital-letters-events'; import { - ChannelStatusFailedEvent, ChannelStatusPublishedEvent, - MessageStatusFailedEvent, + MessageStatusPublishedEvent, } from 'utils'; export const messageDownloadedEvent: MESHInboxMessageDownloaded = { @@ -30,32 +29,32 @@ export const messageDownloadedEvent: MESHInboxMessageDownloaded = { }, }; -export const nhsAppStatusEvent: ChannelStatusPublishedEvent = { +export const channelStatusDeliveredEvent: ChannelStatusPublishedEvent = { + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, supplierStatus: 'paper_letter_opted_out', }, }; -export const channelStatusFailedEvent: ChannelStatusFailedEvent = { - source: '/nhs/england/notify/production/primary/digitalletters/messaging', +export const channelStatusFailedEvent: ChannelStatusPublishedEvent = { + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { - messageReference: 'sender1_ref1', + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', channelStatus: 'failed', + messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, supplierStatus: 'rejected', - channelFailureReasonCode: 'NO_NHS_APP_ACCOUNT', - channelFailedReason: - 'Patient does not have an NHS App account or the App installed', }, }; -export const messageStatusFailedEvent: MessageStatusFailedEvent = { - source: '/nhs/england/notify/production/primary/digitalletters/messaging', +export const messageStatusEvent: MessageStatusPublishedEvent = { + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { - messageReference: 'sender1_ref1', + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', + messageReference: `${messageDownloadedEvent.data.senderId}_${messageDownloadedEvent.data.messageReference}`, messageStatus: 'failed', - messageStatusDescription: 'PDS enrichment failed for the patient', - messageFailureReasonCode: 'PDS_ENRICHMENT_FAILED', - channels: [], + resolvedChannels: ['letter'], }, }; 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/core-status-handler/src/__tests__/infra/ttl-repository.test.ts b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts new file mode 100644 index 000000000..0fe627eb0 --- /dev/null +++ b/lambdas/core-status-handler/src/__tests__/infra/ttl-repository.test.ts @@ -0,0 +1,88 @@ +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; +import { DeleteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { channelStatusDeliveredEvent } from '__tests__/data'; +import { TtlRepository } from 'infra/ttl-repository'; + +describe('TtlRepository', () => { + let dynamoDocumentClient: any; + let repo: TtlRepository; + const tableName = 'table'; + + beforeEach(() => { + dynamoDocumentClient = { send: jest.fn().mockResolvedValue({}) }; + repo = new TtlRepository(tableName, dynamoDocumentClient); + }); + + describe('markWithdrawn', () => { + it('marks item as withdrawn', async () => { + await repo.markWithdrawn( + channelStatusDeliveredEvent.data.messageReference, + ); + + const updateCommand: UpdateCommand = + dynamoDocumentClient.send.mock.calls[0][0]; + expect(updateCommand.input).toStrictEqual({ + TableName: tableName, + Key: { + PK: channelStatusDeliveredEvent.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: {}, + }); + dynamoDocumentClient.send.mockRejectedValue(error); + + const result = await repo.markWithdrawn( + channelStatusDeliveredEvent.data.messageReference, + ); + + expect(result).toBeUndefined(); + }); + + it('errors on dynamo error', async () => { + const error = new Error('fail'); + dynamoDocumentClient.send.mockRejectedValue(error); + + await expect( + repo.markWithdrawn(channelStatusDeliveredEvent.data.messageReference), + ).rejects.toThrow(error); + }); + }); + + describe('delete', () => { + it('deletes item', async () => { + await repo.delete(channelStatusDeliveredEvent.data.messageReference); + + const deleteCommand: DeleteCommand = + dynamoDocumentClient.send.mock.calls[0][0]; + expect(deleteCommand.input).toStrictEqual({ + TableName: tableName, + Key: { + PK: channelStatusDeliveredEvent.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(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 new file mode 100644 index 000000000..ef892053d --- /dev/null +++ b/lambdas/core-status-handler/src/apis/sqs-trigger-lambda.ts @@ -0,0 +1,95 @@ +import type { + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from 'aws-lambda'; +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; + logger: Logger; +} + +export const createHandler = ({ + eventSender, + logger, + statusActionResolver, +}: CreateHandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const batchItemFailures: SQSBatchItemFailure[] = []; + + const promises = sqsEvent.Records.map( + async ({ body, messageId }): Promise => { + try { + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; + const { + data: item, + error: parseError, + success: parseSuccess, + } = $StatusPublishedEvent.safeParse(sqsEventDetail); + + if (!parseSuccess) { + logger.warn({ + err: parseError, + messageReference: + sqsEventDetail?.data?.messageReference || 'not present', + description: 'Error parsing sqs record', + }); + + batchItemFailures.push({ itemIdentifier: messageId }); + return { outcome: { result: 'failed' } }; + } + + const result = await statusActionResolver.resolve(item); + + if (result.result === 'failed') { + batchItemFailures.push({ itemIdentifier: messageId }); + return { outcome: { result: 'failed' } }; + } + + return { outcome: result }; + } catch (error) { + logger.warn({ + err: error, + description: 'Error during SQS trigger handler', + }); + + batchItemFailures.push({ itemIdentifier: messageId }); + + return { outcome: { result: 'failed' } }; + } + }, + ); + + const results = await Promise.allSettled(promises); + + const { + digitalLetterReadEvents, + digitalLetterUnsuccessfulEvents, + processed, + } = aggregateResults(results, logger); + + if (digitalLetterReadEvents.length > 0) { + await eventSender.digitalLetterRead(digitalLetterReadEvents); + } + + if (digitalLetterUnsuccessfulEvents.length > 0) { + await eventSender.digitalLetterUnsuccessful( + digitalLetterUnsuccessfulEvents, + ); + } + + logger.info({ + description: 'Processed SQS Event.', + ...processed, + }); + + return { batchItemFailures }; + }; + +export default createHandler; 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 new file mode 100644 index 000000000..a50991676 --- /dev/null +++ b/lambdas/core-status-handler/src/app/status-action-resolver.ts @@ -0,0 +1,91 @@ +import { Logger, StatusPublishedEvent } from 'utils'; +import { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; + +export type StatusActionResolverOutcome = + | { result: 'skipped' } + | (TtlActionOutcome & { + publishRead?: { supplierStatus: string }; + publishUnsuccessful?: { reasonCode: string; reasonText: string }; + }); + +type Action = + | { + kind: 'markWithdrawn' | 'delete'; + publishRead?: { supplierStatus: string }; + publishUnsuccessful?: { reasonCode: string; reasonText: 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', + publishRead: { supplierStatus: item.data.supplierStatus }, + }; + } + if (item.data.supplierStatus === 'paper_letter_opted_in') { + return { + kind: 'delete', + publishRead: { supplierStatus: item.data.supplierStatus }, + }; + } + if ( + item.data.supplierStatus === 'rejected' && + item.data.channelStatus === 'failed' + ) { + return { + kind: 'delete', + publishUnsuccessful: { + reasonCode: item.data.channelFailureReasonCode, + reasonText: item.data.channelFailedReason, + }, + }; + } + } + + if ( + item.type === 'uk.nhs.notify.message.status.PUBLISHED.v1' && + item.data.messageStatus === 'failed' && + !item.data.resolvedChannels?.length + ) { + return { + kind: 'markWithdrawn', + publishUnsuccessful: { + reasonCode: item.data.messageFailureReasonCode, + reasonText: item.data.messageFailedReason, + }, + }; + } + + 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, + publishRead: action.publishRead, + publishUnsuccessful: action.publishUnsuccessful, + }; + } +} diff --git a/lambdas/nhsapp-status-handler/src/app/ttl-actions.ts b/lambdas/core-status-handler/src/app/ttl-actions.ts similarity index 52% rename from lambdas/nhsapp-status-handler/src/app/ttl-actions.ts rename to lambdas/core-status-handler/src/app/ttl-actions.ts index ae6b0f22f..3f4abae35 100644 --- a/lambdas/nhsapp-status-handler/src/app/ttl-actions.ts +++ b/lambdas/core-status-handler/src/app/ttl-actions.ts @@ -1,8 +1,6 @@ -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 } @@ -14,9 +12,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 +43,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/nhsapp-status-handler/src/container.ts b/lambdas/core-status-handler/src/container.ts similarity index 76% rename from lambdas/nhsapp-status-handler/src/container.ts rename to lambdas/core-status-handler/src/container.ts index 4f829ccf1..f56b6655b 100644 --- a/lambdas/nhsapp-status-handler/src/container.ts +++ b/lambdas/core-status-handler/src/container.ts @@ -8,7 +8,9 @@ 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'; +import { EventSender } from 'app/event-sender'; export const createContainer = () => { const { @@ -26,6 +28,8 @@ export const createContainer = () => { const ttlActions = new TtlActions(requestTtlRepository, logger); + const statusActionResolver = new StatusActionResolver(ttlActions, logger); + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, @@ -37,10 +41,12 @@ export const createContainer = () => { ]), }); + const eventSender = new EventSender(eventPublisher, logger); + return { - ttlActions, - eventPublisher, + eventSender, logger, + statusActionResolver, }; }; 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 86% rename from lambdas/nhsapp-status-handler/src/infra/config.ts rename to lambdas/core-status-handler/src/infra/config.ts index 060de7e7a..60043f398 100644 --- a/lambdas/nhsapp-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 NhsappStatusHandlerConfig = { +export type CoreStatusHandlerConfig = { environment: string; ttlTableName: string; eventPublisherEventBusArn: string; @@ -8,7 +8,7 @@ export type NhsappStatusHandlerConfig = { dlMetricsNamespace: string; }; -export function loadConfig(): NhsappStatusHandlerConfig { +export function loadConfig(): CoreStatusHandlerConfig { return { environment: defaultConfigReader.getValue('ENVIRONMENT'), ttlTableName: defaultConfigReader.getValue('TTL_TABLE_NAME'), diff --git a/lambdas/nhsapp-status-handler/src/infra/ttl-repository.ts b/lambdas/core-status-handler/src/infra/ttl-repository.ts similarity index 58% rename from lambdas/nhsapp-status-handler/src/infra/ttl-repository.ts rename to lambdas/core-status-handler/src/infra/ttl-repository.ts index f37753996..06bac389d 100644 --- a/lambdas/nhsapp-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/nhsapp-status-handler/src/types/types.ts b/lambdas/core-status-handler/src/types/types.ts similarity index 74% rename from lambdas/nhsapp-status-handler/src/types/types.ts rename to lambdas/core-status-handler/src/types/types.ts index 7e4bab326..24872109a 100644 --- a/lambdas/nhsapp-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/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/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts deleted file mode 100644 index f459bf6cd..000000000 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ /dev/null @@ -1,729 +0,0 @@ -import { - channelStatusFailedEvent, - messageDownloadedEvent, - messageStatusFailedEvent, - nhsAppStatusEvent, -} from '__tests__/data'; -import { createHandler } from 'apis/sqs-trigger-lambda'; -import type { SQSEvent } from 'aws-lambda'; -import { - DigitalLetterRead, - validateDigitalLetterRead, - validateDigitalLetterUnsuccessful, -} from 'digital-letters-events'; -import { randomUUID } from 'node:crypto'; - -jest.mock('node:crypto', () => ({ - ...jest.requireActual('node:crypto'), - randomUUID: jest.fn(), -})); - -const mockRandomUUID = randomUUID as jest.MockedFunction; -const mockDate = jest.spyOn(Date.prototype, 'toISOString'); -mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001'); -mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); - -describe('createHandler', () => { - let ttlActions: any; - let eventPublisher: any; - let logger: any; - let handler: any; - - const eventBusEvent = { - detail: nhsAppStatusEvent, - }; - - 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, - }, - }; - - beforeEach(() => { - ttlActions = { markWithdrawn: jest.fn() }; - eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; - logger = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; - handler = createHandler({ - ttlActions, - eventPublisher, - logger, - }); - }); - - it('processes a valid SQS event and returns success', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(nhsAppStatusEvent); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent], - validateDigitalLetterRead, - ); - - const publishedEvent = eventPublisher.sendEvents.mock.lastCall?.[0]; - expect(publishedEvent).toHaveLength(1); - expect(() => - validateDigitalLetterRead(publishedEvent?.[0], logger), - ).not.toThrow(); - - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 0, - retrieved: 1, - success: 1, - }); - }); - - it('handles unknown event type and logs warning', async () => { - const event: SQSEvent = { - Records: [{ body: '{}', messageId: 'msg1' }], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Error parsing sqs record', - messageReference: 'not present', - }), - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 1, - retrieved: 1, - success: 0, - }); - }); - - it('handles unknown event type with message reference present', async () => { - const messageReference = randomUUID(); - const event: SQSEvent = { - Records: [ - { - body: `{ "detail": { "data": { "messageReference": "${messageReference}" } } }`, - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Error parsing sqs record', - messageReference, - }), - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 1, - retrieved: 1, - success: 0, - }); - }); - - it('handles unknown event type with non-string message reference', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify({ detail: { data: { messageReference: 123 } } }), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Error parsing sqs record', - messageReference: 'not present', - }), - ); - }); - - it('handles unknown event type when detail.data is missing', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify({ detail: {} }), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Error parsing sqs record', - messageReference: 'not present', - }), - ); - }); - - it('handles ttlActions.markWithdrawn failure', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ result: 'failed' }); - const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], - } as any; - - const res = await handler(event); - - expect(ttlActions.markWithdrawn).toHaveBeenCalledWith(nhsAppStatusEvent); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 1, - retrieved: 1, - success: 0, - }); - }); - - it('handles thrown error and logs', async () => { - const event: SQSEvent = { - Records: [{ body: 'I am not json', messageId: 'msg1' }], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining( - 'Error during SQS trigger handler', - ), - err: expect.objectContaining({ - message: expect.stringContaining('is not valid JSON'), - }), - }), - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 1, - retrieved: 1, - success: 0, - }); - }); - - 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() - .mockResolvedValue([ - { status: 'rejected', reason: new Error('forced rejection') }, - ]); - - await handler(event); - - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Error) }), - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 1, - retrieved: 1, - success: 0, - }); - - Promise.allSettled = originalAllSettled; - }); - - it('processes multiple successful events and sends them as a batch', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - const sqsEvent: SQSEvent = { - Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg2' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, - ], - } as any; - - const res = await handler(sqsEvent); - - expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).toHaveBeenCalledTimes(3); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent, digitalLetterReadEvent, digitalLetterReadEvent], - validateDigitalLetterRead, - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 0, - retrieved: 3, - success: 3, - }); - }); - - it('handles partial event publishing failures and logs warning', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - const failedEvents = [messageDownloadedEvent]; - eventPublisher.sendEvents.mockResolvedValue(failedEvents); - - const event: SQSEvent = { - Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, - { body: JSON.stringify(eventBusEvent), 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 () => { - ttlActions.markWithdrawn.mockResolvedValue({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - const publishError = new Error('EventBridge error'); - eventPublisher.sendEvents.mockRejectedValue(publishError); - - const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), 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 () => { - ttlActions.markWithdrawn.mockResolvedValue({ result: 'failed' }); - - const event: SQSEvent = { - Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 1, - retrieved: 1, - success: 0, - }); - }); - - it('does not call eventPublisher when no TTL record is found', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ result: 'success' }); - - 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, - success: 1, - }); - }); - - it('handles mixed success and failure scenarios', async () => { - ttlActions.markWithdrawn - .mockResolvedValueOnce({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }) - .mockResolvedValueOnce({ result: 'failed' }); - - const event: SQSEvent = { - Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, - { body: '{}', messageId: 'msg2' }, - { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([ - { itemIdentifier: 'msg2' }, - { itemIdentifier: 'msg3' }, - ]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent], - validateDigitalLetterRead, - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 2, - retrieved: 3, - success: 1, - }); - }); - - describe('channel status failed events', () => { - const channelFailedBusEvent = { - detail: channelStatusFailedEvent, - }; - - it('publishes DigitalLetterUnsuccessful for channel status failed event', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify(channelFailedBusEvent), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [ - expect.objectContaining({ - specversion: '1.0', - subject: 'customer/sender1/recipient/ref1', - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', - source: - '/nhs/england/notify/production/primary/digitalletters/queue', - data: { - messageReference: 'ref1', - senderId: 'sender1', - reasonCode: - channelStatusFailedEvent.data.channelFailureReasonCode, - reasonText: channelStatusFailedEvent.data.channelFailedReason, - }, - }), - ], - validateDigitalLetterUnsuccessful, - ); - - expect(logger.info).toHaveBeenCalledWith({ - description: 'Channel status failed event received', - messageReference: 'ref1', - senderId: 'sender1', - channelFailureReasonCode: - channelStatusFailedEvent.data.channelFailureReasonCode, - }); - - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 0, - retrieved: 1, - success: 1, - }); - }); - - it('handles DigitalLetterUnsuccessful publish failure for channel status', async () => { - eventPublisher.sendEvents.mockRejectedValue( - new Error('EventBridge error'), - ); - - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify(channelFailedBusEvent), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith({ - err: expect.any(Error), - description: - 'Failed to send DigitalLetterUnsuccessful events to EventBridge', - eventCount: 1, - }); - }); - - it('logs warning when some DigitalLetterUnsuccessful events fail to publish', async () => { - eventPublisher.sendEvents.mockResolvedValue([{ id: 'failed-event' }]); - - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify(channelFailedBusEvent), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith({ - description: 'Some DigitalLetterUnsuccessful events failed to publish', - failedCount: 1, - totalAttempted: 1, - }); - }); - }); - - describe('message status failed events', () => { - const messageFailedBusEvent = { - detail: messageStatusFailedEvent, - }; - - it('publishes DigitalLetterUnsuccessful for message status failed event', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify(messageFailedBusEvent), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [ - expect.objectContaining({ - specversion: '1.0', - subject: 'customer/sender1/recipient/ref1', - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', - source: - '/nhs/england/notify/production/primary/digitalletters/queue', - data: { - messageReference: 'ref1', - senderId: 'sender1', - reasonCode: - messageStatusFailedEvent.data.messageFailureReasonCode, - reasonText: - messageStatusFailedEvent.data.messageStatusDescription, - }, - }), - ], - validateDigitalLetterUnsuccessful, - ); - - expect(logger.info).toHaveBeenCalledWith({ - description: 'Message status failed event received', - messageReference: 'ref1', - senderId: 'sender1', - messageFailureReasonCode: - messageStatusFailedEvent.data.messageFailureReasonCode, - }); - - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 0, - retrieved: 1, - success: 1, - }); - }); - - it('handles DigitalLetterUnsuccessful publish failure for message status', async () => { - eventPublisher.sendEvents.mockRejectedValue( - new Error('EventBridge error'), - ); - - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify(messageFailedBusEvent), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith({ - err: expect.any(Error), - description: - 'Failed to send DigitalLetterUnsuccessful events to EventBridge', - eventCount: 1, - }); - }); - - it('fails the record when a message status event has no source', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify({ - detail: { - data: { - ...messageStatusFailedEvent.data, - }, - }, - }), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Error parsing sqs record', - messageReference: messageStatusFailedEvent.data.messageReference, - }), - ); - }); - - it('fails the record when a message status event has an invalid Notify message reference', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify({ - detail: { - ...messageStatusFailedEvent, - source: - '/nhs/england/notify/production/primary/digitalletters/messaging', - data: { - ...messageStatusFailedEvent.data, - messageReference: 'invalid-reference', - }, - }, - }), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith({ - description: 'Invalid message reference', - messageReference: 'invalid-reference', - }); - }); - }); - - describe('mixed event types', () => { - it('handles channel status opted out and channel status failed in same batch', async () => { - ttlActions.markWithdrawn.mockResolvedValue({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - - const event: SQSEvent = { - Records: [ - { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, - { - body: JSON.stringify({ detail: channelStatusFailedEvent }), - messageId: 'msg2', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([]); - expect(ttlActions.markWithdrawn).toHaveBeenCalledTimes(1); - expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(2); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [digitalLetterReadEvent], - validateDigitalLetterRead, - ); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith( - [ - expect.objectContaining({ - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', - }), - ], - validateDigitalLetterUnsuccessful, - ); - expect(logger.info).toHaveBeenCalledWith({ - description: 'Processed SQS Event.', - failed: 0, - retrieved: 2, - success: 2, - }); - }); - - it('fails the record when a failed status event has an invalid Notify message reference', async () => { - const event: SQSEvent = { - Records: [ - { - body: JSON.stringify({ - detail: { - ...channelStatusFailedEvent, - source: - '/nhs/england/notify/production/primary/digitalletters/messaging', - data: { - ...channelStatusFailedEvent.data, - messageReference: 'invalid-reference', - }, - }, - }), - messageId: 'msg1', - }, - ], - } as any; - - const res = await handler(event); - - expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith({ - description: 'Invalid message reference', - messageReference: 'invalid-reference', - }); - }); - }); -}); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/app/ttl-actions.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/app/ttl-actions.test.ts deleted file mode 100644 index b39e5dc7b..000000000 --- a/lambdas/nhsapp-status-handler/src/__tests__/app/ttl-actions.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { messageDownloadedEvent, nhsAppStatusEvent } from '__tests__/data'; -import { TtlActions } from 'app/ttl-actions'; -import { TtlRepository } from 'infra/ttl-repository'; - -describe('TtlActions', () => { - let repo: jest.Mocked; - 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); - }); - - it('returns success when markWithdrawn succeeds', async () => { - repo.markWithdrawn.mockResolvedValue({ event: messageDownloadedEvent }); - - const result = await ttlActions.markWithdrawn(nhsAppStatusEvent); - - expect(result).toEqual({ - result: 'success', - ttlItem: { event: messageDownloadedEvent }, - }); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining('TTL record marked as withdrawn'), - messageReference: nhsAppStatusEvent.data.messageReference, - }), - ); - expect(repo.markWithdrawn).toHaveBeenCalledWith( - nhsAppStatusEvent.data.messageReference, - ); - }); - - 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(nhsAppStatusEvent); - - expect(result).toEqual({ result: 'success' }); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining('TTL record not found'), - messageReference: nhsAppStatusEvent.data.messageReference, - }), - ); - expect(repo.markWithdrawn).toHaveBeenCalledWith( - nhsAppStatusEvent.data.messageReference, - ); - }); - - it('returns failed and logs error when markWithdrawn throws', async () => { - const error = new Error('fail'); - repo.markWithdrawn.mockRejectedValue(error); - - const result = await ttlActions.markWithdrawn(nhsAppStatusEvent); - - expect(result).toEqual({ result: 'failed' }); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - description: expect.stringContaining('Error marking TTL withdrawn'), - messageReference: nhsAppStatusEvent.data.messageReference, - err: error, - }), - ); - }); -}); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/infra/ttl-repository.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/infra/ttl-repository.test.ts deleted file mode 100644 index 6fdf14a9a..000000000 --- a/lambdas/nhsapp-status-handler/src/__tests__/infra/ttl-repository.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; -import { UpdateCommand } from '@aws-sdk/lib-dynamodb'; -import { nhsAppStatusEvent } from '__tests__/data'; -import { TtlRepository } from 'infra/ttl-repository'; - -describe('TtlRepository', () => { - let dynamoDocumentClient: any; - let repo: TtlRepository; - const tableName = 'table'; - - beforeEach(() => { - dynamoDocumentClient = { send: jest.fn().mockResolvedValue({}) }; - repo = new TtlRepository(tableName, dynamoDocumentClient); - }); - - it('marks item as withdrawn', async () => { - await repo.markWithdrawn(nhsAppStatusEvent.data.messageReference); - - const updateCommand: UpdateCommand = - dynamoDocumentClient.send.mock.calls[0][0]; - expect(updateCommand.input).toStrictEqual({ - TableName: tableName, - Key: { - PK: nhsAppStatusEvent.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: {}, - }); - dynamoDocumentClient.send.mockRejectedValue(error); - - const result = await repo.markWithdrawn( - nhsAppStatusEvent.data.messageReference, - ); - - expect(result).toBeUndefined(); - }); - - it('errors on dynamo error', async () => { - const error = new Error('fail'); - dynamoDocumentClient.send.mockRejectedValue(error); - - await expect( - repo.markWithdrawn(nhsAppStatusEvent.data.messageReference), - ).rejects.toThrow(error); - }); -}); diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts deleted file mode 100644 index a7f08fac4..000000000 --- a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts +++ /dev/null @@ -1,400 +0,0 @@ -import type { - SQSBatchItemFailure, - SQSBatchResponse, - SQSEvent, -} from 'aws-lambda'; -import { randomUUID } from 'node:crypto'; -import type { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; -import { - $ChannelStatusFailedEvent, - $ChannelStatusPublishedEvent, - $MessageStatusFailedEvent, - EventPublisher, - Logger, -} from 'utils'; -import { - DigitalLetterRead, - DigitalLetterUnsuccessful, - MESHInboxMessageDownloaded, - validateDigitalLetterRead, - validateDigitalLetterUnsuccessful, -} from 'digital-letters-events'; - -interface ProcessingResult { - outcome: TtlActionOutcome; - unsuccessfulEventData?: { - source: string; - messageReference: string; - senderId: string; - reasonCode: string; - reasonText: string; - }; -} - -type UnsuccessfulEventData = NonNullable< - ProcessingResult['unsuccessfulEventData'] ->; - -type RecordProcessingResult = ProcessingResult & { messageId: string }; - -interface CreateHandlerDependencies { - ttlActions: TtlActions; - eventPublisher: EventPublisher; - logger: Logger; -} - -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null; - -const parseFailedMessageReference = (sqsEventDetail: unknown): string => { - if (!isRecord(sqsEventDetail)) { - return 'not present'; - } - - const { data } = sqsEventDetail; - if (!isRecord(data)) { - return 'not present'; - } - - return typeof data.messageReference === 'string' - ? data.messageReference - : 'not present'; -}; - -const parseNotifyMessageReference = ( - notifyMessageReference: string, -): Pick | undefined => { - const separatorIndex = notifyMessageReference.indexOf('_'); - - if ( - separatorIndex <= 0 || - separatorIndex === notifyMessageReference.length - 1 - ) { - return undefined; - } - - return { - senderId: notifyMessageReference.slice(0, separatorIndex), - messageReference: notifyMessageReference.slice(separatorIndex + 1), - }; -}; - -const deriveDigitalLettersQueueSource = (source: string): string => { - return source - .replace(/\/data-plane\/messaging$/, '/digitalletters/queue') - .replace(/\/digitalletters\/messaging$/, '/digitalletters/queue') - .replace(/\/[^/]+$/, '/queue'); -}; - -const buildUnsuccessfulEventData = ( - logger: Logger, - source: string, - notifyMessageReference: string, - reasonCode: string, - reasonText: string, -): UnsuccessfulEventData | undefined => { - const messageReferenceParts = parseNotifyMessageReference( - notifyMessageReference, - ); - - if (!messageReferenceParts) { - logger.error({ - description: 'Invalid message reference', - messageReference: notifyMessageReference, - }); - - return undefined; - } - - return { - source, - ...messageReferenceParts, - reasonCode, - reasonText, - }; -}; - -const processRecord = async ( - body: string, - messageId: string, - logger: Logger, - ttlActions: TtlActions, -): Promise => { - try { - const sqsEventBody = JSON.parse(body); - const sqsEventDetail = sqsEventBody.detail; - - const channelStatusResult = - $ChannelStatusPublishedEvent.safeParse(sqsEventDetail); - - if (channelStatusResult.success) { - const result = await ttlActions.markWithdrawn(channelStatusResult.data); - - if (result.result === 'failed') { - return { outcome: { result: 'failed' }, messageId }; - } - - return { outcome: result, messageId }; - } - - const channelFailedResult = - $ChannelStatusFailedEvent.safeParse(sqsEventDetail); - - if (channelFailedResult.success) { - const { data } = channelFailedResult; - const unsuccessfulEventData = buildUnsuccessfulEventData( - logger, - deriveDigitalLettersQueueSource(data.source), - data.data.messageReference, - data.data.channelFailureReasonCode, - data.data.channelFailedReason, - ); - - if (!unsuccessfulEventData) { - return { outcome: { result: 'failed' }, messageId }; - } - - logger.info({ - description: 'Channel status failed event received', - messageReference: unsuccessfulEventData.messageReference, - senderId: unsuccessfulEventData.senderId, - channelFailureReasonCode: data.data.channelFailureReasonCode, - }); - - return { - outcome: { result: 'success', ttlItem: undefined }, - unsuccessfulEventData, - messageId, - }; - } - - const messageFailedResult = - $MessageStatusFailedEvent.safeParse(sqsEventDetail); - - if (messageFailedResult.success) { - const { data } = messageFailedResult; - const unsuccessfulEventData = buildUnsuccessfulEventData( - logger, - deriveDigitalLettersQueueSource(data.source), - data.data.messageReference, - data.data.messageFailureReasonCode, - data.data.messageStatusDescription, - ); - - if (!unsuccessfulEventData) { - return { outcome: { result: 'failed' }, messageId }; - } - - logger.info({ - description: 'Message status failed event received', - messageReference: unsuccessfulEventData.messageReference, - senderId: unsuccessfulEventData.senderId, - messageFailureReasonCode: data.data.messageFailureReasonCode, - }); - - return { - outcome: { result: 'success', ttlItem: undefined }, - unsuccessfulEventData, - messageId, - }; - } - - logger.error({ - err: channelStatusResult.error, - messageReference: parseFailedMessageReference(sqsEventDetail), - description: 'Error parsing sqs record', - }); - - return { outcome: { result: 'failed' }, messageId }; - } catch (error) { - logger.error({ - err: error, - description: 'Error during SQS trigger handler', - }); - - return { outcome: { result: 'failed' }, messageId }; - } -}; - -const collectProcessingResults = ( - results: PromiseSettledResult[], - logger: Logger, -) => { - const processed: Record = { - retrieved: results.length, - success: 0, - failed: 0, - }; - const batchItemFailures: SQSBatchItemFailure[] = []; - const successfulEvents: MESHInboxMessageDownloaded[] = []; - const unsuccessfulEvents: UnsuccessfulEventData[] = []; - - for (const result of results) { - if (result.status === 'fulfilled') { - const { messageId, outcome, unsuccessfulEventData } = result.value; - - processed[outcome.result] += 1; - - if (outcome.result === 'failed') { - batchItemFailures.push({ itemIdentifier: messageId }); - } - - if (outcome.result === 'success' && outcome.ttlItem) { - successfulEvents.push(outcome.ttlItem.event); - } - - if (unsuccessfulEventData) { - unsuccessfulEvents.push(unsuccessfulEventData); - } - } else { - logger.warn({ err: result.reason }); - processed.failed += 1; - } - } - - return { - processed, - batchItemFailures, - successfulEvents, - unsuccessfulEvents, - }; -}; - -const publishDigitalLetterReadEvents = async ( - eventPublisher: EventPublisher, - logger: Logger, - successfulEvents: MESHInboxMessageDownloaded[], -): Promise => { - if (successfulEvents.length === 0) { - return; - } - - try { - const failedEvents = await eventPublisher.sendEvents( - successfulEvents.map((event) => ({ - ...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(/\/[^/]+$/, '/queue'), - data: { - messageReference: event.data.messageReference, - senderId: event.data.senderId, - }, - })), - 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, - }); - } -}; - -const publishDigitalLetterUnsuccessfulEvents = async ( - eventPublisher: EventPublisher, - logger: Logger, - unsuccessfulEvents: UnsuccessfulEventData[], -): Promise => { - if (unsuccessfulEvents.length === 0) { - return; - } - - try { - const failedEvents = - await eventPublisher.sendEvents( - unsuccessfulEvents.map((event) => ({ - specversion: '1.0' as const, - id: randomUUID(), - source: event.source, - subject: `customer/${event.senderId}/recipient/${event.messageReference}`, - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1' as const, - plane: 'data' as const, - time: new Date().toISOString(), - recordedtime: new Date().toISOString(), - datacontenttype: 'application/json' as const, - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.json' as const, - dataschemaversion: '1.0.0' as const, - traceparent: - '00-00000000000000000000000000000000-0000000000000000-00', - severitynumber: 3, - data: { - messageReference: event.messageReference, - senderId: event.senderId, - reasonCode: event.reasonCode, - reasonText: event.reasonText, - }, - })), - validateDigitalLetterUnsuccessful, - ); - - if (failedEvents.length > 0) { - logger.warn({ - description: 'Some DigitalLetterUnsuccessful events failed to publish', - failedCount: failedEvents.length, - totalAttempted: unsuccessfulEvents.length, - }); - } - } catch (error) { - logger.warn({ - err: error, - description: - 'Failed to send DigitalLetterUnsuccessful events to EventBridge', - eventCount: unsuccessfulEvents.length, - }); - } -}; - -export const createHandler = ({ - eventPublisher, - logger, - ttlActions, -}: CreateHandlerDependencies) => - async function handler(sqsEvent: SQSEvent): Promise { - const promises = sqsEvent.Records.map(({ body, messageId }) => - processRecord(body, messageId, logger, ttlActions), - ); - - const results = await Promise.allSettled(promises); - - const { - batchItemFailures, - processed, - successfulEvents, - unsuccessfulEvents, - } = collectProcessingResults(results, logger); - - await publishDigitalLetterReadEvents( - eventPublisher, - logger, - successfulEvents, - ); - await publishDigitalLetterUnsuccessfulEvents( - eventPublisher, - logger, - unsuccessfulEvents, - ); - - logger.info({ - description: 'Processed SQS Event.', - ...processed, - }); - - return { batchItemFailures }; - }; - -export default createHandler; 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/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/package-lock.json b/package-lock.json index c2bf6e7ea..466f33897 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" }, @@ -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", @@ -22136,6 +22136,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 @@ -22144,10 +22148,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 @@ -27706,7 +27706,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", @@ -28085,7 +28085,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/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/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" } 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/pact-tests/consumer/channel-status-published.consumer.pact.test.ts b/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts deleted file mode 100644 index e85f905d7..000000000 --- a/tests/pact-tests/consumer/channel-status-published.consumer.pact.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import ChannelStatusPublishedEventPaperLetterOptedOut from '@nhsdigital/nhs-notify-event-schemas-status-published/examples/ChannelStatusPublishedEvent/v1/paper_letter_opted_out.json'; -import { - MatchersV3, - MessageConsumerPact, - asynchronousBodyHandler, -} from '@pact-foundation/pact'; -import { $ChannelStatusPublishedEvent } from 'utils'; -import { - 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 nhsapp-status-handler to validate the event. - $ChannelStatusPublishedEvent.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 channel status published event', async () => { - await expect( - messagePact - .expectsToReceive(PACT_MESSAGE_DESCRIPTION) - .withContent({ - data: { - messageReference: MatchersV3.string( - ChannelStatusPublishedEventPaperLetterOptedOut.data - .messageReference, - ), - supplierStatus: - ChannelStatusPublishedEventPaperLetterOptedOut.data - .supplierStatus, - }, - }) - .verify(asynchronousBodyHandler(handle)), - ).resolves.not.toThrow(); - }); -}); diff --git a/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts b/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts new file mode 100644 index 000000000..2be9b9a74 --- /dev/null +++ b/tests/pact-tests/consumer/core-notify.consumer.pact.test.ts @@ -0,0 +1,118 @@ +import { + MatchersV3, + MessageConsumerPact, + asynchronousBodyHandler, +} from '@pact-foundation/pact'; +import { + $ChannelStatusPublishedEvent, + $MessageStatusPublishedEvent, +} from 'utils'; +import { + PACT_CONSUMER, + 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'; + +async function handleChannelStatus(event: unknown) { + $ChannelStatusPublishedEvent.parse(event); +} + +async function handleMessageStatus(event: unknown) { + $MessageStatusPublishedEvent.parse(event); +} + +const PACT_DIRECTORY = getPathFromProvider(PACT_CORE_NOTIFY_PROVIDER); + +describe('Pact message consumer - core notify events', () => { + const messagePact = new MessageConsumerPact({ + consumer: PACT_CONSUMER, + provider: PACT_CORE_NOTIFY_PROVIDER, + dir: PACT_DIRECTORY, + logLevel: 'error', + pactfileWriteMode: 'update', + }); + + it('validates a channel status published event with supplierStatus paper_letter_opted_out', async () => { + await expect( + messagePact + .expectsToReceive(PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION) + .withContent({ + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + messageReference: MatchersV3.string( + 'fa5a36ce-20e2-4d72-889e-eec4ac06d8d0_53189467-8375-4c50-8c49-d53483a6d5e9', + ), + supplierStatus: 'paper_letter_opted_out', + }, + }) + .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_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION) + .withContent({ + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + messageReference: MatchersV3.string( + '96dfed4f-cc43-4c70-99e5-caf98bfe3910_a273ecc5-afa0-4327-95b1-160827ccb665', + ), + 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_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION, + ) + .withContent({ + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + 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', + ), + supplierStatus: 'rejected', + }, + }) + .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({ + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: { + 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', + ), + 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/core-notify.provider.pact.test.ts b/tests/pact-tests/pact-verification/core-notify.provider.pact.test.ts new file mode 100644 index 000000000..49dc5cc43 --- /dev/null +++ b/tests/pact-tests/pact-verification/core-notify.provider.pact.test.ts @@ -0,0 +1,135 @@ +import { MessageProviderPact } from '@pact-foundation/pact'; +import { + $ChannelStatusPublishedEventV1, + $MessageStatusPublishedEventV1, +} from '@nhsdigital/nhs-notify-event-schemas-status-published'; +import { getPactFilePath } from '../utils/path-utils'; +import { + PACT_CONSUMER, + 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'; + +/* + !! IMPORTANT !! + These tests are a copy of the real provider tests found in comms-mgr. + https://github.com/NHSDigital/comms-mgr/blob/main/packages/pact-tests/src/pact-tests/provider/digital-letters.provider.pact.test.ts + If you have made any changes here, you will most likely need to make the same changes there. +*/ + +const PACT_FILE = getPactFilePath(PACT_CONSUMER, PACT_CORE_NOTIFY_PROVIDER); + +const CHANNEL_STATUS_ENVELOPE = { + specversion: '1.0', + id: '34810833-915a-4d29-b6ea-a5a9183298d4', + source: '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + subject: + 'customer/037f5f76-352c-445f-89a7-c3d18776ce86/message/3COesqsClaLyf0WNuLuhz1RDbWs/plan/3COezubdtrUFJlDOV4ucAQ93Akr', + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1' as const, + time: '2026-02-05T14:30:00.000Z', + sequence: '00000000000451468843', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/messaging/channel-status.published.1.1.0.schema.json', + dataschemaversion: '1.1.0', + plane: 'data' as const, + traceparent: '00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01', +}; + +const MESSAGE_STATUS_ENVELOPE = { + specversion: '1.0', + id: 'e533bf9d-a8a6-44d4-874d-d4c6666251f2', + source: '/nhs/england/notify/comms-mgr-prod/prod/data-plane/messaging', + subject: + 'customer/37603473-307f-4bac-a136-dafcb8c7eb57/message/2mCpnpLgNu33Ld4EW4SeAB7Vmu7', + type: 'uk.nhs.notify.message.status.PUBLISHED.v1' as const, + time: '2026-02-05T14:30:00.000Z', + sequence: '00000000000451468842', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/messaging/message-status.published.1.1.0.schema.json', + dataschemaversion: '1.1.0', + plane: 'data' as const, + traceparent: '00-4d678967f96e353c07a0a31c1849b500-07f83ba58dd8df70-01', +}; + +const SHARED_CHANNEL_DATA = { + clientId: '037f5f76-352c-445f-89a7-c3d18776ce86', + messageId: '3COesqsClaLyf0WNuLuhz1RDbWs', + messageReference: '22ea58fc-69da-483f-b49a-67c5ccb3f0ca', + channel: 'nhsapp', + channelStatus: 'delivered', + cascadeType: 'primary' as const, + cascadeOrder: 1, + timestamp: '2026-02-05T14:29:55Z', + retryCount: 0, + dataSources: [], +}; + +const SHARED_MESSAGE_DATA = { + clientId: '37603473-307f-4bac-a136-dafcb8c7eb57', + messageId: '2mCpnpLgNu33Ld4EW4SeAB7Vmu7', + messageReference: + '6e863c5b-1067-464c-9a07-d8333ce6def4_6fc872ba-1533-4006-8f6d-1345d5581de4', + timestamp: '2026-02-05T14:29:55Z', + dataSources: [], +}; + +describe('digital letters provider tests', () => { + test('verify pacts', async () => { + const p = new MessageProviderPact({ + provider: PACT_CORE_NOTIFY_PROVIDER, + pactUrls: [PACT_FILE], + messageProviders: { + [PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_OUT_DESCRIPTION]: async () => + $ChannelStatusPublishedEventV1.parse({ + ...CHANNEL_STATUS_ENVELOPE, + data: { + ...SHARED_CHANNEL_DATA, + supplierStatus: 'paper_letter_opted_out', + }, + }), + + [PACT_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION]: async () => + $ChannelStatusPublishedEventV1.parse({ + ...CHANNEL_STATUS_ENVELOPE, + data: { + ...SHARED_CHANNEL_DATA, + supplierStatus: 'paper_letter_opted_in', + }, + }), + + [PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION]: + async () => + $ChannelStatusPublishedEventV1.parse({ + ...CHANNEL_STATUS_ENVELOPE, + data: { + ...SHARED_CHANNEL_DATA, + channelStatus: 'failed', + supplierStatus: 'rejected', + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', + }, + }), + + [PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION]: async () => + $MessageStatusPublishedEventV1.parse({ + ...MESSAGE_STATUS_ENVELOPE, + data: { + ...SHARED_MESSAGE_DATA, + messageStatus: 'failed', + messageFailedReason: 'Failed reason: contact detail missing', + messageFailureReasonCode: 'MFR_CFGV_0005', + resolvedChannels: [], + }, + }), + }, + logLevel: 'error', + }); + + await expect(p.verify()).resolves.not.toThrow(); + }, 60_000); +}); diff --git a/tests/pact-tests/utils/pact-config.ts b/tests/pact-tests/utils/pact-config.ts index 1188dc28c..55d9b3e51 100644 --- a/tests/pact-tests/utils/pact-config.ts +++ b/tests/pact-tests/utils/pact-config.ts @@ -1,6 +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_MESSAGE_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_CORE_NOTIFY_CHANNEL_STATUS_OPTED_IN_DESCRIPTION = + 'CoreNotify-ChannelStatusPublishedEvent-paper_letter_opted_in'; + +export const PACT_CORE_NOTIFY_CHANNEL_STATUS_SUPPLIER_REJECTED_DESCRIPTION = + 'CoreNotify-ChannelStatusPublishedEvent-supplier_rejected'; + +export const PACT_CORE_NOTIFY_MESSAGE_STATUS_FAILED_DESCRIPTION = + 'CoreNotify-MessageStatusPublishedEvent-failed'; diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index 280192bd7..44d316a5d 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,8 @@ 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`; +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/core-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts new file mode 100644 index 000000000..e4d5c6a76 --- /dev/null +++ b/tests/playwright/digital-letters-component-tests/core-status-handler.component.spec.ts @@ -0,0 +1,715 @@ +import { expect, test } from '@playwright/test'; +import { + CORE_STATUS_HANDLER_DLQ_NAME, + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + 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'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; +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', () => { + test.beforeAll(async () => { + await purgeQueue(CORE_STATUS_HANDLER_DLQ_NAME); + }); + + const baseEvent: MESHInboxMessageDownloaded = { + id: 'id', + specversion: '1.0', + source: '/nhs/england/notify/production/primary/digitalletters/mesh', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', + time: '2023-06-20T12:00:00Z', + plane: 'data', + recordedtime: '2023-06-20T12:00:00.250Z', + 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-mesh-inbox-message-downloaded-data.schema.json', + dataschemaversion: '1.0.0', + severitytext: 'INFO', + data: { + meshMessageId: '12345', + messageUri: 'uri', + messageReference: 'ref1', + senderId: SENDER_ID_VALID_FOR_NOTIFY_SANDBOX, + }, + }; + + 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', + 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 and publish digital.letter.unsuccessful event', async () => { + const event = { + ...baseEvent, + data: { + ...baseEvent.data, + channelFailedReason: 'Could not send notification', + channelFailureReasonCode: 'CFR_CNSN_0001', + 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( + [ + { + ...channelFailedEvent, + source: statusEventSource, + data: { + ...channelFailedEvent.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.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), + + 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', () => { + const optedOutEvent: ChannelStatusPublishedEvent = { + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + 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( + [ + { + ...optedOutEvent, + source: statusEventSource, + data: { + ...optedOutEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => 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(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 duplicate event is received for the same TTL record - process them both', 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 = { + ...optedOutEvent, + source: statusEventSource, + 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); + }, 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(2); + }, 150), + ]); + }); + + test('when TTL record is not found - perform no operations', async () => { + const concatedReference = `${uuidv4()}_${uuidv4()}`; + + await eventPublisher.sendEvents( + [ + { + ...optedOutEvent, + source: statusEventSource, + 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 = { + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + 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); + + await eventPublisher.sendEvents( + [ + { + ...optedInEvent, + source: statusEventSource, + 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); + }, 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('when duplicate event is received for the same TTL record - process them both', 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, + source: statusEventSource, + 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); + }, 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), + + 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, + source: statusEventSource, + 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 = { + 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 and publish digital.letter.unsuccessful 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( + [ + { + ...messageEvent, + source: statusEventSource, + data: { + ...messageEvent.data, + messageReference: concatedReference, + }, + }, + ], + () => 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.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), + + 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 () => { + 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( + [ + { + ...messageEvent, + source: statusEventSource, + data: { + ...messageEvent.data, + messageReference: concatedReference, + resolvedChannels: ['LETTER'], + }, + }, + ], + () => true, + ); + + 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('when event is invalid - send to dlq', async () => { + test.setTimeout(160_000); + + const concatedReference = `${uuidv4()}_${uuidv4()}`; + + await eventPublisher.sendEvents( + [ + { + source: statusEventSource, + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: { + messageReference: concatedReference, + messageStatus: 'I am not valid', + }, + }, + ], + () => true, + ); + + await Promise.all([ + expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + CORE_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "Error parsing sqs record"', + `$.message.messageReference = "${concatedReference}"`, + String.raw`$.message.err.message = "*\"invalid_value\"*"`, + String.raw`$.message.err.message = "*\"messageStatus\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 150), + + expectMessageContainingString( + CORE_STATUS_HANDLER_DLQ_NAME, + concatedReference, + 150, + ), + ]); + }); +}); 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..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 @@ -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(); @@ -102,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 () => { @@ -151,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/nhsapp-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts deleted file mode 100644 index 48bf9de33..000000000 --- a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { - ENV, - EVENT_BUS_LOG_GROUP_NAME, - 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'; -import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; -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 { v4 as uuidv4 } from 'uuid'; - -test.describe('Digital Letters - NHSApp Status Handler', () => { - const statusEventSource = - '/nhs/england/notify/development/primary/data-plane/messaging'; - - test.beforeAll(async () => { - await purgeQueue(NHSAPP_STATUS_HANDLER_DLQ_NAME); - }); - - const baseEvent: MESHInboxMessageDownloaded = { - id: 'id', - specversion: '1.0', - source: '/nhs/england/notify/production/primary/digitalletters/mesh', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', - time: '2023-06-20T12:00:00Z', - plane: 'data', - recordedtime: '2023-06-20T12:00:00.250Z', - 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-mesh-inbox-message-downloaded-data.schema.json', - dataschemaversion: '1.0.0', - severitytext: 'INFO', - data: { - meshMessageId: '12345', - messageUri: 'uri', - messageReference: 'ref1', - senderId: SENDER_ID_VALID_FOR_NOTIFY_SANDBOX, - }, - }; - - test('should 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( - [ - { - source: statusEventSource, - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: concatedReference, - supplierStatus: 'paper_letter_opted_out', - }, - }, - ], - () => 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( - `/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}\\"*"`, - ], - ); - - expect(eventLogEntry.length).toEqual(1); - }), - - expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - [ - '$.message.description = "TTL record marked as withdrawn"', - `$.message.messageReference = "${concatedReference}"`, - ], - ); - - expect(eventLogEntry.length).toEqual(1); - }, 150), - ]); - }); - - 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 ttlItem = { - PK: concatedReference, - SK: 'TTL', - dateOfExpiry: '2023-12-31#0', - event, - ttl: Date.now() / 1000 + 3600, - }; - - const channelStatusPublishedEvent = { - source: statusEventSource, - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: concatedReference, - supplierStatus: 'paper_letter_opted_out', - }, - }; - - 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( - `/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}\\"*"`, - ], - ); - - expect(eventLogEntry.length).toEqual(2); - }), - - expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - [ - '$.message.description = "TTL record marked as withdrawn"', - `$.message.messageReference = "${concatedReference}"`, - ], - ); - - expect(eventLogEntry.length).toEqual(2); - }, 150), - ]); - }); - - test('should handle missing TTL record', async () => { - const concatedReference = `${uuidv4()}_${uuidv4()}`; - - await eventPublisher.sendEvents( - [ - { - source: statusEventSource, - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: concatedReference, - supplierStatus: 'paper_letter_opted_out', - }, - }, - ], - () => true, - ); - - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - [ - '$.message.description = "TTL record not found"', - `$.message.messageReference = "${concatedReference}"`, - ], - ); - - expect(eventLogEntry.length).toEqual(1); - }, 150); - }); - - test('should send invalid event to nhsapp status handler dlq', async () => { - test.setTimeout(160_000); - - const concatedReference = `${uuidv4()}_${uuidv4()}`; - - await eventPublisher.sendEvents( - [ - { - source: statusEventSource, - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: concatedReference, - supplierStatus: 'I am not valid', - }, - }, - ], - () => true, - ); - - await Promise.all([ - expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, - [ - '$.message.description = "Error parsing sqs record"', - `$.message.messageReference = "${concatedReference}"`, - String.raw`$.message.err.message = "*\"invalid_value\"*"`, - String.raw`$.message.err.message = "*\"supplierStatus\"*"`, - ], - ); - - expect(eventLogEntry.length).toEqual(1); - }, 150), - - expectMessageContainingString( - NHSAPP_STATUS_HANDLER_DLQ_NAME, - concatedReference, - 150, - ), - ]); - }); - - test('should publish digital.letter.unsuccessful event for channel status failed', async () => { - const senderId = `sender-${uuidv4()}`; - const messageReference = uuidv4(); - const notifyMessageReference = `${senderId}_${messageReference}`; - - await eventPublisher.sendEvents( - [ - { - source: statusEventSource, - type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', - data: { - messageReference: notifyMessageReference, - channelStatus: 'failed', - supplierStatus: 'rejected', - channelFailureReasonCode: 'CFR_SUPE_0001', - channelFailedReason: 'Failed reason: Not registered with NHS App', - }, - }, - ], - () => true, - ); - - await 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\":\"${messageReference}\"*"`, - String.raw`$.details.event_detail = "*\"senderId\":\"${senderId}\"*"`, - String.raw`$.details.event_detail = "*\"reasonCode\":\"CFR_SUPE_0001\"*"`, - ], - ); - - expect(eventLogEntry.length).toEqual(1); - }, 240); - }); - - test('should publish digital.letter.unsuccessful event for message status failed', async () => { - const senderId = `sender-${uuidv4()}`; - const messageReference = uuidv4(); - const notifyMessageReference = `${senderId}_${messageReference}`; - - await eventPublisher.sendEvents( - [ - { - source: statusEventSource, - type: 'uk.nhs.notify.message.status.PUBLISHED.v1', - data: { - messageReference: notifyMessageReference, - messageStatus: 'failed', - messageStatusDescription: - 'Failed reason: No reachable communication channels', - messageFailureReasonCode: 'MFR_CFGV_0002', - channels: [], - }, - }, - ], - () => true, - ); - - await 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\":\"${messageReference}\"*"`, - String.raw`$.details.event_detail = "*\"senderId\":\"${senderId}\"*"`, - String.raw`$.details.event_detail = "*\"reasonCode\":\"MFR_CFGV_0002\"*"`, - ], - ); - - expect(eventLogEntry.length).toEqual(1); - }, 240); - }); -}); 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/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'; diff --git a/tests/playwright/helpers/event-builders.ts b/tests/playwright/helpers/event-builders.ts index 9632d0e82..ae85c2692 100644 --- a/tests/playwright/helpers/event-builders.ts +++ b/tests/playwright/helpers/event-builders.ts @@ -32,6 +32,7 @@ export function buildDigitalLetterReadEvent( time: string, messageReference: string, senderId: string, + supplierStatus = 'paper_letter_opted_out', ): DigitalLetterRead { const baseEvent = buildBaseEvent('queue', time); return { @@ -43,6 +44,7 @@ export function buildDigitalLetterReadEvent( data: { messageReference, senderId, + supplierStatus, }, } as DigitalLetterRead; } 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", 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-failed-event.ts b/utils/utils/src/types/channel-status-failed-event.ts deleted file mode 100644 index 70b1146ab..000000000 --- a/utils/utils/src/types/channel-status-failed-event.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; - -export const $ChannelStatusFailedEvent = z.object({ - source: z.string(), - data: z.object({ - messageReference: z.string(), - channelStatus: z.literal('failed'), - supplierStatus: z.literal('rejected'), - channelFailureReasonCode: z.string(), - channelFailedReason: z.string(), - }), -}); - -export type ChannelStatusFailedEvent = z.infer< - typeof $ChannelStatusFailedEvent ->; 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..a63770f54 --- /dev/null +++ b/utils/utils/src/types/core-status-published-event.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +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'), + }), + z.object({ + 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({ + type: z.literal('uk.nhs.notify.message.status.PUBLISHED.v1'), + data: z.object({ + messageReference: z.string(), + messageStatus: z.literal('failed'), + messageFailedReason: z.string(), + messageFailureReasonCode: z.string(), + 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 db4ba746d..1c270e0b5 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -1,6 +1,4 @@ -export * from './channel-status-failed-event'; -export * from './channel-status-published-event'; -export * from './message-status-failed-event'; +export * from './core-status-published-event'; export * from './pdm-types'; export * from './sender'; export * from './supplier-api-letter-event'; diff --git a/utils/utils/src/types/message-status-failed-event.ts b/utils/utils/src/types/message-status-failed-event.ts deleted file mode 100644 index a70e33a25..000000000 --- a/utils/utils/src/types/message-status-failed-event.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; - -export const $MessageStatusFailedEvent = z.object({ - source: z.string(), - data: z.object({ - messageReference: z.string(), - messageStatus: z.literal('failed'), - messageStatusDescription: z.string(), - messageFailureReasonCode: z.string(), - channels: z.array(z.unknown()).length(0), - }), -}); - -export type MessageStatusFailedEvent = z.infer< - typeof $MessageStatusFailedEvent ->;