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
->;