diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index ab2bdf4e4..26aeafacd 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -11,10 +11,11 @@ No requirements. |------|-------------|------|---------|:--------:| | [apim\_auth\_token\_schedule](#input\_apim\_auth\_token\_schedule) | Schedule to renew the APIM auth token | `string` | `"rate(9 minutes)"` | no | | [apim\_auth\_token\_url](#input\_apim\_auth\_token\_url) | URL to generate an APIM auth token | `string` | `"https://int.api.service.nhs.uk/oauth2/token"` | no | -| [apim\_base\_url](#input\_apim\_base\_url) | The URL used to send requests to Notify and PDM | `string` | `"https://int.api.service.nhs.uk"` | no | +| [apim\_base\_url](#input\_apim\_base\_url) | The URL used to send requests to PDM | `string` | `"https://int.api.service.nhs.uk"` | no | | [apim\_keygen\_schedule](#input\_apim\_keygen\_schedule) | Schedule to refresh key pairs if necessary | `string` | `"cron(0 14 * * ? *)"` | no | | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"dl"` | no | +| [core\_notify\_url](#input\_core\_notify\_url) | The URL used to send requests to Notify | `string` | `"https://sandbox.api.service.nhs.uk"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [enable\_dynamodb\_delete\_protection](#input\_enable\_dynamodb\_delete\_protection) | Enable DynamoDB Delete Protection on all Tables | `bool` | `true` | no | | [enable\_mock\_mesh](#input\_enable\_mock\_mesh) | Enable mock mesh access (dev only). Grants lambda permission to read mock-mesh prefix in non-pii bucket. | `bool` | `false` | no | @@ -40,6 +41,7 @@ No requirements. | Name | Source | Version | |------|--------|---------| +| [core\_notifier](#module\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-kms.zip | n/a | | [lambda\_apim\_key\_generation](#module\_lambda\_apim\_key\_generation) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [lambda\_lambda\_apim\_refresh\_token](#module\_lambda\_lambda\_apim\_refresh\_token) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | @@ -53,6 +55,7 @@ No requirements. | [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | +| [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_available.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_available.tf new file mode 100644 index 000000000..1e4aff1fb --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_available.tf @@ -0,0 +1,18 @@ +resource "aws_cloudwatch_event_rule" "pdm_resource_available" { + name = "${local.csi}-pdm-resource-available" + description = "PDM resource available event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.digital.letters.pdm.resource.available.v1" + ], + } + }) +} + +resource "aws_cloudwatch_event_target" "pdm_resource_available_core_notifier" { + rule = aws_cloudwatch_event_rule.pdm_resource_available.name + arn = module.sqs_core_notifier.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_notifier_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_notifier_lambda.tf new file mode 100644 index 000000000..54e7a8a75 --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_notifier_lambda.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "core_notifier_lambda" { + event_source_arn = module.sqs_core_notifier.sqs_queue_arn + function_name = module.core_notifier.function_arn + 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_core_notifier.tf b/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf new file mode 100644 index 000000000..c39fbba9d --- /dev/null +++ b/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf @@ -0,0 +1,119 @@ +module "core_notifier" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" + + function_name = "core-notifier" + description = "A function to send messages to core Notify when a PDM resource is available" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.core_notifier_lambda.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 = "core-notifier-lambda/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 128 + timeout = 60 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.log_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + "APIM_BASE_URL" = var.core_notify_url + "APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME" = local.apim_access_token_ssm_parameter_name + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + } +} + +data "aws_iam_policy_document" "core_notifier_lambda" { + statement { + sid = "AllowSSMParam" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + + resources = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/apim/*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/senders/*" + ] + } + + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } + + statement { + sid = "SQSPermissionsUploadToCoreNotifierQueue" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ] + + resources = [ + module.sqs_core_notifier.sqs_queue_arn, + ] + } + + statement { + sid = "PutEvents" + effect = "Allow" + + actions = [ + "events:PutEvents", + ] + + resources = [ + aws_cloudwatch_event_bus.main.arn, + ] + } + + statement { + sid = "SQSPermissionsDLQ" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:SendMessageBatch", + ] + + resources = [ + module.sqs_event_publisher_errors.sqs_queue_arn, + ] + } +} diff --git a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf new file mode 100644 index 000000000..6aab47018 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf @@ -0,0 +1,44 @@ +module "sqs_core_notifier" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + name = "core-notifier" + + sqs_kms_key_arn = module.kms.key_arn + + visibility_timeout_seconds = 60 + + create_dlq = true + + sqs_policy_overload = data.aws_iam_policy_document.sqs_inbound_event.json +} + +data "aws_iam_policy_document" "sqs_inbound_event" { + statement { + sid = "AllowEventBridgeToSendMessage" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + + actions = [ + "sqs:SendMessage" + ] + + resources = [ + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-core-notifier-queue" + ] + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = [ aws_cloudwatch_event_rule.pdm_resource_available.arn ] + } + } +} diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf index ca1109ee0..be79f0153 100644 --- a/infrastructure/terraform/components/dl/variables.tf +++ b/infrastructure/terraform/components/dl/variables.tf @@ -136,10 +136,17 @@ variable "pdm_use_non_mock_token" { variable "apim_base_url" { type = string - description = "The URL used to send requests to Notify and PDM" + description = "The URL used to send requests to PDM" default = "https://int.api.service.nhs.uk" } + +variable "core_notify_url" { + type = string + description = "The URL used to send requests to Notify" + default = "https://sandbox.api.service.nhs.uk" +} + variable "apim_auth_token_url" { type = string description = "URL to generate an APIM auth token" diff --git a/lambdas/core-notifier-lambda/jest.config.ts b/lambdas/core-notifier-lambda/jest.config.ts new file mode 100644 index 000000000..c02601aec --- /dev/null +++ b/lambdas/core-notifier-lambda/jest.config.ts @@ -0,0 +1,5 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = baseJestConfig; + +export default config; diff --git a/lambdas/core-notifier-lambda/package.json b/lambdas/core-notifier-lambda/package.json new file mode 100644 index 000000000..f3d1a6950 --- /dev/null +++ b/lambdas/core-notifier-lambda/package.json @@ -0,0 +1,27 @@ +{ + "dependencies": { + "aws-lambda": "^1.0.7", + "axios": "^1.13.2", + "digital-letters-events": "^0.0.1", + "sender-management": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@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" + }, + "name": "nhs-notify-digital-core-notifier-lambda", + "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", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts new file mode 100644 index 000000000..ceb3a6985 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -0,0 +1,297 @@ +import type { SQSEvent, SQSRecord } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import { EventPublisher, Logger, Sender } from 'utils'; +import { NotifyMessageProcessor } from 'app/notify-message-processor'; +import { ISenderManagement } from 'sender-management'; +import { SqsHandlerDependencies, createHandler } from 'apis/sqs-handler'; +import { parseSqsRecord } from 'app/parse-sqs-message'; +import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event'; +import { RequestNotifyError } from 'domain/request-notify-error'; +import { validPdmEvent, validSender } from '__tests__/constants'; +import { + MessageRequestRejected, + MessageRequestSkipped, + MessageRequestSubmitted, +} from 'digital-letters-events'; + +jest.mock('app/parse-sqs-message'); + +const mockLogger = mock(); +const mockNotifyMessageProcessor = mock(); +const mockSenderManagement = mock(); +const mockEventPublisher = mock(); +const mockParseSqsRecord = jest.mocked(parseSqsRecord); + +const createSqsRecord = (messageId: string): SQSRecord => ({ + messageId, + receiptHandle: 'receipt-handle', + body: JSON.stringify({ + detail: validPdmEvent, + }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', +}); + +const createSqsEvent = (recordCount: number): SQSEvent => ({ + Records: Array.from({ length: recordCount }, (_, i) => + createSqsRecord(`message-id-${i + 1}`), + ), +}); + +describe('createHandler', () => { + const dependencies: SqsHandlerDependencies = { + logger: mockLogger, + notifyMessageProcessor: mockNotifyMessageProcessor, + senderManagement: mockSenderManagement, + eventPublisher: mockEventPublisher, + }; + + const senderId = 'sender-123'; + const messageReference = 'msg-ref-123'; + const notifyId = 'notify-id-123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when processing a single successful SQS record', () => { + it('processes the message and returns no batch item failures', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + + mockParseSqsRecord.mockReturnValueOnce(validPdmEvent); + mockSenderManagement.getSender.mockResolvedValue(validSender); + mockNotifyMessageProcessor.process.mockResolvedValueOnce(notifyId); + + const result = await handler(sqsEvent); + + expect(result).toEqual({ batchItemFailures: [] }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Received SQS Event of 1 record(s)', + }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: '1 of 1 records processed successfully', + }); + expect(mockParseSqsRecord).toHaveBeenCalledWith( + sqsEvent.Records[0], + mockLogger, + ); + expect(mockSenderManagement.getSender).toHaveBeenCalledWith({ + senderId, + }); + expect(mockNotifyMessageProcessor.process).toHaveBeenCalledTimes(1); + expect( + mockEventPublisher.sendEvents, + ).toHaveBeenCalledTimes(1); + }); + + it('skips the message and publishes a skipped event', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + const senderWithoutRouting: Sender = { + ...validSender, + routingConfigId: undefined, + }; + + mockParseSqsRecord.mockReturnValueOnce(validPdmEvent); + mockSenderManagement.getSender.mockResolvedValue(senderWithoutRouting); + + const result = await handler(sqsEvent); + + expect(result).toEqual({ batchItemFailures: [] }); + expect(mockNotifyMessageProcessor.process).not.toHaveBeenCalled(); + expect( + mockEventPublisher.sendEvents, + ).toHaveBeenCalledTimes(1); + expect( + mockEventPublisher.sendEvents, + ).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + senderId: validSender.senderId, + }), + }), + ]), + expect.any(Function), + ); + }); + + it('throws an error when sender is not found', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + + mockParseSqsRecord.mockReturnValueOnce(validPdmEvent); + mockSenderManagement.getSender.mockResolvedValue(null); + + const result = await handler(sqsEvent); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: sqsEvent.Records[0].messageId }], + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Failed processing message', + messageId: sqsEvent.Records[0].messageId, + senderId: validSender.senderId, + }), + ); + }); + }); + + describe('when processing multiple SQS records', () => { + it('processes all records successfully', async () => { + const sqsEvent = createSqsEvent(3); + const handler = createHandler(dependencies); + + mockParseSqsRecord.mockReturnValue(validPdmEvent); + mockSenderManagement.getSender.mockResolvedValue(validSender); + mockNotifyMessageProcessor.process.mockResolvedValue(notifyId); + + const result = await handler(sqsEvent); + + expect(result).toEqual({ batchItemFailures: [] }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Received SQS Event of 3 record(s)', + }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: '3 of 3 records processed successfully', + }); + expect(mockParseSqsRecord).toHaveBeenCalledTimes(3); + expect(mockNotifyMessageProcessor.process).toHaveBeenCalledTimes(3); + }); + + it('returns only failed message IDs', async () => { + const sqsEvent = createSqsEvent(3); + const handler = createHandler(dependencies); + + mockParseSqsRecord + .mockReturnValueOnce(validPdmEvent) + .mockImplementationOnce(() => { + throw new Error('Parse error'); + }) + .mockReturnValueOnce(validPdmEvent); + + mockSenderManagement.getSender.mockResolvedValue(validSender); + mockNotifyMessageProcessor.process.mockResolvedValue(notifyId); + + const result = await handler(sqsEvent); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: 'message-id-2' }], + }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: '2 of 3 records processed successfully', + }); + }); + }); + + describe('when parseSqsRecord throws InvalidPdmResourceAvailableEvent', () => { + it('marks the message as failed for retry', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + const { messageId } = sqsEvent.Records[0]; + + mockParseSqsRecord.mockImplementationOnce(() => { + throw new InvalidPdmResourceAvailableEvent(messageId); + }); + + const result = await handler(sqsEvent); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: messageId }], + }); + expect(mockLogger.warn).toHaveBeenCalledWith({ + error: 'Unable to parse PDMResourceAvailable event from SQS message', + description: 'Failed processing message', + messageId, + senderId: undefined, + }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: '0 of 1 records processed successfully', + }); + }); + }); + + describe('when processing throws error', () => { + it('marks the message as failed as is not a RequestNotifyError', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + const { messageId } = sqsEvent.Records[0]; + const error = new Error('Validation failed'); + + mockParseSqsRecord.mockReturnValueOnce(validPdmEvent); + mockSenderManagement.getSender.mockResolvedValue(validSender); + mockNotifyMessageProcessor.process.mockRejectedValueOnce(error); + + const result = await handler(sqsEvent); + + // Since RequestNotifyError doesn't have messageReference property, + // it falls through to the else branch and is treated as a transient error + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: messageId }], + }); + expect(mockLogger.warn).toHaveBeenCalledWith({ + error: error.message, + description: 'Failed processing message', + messageId, + senderId: validSender.senderId, + }); + expect( + mockEventPublisher.sendEvents, + ).not.toHaveBeenCalled(); + }); + + it('publishes rejected event when error has messageReference property', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + const { messageId } = sqsEvent.Records[0]; + const errorCode = 'VALIDATION_ERROR'; + const correlationId = 'corr-123'; + const error = new RequestNotifyError( + new Error('Validation failed'), + correlationId, + errorCode, + ); + // Add messageReference property dynamically to trigger the terminal error path + (error as any).messageReference = messageReference; + + mockParseSqsRecord.mockReturnValueOnce(validPdmEvent); + mockSenderManagement.getSender.mockResolvedValue(validSender); + mockNotifyMessageProcessor.process.mockRejectedValueOnce(error); + + const result = await handler(sqsEvent); + + // With messageReference property, it's treated as terminal error and not retried + expect(result).toEqual({ batchItemFailures: [] }); + expect(mockLogger.warn).toHaveBeenCalledWith({ + error: error.message, + description: 'Failed processing message', + messageId, + senderId: validSender.senderId, + }); + expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(1); + expect(mockEventPublisher.sendEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + senderId, + messageReference, + failureCode: errorCode, + }), + }), + ]), + expect.any(Function), + ); + }); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/app/notify-api-client.test.ts b/lambdas/core-notifier-lambda/src/__tests__/app/notify-api-client.test.ts new file mode 100644 index 000000000..59af51fd2 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/app/notify-api-client.test.ts @@ -0,0 +1,267 @@ +import { randomUUID } from 'node:crypto'; +import { mock } from 'jest-mock-extended'; +import axios, { + type AxiosInstance, + type AxiosResponse, + type AxiosStatic, +} from 'axios'; +import { RetryErrorConditionFn, conditionalRetry as _retry } from 'utils'; +import type { Logger } from 'utils'; +import { mockRequest1, mockResponse } from '__tests__/constants'; +import { IAccessTokenRepository, NotifyClient } from 'app/notify-api-client'; +import { RequestAlreadyReceivedError } from 'domain/request-already-received-error'; + +jest.mock('utils'); +jest.mock('node:crypto'); +jest.mock('axios', () => { + const original: AxiosStatic = jest.requireActual('axios'); + + return { ...original, create: jest.fn() }; +}); + +const mockRetry = async ( + fn: (attempt: number) => Promise, + isRetryable: RetryErrorConditionFn, + _: unknown, + attempt = 1, +): Promise => { + try { + return await fn(attempt); + } catch (error) { + if (isRetryable(error)) { + return mockRetry(fn, isRetryable, attempt + 1); + } + throw error; + } +}; + +jest.mocked(_retry).mockImplementation(mockRetry); + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +const apimBaseUrl = 'https://api.service.nhs.uk'; + +function setup() { + const logger = mock(); + + const accessTokenRepository = mock(); + accessTokenRepository.getAccessToken.mockResolvedValue('fake-access-token'); + + const axiosInstance = mock(); + (axios.create as jest.Mock).mockReturnValueOnce(axiosInstance); + + (randomUUID as jest.Mock).mockReturnValue('not-random-uuid'); + + const mocks = { accessTokenRepository, axiosInstance, logger }; + + const client = new NotifyClient(apimBaseUrl, accessTokenRepository, logger); + + return { client, mocks }; +} + +describe('constructor', () => { + it('creates a new axios instance with correct config', () => { + setup(); + + expect(axios.create).toHaveBeenCalledWith({ + baseURL: apimBaseUrl, + }); + }); +}); + +describe('sendRequest', () => { + it('successfully sends a request', async () => { + const { client, mocks } = setup(); + + const response = { + status: 200, + data: mockResponse, + }; + + mocks.axiosInstance.post.mockResolvedValueOnce(response); + + const actual = await client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ); + + expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); + expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(1); + expect(mocks.axiosInstance.post).toHaveBeenCalledWith( + '/comms/v1/messages', + { + data: mockRequest1.data, + }, + { + headers: { + Authorization: 'Bearer fake-access-token', + 'Content-Type': 'application/json', + 'X-Correlation-ID': 'request-item-id_request-item-plan-id', + }, + }, + ); + + expect(actual).toBe(response.data); + }); + + it('successfully sends a request without auhtorisation header', async () => { + const { client, mocks } = setup(); + + mocks.accessTokenRepository.getAccessToken.mockResolvedValue(''); + + const response = { + status: 200, + data: mockResponse, + }; + + mocks.axiosInstance.post.mockResolvedValueOnce(response); + + const actual = await client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ); + + expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); + expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(1); + expect(mocks.axiosInstance.post).toHaveBeenCalledWith( + '/comms/v1/messages', + { + data: mockRequest1.data, + }, + { + headers: { + 'Content-Type': 'application/json', + 'X-Correlation-ID': 'request-item-id_request-item-plan-id', + }, + }, + ); + + expect(actual).toBe(response.data); + }); + + it('retries on 429 status code errors and re-fetches access token each time', async () => { + const { client, mocks } = setup(); + + const error = { + isAxiosError: true, + response: { status: 429 }, + }; + + const response = mock({ + status: 200, + data: { type: 'Message' }, + }); + + mocks.axiosInstance.post + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(response); + + await client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ); + + expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(2); + expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(2); + }); + + it.each([400, 401, 403, 404])( + 'rejects %d status code errors immediately', + async (status) => { + const { client, mocks } = setup(); + + const error = { + isAxiosError: true, + response: { + status, + data: { + errors: [ + { + id: 'rrt-1931948104716186917-c-geu2-10664-3111479-3.0', + code: 'CM_MISSING_ROUTING_PLAN_TEMPLATE', + links: { + about: + 'https://digital.nhs.uk/developer/api-catalogue/nhs-notify', + }, + status, + title: 'Templates missing', + detail: + 'The templates required to use the routing plan were not found.', + source: { + pointer: '/data/attributes/routingPlanId', + }, + }, + ], + }, + }, + }; + + mocks.axiosInstance.post.mockRejectedValue(error); + + await expect( + client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ), + ).rejects.toMatchObject({ + errorCode: 'CM_MISSING_ROUTING_PLAN_TEMPLATE', + correlationId: 'request-item-id_request-item-plan-id', + }); + }, + ); + + it('throws the appropriate error when a 422 status is returned', async () => { + const { client, mocks } = setup(); + + const error = { + isAxiosError: true, + response: { status: 422 }, + }; + + mocks.axiosInstance.post.mockRejectedValue(error); + + await expect( + client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ), + ).rejects.toBeInstanceOf(RequestAlreadyReceivedError); + }); + + it('rejects non-axios errors immediately', async () => { + const { client, mocks } = setup(); + + const error = new Error('wahh'); + + mocks.axiosInstance.post.mockRejectedValue(error); + + await expect( + client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ), + ).rejects.toEqual(error); + }); + + it('rejects if unable to get the access token', async () => { + const { client, mocks } = setup(); + + const error = new Error('wahh'); + + mocks.accessTokenRepository.getAccessToken.mockRejectedValue(error); + + await expect( + client.sendRequest( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ), + ).rejects.toEqual(error); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/app/notify-message-processor.test.ts b/lambdas/core-notifier-lambda/src/__tests__/app/notify-message-processor.test.ts new file mode 100644 index 000000000..71a46c030 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/app/notify-message-processor.test.ts @@ -0,0 +1,87 @@ +import { mock } from 'jest-mock-extended'; +import { logger } from 'utils'; +import { NotifyMessageProcessor } from 'app/notify-message-processor'; +import { mockRequest1, mockResponse } from '__tests__/constants'; +import { NotifyClient } from 'app/notify-api-client'; +import { RequestAlreadyReceivedError } from 'domain/request-already-received-error'; + +jest.mock('utils'); + +const mockClient = mock(); + +const mockLogger = jest.mocked(logger); +const senderId = 'test-sender-id'; + +const notifyMessageProcessor = new NotifyMessageProcessor({ + nhsNotifyClient: mockClient, + logger: mockLogger, +}); + +describe('NotifyMessageProcessor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('completes when the API client succeeds', async () => { + mockClient.sendRequest.mockResolvedValueOnce(mockResponse); + + expect( + await notifyMessageProcessor.process(mockRequest1, senderId), + ).toEqual(mockResponse.data.id); + + expect(mockClient.sendRequest).toHaveBeenCalledTimes(1); + expect(mockClient.sendRequest).toHaveBeenCalledWith( + mockRequest1, + mockRequest1.data.attributes.messageReference, + ); + + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Sending request to Notify API', + messageReference: mockRequest1.data.attributes.messageReference, + senderId, + }); + + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Successfully processed request and sent to Notify', + messageReference: mockRequest1.data.attributes.messageReference, + messageItemId: mockResponse.data.id, + senderId, + }); + }); + + it('re-throws when the API client fails', async () => { + const errorMessage = 'API failure'; + const err = new Error(errorMessage); + mockClient.sendRequest.mockRejectedValue(err); + + await expect( + notifyMessageProcessor.process(mockRequest1, senderId), + ).rejects.toThrow(err); + + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending request to Notify API', + messageReference: mockRequest1.data.attributes.messageReference, + senderId, + error: errorMessage, + }); + }); + + it('re-throw when a RequestAlreadyReceivedError is thrown by the API client', async () => { + const { messageReference } = mockRequest1.data.attributes; + const err = new RequestAlreadyReceivedError( + new Error('Request was already received!'), + messageReference, + ); + mockClient.sendRequest.mockRejectedValue(err); + + await expect( + notifyMessageProcessor.process(mockRequest1, senderId), + ).rejects.toThrow(err); + + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Request has already been received by Notify', + messageReference, + senderId, + }); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/app/parse-sqs-message.test.ts b/lambdas/core-notifier-lambda/src/__tests__/app/parse-sqs-message.test.ts new file mode 100644 index 000000000..ad6257ff8 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/app/parse-sqs-message.test.ts @@ -0,0 +1,101 @@ +import { SQSRecord } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import { Logger } from 'utils'; +import { parseSqsRecord } from 'app/parse-sqs-message'; +import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event'; +import { validPdmEvent } from '__tests__/constants'; + +const mockLogger = mock(); + +describe('parseSqsRecord', () => { + const messageId = 'test-message-id-123'; + + const createSqsRecord = (detailContents: any): SQSRecord => ({ + messageId, + receiptHandle: 'receipt-handle', + body: JSON.stringify({ detail: detailContents }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when SQS record contains a valid PDMResourceAvailable event', () => { + it('parses and returns the PDMResourceAvailable event', () => { + const sqsRecord = createSqsRecord(validPdmEvent); + + const result = parseSqsRecord(sqsRecord, mockLogger); + + expect(result).toEqual(validPdmEvent); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Parsing SQS Record', + messageId, + }); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Parsed valid PDMResourceAvailable Event', + messageId, + messageReference: validPdmEvent.data.messageReference, + senderId: validPdmEvent.data.senderId, + resourceId: validPdmEvent.data.resourceId, + }); + }); + }); + + describe('when SQS record contains an invalid PDMResourceAvailable event', () => { + it('logs error and throws InvalidPdmResourceAvailableEvent', () => { + const invalidEvent = { ...validPdmEvent, data: {} }; + const sqsRecord = createSqsRecord(invalidEvent); + + expect(() => parseSqsRecord(sqsRecord, mockLogger)).toThrow( + InvalidPdmResourceAvailableEvent, + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: + 'The SQS message does not contain a valid PDMResourceAvailable event', + messageId, + error: expect.any(Array), + }), + ); + }); + }); + + describe('when SQS record body is malformed JSON', () => { + it('throws a JSON parse error', () => { + const sqsRecord: SQSRecord = { + messageId, + receiptHandle: 'receipt-handle', + body: 'not valid json{', + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', + }; + + expect(() => parseSqsRecord(sqsRecord, mockLogger)).toThrow(SyntaxError); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Parsing SQS Record', + messageId, + }); + }); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/constants.ts b/lambdas/core-notifier-lambda/src/__tests__/constants.ts new file mode 100644 index 000000000..e81c888a0 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/constants.ts @@ -0,0 +1,89 @@ +import type { + SingleMessageRequest, + SingleMessageResponse, +} from 'domain/request'; +import { Sender } from 'utils'; + +import { PDMResourceAvailable } from 'digital-letters-events'; + +export const mockRequest1: SingleMessageRequest = { + data: { + type: 'Message', + attributes: { + routingPlanId: 'routing-plan-id', + messageReference: 'request-item-id_request-item-plan-id', + billingReference: + 'test-client-id_test-campaign-id_test-billing-reference', + recipient: { + nhsNumber: '9999999786', + }, + originator: { + odsCode: 'A12345', + }, + personalisation: { + digitalLetterURL: + 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=12345', + }, + }, + }, +}; + +export const mockRequest2 = { + ...mockRequest1, + recipient: { + nhsNumber: '9999999788', + }, +}; + +export const mockResponse: SingleMessageResponse = { + data: { + type: 'Message', + id: '30XcAOfwjq59r72AQTjxL4V7Heg', + attributes: { + messageReference: '6e6aca3f-9e83-4c37-8bc0-b2bb0b2c7e0d', + messageStatus: 'created', + timestamps: { + created: '2025-07-29T08:20:13.408Z', + }, + routingPlan: { + id: 'fc4f8c6b-1547-4216-9237-c7027c97ae60', + version: '4HMorh_sMD7kr98GL43u0KR3qZNik4dW', + createdDate: '2025-07-23T10:34:13.000Z', + name: 'SMS nudge V1.0', + }, + }, + links: { + self: 'https://some.url/comms/v1/messages/30XcAOfwjq59r72AQTjxL4V7Heg', + }, + }, +}; + +export const validPdmEvent: PDMResourceAvailable = { + id: 'event-id-123', + source: + '/nhs/england/notify/development/dev-12345/data-plane/digitalletters/pdm', + specversion: '1.0', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + time: '2025-12-15T10:00:00Z', + datacontenttype: 'application/json', + subject: 'message-subject-123', + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + recordedtime: '2025-12-15T10:00:00Z', + severitynumber: 2, + data: { + senderId: 'sender-123', + messageReference: 'msg-ref-123', + resourceId: 'f5524783-e5d7-473e-b2a0-29582ff231da', + nhsNumber: '9991234566', + odsCode: 'A12345', + }, +}; + +export const validSender: Sender = { + senderId: 'sender-123', + routingConfigId: 'routing-config-123', + senderName: 'Test Sender', + meshMailboxSenderId: 'meshMailBoxSender-123', + meshMailboxReportsId: 'meshMailBoxReports-123', + fallbackWaitTimeSeconds: 100, +}; diff --git a/lambdas/core-notifier-lambda/src/__tests__/container.test.ts b/lambdas/core-notifier-lambda/src/__tests__/container.test.ts new file mode 100644 index 000000000..eac4385e8 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/container.test.ts @@ -0,0 +1,132 @@ +import { mock } from 'jest-mock-extended'; +import { EventPublisher, ParameterStoreCache, logger } from 'utils'; +import { NotifyClient } from 'app/notify-api-client'; +import { NotifyMessageProcessor } from 'app/notify-message-processor'; +import { ISenderManagement, SenderManagement } from 'sender-management'; +import { createContainer } from 'container'; +import { loadConfig } from 'infra/config'; + +jest.mock('utils', () => ({ + ParameterStoreCache: jest.fn(), + createGetApimAccessToken: jest.fn(() => jest.fn()), + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + EventPublisher: jest.fn(), + eventBridgeClient: {}, + sqsClient: {}, +})); + +jest.mock('app/notify-api-client'); +jest.mock('app/notify-message-processor'); +jest.mock('sender-management'); +jest.mock('infra/config'); + +describe('createContainer', () => { + const mockParameterStore = mock(); + const mockConfig = { + eventPublisherEventBusArn: + 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus', + eventPublisherDlqUrl: + 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq', + apimAccessTokenSsmParameterName: '/test/apim/access-token', + apimBaseUrl: 'https://api.test.nhs.uk', + environment: 'test', + }; + + const mockSenderManagement = mock(); + const mockNotifyClient = mock(); + const mockNotifyMessageProcessor = mock(); + const mockEventPublisher = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + + (ParameterStoreCache as jest.Mock).mockImplementation( + () => mockParameterStore, + ); + (loadConfig as jest.Mock).mockReturnValue(mockConfig); + (SenderManagement as jest.Mock).mockImplementation( + () => mockSenderManagement, + ); + (NotifyClient as jest.Mock).mockImplementation(() => mockNotifyClient); + (NotifyMessageProcessor as jest.Mock).mockImplementation( + () => mockNotifyMessageProcessor, + ); + (EventPublisher as jest.Mock).mockImplementation(() => mockEventPublisher); + }); + + it('creates and returns a container with all dependencies', async () => { + const container = await createContainer(); + + expect(container).toEqual({ + notifyMessageProcessor: mockNotifyMessageProcessor, + logger, + senderManagement: mockSenderManagement, + eventPublisher: mockEventPublisher, + }); + }); + + it('initializes ParameterStoreCache', async () => { + await createContainer(); + + expect(ParameterStoreCache).toHaveBeenCalledTimes(1); + expect(ParameterStoreCache).toHaveBeenCalledWith(); + }); + + it('loads configuration', async () => { + await createContainer(); + + expect(loadConfig).toHaveBeenCalledTimes(1); + }); + + it('creates SenderManagement with parameter store', async () => { + await createContainer(); + + expect(SenderManagement).toHaveBeenCalledTimes(1); + expect(SenderManagement).toHaveBeenCalledWith({ + parameterStore: mockParameterStore, + }); + }); + + it('creates NotifyClient with config and dependencies', async () => { + await createContainer(); + + expect(NotifyClient).toHaveBeenCalledTimes(1); + expect(NotifyClient).toHaveBeenCalledWith( + mockConfig.apimBaseUrl, + expect.objectContaining({ + getAccessToken: expect.any(Function), + }), + logger, + ); + }); + + it('creates NotifyMessageProcessor with client and logger', async () => { + await createContainer(); + + expect(NotifyMessageProcessor).toHaveBeenCalledTimes(1); + expect(NotifyMessageProcessor).toHaveBeenCalledWith({ + nhsNotifyClient: mockNotifyClient, + logger, + }); + }); + + it('creates EventPublisher instances with config', async () => { + await createContainer(); + + expect(EventPublisher).toHaveBeenCalledTimes(1); + expect(EventPublisher).toHaveBeenCalledWith( + expect.objectContaining({ + eventBusArn: mockConfig.eventPublisherEventBusArn, + dlqUrl: mockConfig.eventPublisherDlqUrl, + logger, + sqsClient: expect.any(Object), + eventBridgeClient: expect.any(Object), + }), + ); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/domain/mapper.test.ts b/lambdas/core-notifier-lambda/src/__tests__/domain/mapper.test.ts new file mode 100644 index 000000000..e1bde3bc7 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/domain/mapper.test.ts @@ -0,0 +1,338 @@ +import { Sender, logger } from 'utils'; +import { + mapPdmEventToMessageRequestRejected, + mapPdmEventToMessageRequestSkipped, + mapPdmEventToMessageRequestSubmitted, + mapPdmEventToSingleMessageRequest, +} from 'domain/mapper'; +import { PDMResourceAvailable } from 'digital-letters-events'; +import { randomUUID } from 'node:crypto'; + +jest.mock('utils'); +jest.mock('node:crypto'); + +const mockLogger = jest.mocked(logger); +const mockRandomUUID = jest.mocked(randomUUID); + +describe('mapper', () => { + const mockSender: Sender = { + senderId: 'test-sender-id', + senderName: 'Test Sender', + meshMailboxSenderId: 'mesh-sender', + meshMailboxReportsId: 'mesh-reports', + fallbackWaitTimeSeconds: 300, + routingConfigId: 'routing-config-123', + }; + + const mockPdmEvent: PDMResourceAvailable = { + specversion: '1.0', + id: 'event-123', + source: 'pdm-service', + subject: 'resource/available', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + time: '2024-01-15T10:30:00Z', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', + data: { + messageReference: 'msg-ref-123', + senderId: 'sender-456', + resourceId: 'resource-789', + nhsNumber: '9999999999', + odsCode: 'ODS123', + }, + traceparent: '00-trace-parent', + recordedtime: '2024-01-15T10:30:00Z', + severitynumber: 2, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockRandomUUID.mockReturnValue('45e7d942-0d33-46d1-a678-ada01e5de9fe'); + }); + + describe('mapPdmEventToSingleMessageRequest', () => { + it('correctly maps PDM event to single message request', () => { + const result = mapPdmEventToSingleMessageRequest( + mockPdmEvent, + mockSender, + ); + + expect(result).toEqual({ + data: { + type: 'Message', + attributes: { + routingPlanId: 'routing-config-123', + messageReference: 'msg-ref-123', + billingReference: 'test-sender-id', + recipient: { + nhsNumber: '9999999999', + }, + originator: { + odsCode: 'ODS123', + }, + personalisation: { + digitalLetterURL: + 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789', + }, + }, + }, + }); + + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Mapping resource available', + messageReference: 'msg-ref-123', + senderId: 'test-sender-id', + }); + }); + }); + + describe('mapPdmEventToMessageRequestSubmitted', () => { + it('correctly maps PDM event to MessageRequestSubmitted', () => { + const notifyId = 'notify-123'; + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any); + + const result = mapPdmEventToMessageRequestSubmitted( + mockPdmEvent, + mockSender, + notifyId, + ); + + expect(result).toEqual({ + ...mockPdmEvent, + id: '45e7d942-0d33-46d1-a678-ada01e5de9fe', + time: '2024-01-15T12:00:00.000Z', + recordedtime: '2024-01-15T12:00:00.000Z', + type: 'uk.nhs.notify.digital.letters.messages.request.submitted.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-submitted-data.schema.json', + data: { + messageReference: 'msg-ref-123', + senderId: 'test-sender-id', + notifyId: 'notify-123', + messageUri: + 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789', + }, + }); + + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + }); + }); + + describe('mapPdmEventToMessageRequestSkipped', () => { + it('correctly maps PDM event to MessageRequestSkipped', () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any); + + const result = mapPdmEventToMessageRequestSkipped( + mockPdmEvent, + mockSender, + ); + + expect(result).toEqual({ + ...mockPdmEvent, + id: '45e7d942-0d33-46d1-a678-ada01e5de9fe', + time: '2024-01-15T12:00:00.000Z', + recordedtime: '2024-01-15T12:00:00.000Z', + type: 'uk.nhs.notify.digital.letters.messages.request.skipped.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-skipped-data.schema.json', + data: { + messageReference: 'msg-ref-123', + senderId: 'test-sender-id', + }, + }); + + expect(mockRandomUUID).toHaveBeenCalled(); + }); + + it('generates new UUID for event', () => { + mapPdmEventToMessageRequestSkipped(mockPdmEvent, mockSender); + + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + }); + + it('includes messageReference in data', () => { + const result = mapPdmEventToMessageRequestSkipped( + mockPdmEvent, + mockSender, + ); + + expect(result.data.messageReference).toBe('msg-ref-123'); + }); + + it('uses sender senderId in data', () => { + const result = mapPdmEventToMessageRequestSkipped( + mockPdmEvent, + mockSender, + ); + + expect(result.data.senderId).toBe('test-sender-id'); + }); + + it('sets correct event type', () => { + const result = mapPdmEventToMessageRequestSkipped( + mockPdmEvent, + mockSender, + ); + + expect(result.type).toBe( + 'uk.nhs.notify.digital.letters.messages.request.skipped.v1', + ); + }); + + it('sets correct dataschema', () => { + const result = mapPdmEventToMessageRequestSkipped( + mockPdmEvent, + mockSender, + ); + + expect(result.dataschema).toBe( + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-skipped-data.schema.json', + ); + }); + + it('preserves CloudEvents properties from PDM event', () => { + const result = mapPdmEventToMessageRequestSkipped( + mockPdmEvent, + mockSender, + ); + + expect(result.specversion).toBe('1.0'); + expect(result.source).toBe('pdm-service'); + expect(result.subject).toBe('resource/available'); + expect(result.traceparent).toBe('00-trace-parent'); + }); + }); + + describe('mapPdmEventToMessageRequestRejected', () => { + it('correctly maps PDM event to MessageRequestRejected', () => { + const failureCode = 'INVALID_NHS_NUMBER'; + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any); + + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result).toEqual({ + ...mockPdmEvent, + id: '45e7d942-0d33-46d1-a678-ada01e5de9fe', + time: '2024-01-15T12:00:00.000Z', + recordedtime: '2024-01-15T12:00:00.000Z', + type: 'uk.nhs.notify.digital.letters.messages.request.rejected.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json', + data: { + messageReference: 'msg-ref-123', + senderId: 'test-sender-id', + failureCode: 'INVALID_NHS_NUMBER', + messageUri: + 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789', + }, + }); + + expect(mockRandomUUID).toHaveBeenCalled(); + }); + + it('generates new UUID for event', () => { + const failureCode = 'VALIDATION_ERROR'; + mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + }); + + it('includes failureCode in data', () => { + const failureCode = 'ROUTING_FAILED'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.data.failureCode).toBe('ROUTING_FAILED'); + }); + + it('includes messageUri with resource ID', () => { + const failureCode = 'TIMEOUT'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.data.messageUri).toBe( + 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789', + ); + }); + + it('uses sender senderId in data', () => { + const failureCode = 'UNKNOWN_ERROR'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.data.senderId).toBe('test-sender-id'); + }); + + it('uses messageReference from PDM event', () => { + const failureCode = 'DUPLICATE_REQUEST'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.data.messageReference).toBe('msg-ref-123'); + }); + + it('sets correct event type', () => { + const failureCode = 'SYSTEM_ERROR'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.type).toBe( + 'uk.nhs.notify.digital.letters.messages.request.rejected.v1', + ); + }); + + it('sets correct dataschema', () => { + const failureCode = 'CONFIG_ERROR'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.dataschema).toBe( + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json', + ); + }); + + it('preserves CloudEvents properties from PDM event', () => { + const failureCode = 'NETWORK_ERROR'; + const result = mapPdmEventToMessageRequestRejected( + mockPdmEvent, + mockSender, + failureCode, + ); + + expect(result.specversion).toBe('1.0'); + expect(result.source).toBe('pdm-service'); + expect(result.subject).toBe('resource/available'); + expect(result.traceparent).toBe('00-trace-parent'); + }); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/index.test.ts b/lambdas/core-notifier-lambda/src/__tests__/index.test.ts new file mode 100644 index 000000000..ee9c6f51e --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/index.test.ts @@ -0,0 +1,96 @@ +import type { SQSEvent, SQSRecord } from 'aws-lambda'; +import { handler } from 'index'; +import { createContainer } from 'container'; +import { createHandler as createSqsHandler } from 'apis/sqs-handler'; +import type { SqsHandlerDependencies } from 'apis/sqs-handler'; +import { mock } from 'jest-mock-extended'; + +jest.mock('container'); +jest.mock('apis/sqs-handler'); + +const createSqsEvent = (recordCount: number): SQSEvent => ({ + Records: Array.from( + { length: recordCount }, + (_, i): SQSRecord => ({ + messageId: `message-id-${i + 1}`, + receiptHandle: `receipt-handle-${i + 1}`, + body: JSON.stringify({ + detail: { + id: `event-id-${i + 1}`, + source: 'test', + specversion: '1.0', + type: 'test.event', + time: '2025-12-16T10:00:00Z', + datacontenttype: 'application/json', + data: {}, + }, + }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', + }), + ), +}); + +describe('Lambda handler', () => { + const mockContainer = mock(); + const mockSqsHandler = jest.fn(); + const mockCreateContainer = jest.mocked(createContainer); + const mockCreateSqsHandler = jest.mocked(createSqsHandler); + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateContainer.mockResolvedValue(mockContainer); + mockCreateSqsHandler.mockReturnValue(mockSqsHandler); + mockSqsHandler.mockResolvedValue({ batchItemFailures: [] }); + }); + + it('creates an SQS handler with the container dependencies and handler being invoked', async () => { + const sqsEvent = createSqsEvent(1); + + await handler(sqsEvent); + + expect(mockCreateSqsHandler).toHaveBeenCalledTimes(1); + expect(mockCreateSqsHandler).toHaveBeenCalledWith(mockContainer); + expect(mockSqsHandler).toHaveBeenCalledTimes(1); + expect(mockSqsHandler).toHaveBeenCalledWith(sqsEvent); + }); + + it('when fails to process a message it returns the id of the failed message', async () => { + const sqsEvent = createSqsEvent(1); + const expectedResult = { + batchItemFailures: [{ itemIdentifier: 'message-id-1' }], + }; + mockSqsHandler.mockResolvedValue(expectedResult); + + const result = await handler(sqsEvent); + + expect(result).toEqual(expectedResult); + }); + + it('propagates errors from createContainer', async () => { + const sqsEvent = createSqsEvent(1); + const error = new Error('Failed to create container'); + mockCreateContainer.mockRejectedValue(error); + + await expect(handler(sqsEvent)).rejects.toThrow( + 'Failed to create container', + ); + }); + + it('propagates errors from the SQS handler', async () => { + const sqsEvent = createSqsEvent(1); + const error = new Error('Handler failed'); + mockSqsHandler.mockRejectedValue(error); + + await expect(handler(sqsEvent)).rejects.toThrow('Handler failed'); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts b/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts new file mode 100644 index 000000000..6f19f2e05 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts @@ -0,0 +1,68 @@ +import { NotifySendMessageConfig, loadConfig } from 'infra/config'; +import { defaultConfigReader } from 'utils'; + +jest.mock('utils', () => ({ + defaultConfigReader: { + getValue: jest.fn(), + }, +})); + +describe('loadConfig', () => { + const mockGetValue = jest.mocked(defaultConfigReader.getValue); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads all configuration values from environment', () => { + const mockConfig = { + eventPublisherEventBusArn: + 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus', + eventPublisherDlqUrl: + 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq', + apimAccessTokenSsmParameterName: '/test/apim/access-token', + apimBaseUrl: 'https://api.test.nhs.uk', + environment: 'test', + }; + + mockGetValue + .mockReturnValueOnce(mockConfig.eventPublisherEventBusArn) + .mockReturnValueOnce(mockConfig.eventPublisherDlqUrl) + .mockReturnValueOnce(mockConfig.apimAccessTokenSsmParameterName) + .mockReturnValueOnce(mockConfig.apimBaseUrl) + .mockReturnValueOnce(mockConfig.environment); + + const result = loadConfig(); + + expect(result).toEqual(mockConfig); + expect(mockGetValue).toHaveBeenCalledTimes(5); + expect(mockGetValue).toHaveBeenNthCalledWith( + 1, + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ); + expect(mockGetValue).toHaveBeenNthCalledWith(2, 'EVENT_PUBLISHER_DLQ_URL'); + expect(mockGetValue).toHaveBeenNthCalledWith( + 3, + 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', + ); + expect(mockGetValue).toHaveBeenNthCalledWith(4, 'APIM_BASE_URL'); + expect(mockGetValue).toHaveBeenNthCalledWith(5, 'ENVIRONMENT'); + }); + + it('returns config with correct types', () => { + mockGetValue + .mockReturnValueOnce('arn:test') + .mockReturnValueOnce('https://dlq') + .mockReturnValueOnce('/param') + .mockReturnValueOnce('https://api') + .mockReturnValueOnce('prod'); + + const result: NotifySendMessageConfig = loadConfig(); + + expect(typeof result.eventPublisherEventBusArn).toBe('string'); + expect(typeof result.eventPublisherDlqUrl).toBe('string'); + expect(typeof result.apimAccessTokenSsmParameterName).toBe('string'); + expect(typeof result.apimBaseUrl).toBe('string'); + expect(typeof result.environment).toBe('string'); + }); +}); diff --git a/lambdas/core-notifier-lambda/src/apis/sqs-handler.ts b/lambdas/core-notifier-lambda/src/apis/sqs-handler.ts new file mode 100644 index 000000000..82ab8fc4c --- /dev/null +++ b/lambdas/core-notifier-lambda/src/apis/sqs-handler.ts @@ -0,0 +1,169 @@ +import type { + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, + SQSRecord, +} from 'aws-lambda'; +import { EventPublisher, Logger, Sender } from 'utils'; +import { + MessageRequestRejected, + MessageRequestSkipped, + MessageRequestSubmitted, + PDMResourceAvailable, +} from 'digital-letters-events'; +import { + mapPdmEventToMessageRequestRejected, + mapPdmEventToMessageRequestSkipped, + mapPdmEventToMessageRequestSubmitted, + mapPdmEventToSingleMessageRequest, +} from 'domain/mapper'; +import messageRequestSubmittedValidator from 'digital-letters-events/MessageRequestSubmitted.js'; +import messageRequestRejectedValidator from 'digital-letters-events/MessageRequestRejected.js'; +import messageRequestSkippedValidator from 'digital-letters-events/MessageRequestSkipped.js'; +import { parseSqsRecord } from 'app/parse-sqs-message'; + +import type { NotifyMessageProcessor } from 'app/notify-message-processor'; +import { ISenderManagement } from 'sender-management'; +import { RequestNotifyError } from 'domain/request-notify-error'; + +export interface SqsHandlerDependencies { + logger: Logger; + notifyMessageProcessor: NotifyMessageProcessor; + senderManagement: ISenderManagement; + eventPublisher: EventPublisher; +} + +type EventToPublish = { + skipped?: MessageRequestSkipped; + submitted?: MessageRequestSubmitted; + rejected?: MessageRequestRejected; +}; + +async function handlePdmResourceAvailable( + notifyMessageProcessor: NotifyMessageProcessor, + incoming: PDMResourceAvailable, + sender: Sender, +): Promise { + const eventToPublish = {} as EventToPublish; + + if (sender.routingConfigId === undefined) { + const messageRequestSkipped = mapPdmEventToMessageRequestSkipped( + incoming, + sender, + ); + eventToPublish.skipped = messageRequestSkipped; + } else { + const request = mapPdmEventToSingleMessageRequest(incoming, sender); + const notifyId = await notifyMessageProcessor.process( + request, + incoming.data.senderId, + ); + const messageRequestSubmitted = mapPdmEventToMessageRequestSubmitted( + incoming, + sender, + notifyId, + ); + eventToPublish.submitted = messageRequestSubmitted; + } + + return eventToPublish; +} + +export const createHandler = ({ + eventPublisher, + logger, + notifyMessageProcessor, + senderManagement, +}: SqsHandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const receivedItemCount = sqsEvent.Records.length; + + logger.info({ + description: `Received SQS Event of ${receivedItemCount} record(s)`, + }); + + const batchItemFailures: SQSBatchItemFailure[] = []; + const skippedEvents: MessageRequestSkipped[] = []; + const submittedEvents: MessageRequestSubmitted[] = []; + const rejectedEvents: MessageRequestRejected[] = []; + + let incoming: PDMResourceAvailable; + let sender: Sender | null; + + await Promise.all( + sqsEvent.Records.map(async (sqsRecord: SQSRecord) => { + try { + incoming = parseSqsRecord(sqsRecord, logger); + sender = await senderManagement.getSender({ + senderId: incoming.data.senderId, + }); + if (sender === null) { + throw new Error( + `Sender not found for senderId: ${incoming.data.senderId}`, + ); + } + + const eventToPublish = await handlePdmResourceAvailable( + notifyMessageProcessor, + incoming, + sender, + ); + + if (eventToPublish.submitted) { + submittedEvents.push(eventToPublish.submitted); + } + if (eventToPublish.skipped) { + skippedEvents.push(eventToPublish.skipped); + } + } catch (error: any) { + logger.warn({ + error: error.message, + description: 'Failed processing message', + messageId: sqsRecord.messageId, + senderId: incoming?.data.senderId, + }); + if (error instanceof RequestNotifyError) { + // CCM-12858 A/C 5: When any other response other than a 201 is returned, don't retry the message + rejectedEvents.push( + mapPdmEventToMessageRequestRejected( + incoming, + sender!, + error.errorCode, + ), + ); + } else { + // this might be a transient error so we notify the queue to retry + batchItemFailures.push({ itemIdentifier: sqsRecord.messageId }); + } + } + }), + ); + + await Promise.all( + [ + submittedEvents.length > 0 && + eventPublisher.sendEvents( + submittedEvents, + messageRequestSubmittedValidator, + ), + skippedEvents.length > 0 && + eventPublisher.sendEvents( + skippedEvents, + messageRequestSkippedValidator, + ), + rejectedEvents.length > 0 && + eventPublisher.sendEvents( + rejectedEvents, + messageRequestRejectedValidator, + ), + ].filter(Boolean), + ); + + const processedItemCount = receivedItemCount - batchItemFailures.length; + + logger.info({ + description: `${processedItemCount} of ${receivedItemCount} records processed successfully`, + }); + + return { batchItemFailures }; + }; diff --git a/lambdas/core-notifier-lambda/src/app/notify-api-client.ts b/lambdas/core-notifier-lambda/src/app/notify-api-client.ts new file mode 100644 index 000000000..9f1ada7bc --- /dev/null +++ b/lambdas/core-notifier-lambda/src/app/notify-api-client.ts @@ -0,0 +1,119 @@ +import axios, { AxiosInstance, isAxiosError } from 'axios'; +import type { AxiosError } from 'axios'; +import type { Readable } from 'node:stream'; +import { constants as HTTP2_CONSTANTS } from 'node:http2'; +import type { + SingleMessageErrorResponse, + SingleMessageRequest, + SingleMessageResponse, +} from 'domain/request'; +import { RequestAlreadyReceivedError } from 'domain/request-already-received-error'; +import { RetryConfig, conditionalRetry } from 'utils'; +import type { Logger } from 'utils'; +import { RequestNotifyError } from 'domain/request-notify-error'; + +export interface IAccessTokenRepository { + getAccessToken(): Promise; +} + +export type Response = { + data: Readable; +}; + +export interface INotifyClient { + sendRequest( + apiRequest: SingleMessageRequest, + correlationId?: string, + ): Promise; +} +/* + * Client for sending requests to the NHS Notify API see + * https://digital.nhs.uk/developer/api-catalogue/nhs-notify + */ +export class NotifyClient implements INotifyClient { + private client: AxiosInstance; + + constructor( + private apimBaseUrl: string, + private accessTokenRepository: IAccessTokenRepository, + private logger: Logger, + private backoffConfig: RetryConfig = { + maxDelayMs: 10_000, + intervalMs: 1000, + exponentialRate: 2, + maxAttempts: 10, + }, + ) { + this.client = axios.create({ + baseURL: this.apimBaseUrl, + }); + } + + public async sendRequest( + apiRequest: SingleMessageRequest, + correlationId: string, + ): Promise { + try { + return await conditionalRetry( + async (attempt) => { + const accessToken = await this.accessTokenRepository.getAccessToken(); + + this.logger.debug({ + correlationId, + description: 'Sending request', + attempt, + }); + + const headers = { + 'Content-Type': 'application/json', + 'X-Correlation-ID': correlationId, + ...(accessToken === '' + ? {} + : { + Authorization: `Bearer ${accessToken}`, + }), + }; + const response = await this.client.post( + '/comms/v1/messages', + apiRequest, + { headers }, + ); + + return response.data; + }, + (err) => + Boolean( + isAxiosError(err) && + err.response?.status === + HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS, + ), + this.backoffConfig, + ); + } catch (error: any) { + this.logger.error({ + description: 'Failed sending request to Notify', + err: error, + }); + + if (isAxiosError(error)) { + const axiosError: AxiosError = error; + if ( + axiosError.response?.status === + HTTP2_CONSTANTS.HTTP_STATUS_UNPROCESSABLE_ENTITY + ) { + throw new RequestAlreadyReceivedError(error, correlationId); + } else { + const errorBody: SingleMessageErrorResponse = axiosError.response + ?.data as SingleMessageErrorResponse; + throw new RequestNotifyError( + error, + correlationId, + errorBody?.errors[0].code, + ); + } + } + + throw error; + } + } +} diff --git a/lambdas/core-notifier-lambda/src/app/notify-message-processor.ts b/lambdas/core-notifier-lambda/src/app/notify-message-processor.ts new file mode 100644 index 000000000..6f92e7478 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/app/notify-message-processor.ts @@ -0,0 +1,69 @@ +import { Logger } from 'utils/logger'; +import type { NotifyClient } from 'app/notify-api-client'; +import type { SingleMessageRequest } from 'domain/request'; +import { RequestAlreadyReceivedError } from 'domain/request-already-received-error'; + +type Dependencies = { + nhsNotifyClient: NotifyClient; + logger: Logger; +}; + +export class NotifyMessageProcessor { + private readonly logger: Logger; + + private readonly nhsNotifyClient: NotifyClient; + + constructor({ logger, nhsNotifyClient }: Dependencies) { + this.logger = logger; + this.nhsNotifyClient = nhsNotifyClient; + } + + /** + * + * @param payload the single message request + * @returns the ID returned by Core Notify for a succesful response. + */ + public async process( + payload: SingleMessageRequest, + senderId: string, + ): Promise { + const { messageReference } = payload.data.attributes; + + this.logger.info({ + description: 'Sending request to Notify API', + messageReference, + senderId, + }); + try { + const response = await this.nhsNotifyClient.sendRequest( + payload, + messageReference, + ); + const messageItemId = response.data.id; + this.logger.info({ + description: 'Successfully processed request and sent to Notify', + messageReference, + senderId, + messageItemId, + }); + return messageItemId; + } catch (error: any) { + if (error instanceof RequestAlreadyReceivedError) { + this.logger.info({ + description: 'Request has already been received by Notify', + messageReference, + senderId, + }); + throw error; + } + + this.logger.error({ + description: 'Failed sending request to Notify API', + messageReference, + senderId, + error: error.message, + }); + throw error; + } + } +} diff --git a/lambdas/core-notifier-lambda/src/app/parse-sqs-message.ts b/lambdas/core-notifier-lambda/src/app/parse-sqs-message.ts new file mode 100644 index 000000000..0bb3aa1d1 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/app/parse-sqs-message.ts @@ -0,0 +1,38 @@ +import { SQSRecord } from 'aws-lambda'; +import { Logger } from 'utils'; +import { PDMResourceAvailable } from 'digital-letters-events'; +import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event'; +import messagePDMResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js'; + +export const parseSqsRecord = ( + sqsRecord: SQSRecord, + logger: Logger, +): PDMResourceAvailable => { + logger.info({ + description: 'Parsing SQS Record', + messageId: sqsRecord.messageId, + }); + + const sqsEventBody = JSON.parse(sqsRecord.body); + const sqsEventDetail = sqsEventBody.detail; + + if (!messagePDMResourceAvailableValidator(sqsEventDetail)) { + logger.error({ + error: messagePDMResourceAvailableValidator.errors, + description: + 'The SQS message does not contain a valid PDMResourceAvailable event', + messageId: sqsRecord.messageId, + }); + throw new InvalidPdmResourceAvailableEvent(sqsRecord.messageId); + } + + logger.info({ + description: 'Parsed valid PDMResourceAvailable Event', + messageId: sqsRecord.messageId, + messageReference: sqsEventDetail.data.messageReference, + senderId: sqsEventDetail.data.senderId, + resourceId: sqsEventDetail.data.resourceId, + }); + + return sqsEventDetail; +}; diff --git a/lambdas/core-notifier-lambda/src/container.ts b/lambdas/core-notifier-lambda/src/container.ts new file mode 100644 index 000000000..6dfadfed9 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/container.ts @@ -0,0 +1,57 @@ +import { + EventPublisher, + ParameterStoreCache, + createGetApimAccessToken, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; +import { NotifyClient } from 'app/notify-api-client'; +import { NotifyMessageProcessor } from 'app/notify-message-processor'; +import type { SqsHandlerDependencies } from 'apis/sqs-handler'; +import { loadConfig } from 'infra/config'; +import { SenderManagement } from 'sender-management'; + +export async function createContainer(): Promise { + const parameterStore = new ParameterStoreCache(); + const config = loadConfig(); + const senderManagement = SenderManagement({ + parameterStore, + }); + + const accessTokenRepository = { + getAccessToken: createGetApimAccessToken( + config.apimAccessTokenSsmParameterName, + logger, + parameterStore, + ), + }; + + const notifyClient = new NotifyClient( + config.apimBaseUrl, + accessTokenRepository, + logger, + ); + + const notifyMessageProcessor = new NotifyMessageProcessor({ + nhsNotifyClient: notifyClient, + logger, + }); + + const { eventPublisherDlqUrl, eventPublisherEventBusArn } = config; + + const eventPublisher = new EventPublisher({ + eventBusArn: eventPublisherEventBusArn, + dlqUrl: eventPublisherDlqUrl, + logger, + sqsClient, + eventBridgeClient, + }); + + return { + logger, + notifyMessageProcessor, + senderManagement, + eventPublisher, + }; +} diff --git a/lambdas/core-notifier-lambda/src/domain/invalid-pdm-resource-available-event.ts b/lambdas/core-notifier-lambda/src/domain/invalid-pdm-resource-available-event.ts new file mode 100644 index 000000000..f803a3f9a --- /dev/null +++ b/lambdas/core-notifier-lambda/src/domain/invalid-pdm-resource-available-event.ts @@ -0,0 +1,8 @@ +export class InvalidPdmResourceAvailableEvent extends Error { + readonly sqsMessageId: string; + + constructor(sqsMessageId: string) { + super('Unable to parse PDMResourceAvailable event from SQS message'); + this.sqsMessageId = sqsMessageId; + } +} diff --git a/lambdas/core-notifier-lambda/src/domain/mapper.ts b/lambdas/core-notifier-lambda/src/domain/mapper.ts new file mode 100644 index 000000000..61455bede --- /dev/null +++ b/lambdas/core-notifier-lambda/src/domain/mapper.ts @@ -0,0 +1,122 @@ +import { Sender, logger } from 'utils'; +import { randomUUID } from 'node:crypto'; +import { + MessageRequestRejected, + MessageRequestSkipped, + MessageRequestSubmitted, + PDMResourceAvailable, +} from 'digital-letters-events'; +import type { SingleMessageRequest } from 'domain/request'; + +const DIGITAL_LETTER_URL = + 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid='; + +export function mapPdmEventToSingleMessageRequest( + pdmResourceAvailable: PDMResourceAvailable, + sender: Sender, +): SingleMessageRequest { + const { data } = pdmResourceAvailable; + const { messageReference } = data; + + logger.info({ + description: 'Mapping resource available', + messageReference, + senderId: sender.senderId, + }); + + const request: SingleMessageRequest = { + data: { + type: 'Message', + attributes: { + routingPlanId: sender.routingConfigId!, + messageReference, + billingReference: sender.senderId, + recipient: { + nhsNumber: data.nhsNumber, + }, + originator: { + odsCode: data.odsCode, + }, + personalisation: { + digitalLetterURL: `${DIGITAL_LETTER_URL}${data.resourceId}`, + }, + }, + }, + }; + return request; +} + +export function mapPdmEventToMessageRequestSubmitted( + pdmResourceAvailable: PDMResourceAvailable, + sender: Sender, + notifyId: string, +): MessageRequestSubmitted { + const { data } = pdmResourceAvailable; + const { messageReference } = data; + + return { + ...pdmResourceAvailable, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.messages.request.submitted.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-submitted-data.schema.json', + source: pdmResourceAvailable.source.replace(/\/pdm$/, '/messages'), + data: { + messageReference, + senderId: sender.senderId, + notifyId, + messageUri: `${DIGITAL_LETTER_URL}${data.resourceId}`, + }, + }; +} + +export function mapPdmEventToMessageRequestSkipped( + pdmResourceAvailable: PDMResourceAvailable, + sender: Sender, +): MessageRequestSkipped { + const { data } = pdmResourceAvailable; + const { messageReference } = data; + + return { + ...pdmResourceAvailable, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.messages.request.skipped.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-skipped-data.schema.json', + source: pdmResourceAvailable.source.replace(/\/pdm$/, '/messages'), + data: { + messageReference, + senderId: sender.senderId, + }, + }; +} + +export function mapPdmEventToMessageRequestRejected( + pdmResourceAvailable: PDMResourceAvailable, + sender: Sender, + notifyFailureCode: string, +): MessageRequestRejected { + const { data } = pdmResourceAvailable; + const { messageReference } = data; + + return { + ...pdmResourceAvailable, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.messages.request.rejected.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json', + source: pdmResourceAvailable.source.replace(/\/pdm$/, '/messages'), + data: { + messageReference, + senderId: sender.senderId, + failureCode: notifyFailureCode, + messageUri: `${DIGITAL_LETTER_URL}${data.resourceId}`, + }, + }; +} diff --git a/lambdas/core-notifier-lambda/src/domain/request-already-received-error.ts b/lambdas/core-notifier-lambda/src/domain/request-already-received-error.ts new file mode 100644 index 000000000..d4ebaff0b --- /dev/null +++ b/lambdas/core-notifier-lambda/src/domain/request-already-received-error.ts @@ -0,0 +1,12 @@ +export class RequestAlreadyReceivedError extends Error { + readonly cause: Error; + + readonly correlationId: string; + + constructor(cause: Error, correlationId: string) { + super('The request has already been received.'); + + this.cause = cause; + this.correlationId = correlationId; + } +} diff --git a/lambdas/core-notifier-lambda/src/domain/request-notify-error.ts b/lambdas/core-notifier-lambda/src/domain/request-notify-error.ts new file mode 100644 index 000000000..98d0fdf98 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/domain/request-notify-error.ts @@ -0,0 +1,19 @@ +/** + * To represent an HTTP status not 2xx returned from Notify API when sending a Single Message Request. + * Note that 429 and 422 are handled separately in the Notify API client. + */ +export class RequestNotifyError extends Error { + readonly cause: Error; + + readonly correlationId: string; + + readonly errorCode: string; + + constructor(cause: Error, correlationId: string, errorCode: string) { + super('Error received from Core Notify API'); + + this.cause = cause; + this.correlationId = correlationId; + this.errorCode = errorCode; + } +} diff --git a/lambdas/core-notifier-lambda/src/domain/request.ts b/lambdas/core-notifier-lambda/src/domain/request.ts new file mode 100644 index 000000000..b5860b5d8 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/domain/request.ts @@ -0,0 +1,51 @@ +export type SingleMessageRequest = { + data: { + type: string; + attributes: { + routingPlanId: string; + messageReference: string; + billingReference: string; + recipient: { + nhsNumber: string; + }; + originator: { + odsCode: string; + }; + personalisation: { + digitalLetterURL: string; + }; + }; + }; +}; + +export type SingleMessageResponse = { + data: { + type: string; + id: string; + attributes: { + messageReference: string; + messageStatus: string; + timestamps: { + created: string; + }; + routingPlan: { + id: string; + version: string; + createdDate: string; + name: string; + }; + }; + links: { + self: string; + }; + }; +}; + +export type SingleMessageErrorResponse = { + errors: [ + { + id: string; + code: string; + }, + ]; +}; diff --git a/lambdas/core-notifier-lambda/src/index.ts b/lambdas/core-notifier-lambda/src/index.ts new file mode 100644 index 000000000..655bf5054 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/index.ts @@ -0,0 +1,10 @@ +// This is a Lambda entrypoint file. +import type { SQSEvent } from 'aws-lambda'; +import { createContainer } from 'container'; +import { createHandler as createSqsHandler } from 'apis/sqs-handler'; + +export const handler = async (sqsEvent: SQSEvent) => { + const container = await createContainer(); + const sqsHandler = createSqsHandler(container); + return sqsHandler(sqsEvent); +}; diff --git a/lambdas/core-notifier-lambda/src/infra/config.ts b/lambdas/core-notifier-lambda/src/infra/config.ts new file mode 100644 index 000000000..745b63793 --- /dev/null +++ b/lambdas/core-notifier-lambda/src/infra/config.ts @@ -0,0 +1,25 @@ +import { defaultConfigReader } from 'utils'; + +export type NotifySendMessageConfig = { + eventPublisherEventBusArn: string; + eventPublisherDlqUrl: string; + apimAccessTokenSsmParameterName: string; + apimBaseUrl: string; + environment: string; +}; + +export function loadConfig(): NotifySendMessageConfig { + return { + eventPublisherEventBusArn: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ), + eventPublisherDlqUrl: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_DLQ_URL', + ), + apimAccessTokenSsmParameterName: defaultConfigReader.getValue( + 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', + ), + apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'), + environment: defaultConfigReader.getValue('ENVIRONMENT'), + }; +} diff --git a/lambdas/core-notifier-lambda/tsconfig.json b/lambdas/core-notifier-lambda/tsconfig.json new file mode 100644 index 000000000..f7bcaa1ff --- /dev/null +++ b/lambdas/core-notifier-lambda/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./src/", + "isolatedModules": true + }, + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index dad97a7b7..07ad56f2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", "lambdas/pdm-uploader-lambda", + "lambdas/core-notifier-lambda", "utils/utils", "utils/sender-management", "src/cloudevents", @@ -60,6 +61,100 @@ "typescript-eslint": "^8.46.1" } }, + "lambdas/core-notifier": { + "name": "nhs-notify-digital-core-notifier", + "version": "0.0.1", + "extraneous": true, + "dependencies": { + "aws-lambda": "^1.0.7", + "axios": "1.10.0", + "digital-letters-events": "^0.0.1", + "sender-management": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + } + }, + "lambdas/core-notifier-lambda": { + "name": "nhs-notify-digital-core-notifier-lambda", + "version": "0.0.1", + "dependencies": { + "aws-lambda": "^1.0.7", + "axios": "^1.13.2", + "digital-letters-events": "^0.0.1", + "sender-management": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@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/core-notifier-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": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "lambdas/core-notifier-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", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/core-notifier-lambda/node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "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/key-generation": { "version": "0.0.1", "dependencies": { @@ -12241,6 +12336,10 @@ "dev": true, "license": "MIT" }, + "node_modules/nhs-notify-digital-core-notifier-lambda": { + "resolved": "lambdas/core-notifier-lambda", + "link": true + }, "node_modules/nhs-notify-digital-letters-integration-tests": { "resolved": "tests/playwright", "link": true diff --git a/package.json b/package.json index 5208ebc85..4e4c5e124 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", "lambdas/pdm-uploader-lambda", + "lambdas/core-notifier-lambda", "utils/utils", "utils/sender-management", "src/cloudevents", diff --git a/tests/playwright/config/component/senders.setup.ts b/tests/playwright/config/component/senders.setup.ts index 68e5aa48d..43fb2addb 100644 --- a/tests/playwright/config/component/senders.setup.ts +++ b/tests/playwright/config/component/senders.setup.ts @@ -1,15 +1,36 @@ import { test as setup } from '@playwright/test'; import senderRepository from 'helpers/sender-helpers'; import { Sender } from 'utils'; +import { + SENDER_ID_SKIPS_NOTIFY, + SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX, + SENDER_ID_VALID_FOR_NOTIFY_SANDBOX, +} from 'constants/tests-constants'; const testSenders: Sender[] = [ { - senderId: 'test-sender-1', + senderId: SENDER_ID_SKIPS_NOTIFY, senderName: 'Test Sender 1', meshMailboxSenderId: 'test-mesh-sender-1', meshMailboxReportsId: 'test-mesh-reports-1', fallbackWaitTimeSeconds: 24 * 3600, }, + { + senderId: SENDER_ID_VALID_FOR_NOTIFY_SANDBOX, + senderName: 'componentTestSender_RoutingConfig', + meshMailboxSenderId: 'meshMailboxSender1', + meshMailboxReportsId: 'meshMailboxReports1', + routingConfigId: 'b838b13c-f98c-4def-93f0-515d4e4f4ee1', + fallbackWaitTimeSeconds: 100, + }, + { + senderId: SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX, + senderName: 'componentTestSender_RoutingConfigInvalid', + meshMailboxSenderId: 'meshMailboxSender2', + meshMailboxReportsId: 'meshMailboxReports2', + routingConfigId: 'invalid', + fallbackWaitTimeSeconds: 100, + }, ]; setup('Create senders', async () => { diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index fd2fae572..0af36d564 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -11,12 +11,14 @@ export const CSI = `nhs-${ENV}-dl`; export const MESH_POLL_LAMBDA_NAME = `${CSI}-mesh-poll`; export const TTL_CREATE_LAMBDA_NAME = `${CSI}-ttl-create`; export const TTL_POLL_LAMBDA_NAME = `${CSI}-ttl-poll`; +export const CORE_NOTIFIER_LAMBDA_NAME = `${CSI}-core-notifier`; // Queue Names export const TTL_QUEUE_NAME = `${CSI}-ttl-queue`; export const TTL_DLQ_NAME = `${CSI}-ttl-dlq`; export const PDM_UPLOADER_DLQ_NAME = `${CSI}-pdm-uploader-dlq`; export const PDM_POLL_DLQ_NAME = `${CSI}-pdm-poll-dlq`; +export const CORE_NOTIFIER_DLQ_NAME = `${CSI}-core-notifier-dlq`; // Queue Url Prefix export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`; @@ -35,3 +37,4 @@ export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGIO // Cloudwatch export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`; export const PDM_POLL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-poll`; +export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-notifier`; diff --git a/tests/playwright/constants/tests-constants.ts b/tests/playwright/constants/tests-constants.ts new file mode 100644 index 000000000..3936dc85b --- /dev/null +++ b/tests/playwright/constants/tests-constants.ts @@ -0,0 +1,6 @@ +// senderIds +export const SENDER_ID_VALID_FOR_NOTIFY_SANDBOX = + 'componentTestSender_RoutingConfig'; +export const SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX = + 'componentTestSender_RoutingConfigInvalid'; +export const SENDER_ID_SKIPS_NOTIFY = 'test-sender-1'; diff --git a/tests/playwright/digital-letters-component-tests/core-notify.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-notify.component.spec.ts new file mode 100644 index 000000000..af2d59703 --- /dev/null +++ b/tests/playwright/digital-letters-component-tests/core-notify.component.spec.ts @@ -0,0 +1,224 @@ +import { expect, test } from '@playwright/test'; +import { + CORE_NOTIFIER_DLQ_NAME, + CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME, + EVENT_BUS_LOG_GROUP_NAME, +} from 'constants/backend-constants'; +import { + SENDER_ID_SKIPS_NOTIFY, + SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX, + SENDER_ID_VALID_FOR_NOTIFY_SANDBOX, +} from 'constants/tests-constants'; +import { PDMResourceAvailable } from 'digital-letters-events'; +import messagePDMResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-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'; + +const baseEvent: Omit = { + specversion: '1.0', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/pdm', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + time: '2023-06-20T12:00:00Z', + 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-pdm-resource-available-data.schema.json', + severitytext: 'INFO', +}; + +test.describe('Digital Letters - Core Notify', () => { + test.beforeAll(async () => { + await purgeQueue(CORE_NOTIFIER_DLQ_NAME); + test.setTimeout(250_000); + }); + + test.afterAll(async () => { + await purgeQueue(CORE_NOTIFIER_DLQ_NAME); + }); + + test('given PDMResourceAvailable event, when client has routingConfigId then a message is sent to core Notify', async () => { + const eventId = uuidv4(); + const messageReference = uuidv4(); + const resourceId = 'resource-222'; + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + data: { + messageReference, + senderId: SENDER_ID_VALID_FOR_NOTIFY_SANDBOX, + resourceId, + nhsNumber: '9990548609', + odsCode: 'A12345', + }, + }, + ], + messagePDMResourceAvailableValidator, + ); + + // Verify the event is processed and a message appears in the Lambda logs + await expectToPassEventually(async () => { + const filteredLogs = await getLogsFromCloudwatch( + CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "Successfully processed request and sent to Notify"', + `$.message.messageReference = "${messageReference}"`, + ], + ); + + expect(filteredLogs.length).toEqual(1); + }, 240); + + // Verify the event is published in the event bus + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.messages.request.submitted.v1"', + `$.details.event_detail = "*\\"notifyId\\":\\"*\\"*"`, + `$.details.event_detail = "*\\"messageUri\\":\\"https://www.nhsapp.service.nhs.uk/digital-letters?letterid=${resourceId}\\"*"`, + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_VALID_FOR_NOTIFY_SANDBOX}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + }); + + test('given PDMResourceAvailable event with a client configured with a Routing plan not recognized by the Core Notify sandbox, when the sandbox receives the event then it replies with an error', async () => { + const eventId = uuidv4(); + const messageReference = uuidv4(); + const resourceId = 'resource-999'; + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + data: { + messageReference, + senderId: SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX, + resourceId, + nhsNumber: '9434765919', + odsCode: 'A12345', + }, + }, + ], + messagePDMResourceAvailableValidator, + ); + + // Verify the event is processed and a message appears in the Lambda logs + await expectToPassEventually(async () => { + const filteredLogs = await getLogsFromCloudwatch( + CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "Failed sending request to Notify API"', + `$.message.messageReference = "${messageReference}"`, + ], + ); + + expect(filteredLogs.length).toEqual(1); + }, 240); + + // Verify the event is published in the event bus + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.messages.request.rejected.v1"', + `$.details.event_detail = "*\\"failureCode\\":\\"CM_INVALID_VALUE\\"*"`, + `$.details.event_detail = "*\\"messageUri\\":\\"https://www.nhsapp.service.nhs.uk/digital-letters?letterid=${resourceId}\\"*"`, + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + }); + + test('given PDMResourceAvailable event, when client does NOT have routingConfigId then a message is NOT sent to core Notify', async () => { + const eventId = uuidv4(); + const messageReference = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + data: { + messageReference, + senderId: SENDER_ID_SKIPS_NOTIFY, + resourceId: 'resource-7777', + nhsNumber: '9990548609', + odsCode: 'A12345', + }, + }, + ], + messagePDMResourceAvailableValidator, + ); + + // Verify the event is published in the event bus + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.messages.request.skipped.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_SKIPS_NOTIFY}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + }); + + test('given PDMResourceAvailable event, when client does NOT exist then it goes to DLQ', async () => { + test.setTimeout(550_000); + const eventId = uuidv4(); + const messageReference = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + data: { + messageReference, + senderId: 'senderId_that_does_not_exist', + resourceId: 'resource-1234', + nhsNumber: '9990548609', + odsCode: 'A12345', + }, + }, + ], + messagePDMResourceAvailableValidator, + ); + + // Verify the event is processed and a message appears in the Lambda logs + await expectToPassEventually(async () => { + const filteredLogs = await getLogsFromCloudwatch( + CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME, + ['$.message.description = "0 of 1 records processed successfully"'], + ); + + expect(filteredLogs.length).toBeGreaterThanOrEqual(1); + }, 240); + // Verify there is a message in the DLQ + await expectMessageContainingString(CORE_NOTIFIER_DLQ_NAME, eventId, 420); + }); +}); diff --git a/utils/sender-management/README.md b/utils/sender-management/README.md index 7a71c952e..9b6f4e125 100644 --- a/utils/sender-management/README.md +++ b/utils/sender-management/README.md @@ -14,12 +14,12 @@ npm --prefix utils/sender-management run-script cli -- [options] ### Library Usage -Install the package as `@sender-management` +Install the package as `sender-management` Instantiate an instance of the library as follows. The library should take an implementation of an `IParameterStore` to define how the library will interact with SSM (e.g. caching vs non-caching). ```ts -import { SenderManagement } from '@sender-management'; +import { SenderManagement } from 'sender-management'; const sm = SenderManagement({ parameterStore: new ParameterStore() }); ```