From aac68f4830a32145f69b516e39fb249e5d4566cf Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 8 Dec 2025 15:06:06 +0000 Subject: [PATCH 01/38] CCM-12614: add basic lambda function --- .../terraform/components/dl/README.md | 1 + .../components/dl/module_lambda_poll_pdm.tf | 73 +++++++++++++++++ lambdas/poll-pdm-lambda/jest.config.ts | 5 ++ lambdas/poll-pdm-lambda/package.json | 29 +++++++ .../src/__tests__/apis/sqs-handler.test.ts | 73 +++++++++++++++++ .../src/__tests__/container.test.ts | 26 ++++++ .../src/__tests__/index.test.ts | 15 ++++ .../src/__tests__/infra/config.test.ts | 15 ++++ .../poll-pdm-lambda/src/apis/sqs-handler.ts | 44 +++++++++++ lambdas/poll-pdm-lambda/src/container.ts | 8 ++ lambdas/poll-pdm-lambda/src/index.ts | 6 ++ lambdas/poll-pdm-lambda/src/infra/config.ts | 23 ++++++ lambdas/poll-pdm-lambda/tsconfig.json | 11 +++ package-lock.json | 79 +++++++++++++++++++ package.json | 1 + 15 files changed, 409 insertions(+) create mode 100644 infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf create mode 100644 lambdas/poll-pdm-lambda/jest.config.ts create mode 100644 lambdas/poll-pdm-lambda/package.json create mode 100644 lambdas/poll-pdm-lambda/src/__tests__/apis/sqs-handler.test.ts create mode 100644 lambdas/poll-pdm-lambda/src/__tests__/container.test.ts create mode 100644 lambdas/poll-pdm-lambda/src/__tests__/index.test.ts create mode 100644 lambdas/poll-pdm-lambda/src/__tests__/infra/config.test.ts create mode 100644 lambdas/poll-pdm-lambda/src/apis/sqs-handler.ts create mode 100644 lambdas/poll-pdm-lambda/src/container.ts create mode 100644 lambdas/poll-pdm-lambda/src/index.ts create mode 100644 lambdas/poll-pdm-lambda/src/infra/config.ts create mode 100644 lambdas/poll-pdm-lambda/tsconfig.json diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index db9e38e7..5439e295 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -40,6 +40,7 @@ No requirements. | [lambda\_apim\_key\_generation](#module\_lambda\_apim\_key\_generation) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/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.24/terraform-lambda.zip | n/a | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [poll\_pdm](#module\_poll\_pdm) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | 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 | diff --git a/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf b/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf new file mode 100644 index 00000000..99340885 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf @@ -0,0 +1,73 @@ +module "poll_pdm" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" + + function_name = "poll-pdm" + description = "A function for polling PDM document status" + + 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.poll_pdm_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 = "poll-pdm-lambda/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 128 + timeout = 360 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + send_to_firehose = true + log_destination_arn = local.log_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + "APIM_BASE_URL" = var.apim_base_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 + } +} + +data "aws_iam_policy_document" "poll_pdm_lambda" { + statement { + sid = "PutEvents" + effect = "Allow" + + actions = [ + "events:PutEvents", + ] + + resources = [ + aws_cloudwatch_event_bus.main.arn, + ] + } + + statement { + sid = "SQSPermissionsDLQs" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:SendMessageBatch", + ] + + resources = [ + module.sqs_event_publisher_errors.sqs_queue_arn, + ] + } +} diff --git a/lambdas/poll-pdm-lambda/jest.config.ts b/lambdas/poll-pdm-lambda/jest.config.ts new file mode 100644 index 00000000..c02601ae --- /dev/null +++ b/lambdas/poll-pdm-lambda/jest.config.ts @@ -0,0 +1,5 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = baseJestConfig; + +export default config; diff --git a/lambdas/poll-pdm-lambda/package.json b/lambdas/poll-pdm-lambda/package.json new file mode 100644 index 00000000..b56303f3 --- /dev/null +++ b/lambdas/poll-pdm-lambda/package.json @@ -0,0 +1,29 @@ +{ + "dependencies": { + "aws-lambda": "^1.0.7", + "lodash": "^4.17.21", + "p-limit": "^3.1.0", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.20", + "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" + }, + "name": "nhs-notify-digital-letters-poll-pdm-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/poll-pdm-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/poll-pdm-lambda/src/__tests__/apis/sqs-handler.test.ts new file mode 100644 index 00000000..8ad6fd62 --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -0,0 +1,73 @@ +import { createHandler } from 'apis/sqs-handler'; +import { Logger } from 'utils'; +import { SQSEvent, SQSRecord } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; + +const logger = mock(); + +const event = { + sourceEventId: 'test-event-id', +}; + +const sqsRecord1: SQSRecord = { + messageId: '1', + receiptHandle: 'abc', + body: JSON.stringify(event), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '2025-07-03T14:23:30Z', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '2025-07-03T14:23:30Z', + }, + messageAttributes: {}, + md5OfBody: '', + eventSource: 'aws:sqs', + eventSourceARN: '', + awsRegion: '', +}; + +const singleRecordEvent: SQSEvent = { + Records: [sqsRecord1], +}; + +const handler = createHandler({ + logger, +}); + +describe('SQS Handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('processes a single record', async () => { + const response = await handler(singleRecordEvent); + + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + + it('should return failed items to the queue if an error occurs while processing them', async () => { + singleRecordEvent.Records[0].body = 'not-json'; + + const result = await handler(singleRecordEvent); + + expect(logger.warn).toHaveBeenCalledWith({ + error: `Unexpected token 'o', "not-json" is not valid JSON`, + description: 'Failed processing message', + messageId: '1', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); +}); diff --git a/lambdas/poll-pdm-lambda/src/__tests__/container.test.ts b/lambdas/poll-pdm-lambda/src/__tests__/container.test.ts new file mode 100644 index 00000000..e1291f38 --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/__tests__/container.test.ts @@ -0,0 +1,26 @@ +import { createContainer } from 'container'; + +jest.mock('infra/config', () => ({ + loadConfig: jest.fn(() => ({ + apimBaseUrl: 'https://test-apim-url', + apimAccessTokenSsmParameterName: 'test-ssm-parameter-name', + eventPublisherDlqUrl: 'test-url', + eventPublisherEventBusArn: 'test-arn', + })), +})); + +jest.mock('utils', () => ({ + EventPublisher: jest.fn(() => ({})), + eventBridgeClient: {}, + logger: {}, + sqsClient: {}, + ParameterStoreCache: jest.fn(() => ({})), + createGetApimAccessToken: jest.fn(() => ({})), +})); + +describe('container', () => { + it('should create container', () => { + const container = createContainer(); + expect(container).toBeDefined(); + }); +}); diff --git a/lambdas/poll-pdm-lambda/src/__tests__/index.test.ts b/lambdas/poll-pdm-lambda/src/__tests__/index.test.ts new file mode 100644 index 00000000..b5465321 --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/__tests__/index.test.ts @@ -0,0 +1,15 @@ +import { handler } from 'index'; + +jest.mock('apis/sqs-handler', () => ({ + createHandler: jest.fn(() => jest.fn()), +})); + +jest.mock('container', () => ({ + createContainer: jest.fn(() => ({})), +})); + +describe('index', () => { + it('should export handler', () => { + expect(handler).toBeDefined(); + }); +}); diff --git a/lambdas/poll-pdm-lambda/src/__tests__/infra/config.test.ts b/lambdas/poll-pdm-lambda/src/__tests__/infra/config.test.ts new file mode 100644 index 00000000..2902c80f --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/__tests__/infra/config.test.ts @@ -0,0 +1,15 @@ +import { loadConfig } from 'infra/config'; + +jest.mock('utils', () => ({ + defaultConfigReader: { + getValue: jest.fn(), + getInt: jest.fn(), + }, +})); + +describe('config', () => { + it('should load config', () => { + const config = loadConfig(); + expect(config).toBeDefined(); + }); +}); diff --git a/lambdas/poll-pdm-lambda/src/apis/sqs-handler.ts b/lambdas/poll-pdm-lambda/src/apis/sqs-handler.ts new file mode 100644 index 00000000..3dbda2b3 --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/apis/sqs-handler.ts @@ -0,0 +1,44 @@ +import type { + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, + SQSRecord, +} from 'aws-lambda'; +import { Logger } from 'utils'; + +export interface HandlerDependencies { + logger: Logger; +} + +export const createHandler = ({ logger }: HandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const receivedItemCount = sqsEvent.Records.length; + + logger.info(`Received SQS Event of ${receivedItemCount} record(s)`); + + const batchItemFailures: SQSBatchItemFailure[] = []; + + await Promise.all( + sqsEvent.Records.map(async (sqsRecord: SQSRecord) => { + try { + logger.info({ + event: JSON.parse(sqsRecord.body), + }); + } catch (error: any) { + logger.warn({ + error: error.message, + description: 'Failed processing message', + messageId: sqsRecord.messageId, + }); + batchItemFailures.push({ itemIdentifier: sqsRecord.messageId }); + } + }), + ); + + const processedItemCount = receivedItemCount - batchItemFailures.length; + logger.info( + `${processedItemCount} of ${receivedItemCount} records processed successfully`, + ); + + return { batchItemFailures }; + }; diff --git a/lambdas/poll-pdm-lambda/src/container.ts b/lambdas/poll-pdm-lambda/src/container.ts new file mode 100644 index 00000000..5989e0b2 --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/container.ts @@ -0,0 +1,8 @@ +import { logger } from 'utils'; +import { HandlerDependencies } from 'apis/sqs-handler'; + +export const createContainer = (): HandlerDependencies => { + return { logger }; +}; + +export default createContainer; diff --git a/lambdas/poll-pdm-lambda/src/index.ts b/lambdas/poll-pdm-lambda/src/index.ts new file mode 100644 index 00000000..f25a8086 --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/index.ts @@ -0,0 +1,6 @@ +import { createHandler } from 'apis/sqs-handler'; +import { createContainer } from 'container'; + +export const handler = createHandler(createContainer()); + +export default handler; diff --git a/lambdas/poll-pdm-lambda/src/infra/config.ts b/lambdas/poll-pdm-lambda/src/infra/config.ts new file mode 100644 index 00000000..2005ccdf --- /dev/null +++ b/lambdas/poll-pdm-lambda/src/infra/config.ts @@ -0,0 +1,23 @@ +import { defaultConfigReader } from 'utils'; + +export type PdmCreateConfig = { + apimBaseUrl: string; + apimAccessTokenSsmParameterName: string; + eventPublisherEventBusArn: string; + eventPublisherDlqUrl: string; +}; + +export function loadConfig(): PdmCreateConfig { + return { + apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'), + apimAccessTokenSsmParameterName: defaultConfigReader.getValue( + 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', + ), + eventPublisherEventBusArn: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ), + eventPublisherDlqUrl: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_DLQ_URL', + ), + }; +} diff --git a/lambdas/poll-pdm-lambda/tsconfig.json b/lambdas/poll-pdm-lambda/tsconfig.json new file mode 100644 index 00000000..f7bcaa1f --- /dev/null +++ b/lambdas/poll-pdm-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 d8dc228c..d268c0a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "lambdas/key-generation", "lambdas/refresh-apim-access-token", "lambdas/mesh-poll", + "lambdas/poll-pdm-lambda", "lambdas/ttl-create-lambda", "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", @@ -199,6 +200,80 @@ } } }, + "lambdas/poll-pdm-lambda": { + "name": "nhs-notify-digital-letters-poll-pdm-lambda", + "version": "0.0.1", + "dependencies": { + "aws-lambda": "^1.0.7", + "lodash": "^4.17.21", + "p-limit": "^3.1.0", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.20", + "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/poll-pdm-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/poll-pdm-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/poll-pdm-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/refresh-apim-access-token": { "version": "0.0.1", "dependencies": { @@ -14630,6 +14705,10 @@ "resolved": "lambdas/mesh-poll", "link": true }, + "node_modules/nhs-notify-digital-letters-poll-pdm-lambda": { + "resolved": "lambdas/poll-pdm-lambda", + "link": true + }, "node_modules/nhs-notify-digital-letters-ttl-create-lambda": { "resolved": "lambdas/ttl-create-lambda", "link": true diff --git a/package.json b/package.json index df3f7110..89118a9b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "lambdas/key-generation", "lambdas/refresh-apim-access-token", "lambdas/mesh-poll", + "lambdas/poll-pdm-lambda", "lambdas/ttl-create-lambda", "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", From e56bd7338dea4a48ef53233f67ad5e70fe5a38d3 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 9 Dec 2025 16:37:44 +0000 Subject: [PATCH 02/38] CCM-12614: add sqs queue and rule --- .../terraform/components/dl/README.md | 1 + ...watch_event_rule_pdm_resource_submitted.tf | 20 +++++++++++ ...da_event_source_mapping_poll_pdm_lambda.tf | 10 ++++++ .../components/dl/module_lambda_poll_pdm.tf | 17 ++++++++- .../components/dl/module_sqs_poll_pdm.tf | 35 +++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf create mode 100644 infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf create mode 100644 infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 5439e295..7a859366 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -45,6 +45,7 @@ No requirements. | [s3bucket\_letters](#module\_s3bucket\_letters) | 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\_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\_poll\_pdm](#module\_sqs\_poll\_pdm) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf new file mode 100644 index 00000000..d539cc5a --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf @@ -0,0 +1,20 @@ +resource "aws_cloudwatch_event_rule" "pdm_resource_submitted" { + name = "${local.csi}-pdm-resource-submitted" + description = "PDM resource submitted event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.digital.letters.pdm.resource.submitted.v1" + ] + } + }) +} + +resource "aws_cloudwatch_event_target" "pdm_resource_submitted" { + rule = aws_cloudwatch_event_rule.pdm_resource_submitted.name + arn = module.sqs_poll_pdm.sqs_queue_arn + target_id = "pdm-resource-submitted-target" + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf new file mode 100644 index 00000000..984e621e --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "poll_pdm_lambda" { + event_source_arn = module.sqs_poll_pdm.sqs_queue_arn + function_name = module.poll_pdm.function_name + batch_size = var.queue_batch_size + maximum_batching_window_in_seconds = var.queue_batch_window_seconds + + function_response_types = [ + "ReportBatchItemFailures" + ] +} diff --git a/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf b/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf index 99340885..99d2735d 100644 --- a/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf +++ b/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf @@ -25,7 +25,7 @@ module "poll_pdm" { handler_function_name = "handler" runtime = "nodejs22.x" memory = 128 - timeout = 360 + timeout = 60 log_level = var.log_level force_lambda_code_deploy = var.force_lambda_code_deploy @@ -70,4 +70,19 @@ data "aws_iam_policy_document" "poll_pdm_lambda" { module.sqs_event_publisher_errors.sqs_queue_arn, ] } + statement { + sid = "SQSPermissionsPollPdmQueue" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ] + + resources = [ + module.sqs_poll_pdm.sqs_queue_arn, + ] + } } diff --git a/infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf b/infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf new file mode 100644 index 00000000..c52b3b91 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf @@ -0,0 +1,35 @@ +module "sqs_poll_pdm" { + 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 = "poll-pdm" + sqs_kms_key_arn = module.kms.key_arn + visibility_timeout_seconds = 60 + delay_seconds = 5 + create_dlq = true + sqs_policy_overload = data.aws_iam_policy_document.sqs_poll_pdm.json +} + +data "aws_iam_policy_document" "sqs_poll_pdm" { + 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}:${var.project}-${var.environment}-${local.component}-poll-pdm-queue" + ] + } +} From 27f344fc65df22676f229d508073c1d66f4ea06b Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 10 Dec 2025 08:44:35 +0000 Subject: [PATCH 03/38] CCM-12614: rename poll-pdm to pdm-poll --- README.md | 3 +++ infrastructure/terraform/components/dl/README.md | 4 ++-- .../cloudwatch_event_rule_pdm_resource_submitted.tf | 2 +- ...> lambda_event_source_mapping_pdm_poll_lambda.tf} | 6 +++--- ..._lambda_poll_pdm.tf => module_lambda_pdm_poll.tf} | 12 ++++++------ ...module_sqs_poll_pdm.tf => module_sqs_pdm_poll.tf} | 10 +++++----- .../jest.config.ts | 0 .../package.json | 2 +- .../src/__tests__/apis/sqs-handler.test.ts | 0 .../src/__tests__/container.test.ts | 0 .../src/__tests__/index.test.ts | 0 .../src/__tests__/infra/config.test.ts | 0 .../src/apis/sqs-handler.ts | 0 .../src/container.ts | 0 .../src/index.ts | 0 .../src/infra/config.ts | 0 .../tsconfig.json | 0 package.json | 2 +- 18 files changed, 22 insertions(+), 19 deletions(-) rename infrastructure/terraform/components/dl/{lambda_event_source_mapping_poll_pdm_lambda.tf => lambda_event_source_mapping_pdm_poll_lambda.tf} (52%) rename infrastructure/terraform/components/dl/{module_lambda_poll_pdm.tf => module_lambda_pdm_poll.tf} (89%) rename infrastructure/terraform/components/dl/{module_sqs_poll_pdm.tf => module_sqs_pdm_poll.tf} (82%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/jest.config.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/package.json (93%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/__tests__/apis/sqs-handler.test.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/__tests__/container.test.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/__tests__/index.test.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/__tests__/infra/config.test.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/apis/sqs-handler.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/container.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/index.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/src/infra/config.ts (100%) rename lambdas/{poll-pdm-lambda => pdm-poll-lambda}/tsconfig.json (100%) diff --git a/README.md b/README.md index 198d36d5..b0ae255d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ _No common schemas defined yet._ | **Core.request.submitted Data.v1** | [`src/digital-letters/2025-10-draft/data/core.request.submitted-data.v1.schema.yaml`](src/digital-letters/2025-10-draft/data/core.request.submitted-data.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/core.request.submitted-data.v1.schema.json`](schemas/digital-letters/2025-10-draft/data/core.request.submitted-data.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/core.request.submitted-data.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/core.request.submitted-data.v1.schema.md) | | **Digital Letter Base Data** | [`src/digital-letters/2025-10-draft/data/digital-letter-base-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letter-base-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letter-base-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letter-base-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letter-base-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letter-base-data.schema.md) | | **Digital Letters Digital Letter Read Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-digital-letter-read-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-digital-letter-read-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-digital-letter-read-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-digital-letter-read-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-digital-letter-read-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-digital-letter-read-data.schema.md) | +| **Digital Letters Mesh Inbox Message Acknowledged Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-acknowledged-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-acknowledged-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-acknowledged-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-acknowledged-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-acknowledged-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-acknowledged-data.schema.md) | | **Digital Letters Mesh Inbox Message Downloaded Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.md) | | **Digital Letters Mesh Inbox Message Received Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.md) | | **Digital Letters Message Request Rejected Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.md) | @@ -142,6 +143,7 @@ _No common schemas defined yet._ | **Profile** | [`src/digital-letters/2025-10-draft/digital-letters-reporting-profile.schema.yaml`](src/digital-letters/2025-10-draft/digital-letters-reporting-profile.schema.yaml) | [`schemas/digital-letters/2025-10-draft/digital-letters-reporting-profile.schema.json`](schemas/digital-letters/2025-10-draft/digital-letters-reporting-profile.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/digital-letters-reporting-profile.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/digital-letters-reporting-profile.schema.md) | | **Profile** | [`src/digital-letters/2025-10-draft/digital-letters-viewer-profile.schema.yaml`](src/digital-letters/2025-10-draft/digital-letters-viewer-profile.schema.yaml) | [`schemas/digital-letters/2025-10-draft/digital-letters-viewer-profile.schema.json`](schemas/digital-letters/2025-10-draft/digital-letters-viewer-profile.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/digital-letters-viewer-profile.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/digital-letters-viewer-profile.schema.md) | | **uk.nhs.notify.digital.letters.letter.available.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.letter.available.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.letter.available.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.letter.available.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.letter.available.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.letter.available.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.letter.available.v1.schema.md) | +| **uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1.schema.md) | | **uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1.schema.md) | | **uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1.schema.md) | | **uk.nhs.notify.digital.letters.mesh.report.sent.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.report.sent.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.report.sent.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.report.sent.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.report.sent.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.report.sent.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.mesh.report.sent.v1.schema.md) | @@ -170,6 +172,7 @@ _No common schemas defined yet._ | Event Name | Event Instance | Documentation | | ---------- | -------------- | ------------- | | **Uk.nhs.notify.digital.letters.letter.available.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.letter.available.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.letter.available.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.letter.available.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.letter.available.v1-event.md) | +| **Uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1-event.md) | | **Uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1-event.md) | | **Uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.inbox.message.received.v1-event.md) | | **Uk.nhs.notify.digital.letters.mesh.report.sent.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.report.sent.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.report.sent.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.report.sent.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.mesh.report.sent.v1-event.md) | diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 7a859366..f0a71a17 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -40,12 +40,12 @@ No requirements. | [lambda\_apim\_key\_generation](#module\_lambda\_apim\_key\_generation) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/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.24/terraform-lambda.zip | n/a | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | -| [poll\_pdm](#module\_poll\_pdm) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | 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\_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\_poll\_pdm](#module\_sqs\_poll\_pdm) | 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 | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf index d539cc5a..0ae3c949 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf @@ -14,7 +14,7 @@ resource "aws_cloudwatch_event_rule" "pdm_resource_submitted" { resource "aws_cloudwatch_event_target" "pdm_resource_submitted" { rule = aws_cloudwatch_event_rule.pdm_resource_submitted.name - arn = module.sqs_poll_pdm.sqs_queue_arn + arn = module.sqs_pdm_poll.sqs_queue_arn target_id = "pdm-resource-submitted-target" event_bus_name = aws_cloudwatch_event_bus.main.name } diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf similarity index 52% rename from infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf rename to infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf index 984e621e..acd13036 100644 --- a/infrastructure/terraform/components/dl/lambda_event_source_mapping_poll_pdm_lambda.tf +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf @@ -1,6 +1,6 @@ -resource "aws_lambda_event_source_mapping" "poll_pdm_lambda" { - event_source_arn = module.sqs_poll_pdm.sqs_queue_arn - function_name = module.poll_pdm.function_name +resource "aws_lambda_event_source_mapping" "pdm_poll_lambda" { + event_source_arn = module.sqs_pdm_poll.sqs_queue_arn + function_name = module.pdm_poll.function_name batch_size = var.queue_batch_size maximum_batching_window_in_seconds = var.queue_batch_window_seconds diff --git a/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf similarity index 89% rename from infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf rename to infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf index 99d2735d..75734afd 100644 --- a/infrastructure/terraform/components/dl/module_lambda_poll_pdm.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -1,7 +1,7 @@ -module "poll_pdm" { +module "pdm_poll" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" - function_name = "poll-pdm" + function_name = "pdm-poll" description = "A function for polling PDM document status" aws_account_id = var.aws_account_id @@ -15,12 +15,12 @@ module "poll_pdm" { kms_key_arn = module.kms.key_arn iam_policy_document = { - body = data.aws_iam_policy_document.poll_pdm_lambda.json + body = data.aws_iam_policy_document.pdm_poll_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 = "poll-pdm-lambda/dist" + function_code_dir = "pdm-poll-lambda/dist" function_include_common = true handler_function_name = "handler" runtime = "nodejs22.x" @@ -43,7 +43,7 @@ module "poll_pdm" { } } -data "aws_iam_policy_document" "poll_pdm_lambda" { +data "aws_iam_policy_document" "pdm_poll_lambda" { statement { sid = "PutEvents" effect = "Allow" @@ -82,7 +82,7 @@ data "aws_iam_policy_document" "poll_pdm_lambda" { ] resources = [ - module.sqs_poll_pdm.sqs_queue_arn, + module.sqs_pdm_poll.sqs_queue_arn, ] } } diff --git a/infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf similarity index 82% rename from infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf rename to infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf index c52b3b91..ffe157a3 100644 --- a/infrastructure/terraform/components/dl/module_sqs_poll_pdm.tf +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf @@ -1,4 +1,4 @@ -module "sqs_poll_pdm" { +module "sqs_pdm_poll" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" aws_account_id = var.aws_account_id @@ -6,15 +6,15 @@ module "sqs_poll_pdm" { environment = var.environment project = var.project region = var.region - name = "poll-pdm" + name = "pdm-poll" sqs_kms_key_arn = module.kms.key_arn visibility_timeout_seconds = 60 delay_seconds = 5 create_dlq = true - sqs_policy_overload = data.aws_iam_policy_document.sqs_poll_pdm.json + sqs_policy_overload = data.aws_iam_policy_document.sqs_pdm_poll.json } -data "aws_iam_policy_document" "sqs_poll_pdm" { +data "aws_iam_policy_document" "sqs_pdm_poll" { statement { sid = "AllowEventBridgeToSendMessage" effect = "Allow" @@ -29,7 +29,7 @@ data "aws_iam_policy_document" "sqs_poll_pdm" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-poll-pdm-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-pdm-poll-queue" ] } } diff --git a/lambdas/poll-pdm-lambda/jest.config.ts b/lambdas/pdm-poll-lambda/jest.config.ts similarity index 100% rename from lambdas/poll-pdm-lambda/jest.config.ts rename to lambdas/pdm-poll-lambda/jest.config.ts diff --git a/lambdas/poll-pdm-lambda/package.json b/lambdas/pdm-poll-lambda/package.json similarity index 93% rename from lambdas/poll-pdm-lambda/package.json rename to lambdas/pdm-poll-lambda/package.json index b56303f3..22c0b147 100644 --- a/lambdas/poll-pdm-lambda/package.json +++ b/lambdas/pdm-poll-lambda/package.json @@ -16,7 +16,7 @@ "jest-mock-extended": "^3.0.7", "typescript": "^5.9.3" }, - "name": "nhs-notify-digital-letters-poll-pdm-lambda", + "name": "nhs-notify-digital-letters-pdm-poll-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", diff --git a/lambdas/poll-pdm-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/__tests__/apis/sqs-handler.test.ts rename to lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts diff --git a/lambdas/poll-pdm-lambda/src/__tests__/container.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/__tests__/container.test.ts rename to lambdas/pdm-poll-lambda/src/__tests__/container.test.ts diff --git a/lambdas/poll-pdm-lambda/src/__tests__/index.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/index.test.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/__tests__/index.test.ts rename to lambdas/pdm-poll-lambda/src/__tests__/index.test.ts diff --git a/lambdas/poll-pdm-lambda/src/__tests__/infra/config.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/infra/config.test.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/__tests__/infra/config.test.ts rename to lambdas/pdm-poll-lambda/src/__tests__/infra/config.test.ts diff --git a/lambdas/poll-pdm-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/apis/sqs-handler.ts rename to lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts diff --git a/lambdas/poll-pdm-lambda/src/container.ts b/lambdas/pdm-poll-lambda/src/container.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/container.ts rename to lambdas/pdm-poll-lambda/src/container.ts diff --git a/lambdas/poll-pdm-lambda/src/index.ts b/lambdas/pdm-poll-lambda/src/index.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/index.ts rename to lambdas/pdm-poll-lambda/src/index.ts diff --git a/lambdas/poll-pdm-lambda/src/infra/config.ts b/lambdas/pdm-poll-lambda/src/infra/config.ts similarity index 100% rename from lambdas/poll-pdm-lambda/src/infra/config.ts rename to lambdas/pdm-poll-lambda/src/infra/config.ts diff --git a/lambdas/poll-pdm-lambda/tsconfig.json b/lambdas/pdm-poll-lambda/tsconfig.json similarity index 100% rename from lambdas/poll-pdm-lambda/tsconfig.json rename to lambdas/pdm-poll-lambda/tsconfig.json diff --git a/package.json b/package.json index 89118a9b..0e8145cb 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "lambdas/key-generation", "lambdas/refresh-apim-access-token", "lambdas/mesh-poll", - "lambdas/poll-pdm-lambda", + "lambdas/pdm-poll-lambda", "lambdas/ttl-create-lambda", "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", From bf0685a852fbfe55b1b66933df6d928478c4e6e6 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 11 Dec 2025 13:06:07 +0000 Subject: [PATCH 04/38] CCM-12614: add some basic event handling --- .../terraform/components/dl/README.md | 2 +- ...tch_event_rule_pdm_resource_unavailable.tf | 20 ++ .../terraform/components/dl/variables.tf | 2 +- .../src/__tests__/apis/sqs-handler.test.ts | 224 ++++++++++++++---- .../src/__tests__/app/pdm.test.ts | 77 ++++++ .../src/__tests__/test-data.ts | 75 ++++++ .../pdm-poll-lambda/src/apis/sqs-handler.ts | 45 +++- lambdas/pdm-poll-lambda/src/app/pdm.ts | 43 ++++ lambdas/pdm-poll-lambda/src/container.ts | 21 +- .../__tests__/apis/sqs-trigger-lambda.test.ts | 2 +- package-lock.json | 112 ++++----- 11 files changed, 511 insertions(+), 112 deletions(-) create mode 100644 infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf create mode 100644 lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts create mode 100644 lambdas/pdm-poll-lambda/src/__tests__/test-data.ts create mode 100644 lambdas/pdm-poll-lambda/src/app/pdm.ts diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index f0a71a17..845cebb6 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -28,7 +28,7 @@ No requirements. | [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [queue\_batch\_size](#input\_queue\_batch\_size) | maximum number of queue items to process | `number` | `10` | no | -| [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `10` | no | +| [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `1` | no | | [region](#input\_region) | The AWS Region | `string` | n/a | yes | | [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Shared Infra Account ID (numeric) | `string` | n/a | yes | | [ttl\_poll\_schedule](#input\_ttl\_poll\_schedule) | Schedule to poll for any overdue TTL records | `string` | `"rate(10 minutes)"` | no | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf new file mode 100644 index 00000000..3188f0ec --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf @@ -0,0 +1,20 @@ +resource "aws_cloudwatch_event_rule" "pdm_resource_unavailable" { + name = "${local.csi}-pdm-resource-unavailable" + description = "PDM resource unavailable event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1" + ] + } + }) +} + +resource "aws_cloudwatch_event_target" "pdm_resource_unavailable" { + rule = aws_cloudwatch_event_rule.pdm_resource_unavailable.name + arn = module.sqs_pdm_poll.sqs_queue_arn + target_id = "pdm-resource-unavailable-target" + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf index ef2081c3..e0a51c79 100644 --- a/infrastructure/terraform/components/dl/variables.tf +++ b/infrastructure/terraform/components/dl/variables.tf @@ -101,7 +101,7 @@ variable "queue_batch_size" { variable "queue_batch_window_seconds" { type = number description = "maximum time in seconds between processing events" - default = 10 + default = 1 } variable "enable_dynamodb_delete_protection" { diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 8ad6fd62..090e7151 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -1,36 +1,30 @@ -import { createHandler } from 'apis/sqs-handler'; -import { Logger } from 'utils'; -import { SQSEvent, SQSRecord } from 'aws-lambda'; import { mock } from 'jest-mock-extended'; +import { randomUUID } from 'node:crypto'; +import { createHandler } from 'apis/sqs-handler'; +import { EventPublisher, Logger } from 'utils'; +import { Pdm } from 'app/pdm'; +import { + pdmResourceSubmittedEvent, + pdmResourceUnavailableEvent, + recordEvent, +} from '__tests__/test-data'; const logger = mock(); +const eventPublisher = mock(); +const pdm = mock(); + +jest.mock('node:crypto', () => ({ + randomUUID: jest.fn(), +})); -const event = { - sourceEventId: 'test-event-id', -}; - -const sqsRecord1: SQSRecord = { - messageId: '1', - receiptHandle: 'abc', - body: JSON.stringify(event), - attributes: { - ApproximateReceiveCount: '1', - SentTimestamp: '2025-07-03T14:23:30Z', - SenderId: 'sender-id', - ApproximateFirstReceiveTimestamp: '2025-07-03T14:23:30Z', - }, - messageAttributes: {}, - md5OfBody: '', - eventSource: 'aws:sqs', - eventSourceARN: '', - awsRegion: '', -}; - -const singleRecordEvent: SQSEvent = { - Records: [sqsRecord1], -}; +const mockRandomUUID = randomUUID as jest.MockedFunction; +const mockDate = jest.spyOn(Date.prototype, 'toISOString'); +mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001'); +mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); const handler = createHandler({ + eventPublisher, + pdm, logger, }); @@ -39,35 +33,169 @@ describe('SQS Handler', () => { jest.clearAllMocks(); }); - it('processes a single record', async () => { - const response = await handler(singleRecordEvent); + describe('pdm.resource.submitted', () => { + it('should send pdm.resource.available event when the document is ready', async () => { + pdm.poll.mockResolvedValueOnce('available'); + + const response = await handler(recordEvent([pdmResourceSubmittedEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ + { + ...pdmResourceSubmittedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); - expect(logger.info).toHaveBeenCalledWith( - 'Received SQS Event of 1 record(s)', - ); - expect(logger.info).toHaveBeenCalledWith( - '1 of 1 records processed successfully', - ); - expect(response).toEqual({ batchItemFailures: [] }); + it('should send pdm.resource.unavailable event when the document is not ready', async () => { + pdm.poll.mockResolvedValueOnce('unavailable'); + + const response = await handler(recordEvent([pdmResourceSubmittedEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ + { + ...pdmResourceSubmittedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + ...pdmResourceSubmittedEvent.data, + retryCount: 1, + }, + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); }); - it('should return failed items to the queue if an error occurs while processing them', async () => { - singleRecordEvent.Records[0].body = 'not-json'; + describe('pdm.resource.unavailable', () => { + it('should send pdm.resource.available event when the document is ready', async () => { + pdm.poll.mockResolvedValueOnce('available'); + + const response = await handler( + recordEvent([pdmResourceUnavailableEvent]), + ); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + + it('should send pdm.resource.unavailable event when the document is not ready', async () => { + pdm.poll.mockResolvedValueOnce('unavailable'); - const result = await handler(singleRecordEvent); + const response = await handler( + recordEvent([pdmResourceUnavailableEvent]), + ); - expect(logger.warn).toHaveBeenCalledWith({ - error: `Unexpected token 'o', "not-json" is not valid JSON`, - description: 'Failed processing message', - messageId: '1', + expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + ...pdmResourceSubmittedEvent.data, + retryCount: 2, + }, + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); }); - expect(logger.info).toHaveBeenCalledWith( - '0 of 1 records processed successfully', - ); + it('should send pdm.resource.retries.exceeded event when the document is not ready after 10 retries', async () => { + pdm.poll.mockResolvedValueOnce('unavailable'); + + const testEvent = { + ...pdmResourceUnavailableEvent, + data: { + ...pdmResourceUnavailableEvent.data, + retryCount: 9, + }, + }; + + const response = await handler(recordEvent([testEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', + data: { + ...pdmResourceSubmittedEvent.data, + retryCount: 10, + }, + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + }); + + describe('errors', () => { + it('should return failed items to the queue if an error occurs while processing them', async () => { + const event = recordEvent([pdmResourceSubmittedEvent]); + event.Records[0].body = 'not-json'; + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + error: `Unexpected token 'o', "not-json" is not valid JSON`, + description: 'Failed processing message', + messageId: '1', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); - expect(result).toEqual({ - batchItemFailures: [{ itemIdentifier: '1' }], + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); }); }); }); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts new file mode 100644 index 00000000..0a2d79b4 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts @@ -0,0 +1,77 @@ +import { mock } from 'jest-mock-extended'; +import { Logger } from 'utils'; +import { Pdm, PdmDependencies } from 'app/pdm'; +import { pdmResourceSubmittedEvent } from '__tests__/test-data'; + +const logger = mock(); +const validConfig = (): PdmDependencies => ({ + pdmUrl: 'https://example.com/pdm', + logger, +}); + +describe('Pdm', () => { + describe('constructor', () => { + it('is created when required deps are provided', () => { + const cfg = validConfig(); + expect(() => new Pdm(cfg)).not.toThrow(); + }); + + it('throws if pdmUrl is not provided', () => { + const cfg = { + logger, + } as unknown as PdmDependencies; + + expect(() => new Pdm(cfg)).toThrow('pdmUrl has not been specified'); + }); + + it('throws if logger is not provided', () => { + const cfg = { + pdmUrl: 'https://example.com/pdm', + } as PdmDependencies; + + expect(() => new Pdm(cfg)).toThrow('logger has not been provided'); + }); + }); + + describe('poll', () => { + it('returns available when the document is ready', async () => { + const cfg = validConfig(); + const pdm = new Pdm(cfg); + + const result = await pdm.poll(pdmResourceSubmittedEvent); + + expect(result).toBe('available'); + }); + + it('returns unavailable when the document is not ready', async () => { + const cfg = validConfig(); + const pdm = new Pdm(cfg); + + pdmResourceSubmittedEvent.data.messageReference = 'ref2'; + + const result = await pdm.poll(pdmResourceSubmittedEvent); + + expect(result).toBe('unavailable'); + }); + + it('returns failed and logs error when logger.info throws', async () => { + const cfg = validConfig(); + const thrown = new Error('logger failure'); + cfg.logger.info = jest.fn(() => { + throw thrown; + }); + + const pdm = new Pdm(cfg); + + await expect(pdm.poll(pdmResourceSubmittedEvent)).rejects.toThrow(thrown); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error getting document resource from PDM', + err: thrown, + }), + ); + }); + }); +}); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts new file mode 100644 index 00000000..a947e4b1 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts @@ -0,0 +1,75 @@ +import { SQSEvent, SQSRecord } from 'aws-lambda'; +import { CloudEvent } from 'utils'; + +const baseEvent = { + profileversion: '1.0.0', + profilepublished: '2025-10', + id: '550e8400-e29b-41d4-a716-446655440001', + specversion: '1.0', + source: '/nhs/england/notify/production/primary/data-plane/digital-letters', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.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/digital-letter-base-data.schema.json', + dataschemaversion: '1.0', + severitytext: 'INFO', + data: { + resourceId: 'a2bcbb42-ab7e-42b6-88d6-74f8d3ca4a09', + 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', + messageReference: 'ref1', + senderId: 'sender1', + }, +}; + +export const pdmResourceSubmittedEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', +} as CloudEvent; + +export const pdmResourceUnavailableEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', + data: { + ...baseEvent.data, + retryCount: 1, + }, +} as CloudEvent; + +const busEvent = { + version: '0', + id: 'ab07d406-0797-e919-ff9b-3ad9c5498114', +}; + +const sqsRecord = { + messageId: '1', + receiptHandle: 'abc', + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '2025-07-03T14:23:30Z', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '2025-07-03T14:23:30Z', + }, + messageAttributes: {}, + md5OfBody: '', + eventSource: 'aws:sqs', + eventSourceARN: '', + awsRegion: '', +} as SQSRecord; + +export const recordEvent = (events: CloudEvent[]): SQSEvent => ({ + Records: events.map((event, i) => ({ + ...sqsRecord, + messageId: String(i + 1), + body: JSON.stringify({ ...busEvent, detail: event }), + })), +}); diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index 3dbda2b3..f6020816 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -1,16 +1,24 @@ +import { Pdm } from 'app/pdm'; import type { SQSBatchItemFailure, SQSBatchResponse, SQSEvent, SQSRecord, } from 'aws-lambda'; -import { Logger } from 'utils'; +import { randomUUID } from 'node:crypto'; +import { EventPublisher, Logger } from 'utils'; export interface HandlerDependencies { + eventPublisher: EventPublisher; logger: Logger; + pdm: Pdm; } -export const createHandler = ({ logger }: HandlerDependencies) => +export const createHandler = ({ + eventPublisher, + logger, + pdm, +}: HandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { const receivedItemCount = sqsEvent.Records.length; @@ -21,9 +29,36 @@ export const createHandler = ({ logger }: HandlerDependencies) => await Promise.all( sqsEvent.Records.map(async (sqsRecord: SQSRecord) => { try { - logger.info({ - event: JSON.parse(sqsRecord.body), - }); + const event = JSON.parse(sqsRecord.body); // Note: Add event validation when that ticket is completed. + const eventDetail = event.detail; + + const result = await pdm.poll(eventDetail); + + const retries = (eventDetail.data?.retryCount ?? 0) + 1; + const eventTime = new Date().toISOString(); + let eventType = + 'uk.nhs.notify.digital.letters.pdm.resource.available.v1'; + + if (result === 'unavailable') { + eventType = + retries >= 10 + ? 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1' + : 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1'; + } + + await eventPublisher.sendEvents([ + { + ...eventDetail, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + type: eventType, + data: { + ...eventDetail.data, + ...(result === 'available' ? {} : { retryCount: retries }), + }, + }, + ]); } catch (error: any) { logger.warn({ error: error.message, diff --git a/lambdas/pdm-poll-lambda/src/app/pdm.ts b/lambdas/pdm-poll-lambda/src/app/pdm.ts new file mode 100644 index 00000000..41c03e23 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts @@ -0,0 +1,43 @@ +import { Logger } from 'utils'; + +export type PdmOutcome = 'available' | 'unavailable'; + +export interface PdmDependencies { + pdmUrl: string; + logger: Logger; +} + +export class Pdm { + private readonly pdmUrl: string; + + private readonly logger: Logger; + + constructor(config: PdmDependencies) { + if (!config.pdmUrl) { + throw new Error('pdmUrl has not been specified'); + } + if (!config.logger) { + throw new Error('logger has not been provided'); + } + + this.pdmUrl = config.pdmUrl; + this.logger = config.logger; + } + + async poll(item: any): Promise { + try { + this.logger.info(item); + if (item.data.messageReference === 'ref1') { + return 'available'; + } + return 'unavailable'; + } catch (error) { + this.logger.error({ + description: 'Error getting document resource from PDM', + err: error, + }); + + throw error; + } + } +} diff --git a/lambdas/pdm-poll-lambda/src/container.ts b/lambdas/pdm-poll-lambda/src/container.ts index 5989e0b2..73c3c137 100644 --- a/lambdas/pdm-poll-lambda/src/container.ts +++ b/lambdas/pdm-poll-lambda/src/container.ts @@ -1,8 +1,25 @@ -import { logger } from 'utils'; import { HandlerDependencies } from 'apis/sqs-handler'; +import { Pdm } from 'app/pdm'; +import { loadConfig } from 'infra/config'; +import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; export const createContainer = (): HandlerDependencies => { - return { logger }; + const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); + + const eventPublisher = new EventPublisher({ + eventBusArn: eventPublisherEventBusArn, + dlqUrl: eventPublisherDlqUrl, + logger, + sqsClient, + eventBridgeClient, + }); + + const pdm = new Pdm({ + pdmUrl: 'pdmUrl', + logger, + }); + + return { eventPublisher, pdm, logger }; }; export default createContainer; diff --git a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts index 939e086f..5073a122 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,7 +1,7 @@ import { createHandler } from 'apis/sqs-trigger-lambda'; +import { randomUUID } from 'node:crypto'; import type { SQSEvent } from 'aws-lambda'; import { $TtlItemBusEvent, TtlItemBusEvent } from 'utils'; -import { randomUUID } from 'node:crypto'; jest.mock('node:crypto', () => ({ randomUUID: jest.fn(), diff --git a/package-lock.json b/package-lock.json index 95474198..0f91e226 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "lambdas/key-generation", "lambdas/refresh-apim-access-token", "lambdas/mesh-poll", - "lambdas/poll-pdm-lambda", + "lambdas/pdm-poll-lambda", "lambdas/ttl-create-lambda", "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", @@ -200,8 +200,8 @@ } } }, - "lambdas/poll-pdm-lambda": { - "name": "nhs-notify-digital-letters-poll-pdm-lambda", + "lambdas/pdm-poll-lambda": { + "name": "nhs-notify-digital-letters-pdm-poll-lambda", "version": "0.0.1", "dependencies": { "aws-lambda": "^1.0.7", @@ -221,7 +221,7 @@ "typescript": "^5.9.3" } }, - "lambdas/poll-pdm-lambda/node_modules/@types/jest": { + "lambdas/pdm-poll-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==", @@ -232,7 +232,7 @@ "pretty-format": "^29.0.0" } }, - "lambdas/poll-pdm-lambda/node_modules/jest": { + "lambdas/pdm-poll-lambda/node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", @@ -260,7 +260,7 @@ } } }, - "lambdas/poll-pdm-lambda/node_modules/jest-mock-extended": { + "lambdas/pdm-poll-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==", @@ -274,6 +274,28 @@ "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, + "lambdas/poll-pdm-lambda": { + "name": "nhs-notify-digital-letters-poll-pdm-lambda", + "version": "0.0.1", + "extraneous": true, + "dependencies": { + "aws-lambda": "^1.0.7", + "lodash": "^4.17.21", + "p-limit": "^3.1.0", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.20", + "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/refresh-apim-access-token": { "version": "0.0.1", "dependencies": { @@ -495,7 +517,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.928.0.tgz", "integrity": "sha512-Efenb8zV2fJJDXmp2NE4xj8Ymhp4gVJCkQ6ixhdrpfQXgd2PODO7a20C2+BhFM6aGmN3m6XWYJ64ZyhXF4pAyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -545,7 +566,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.928.0.tgz", "integrity": "sha512-e28J2uKjy2uub4u41dNnmzAu0AN3FGB+LRcLN2Qnwl9Oq3kIcByl5sM8ZD+vWpNG+SFUrUasBCq8cMnHxwXZ4w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@aws-sdk/xml-builder": "3.921.0", @@ -570,7 +590,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.928.0.tgz", "integrity": "sha512-tB8F9Ti0/NFyFVQX8UQtgRik88evtHpyT6WfXOB4bAY6lEnEHA0ubJZmk9y+aUeoE+OsGLx70dC3JUsiiCPJkQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -587,7 +606,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.928.0.tgz", "integrity": "sha512-67ynC/8UW9Y8Gn1ZZtC3OgcQDGWrJelHmkbgpmmxYUrzVhp+NINtz3wiTzrrBFhPH/8Uy6BxvhMfXhn0ptcMEQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -609,7 +627,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.928.0.tgz", "integrity": "sha512-WVWYyj+jox6mhKYp11mu8x1B6Xa2sLbXFHAv5K3Jg8CHvXYpePgTcYlCljq3d4XHC4Jl4nCcsdMtBahSpU9bAA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/credential-provider-env": "3.928.0", @@ -634,7 +651,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.928.0.tgz", "integrity": "sha512-SdXVjxZOIXefIR/NJx+lyXOrn4m0ScTAU2JXpLsFCkW2Cafo6vTqHUghyO8vak/XQ8PpPqpLXVpGbAYFuIPW6Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "3.928.0", "@aws-sdk/credential-provider-http": "3.928.0", @@ -658,7 +674,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.928.0.tgz", "integrity": "sha512-XL0juran8yhqwn0mreV+NJeHJOkcRBaExsvVn9fXWW37A4gLh4esSJxM2KbSNh0t+/Bk3ehBI5sL9xad+yRDuw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -676,7 +691,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.928.0.tgz", "integrity": "sha512-md/y+ePDsO1zqPJrsOyPs4ciKmdpqLL7B0dln1NhqZPnKIS5IBfTqZJ5tJ9eTezqc7Tn4Dbg6HiuemcGvZTeFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/client-sso": "3.928.0", "@aws-sdk/core": "3.928.0", @@ -696,7 +710,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.928.0.tgz", "integrity": "sha512-rd97nLY5e/nGOr73ZfsXD+H44iZ9wyGZTKt/2QkiBN3hot/idhgT9+XHsWhRi+o/dThQbpL8RkpAnpF+0ZGthw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/nested-clients": "3.928.0", @@ -715,7 +728,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.922.0.tgz", "integrity": "sha512-F7Qhwz/bs/Wkbu4SLwKbAeQKoZ7Bzo+JPpVzSqSJGxEely8KBAfsOItXRF8c0d06OEzyeSyml0S6/3TP8T5KUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/endpoint-cache": "3.893.0", "@aws-sdk/types": "3.922.0", @@ -733,7 +745,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.922.0.tgz", "integrity": "sha512-HPquFgBnq/KqKRVkiuCt97PmWbKtxQ5iUNLEc6FIviqOoZTmaYG3EDsIbuFBz9C4RHJU4FKLmHL2bL3FEId6AA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/protocol-http": "^5.3.4", @@ -749,7 +760,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.922.0.tgz", "integrity": "sha512-AkvYO6b80FBm5/kk2E636zNNcNgjztNNUxpqVx+huyGn9ZqGTzS4kLqW2hO6CBe5APzVtPCtiQsXL24nzuOlAg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/types": "^4.8.1", @@ -764,7 +774,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.922.0.tgz", "integrity": "sha512-TtSCEDonV/9R0VhVlCpxZbp/9sxQvTTRKzIf8LxW3uXpby6Wl8IxEciBJlxmSkoqxh542WRcko7NYODlvL/gDA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@aws/lambda-invoke-store": "^0.1.1", @@ -781,7 +790,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.928.0.tgz", "integrity": "sha512-ESvcfLx5PtpdUM3ptCwb80toBTd3y5I4w5jaeOPHihiZr7jkRLE/nsaCKzlqscPs6UQ8xI0maav04JUiTskcHw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -800,7 +808,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.928.0.tgz", "integrity": "sha512-kXzfJkq2cD65KAHDe4hZCsnxcGGEWD5pjHqcZplwG4VFMa/iVn/mWrUY9QdadD2GBpXFNQbgOiKG3U2NkKu+4Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -850,7 +857,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.925.0.tgz", "integrity": "sha512-FOthcdF9oDb1pfQBRCfWPZhJZT5wqpvdAS5aJzB1WDZ+6EuaAhLzLH/fW1slDunIqq1PSQGG3uSnVglVVOvPHQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/config-resolver": "^4.4.2", @@ -867,7 +873,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.928.0.tgz", "integrity": "sha512-533NpTdUJNDi98zBwRp4ZpZoqULrAVfc0YgIy+8AZHzk0v7N+v59O0d2Du3YO6zN4VU8HU8766DgKiyEag6Dzg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.928.0", "@aws-sdk/nested-clients": "3.928.0", @@ -886,7 +891,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.922.0.tgz", "integrity": "sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.8.1", "tslib": "^2.6.2" @@ -915,7 +919,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.922.0.tgz", "integrity": "sha512-4ZdQCSuNMY8HMlR1YN4MRDdXuKd+uQTeKIr5/pIM+g3TjInZoj8imvXudjcrFGA63UF3t92YVTkBq88mg58RXQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/types": "^4.8.1", @@ -932,7 +935,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.922.0.tgz", "integrity": "sha512-qOJAERZ3Plj1st7M4Q5henl5FRpE30uLm6L9edZqZXGR6c7ry9jzexWamWVpQ4H4xVAVmiO9dIEBAfbq4mduOA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "3.922.0", "@smithy/types": "^4.8.1", @@ -945,7 +947,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.928.0.tgz", "integrity": "sha512-s0jP67nQLLWVWfBtqTkZUkSWK5e6OI+rs+wFya2h9VLyWBFir17XSDI891s8HZKIVCEl8eBrup+hhywm4nsIAA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/middleware-user-agent": "3.928.0", "@aws-sdk/types": "3.922.0", @@ -970,7 +971,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.921.0.tgz", "integrity": "sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.8.1", "fast-xml-parser": "5.2.5", @@ -985,7 +985,6 @@ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.0.0" } @@ -995,7 +994,6 @@ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1009,7 +1007,6 @@ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", @@ -1027,7 +1024,6 @@ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.0.tgz", "integrity": "sha512-vGSDXOJFZgOPTatSI1ly7Gwyy/d/R9zh2TO3y0JZ0uut5qQ88p9IaWaZYIWSSqtdekNM4CGok/JppxbAff4KcQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/middleware-serde": "^4.2.5", "@smithy/protocol-http": "^5.3.5", @@ -1049,7 +1045,6 @@ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", @@ -1066,7 +1061,6 @@ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", @@ -1083,7 +1077,6 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.7.tgz", "integrity": "sha512-i8Mi8OuY6Yi82Foe3iu7/yhBj1HBRoOQwBSsUNYglJTNSFaWYTNM2NauBBs/7pq2sqkLRqeUXA3Ogi2utzpUlQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.18.0", "@smithy/middleware-serde": "^4.2.5", @@ -1103,7 +1096,6 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.5.tgz", "integrity": "sha512-La1ldWTJTZ5NqQyPqnCNeH9B+zjFhrNoQIL1jTh4zuqXRlmXhxYHhMtI1/92OlnoAtp6JoN7kzuwhWoXrBwPqg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", @@ -1118,7 +1110,6 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1132,7 +1123,6 @@ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", @@ -1148,7 +1138,6 @@ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/protocol-http": "^5.3.5", @@ -1165,7 +1154,6 @@ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1179,7 +1167,6 @@ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1193,7 +1180,6 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", @@ -1208,7 +1194,6 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1222,7 +1207,6 @@ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1236,7 +1220,6 @@ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.3.tgz", "integrity": "sha512-8tlueuTgV5n7inQCkhyptrB3jo2AO80uGrps/XTYZivv5MFQKKBj3CIWIGMI2fRY5LEduIiazOhAWdFknY1O9w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.18.0", "@smithy/middleware-endpoint": "^4.3.7", @@ -1255,7 +1238,6 @@ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -1268,7 +1250,6 @@ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/querystring-parser": "^4.2.5", "@smithy/types": "^4.9.0", @@ -1283,7 +1264,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.9.tgz", "integrity": "sha512-dgyribrVWN5qE5usYJ0m5M93mVM3L3TyBPZWe1Xl6uZlH2gzfQx3dz+ZCdW93lWqdedJRkOecnvbnoEEXRZ5VQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/config-resolver": "^4.4.3", "@smithy/credential-provider-imds": "^4.2.5", @@ -1302,7 +1282,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", @@ -1317,7 +1296,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" @@ -1331,7 +1309,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", @@ -1351,7 +1328,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/types": "^4.9.0", @@ -1378,6 +1354,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -1449,6 +1426,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -2193,6 +2171,7 @@ "node_modules/@aws-sdk/client-dynamodb": { "version": "3.914.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2835,6 +2814,7 @@ "node_modules/@aws-sdk/client-s3": { "version": "3.914.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -4175,6 +4155,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -4196,6 +4177,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5767,6 +5749,7 @@ "version": "30.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -6083,6 +6066,7 @@ "version": "15.5.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-glob": "3.3.1" } @@ -7720,6 +7704,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8192,6 +8177,7 @@ "version": "4.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/sinon": "^17.0.3", "sinon": "^18.0.1", @@ -8460,6 +8446,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -9484,6 +9471,7 @@ "version": "9.37.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9677,6 +9665,7 @@ "version": "10.1.8", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9732,6 +9721,7 @@ "version": "4.4.4", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "debug": "^4.4.1", "eslint-import-context": "^0.1.8", @@ -9800,6 +9790,7 @@ "version": "2.32.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9832,6 +9823,7 @@ "version": "4.16.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -9939,6 +9931,7 @@ "version": "6.10.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -10026,6 +10019,7 @@ "version": "7.37.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -10057,6 +10051,7 @@ "version": "5.2.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -11721,6 +11716,7 @@ "version": "30.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -13902,6 +13898,7 @@ "version": "26.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -13939,6 +13936,7 @@ "node_modules/jsep": { "version": "1.4.0", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -14729,8 +14727,8 @@ "resolved": "lambdas/mesh-poll", "link": true }, - "node_modules/nhs-notify-digital-letters-poll-pdm-lambda": { - "resolved": "lambdas/poll-pdm-lambda", + "node_modules/nhs-notify-digital-letters-pdm-poll-lambda": { + "resolved": "lambdas/pdm-poll-lambda", "link": true }, "node_modules/nhs-notify-digital-letters-ttl-create-lambda": { @@ -16457,6 +16455,7 @@ "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.2", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16643,6 +16642,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16852,6 +16852,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16864,6 +16865,7 @@ "version": "8.46.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.1", "@typescript-eslint/parser": "8.46.1", @@ -17858,6 +17860,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.933.0.tgz", "integrity": "sha512-zRNDq5phdORYZnlof/p9inwm7B3TBwXWI6vPKzmYd+AmTMv/Ue4FQYsAcCX3JrUbTNXLK36CkaCgaH9/ydnnwg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -19726,6 +19729,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", From 812b27b99f55e3d75273ed570902d4bb0639354c Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 12 Dec 2025 12:13:20 +0000 Subject: [PATCH 05/38] CCM-12614: add call to pdm and various other bits --- .../terraform/components/dl/README.md | 2 +- .../terraform/components/dl/locals.tf | 6 +- .../components/dl/module_lambda_pdm_poll.tf | 15 ++ .../terraform/components/dl/variables.tf | 2 +- .../src/__tests__/apis/sqs-handler.test.ts | 5 +- .../src/__tests__/app/pdm.test.ts | 63 +++++-- .../src/__tests__/container.test.ts | 8 +- .../pdm-poll-lambda/src/apis/sqs-handler.ts | 6 +- lambdas/pdm-poll-lambda/src/app/pdm.ts | 25 ++- lambdas/pdm-poll-lambda/src/container.ts | 34 +++- lambdas/pdm-poll-lambda/src/infra/config.ts | 2 + package-lock.json | 2 + utils/utils/package.json | 1 + utils/utils/src/index.ts | 1 + utils/utils/src/pdm-client/index.ts | 1 + utils/utils/src/pdm-client/pdm-client.ts | 175 ++++++++++++++++++ utils/utils/src/types/index.ts | 1 + utils/utils/src/types/pdm-types.ts | 22 +++ 18 files changed, 334 insertions(+), 37 deletions(-) create mode 100644 utils/utils/src/pdm-client/index.ts create mode 100644 utils/utils/src/pdm-client/pdm-client.ts create mode 100644 utils/utils/src/types/pdm-types.ts diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 845cebb6..31c1d586 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -11,7 +11,7 @@ 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://sandbox.api.service.nhs.uk"` | 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\_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 | diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf index 0424bd1c..24cabc19 100644 --- a/infrastructure/terraform/components/dl/locals.tf +++ b/infrastructure/terraform/components/dl/locals.tf @@ -1,11 +1,11 @@ locals { - aws_lambda_functions_dir_path = "../../../../lambdas" - log_destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" + aws_lambda_functions_dir_path = "../../../../lambdas" + log_destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" apim_access_token_ssm_parameter_name = "/${var.component}/${var.environment}/apim/access_token" apim_api_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/api_key" apim_private_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/private_key" apim_keystore_s3_bucket = "nhs-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-static-assets" root_domain_name = "${var.environment}.${local.acct.route53_zone_names["digital-letters"]}" root_domain_id = local.acct.route53_zone_ids["digital-letters"] - ttl_shard_count = 3 + ttl_shard_count = 3 } diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf index 75734afd..552f7d2a 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -40,10 +40,25 @@ module "pdm_poll" { "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 + "POLL_MAX_RETRIES" = 10 } } data "aws_iam_policy_document" "pdm_poll_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/*" + ] + } statement { sid = "PutEvents" effect = "Allow" diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf index e0a51c79..b09a56d0 100644 --- a/infrastructure/terraform/components/dl/variables.tf +++ b/infrastructure/terraform/components/dl/variables.tf @@ -119,7 +119,7 @@ variable "ttl_poll_schedule" { variable "apim_base_url" { type = string description = "The URL used to send requests to Notify and PDM" - default = "https://sandbox.api.service.nhs.uk" + default = "https://int.api.service.nhs.uk" } variable "apim_auth_token_url" { diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 090e7151..707588be 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -24,8 +24,9 @@ mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); const handler = createHandler({ eventPublisher, - pdm, logger, + pdm, + pollMaxRetries: 10, }); describe('SQS Handler', () => { @@ -71,7 +72,7 @@ describe('SQS Handler', () => { type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', data: { ...pdmResourceSubmittedEvent.data, - retryCount: 1, + retryCount: 0, }, }, ]); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts index 0a2d79b4..b6ea7da5 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts @@ -1,14 +1,40 @@ import { mock } from 'jest-mock-extended'; -import { Logger } from 'utils'; +import { IPdmClient, Logger } from 'utils'; import { Pdm, PdmDependencies } from 'app/pdm'; import { pdmResourceSubmittedEvent } from '__tests__/test-data'; const logger = mock(); +const pdmClient = mock(); const validConfig = (): PdmDependencies => ({ - pdmUrl: 'https://example.com/pdm', + pdmClient, logger, }); +const availableResponse = { + resourceType: 'DocumentReference', + id: '4c5af7c3-ca21-31b8-924b-fa526db5379b', + meta: { + versionId: '1', + lastUpdated: '2025-12-10T09:00:47.068021Z' + }, + status: 'current', + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '9912003071' + } + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + data: 'base64-encoded-pdf', + title: 'Dummy PDF' + } + } + ] +} + describe('Pdm', () => { describe('constructor', () => { it('is created when required deps are provided', () => { @@ -16,18 +42,18 @@ describe('Pdm', () => { expect(() => new Pdm(cfg)).not.toThrow(); }); - it('throws if pdmUrl is not provided', () => { + it('throws if pdmClient is not provided', () => { const cfg = { logger, } as unknown as PdmDependencies; - expect(() => new Pdm(cfg)).toThrow('pdmUrl has not been specified'); + expect(() => new Pdm(cfg)).toThrow('pdmClient has not been specified'); }); it('throws if logger is not provided', () => { const cfg = { - pdmUrl: 'https://example.com/pdm', - } as PdmDependencies; + pdmClient, + } as unknown as PdmDependencies; expect(() => new Pdm(cfg)).toThrow('logger has not been provided'); }); @@ -36,6 +62,8 @@ describe('Pdm', () => { describe('poll', () => { it('returns available when the document is ready', async () => { const cfg = validConfig(); + pdmClient.getDocumentReference.mockResolvedValue(availableResponse); + const pdm = new Pdm(cfg); const result = await pdm.poll(pdmResourceSubmittedEvent); @@ -45,21 +73,28 @@ describe('Pdm', () => { it('returns unavailable when the document is not ready', async () => { const cfg = validConfig(); - const pdm = new Pdm(cfg); + const unavailableResponse = { + ...availableResponse, + content: [{ + attachment: { + contentType: 'application/pdf', + title: 'Dummy PDF' + } + }] + }; + pdmClient.getDocumentReference.mockResolvedValue(unavailableResponse); - pdmResourceSubmittedEvent.data.messageReference = 'ref2'; + const pdm = new Pdm(cfg); const result = await pdm.poll(pdmResourceSubmittedEvent); expect(result).toBe('unavailable'); }); - it('returns failed and logs error when logger.info throws', async () => { + it('logs and throws error when error from PDM', async () => { const cfg = validConfig(); - const thrown = new Error('logger failure'); - cfg.logger.info = jest.fn(() => { - throw thrown; - }); + const thrown = new Error('pdm failure'); + pdmClient.getDocumentReference.mockRejectedValueOnce(thrown) const pdm = new Pdm(cfg); @@ -69,7 +104,7 @@ describe('Pdm', () => { expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: 'Error getting document resource from PDM', - err: thrown, + err: new Error('pdm failure'), }), ); }); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts index e1291f38..8833bc85 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts @@ -6,16 +6,18 @@ jest.mock('infra/config', () => ({ apimAccessTokenSsmParameterName: 'test-ssm-parameter-name', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + maxPollCount: 10, })), })); jest.mock('utils', () => ({ - EventPublisher: jest.fn(() => ({})), + createGetApimAccessToken: jest.fn(() => ({})), eventBridgeClient: {}, + EventPublisher: jest.fn(() => ({})), logger: {}, - sqsClient: {}, ParameterStoreCache: jest.fn(() => ({})), - createGetApimAccessToken: jest.fn(() => ({})), + PdmClient: jest.fn(() => ({})), + sqsClient: {}, })); describe('container', () => { diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index f6020816..aeb13f0c 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -12,12 +12,14 @@ export interface HandlerDependencies { eventPublisher: EventPublisher; logger: Logger; pdm: Pdm; + pollMaxRetries: number; } export const createHandler = ({ eventPublisher, logger, pdm, + pollMaxRetries, }: HandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { const receivedItemCount = sqsEvent.Records.length; @@ -34,14 +36,14 @@ export const createHandler = ({ const result = await pdm.poll(eventDetail); - const retries = (eventDetail.data?.retryCount ?? 0) + 1; + const retries = (eventDetail.data?.retryCount ?? -1) + 1; const eventTime = new Date().toISOString(); let eventType = 'uk.nhs.notify.digital.letters.pdm.resource.available.v1'; if (result === 'unavailable') { eventType = - retries >= 10 + retries >= pollMaxRetries ? 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1' : 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1'; } diff --git a/lambdas/pdm-poll-lambda/src/app/pdm.ts b/lambdas/pdm-poll-lambda/src/app/pdm.ts index 41c03e23..d96bc551 100644 --- a/lambdas/pdm-poll-lambda/src/app/pdm.ts +++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts @@ -1,33 +1,44 @@ -import { Logger } from 'utils'; +import { IPdmClient, Logger } from 'utils'; export type PdmOutcome = 'available' | 'unavailable'; export interface PdmDependencies { - pdmUrl: string; + pdmClient: IPdmClient; logger: Logger; } export class Pdm { - private readonly pdmUrl: string; + private readonly pdmClient: IPdmClient; private readonly logger: Logger; constructor(config: PdmDependencies) { - if (!config.pdmUrl) { - throw new Error('pdmUrl has not been specified'); + if (!config.pdmClient) { + throw new Error('pdmClient has not been specified'); } if (!config.logger) { throw new Error('logger has not been provided'); } - this.pdmUrl = config.pdmUrl; + this.pdmClient = config.pdmClient; this.logger = config.logger; } async poll(item: any): Promise { try { this.logger.info(item); - if (item.data.messageReference === 'ref1') { + + const requestId = crypto.randomUUID(); + + const response = await this.pdmClient.getDocumentReference( + item.data.resourceId, + requestId, + item.id, + ); + + this.logger.info(response); + + if (response.content[0].attachment.data) { return 'available'; } return 'unavailable'; diff --git a/lambdas/pdm-poll-lambda/src/container.ts b/lambdas/pdm-poll-lambda/src/container.ts index 73c3c137..8ad3483b 100644 --- a/lambdas/pdm-poll-lambda/src/container.ts +++ b/lambdas/pdm-poll-lambda/src/container.ts @@ -1,10 +1,24 @@ import { HandlerDependencies } from 'apis/sqs-handler'; import { Pdm } from 'app/pdm'; import { loadConfig } from 'infra/config'; -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + EventPublisher, + ParameterStoreCache, + PdmClient, + createGetApimAccessToken, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; export const createContainer = (): HandlerDependencies => { - const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); + const { + apimAccessTokenSsmParameterName, + apimBaseUrl, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + pollMaxRetries, + } = loadConfig(); const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, @@ -14,12 +28,24 @@ export const createContainer = (): HandlerDependencies => { eventBridgeClient, }); + const parameterStore = new ParameterStoreCache(); + + const accessTokenRepository = { + getAccessToken: createGetApimAccessToken( + apimAccessTokenSsmParameterName, + logger, + parameterStore, + ), + }; + + const pdmClient = new PdmClient(accessTokenRepository, apimBaseUrl, logger); + const pdm = new Pdm({ - pdmUrl: 'pdmUrl', + pdmClient, logger, }); - return { eventPublisher, pdm, logger }; + return { eventPublisher, logger, pdm, pollMaxRetries }; }; export default createContainer; diff --git a/lambdas/pdm-poll-lambda/src/infra/config.ts b/lambdas/pdm-poll-lambda/src/infra/config.ts index 2005ccdf..e40455ff 100644 --- a/lambdas/pdm-poll-lambda/src/infra/config.ts +++ b/lambdas/pdm-poll-lambda/src/infra/config.ts @@ -2,6 +2,7 @@ import { defaultConfigReader } from 'utils'; export type PdmCreateConfig = { apimBaseUrl: string; + pollMaxRetries: number; apimAccessTokenSsmParameterName: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; @@ -10,6 +11,7 @@ export type PdmCreateConfig = { export function loadConfig(): PdmCreateConfig { return { apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'), + pollMaxRetries: defaultConfigReader.getInt('POLL_MAX_RETRIES'), apimAccessTokenSsmParameterName: defaultConfigReader.getValue( 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', ), diff --git a/package-lock.json b/package-lock.json index 0f91e226..e99d9905 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19584,6 +19584,7 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.914.0", "@types/yargs": "^17.0.33", + "axios": "^1.10.0", "tsx": "^4.20.6", "utils": "^0.0.1", "yargs": "^17.7.2", @@ -19663,6 +19664,7 @@ "@aws-sdk/lib-dynamodb": "^3.914.0", "@aws-sdk/lib-storage": "^3.914.0", "async-mutex": "^0.4.0", + "axios": "^1.10.0", "date-fns": "^4.1.0", "node-jose": "^2.2.0", "winston": "^3.17.0", diff --git a/utils/utils/package.json b/utils/utils/package.json index 9b37e42e..33f19722 100644 --- a/utils/utils/package.json +++ b/utils/utils/package.json @@ -9,6 +9,7 @@ "@aws-sdk/lib-dynamodb": "^3.914.0", "@aws-sdk/lib-storage": "^3.914.0", "async-mutex": "^0.4.0", + "axios": "^1.10.0", "date-fns": "^4.1.0", "node-jose": "^2.2.0", "winston": "^3.17.0", diff --git a/utils/utils/src/index.ts b/utils/utils/src/index.ts index 96bc7780..966a504e 100644 --- a/utils/utils/src/index.ts +++ b/utils/utils/src/index.ts @@ -13,3 +13,4 @@ export * from './types'; export * from './event-publisher'; export * from './event-bridge-utils'; export * from './key-generation-utils'; +export * from './pdm-client'; diff --git a/utils/utils/src/pdm-client/index.ts b/utils/utils/src/pdm-client/index.ts new file mode 100644 index 00000000..211c2b7c --- /dev/null +++ b/utils/utils/src/pdm-client/index.ts @@ -0,0 +1 @@ +export * from './pdm-client'; diff --git a/utils/utils/src/pdm-client/pdm-client.ts b/utils/utils/src/pdm-client/pdm-client.ts new file mode 100644 index 00000000..2a89b3d7 --- /dev/null +++ b/utils/utils/src/pdm-client/pdm-client.ts @@ -0,0 +1,175 @@ +import axios, { AxiosInstance, isAxiosError } from 'axios'; +import type { Readable } from 'node:stream'; +import { constants as HTTP2_CONSTANTS } from 'node:http2'; +import { + IAccessibleService, + Logger, + PdmResponse, + RetryConfig, + conditionalRetry, +} from 'utils'; + +export interface IAccessTokenRepository { + getAccessToken(): Promise; +} + +export type Response = { + data: Readable; +}; + +export interface IPdmClient { + createDocumentReference( + fhirRequest: string, + requestId: string, + correlationId?: string, + ): Promise; + getDocumentReference( + documentReferenceId: string, + requestId: string, + correlationId?: string, + ): Promise; +} + +export class PdmClient implements IPdmClient, IAccessibleService { + private client: AxiosInstance; + + constructor( + private accessTokenRepository: IAccessTokenRepository, + private apimBaseUrl: string, + private logger: Logger, + private backoffConfig: RetryConfig = { + maxDelayMs: 10_000, + intervalMs: 1000, + exponentialRate: 2, + maxAttempts: 10, + }, + ) { + this.client = axios.create({ + baseURL: this.apimBaseUrl + }); + } + + public async createDocumentReference( + fhirRequest: string, + requestId: string, + correlationId?: string, + ): Promise { + try { + return await conditionalRetry( + async (attempt) => { + const accessToken = await this.accessTokenRepository.getAccessToken(); + + this.logger.debug({ + requestId, + correlationId, + description: 'Sending request', + attempt, + }); + + const headers = { + 'Content-Type': 'application/json', + 'X-Request-ID': requestId, + 'X-Correlation-ID': correlationId, + ...(accessToken === '' + ? {} + : { + Authorization: `Bearer ${accessToken}`, + }), + }; + const response = await this.client.post( + '/patient-data-manager/FHIR/R4/DocumentReference', + fhirRequest, + { 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 PDM request', + requestId, + correlationId, + err: error, + }); + + throw error; + } + } + + public async getDocumentReference( + documentReferenceId: string, + requestId: string, + correlationId?: string, + ): Promise { + try { + return await conditionalRetry( + async (attempt) => { + const accessToken = await this.accessTokenRepository.getAccessToken(); + + this.logger.debug({ + requestId, + correlationId, + description: 'Sending request', + attempt, + }); + + const headers = { + 'X-Request-ID': requestId, + 'X-Correlation-ID': correlationId, + ...(accessToken === '' + ? {} + : { + Authorization: `Bearer ${accessToken}`, + }), + }; + const response = await this.client.get( + `/patient-data-manager/FHIR/R4/DocumentReference/${documentReferenceId}`, + { 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 PDM request', + requestId, + correlationId, + err: error, + }); + + throw error; + } + } + + public async isAccessible(): Promise { + try { + const accessToken = await this.accessTokenRepository.getAccessToken(); + await this.client.head('/', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + return true; + } catch (error: any) { + this.logger.error({ + description: 'NHS API Unavailable', + err: error, + }); + return false; + } + } +} diff --git a/utils/utils/src/types/index.ts b/utils/utils/src/types/index.ts index 738acf67..73f5564a 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -2,3 +2,4 @@ export * from './cloud-event'; export * from './ttl-dynamodb-record'; export * from './ttl-item-event'; export * from './sender'; +export * from './pdm-types'; diff --git a/utils/utils/src/types/pdm-types.ts b/utils/utils/src/types/pdm-types.ts new file mode 100644 index 00000000..597abef0 --- /dev/null +++ b/utils/utils/src/types/pdm-types.ts @@ -0,0 +1,22 @@ +export type PdmResponse = { + resourceType: string; + id: string; + meta: { + versionId: string; + lastUpdated: string; + }; + status: string; + subject: { + identifier: { + system: string; + value: string; + }; + }; + content: { + attachment: { + contentType: string; + title: string; + data?: string; + }; + }[]; +}; From 55410d900433da49540943bc492f34606a3e2336 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 22 Dec 2025 14:32:34 +0000 Subject: [PATCH 06/38] CCM-12614: tidy up after merging in main --- lambdas/pdm-poll-lambda/package.json | 1 + .../src/__tests__/apis/sqs-handler.test.ts | 206 ++++++++--- .../src/__tests__/test-data.ts | 24 +- .../pdm-poll-lambda/src/apis/sqs-handler.ts | 136 +++++-- lambdas/pdm-poll-lambda/src/app/pdm.ts | 5 +- lambdas/pdm-poll-lambda/src/container.ts | 2 +- .../src/__tests__/app/upload-to-pdm.test.ts | 3 +- .../src/__tests__/container.test.ts | 7 +- .../__tests__/infra/pdm-api-client.test.ts | 168 --------- .../src/app/upload-to-pdm.ts | 3 +- lambdas/pdm-uploader-lambda/src/container.ts | 2 +- .../src/infra/pdm-api-client.ts | 85 ----- package-lock.json | 1 + .../__tests__/pdm-client/pdm-client.test.ts | 348 ++++++++++++++++++ utils/utils/src/pdm-client/pdm-client.ts | 45 +-- 15 files changed, 633 insertions(+), 403 deletions(-) delete mode 100644 lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts delete mode 100644 lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts create mode 100644 utils/utils/src/__tests__/pdm-client/pdm-client.test.ts diff --git a/lambdas/pdm-poll-lambda/package.json b/lambdas/pdm-poll-lambda/package.json index 22c0b147..7d1b591d 100644 --- a/lambdas/pdm-poll-lambda/package.json +++ b/lambdas/pdm-poll-lambda/package.json @@ -1,6 +1,7 @@ { "dependencies": { "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", "lodash": "^4.17.21", "p-limit": "^3.1.0", "utils": "^0.0.1" diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 707588be..8efac833 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -40,15 +40,18 @@ describe('SQS Handler', () => { const response = await handler(recordEvent([pdmResourceSubmittedEvent])); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...pdmResourceSubmittedEvent, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', - }, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceSubmittedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + }, + ], + expect.any(Function), + ); expect(logger.info).toHaveBeenCalledWith( 'Received SQS Event of 1 record(s)', ); @@ -63,19 +66,22 @@ describe('SQS Handler', () => { const response = await handler(recordEvent([pdmResourceSubmittedEvent])); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...pdmResourceSubmittedEvent, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', - data: { - ...pdmResourceSubmittedEvent.data, - retryCount: 0, + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceSubmittedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + ...pdmResourceSubmittedEvent.data, + retryCount: 0, + }, }, - }, - ]); + ], + expect.any(Function), + ); expect(logger.info).toHaveBeenCalledWith( 'Received SQS Event of 1 record(s)', ); @@ -94,15 +100,18 @@ describe('SQS Handler', () => { recordEvent([pdmResourceUnavailableEvent]), ); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...pdmResourceUnavailableEvent, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', - }, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + }, + ], + expect.any(Function), + ); expect(logger.info).toHaveBeenCalledWith( 'Received SQS Event of 1 record(s)', ); @@ -119,19 +128,22 @@ describe('SQS Handler', () => { recordEvent([pdmResourceUnavailableEvent]), ); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...pdmResourceUnavailableEvent, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', - data: { - ...pdmResourceSubmittedEvent.data, - retryCount: 2, + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + ...pdmResourceSubmittedEvent.data, + retryCount: 2, + }, }, - }, - ]); + ], + expect.any(Function), + ); expect(logger.info).toHaveBeenCalledWith( 'Received SQS Event of 1 record(s)', ); @@ -154,19 +166,22 @@ describe('SQS Handler', () => { const response = await handler(recordEvent([testEvent])); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...pdmResourceUnavailableEvent, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', - data: { - ...pdmResourceSubmittedEvent.data, - retryCount: 10, + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', + data: { + ...pdmResourceSubmittedEvent.data, + retryCount: 10, + }, }, - }, - ]); + ], + expect.any(Function), + ); expect(logger.info).toHaveBeenCalledWith( 'Received SQS Event of 1 record(s)', ); @@ -178,16 +193,91 @@ describe('SQS Handler', () => { }); describe('errors', () => { - it('should return failed items to the queue if an error occurs while processing them', async () => { + it('should return failed SQS records to the queue if an error occurs while calling PDM', async () => { + pdm.poll.mockRejectedValueOnce(new Error('PDM error')); const event = recordEvent([pdmResourceSubmittedEvent]); - event.Records[0].body = 'not-json'; const result = await handler(event); expect(logger.warn).toHaveBeenCalledWith({ - error: `Unexpected token 'o', "not-json" is not valid JSON`, + err: 'PDM error', description: 'Failed processing message', - messageId: '1', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + + it('should return failed SQS records to the queue if an error occurs while processing them', async () => { + const event = recordEvent([pdmResourceSubmittedEvent]); + event.Records[0].body = 'not-json'; + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: new SyntaxError( + `Unexpected token 'o', "not-json" is not valid JSON`, + ), + description: 'Error parsing SQS record', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + + it('should return failed items to the queue if an invalid pdm.resource.submitted event is received', async () => { + const invalidSubmittedEvent = { + ...pdmResourceSubmittedEvent, + source: 'invalid pdm.resource.submitted source', + }; + const event = recordEvent([invalidSubmittedEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/source', + }), + ]), + description: 'Error parsing queue entry', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + + it('should return failed items to the queue if an invalid pdm.resource.unavailable event is received', async () => { + const invalidSubmittedEvent = { + ...pdmResourceUnavailableEvent, + source: 'invalid pdm.resource.unavailable source', + }; + const event = recordEvent([invalidSubmittedEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/source', + }), + ]), + description: 'Error parsing queue entry', }); expect(logger.info).toHaveBeenCalledWith( diff --git a/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts index a947e4b1..ec47a851 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts @@ -1,12 +1,14 @@ import { SQSEvent, SQSRecord } from 'aws-lambda'; -import { CloudEvent } from 'utils'; +import { + PDMResourceSubmitted, + PDMResourceUnavailable, +} from 'digital-letters-events'; const baseEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', id: '550e8400-e29b-41d4-a716-446655440001', specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', + 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.submitted.v1', @@ -17,11 +19,9 @@ const baseEvent = { datacontenttype: 'application/json', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', severitytext: 'INFO', data: { resourceId: 'a2bcbb42-ab7e-42b6-88d6-74f8d3ca4a09', - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', messageReference: 'ref1', senderId: 'sender1', }, @@ -31,19 +31,19 @@ export const pdmResourceSubmittedEvent = { ...baseEvent, type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1', dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', -} as CloudEvent; + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-submitted-data.schema.json', +} as PDMResourceSubmitted; export const pdmResourceUnavailableEvent = { ...baseEvent, type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', data: { ...baseEvent.data, retryCount: 1, }, -} as CloudEvent; +} as PDMResourceUnavailable; const busEvent = { version: '0', @@ -66,7 +66,9 @@ const sqsRecord = { awsRegion: '', } as SQSRecord; -export const recordEvent = (events: CloudEvent[]): SQSEvent => ({ +export const recordEvent = ( + events: (PDMResourceSubmitted | PDMResourceUnavailable)[], +): SQSEvent => ({ Records: events.map((event, i) => ({ ...sqsRecord, messageId: String(i + 1), diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index aeb13f0c..4f288728 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -3,8 +3,14 @@ import type { SQSBatchItemFailure, SQSBatchResponse, SQSEvent, - SQSRecord, } from 'aws-lambda'; +import { + PDMResourceSubmitted, + PDMResourceUnavailable, +} from 'digital-letters-events'; +import pdmResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js'; +import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; +import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceUnavailable.js'; import { randomUUID } from 'node:crypto'; import { EventPublisher, Logger } from 'utils'; @@ -15,6 +21,56 @@ export interface HandlerDependencies { pollMaxRetries: number; } +interface ValidatedRecord { + messageId: string; + event: PDMResourceSubmitted | PDMResourceUnavailable; +} + +function validateRecord( + { body, messageId }: { body: string; messageId: string }, + logger: Logger, +): ValidatedRecord | null { + try { + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; + + if ( + sqsEventDetail.type === + 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1' + ) { + const isEventValid = pdmResourceSubmittedValidator(sqsEventDetail); + if (!isEventValid) { + logger.warn({ + err: pdmResourceSubmittedValidator.errors, + description: 'Error parsing queue entry', + }); + + return null; + } + + return { messageId, event: sqsEventDetail }; + } + + const isEventValid = pdmResourceUnavailableValidator(sqsEventDetail); + if (!isEventValid) { + logger.warn({ + err: pdmResourceUnavailableValidator.errors, + description: 'Error parsing queue entry', + }); + + return null; + } + + return { messageId, event: sqsEventDetail }; + } catch (error) { + logger.warn({ + err: error, + description: 'Error parsing SQS record', + }); + return null; + } +} + export const createHandler = ({ eventPublisher, logger, @@ -28,46 +84,70 @@ export const createHandler = ({ const batchItemFailures: SQSBatchItemFailure[] = []; + const validatedRecords: ValidatedRecord[] = []; + for (const record of sqsEvent.Records) { + const validated = validateRecord(record, logger); + if (validated) { + validatedRecords.push(validated); + } else { + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + await Promise.all( - sqsEvent.Records.map(async (sqsRecord: SQSRecord) => { + validatedRecords.map(async (validatedRecord: ValidatedRecord) => { try { - const event = JSON.parse(sqsRecord.body); // Note: Add event validation when that ticket is completed. - const eventDetail = event.detail; - - const result = await pdm.poll(eventDetail); - - const retries = (eventDetail.data?.retryCount ?? -1) + 1; + const { event } = validatedRecord; + const result = await pdm.poll(event); + const retries = + ('retryCount' in event.data ? event.data.retryCount : -1) + 1; const eventTime = new Date().toISOString(); - let eventType = - 'uk.nhs.notify.digital.letters.pdm.resource.available.v1'; if (result === 'unavailable') { - eventType = + const eventType = retries >= pollMaxRetries ? 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1' : 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1'; - } - await eventPublisher.sendEvents([ - { - ...eventDetail, - id: randomUUID(), - time: eventTime, - recordedtime: eventTime, - type: eventType, - data: { - ...eventDetail.data, - ...(result === 'available' ? {} : { retryCount: retries }), - }, - }, - ]); + await eventPublisher.sendEvents( + [ + { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + type: eventType, + data: { + ...event.data, + retryCount: retries, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + } else { + await eventPublisher.sendEvents( + [ + { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + ...event.data, + }, + }, + ], + pdmResourceAvailableValidator, + ); + } } catch (error: any) { logger.warn({ - error: error.message, + err: error.message, description: 'Failed processing message', - messageId: sqsRecord.messageId, }); - batchItemFailures.push({ itemIdentifier: sqsRecord.messageId }); + batchItemFailures.push({ itemIdentifier: validatedRecord.messageId }); } }), ); diff --git a/lambdas/pdm-poll-lambda/src/app/pdm.ts b/lambdas/pdm-poll-lambda/src/app/pdm.ts index d96bc551..ce1a6cf9 100644 --- a/lambdas/pdm-poll-lambda/src/app/pdm.ts +++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts @@ -28,12 +28,9 @@ export class Pdm { try { this.logger.info(item); - const requestId = crypto.randomUUID(); - const response = await this.pdmClient.getDocumentReference( item.data.resourceId, - requestId, - item.id, + item.data.messageReference, ); this.logger.info(response); diff --git a/lambdas/pdm-poll-lambda/src/container.ts b/lambdas/pdm-poll-lambda/src/container.ts index 8ad3483b..d8514aa8 100644 --- a/lambdas/pdm-poll-lambda/src/container.ts +++ b/lambdas/pdm-poll-lambda/src/container.ts @@ -38,7 +38,7 @@ export const createContainer = (): HandlerDependencies => { ), }; - const pdmClient = new PdmClient(accessTokenRepository, apimBaseUrl, logger); + const pdmClient = new PdmClient(apimBaseUrl, accessTokenRepository, logger); const pdm = new Pdm({ pdmClient, diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts index 6365b9b3..2e446a61 100644 --- a/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts +++ b/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts @@ -1,7 +1,6 @@ import { UploadToPdm } from 'app/upload-to-pdm'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; -import { IPdmClient } from 'infra/pdm-api-client'; -import { Logger, getS3ObjectFromUri } from 'utils'; +import { IPdmClient, Logger, getS3ObjectFromUri } from 'utils'; jest.mock('utils', () => ({ ...jest.requireActual('utils'), diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts index e671fbb7..de443d4a 100644 --- a/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts @@ -14,12 +14,13 @@ jest.mock('app/upload-to-pdm', () => ({ })); jest.mock('utils', () => ({ - EventPublisher: jest.fn(() => ({})), + createGetApimAccessToken: jest.fn(() => ({})), eventBridgeClient: {}, + EventPublisher: jest.fn(() => ({})), logger: {}, - sqsClient: {}, ParameterStoreCache: jest.fn(() => ({})), - createGetApimAccessToken: jest.fn(() => ({})), + PdmClient: jest.fn(() => ({})), + sqsClient: {}, })); describe('container', () => { diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts deleted file mode 100644 index 3c75a1eb..00000000 --- a/lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Logger } from 'utils'; -import axios from 'axios'; -import { constants as HTTP2_CONSTANTS } from 'node:http2'; -import { PdmClient } from 'infra/pdm-api-client'; - -jest.mock('axios'); -jest.mock('utils', () => ({ - ...jest.requireActual('utils'), - conditionalRetry: jest.fn(), -})); - -const mockedAxios = axios as jest.Mocked; -const { conditionalRetry } = jest.requireMock('utils'); - -describe('PdmClient', () => { - let pdmClient: PdmClient; - let mockAccessTokenRepository: any; - let mockLogger: jest.Mocked; - let mockAxiosInstance: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockLogger = { - debug: jest.fn(), - error: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - } as unknown as jest.Mocked; - - mockAccessTokenRepository = { - getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), - }; - - mockAxiosInstance = { - post: jest.fn(), - head: jest.fn(), - }; - - mockedAxios.create.mockReturnValue(mockAxiosInstance); - mockedAxios.isAxiosError.mockImplementation( - (error: any) => error.isAxiosError === true, - ); - - pdmClient = new PdmClient( - 'https://api.example.com', - mockAccessTokenRepository, - mockLogger, - ); - }); - - describe('constructor', () => { - it('should create axios instance with base URL', () => { - expect(mockedAxios.create).toHaveBeenCalledWith({ - baseURL: 'https://api.example.com', - }); - }); - }); - - describe('createDocumentReference', () => { - const mockFhirRequest = JSON.stringify({ - resourceType: 'DocumentReference', - }); - const mockRequestId = 'req-123'; - - it('should successfully create document reference', async () => { - const mockResponse = { data: { id: 'doc-123' } }; - conditionalRetry.mockImplementation(async (fn: any) => fn(1)); - mockAxiosInstance.post.mockResolvedValue(mockResponse); - - const result = await pdmClient.createDocumentReference( - mockFhirRequest, - mockRequestId, - ); - - expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalled(); - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - '/patient-data-manager/FHIR/R4/DocumentReference', - mockFhirRequest, - { - headers: { - 'Content-Type': 'application/json', - 'X-Request-ID': mockRequestId, - Authorization: 'Bearer mock-access-token', - }, - }, - ); - expect(result).toEqual(mockResponse.data); - expect(mockLogger.debug).toHaveBeenCalledWith({ - requestId: mockRequestId, - description: 'Sending request', - attempt: 1, - }); - }); - - it('should handle empty access token', async () => { - mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); - const mockResponse = { data: { id: 'doc-123' } }; - conditionalRetry.mockImplementation(async (fn: any) => fn(1)); - mockAxiosInstance.post.mockResolvedValue(mockResponse); - - await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - '/patient-data-manager/FHIR/R4/DocumentReference', - mockFhirRequest, - { - headers: { - 'Content-Type': 'application/json', - 'X-Request-ID': mockRequestId, - }, - }, - ); - }); - - it('should retry on 429 error', async () => { - conditionalRetry.mockImplementation(async (fn: any, shouldRetry: any) => { - const error = { - isAxiosError: true, - response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, - }; - expect(shouldRetry(error)).toBe(true); - - // Simulate successful retry - return fn(2); - }); - - const mockResponse = { data: { id: 'doc-123' } }; - mockAxiosInstance.post.mockResolvedValue(mockResponse); - - const result = await pdmClient.createDocumentReference( - mockFhirRequest, - mockRequestId, - ); - - expect(result).toEqual(mockResponse.data); - }); - - it('should not retry on other errors', async () => { - conditionalRetry.mockImplementation( - async (_fn: any, shouldRetry: any) => { - const error = { - isAxiosError: true, - response: { status: 500 }, - }; - expect(shouldRetry(error)).toBe(false); - }, - ); - - await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); - }); - - it('should log and throw error on failure', async () => { - const mockError = new Error('Network error'); - conditionalRetry.mockRejectedValue(mockError); - - await expect( - pdmClient.createDocumentReference(mockFhirRequest, mockRequestId), - ).rejects.toThrow('Network error'); - - expect(mockLogger.error).toHaveBeenCalledWith({ - description: 'Failed sending PDM request', - requestId: mockRequestId, - err: mockError, - }); - }); - }); -}); diff --git a/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts b/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts index 058fb8e1..3de0cdca 100644 --- a/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts +++ b/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts @@ -1,5 +1,4 @@ -import { Logger, getS3ObjectFromUri } from 'utils'; -import { IPdmClient } from 'infra/pdm-api-client'; +import { IPdmClient, Logger, getS3ObjectFromUri } from 'utils'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; export type UploadToPdmOutcome = 'sent' | 'failed'; diff --git a/lambdas/pdm-uploader-lambda/src/container.ts b/lambdas/pdm-uploader-lambda/src/container.ts index b9915a96..bdca7a87 100644 --- a/lambdas/pdm-uploader-lambda/src/container.ts +++ b/lambdas/pdm-uploader-lambda/src/container.ts @@ -1,6 +1,7 @@ import { EventPublisher, ParameterStoreCache, + PdmClient, createGetApimAccessToken, eventBridgeClient, logger, @@ -8,7 +9,6 @@ import { } from 'utils'; import { loadConfig } from 'infra/config'; import { UploadToPdm } from 'app/upload-to-pdm'; -import { PdmClient } from 'infra/pdm-api-client'; export const createContainer = () => { const { diff --git a/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts b/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts deleted file mode 100644 index 33659628..00000000 --- a/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts +++ /dev/null @@ -1,85 +0,0 @@ -import axios, { AxiosInstance, isAxiosError } from 'axios'; -import { constants as HTTP2_CONSTANTS } from 'node:http2'; -import { Logger, PdmResponse, RetryConfig, conditionalRetry } from 'utils'; - -export interface IAccessTokenRepository { - getAccessToken(): Promise; -} - -export interface IPdmClient { - createDocumentReference( - fhirRequest: string, - requestId: string, - ): Promise; -} - -export class PdmClient implements IPdmClient { - 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 createDocumentReference( - fhirRequest: string, - requestId: string, - ): Promise { - try { - return await conditionalRetry( - async (attempt) => { - const accessToken = await this.accessTokenRepository.getAccessToken(); - - this.logger.debug({ - requestId, - description: 'Sending request', - attempt, - }); - - const headers = { - 'Content-Type': 'application/json', - 'X-Request-ID': requestId, - ...(accessToken === '' - ? {} - : { - Authorization: `Bearer ${accessToken}`, - }), - }; - const response = await this.client.post( - '/patient-data-manager/FHIR/R4/DocumentReference', - fhirRequest, - { 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 PDM request', - requestId, - err: error, - }); - - throw error; - } - } -} diff --git a/package-lock.json b/package-lock.json index 97605e16..63bcc39e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -296,6 +296,7 @@ "version": "0.0.1", "dependencies": { "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", "lodash": "^4.17.21", "p-limit": "^3.1.0", "utils": "^0.0.1" diff --git a/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts new file mode 100644 index 00000000..5badb94c --- /dev/null +++ b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts @@ -0,0 +1,348 @@ +import axios from 'axios'; +import { constants as HTTP2_CONSTANTS } from 'node:http2'; +import { Logger } from '../../logger'; +import { PdmClient } from '../../pdm-client'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('PdmClient', () => { + let pdmClient: PdmClient; + let mockAccessTokenRepository: { getAccessToken: jest.Mock }; + let mockLogger: jest.Mocked; + let mockAxiosInstance: { + get: jest.Mock; + head: jest.Mock; + post: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + } as unknown as jest.Mocked; + + mockAccessTokenRepository = { + getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), + }; + + mockAxiosInstance = { + get: jest.fn(), + head: jest.fn(), + post: jest.fn(), + }; + + mockedAxios.create.mockReturnValue(mockAxiosInstance as any); + mockedAxios.isAxiosError.mockImplementation( + (error: any) => error.isAxiosError === true, + ); + + pdmClient = new PdmClient( + 'https://api.example.com', + mockAccessTokenRepository, + mockLogger, + ); + }); + + describe('constructor', () => { + it('should create axios instance with base URL', () => { + expect(mockedAxios.create).toHaveBeenCalledWith({ + baseURL: 'https://api.example.com', + }); + }); + }); + + describe('createDocumentReference', () => { + const mockFhirRequest = JSON.stringify({ + resourceType: 'DocumentReference', + }); + const mockRequestId = 'req-123'; + + it('should successfully create document reference', async () => { + const mockResponse = { data: { id: 'doc-123' } }; + mockAxiosInstance.post.mockResolvedValue(mockResponse); + + const result = await pdmClient.createDocumentReference( + mockFhirRequest, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/patient-data-manager/FHIR/R4/DocumentReference', + mockFhirRequest, + { + headers: { + 'Content-Type': 'application/json', + 'X-Request-ID': mockRequestId, + Authorization: 'Bearer mock-access-token', + }, + }, + ); + expect(mockLogger.debug).toHaveBeenCalledWith({ + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + }); + + it('should omit Authorization header when access token is empty', async () => { + mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); + const mockResponse = { data: { id: 'doc-123' } }; + mockAxiosInstance.post.mockResolvedValue(mockResponse); + + await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/patient-data-manager/FHIR/R4/DocumentReference', + mockFhirRequest, + { + headers: { + 'Content-Type': 'application/json', + 'X-Request-ID': mockRequestId, + }, + }, + ); + }); + + it('should retry on 429 rate limit errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + const mockResponse = { data: { id: 'doc-123' } }; + + mockAxiosInstance.post + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockResponse); + + const result = await pdmClient.createDocumentReference( + mockFhirRequest, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenNthCalledWith(1, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + expect(mockLogger.debug).toHaveBeenNthCalledWith(3, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 3, + }); + }); + + it('should not retry on 500 server errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: 500 }, + }; + mockAxiosInstance.post.mockRejectedValue(mockError); + + await expect( + pdmClient.createDocumentReference(mockFhirRequest, mockRequestId), + ).rejects.toEqual(mockError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should not retry on non-axios errors', async () => { + const mockError = new Error('Network error'); + mockAxiosInstance.post.mockRejectedValue(mockError); + + await expect( + pdmClient.createDocumentReference(mockFhirRequest, mockRequestId), + ).rejects.toThrow('Network error'); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should respect maxAttempts in retry config', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + mockAxiosInstance.post.mockRejectedValue(mockError); + + const pdmClientWithCustomRetry = new PdmClient( + 'https://api.example.com', + mockAccessTokenRepository, + mockLogger, + { maxDelayMs: 100, intervalMs: 10, exponentialRate: 1, maxAttempts: 3 }, + ); + + await expect( + pdmClientWithCustomRetry.createDocumentReference( + mockFhirRequest, + mockRequestId, + ), + ).rejects.toEqual(mockError); + + // Should attempt 3 times then give up + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + }); + }); + + describe('getDocumentReference', () => { + const mockDocumentReferenceId = 'doc-123'; + const mockResponse = { data: { id: mockDocumentReferenceId } }; + const mockRequestId = 'req-123'; + + it('should successfully fetch document reference', async () => { + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await pdmClient.getDocumentReference( + mockDocumentReferenceId, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentReferenceId}`, + { + headers: { + 'X-Request-ID': mockRequestId, + Authorization: 'Bearer mock-access-token', + }, + }, + ); + expect(mockLogger.debug).toHaveBeenCalledWith({ + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + }); + + it('should omit Authorization header when access token is empty', async () => { + mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await pdmClient.getDocumentReference( + mockDocumentReferenceId, + mockRequestId, + ); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentReferenceId}`, + { + headers: { + 'X-Request-ID': mockRequestId, + }, + }, + ); + }); + + it('should retry on 429 rate limit errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + + mockAxiosInstance.get + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockResponse); + + const result = await pdmClient.getDocumentReference( + mockDocumentReferenceId, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenNthCalledWith(1, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + expect(mockLogger.debug).toHaveBeenNthCalledWith(3, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 3, + }); + }); + + it('should not retry on 500 server errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: 500 }, + }; + mockAxiosInstance.get.mockRejectedValue(mockError); + + await expect( + pdmClient.getDocumentReference(mockDocumentReferenceId, mockRequestId), + ).rejects.toEqual(mockError); + + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should not retry on non-axios errors', async () => { + const mockError = new Error('Network error'); + mockAxiosInstance.get.mockRejectedValue(mockError); + + await expect( + pdmClient.getDocumentReference(mockDocumentReferenceId, mockRequestId), + ).rejects.toThrow('Network error'); + + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should respect maxAttempts in retry config', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + mockAxiosInstance.get.mockRejectedValue(mockError); + + const pdmClientWithCustomRetry = new PdmClient( + 'https://api.example.com', + mockAccessTokenRepository, + mockLogger, + { maxDelayMs: 100, intervalMs: 10, exponentialRate: 1, maxAttempts: 3 }, + ); + + await expect( + pdmClientWithCustomRetry.getDocumentReference( + mockDocumentReferenceId, + mockRequestId, + ), + ).rejects.toEqual(mockError); + + // Should attempt 3 times then give up + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/utils/utils/src/pdm-client/pdm-client.ts b/utils/utils/src/pdm-client/pdm-client.ts index ee662647..30db887e 100644 --- a/utils/utils/src/pdm-client/pdm-client.ts +++ b/utils/utils/src/pdm-client/pdm-client.ts @@ -1,41 +1,30 @@ import axios, { AxiosInstance, isAxiosError } from 'axios'; -import type { Readable } from 'node:stream'; import { constants as HTTP2_CONSTANTS } from 'node:http2'; -import { - IAccessibleService, - Logger, - PdmResponse, - RetryConfig, - conditionalRetry, -} from 'utils'; +import { Logger } from '../logger'; +import { PdmResponse } from '../types'; +import { RetryConfig, conditionalRetry } from '../util-retry'; export interface IAccessTokenRepository { getAccessToken(): Promise; } -export type Response = { - data: Readable; -}; - export interface IPdmClient { createDocumentReference( fhirRequest: string, requestId: string, - correlationId?: string, ): Promise; getDocumentReference( documentReferenceId: string, requestId: string, - correlationId?: string, ): Promise; } -export class PdmClient implements IPdmClient, IAccessibleService { +export class PdmClient implements IPdmClient { private client: AxiosInstance; constructor( - private accessTokenRepository: IAccessTokenRepository, private apimBaseUrl: string, + private accessTokenRepository: IAccessTokenRepository, private logger: Logger, private backoffConfig: RetryConfig = { maxDelayMs: 10_000, @@ -52,7 +41,6 @@ export class PdmClient implements IPdmClient, IAccessibleService { public async createDocumentReference( fhirRequest: string, requestId: string, - correlationId?: string, ): Promise { try { return await conditionalRetry( @@ -61,7 +49,6 @@ export class PdmClient implements IPdmClient, IAccessibleService { this.logger.debug({ requestId, - correlationId, description: 'Sending request', attempt, }); @@ -69,7 +56,6 @@ export class PdmClient implements IPdmClient, IAccessibleService { const headers = { 'Content-Type': 'application/json', 'X-Request-ID': requestId, - 'X-Correlation-ID': correlationId, ...(accessToken === '' ? {} : { @@ -96,7 +82,6 @@ export class PdmClient implements IPdmClient, IAccessibleService { this.logger.error({ description: 'Failed sending PDM request', requestId, - correlationId, err: error, }); @@ -107,7 +92,6 @@ export class PdmClient implements IPdmClient, IAccessibleService { public async getDocumentReference( documentReferenceId: string, requestId: string, - correlationId?: string, ): Promise { try { return await conditionalRetry( @@ -116,14 +100,12 @@ export class PdmClient implements IPdmClient, IAccessibleService { this.logger.debug({ requestId, - correlationId, description: 'Sending request', attempt, }); const headers = { 'X-Request-ID': requestId, - 'X-Correlation-ID': correlationId, ...(accessToken === '' ? {} : { @@ -149,27 +131,10 @@ export class PdmClient implements IPdmClient, IAccessibleService { this.logger.error({ description: 'Failed sending PDM request', requestId, - correlationId, err: error, }); throw error; } } - - public async isAccessible(): Promise { - try { - const accessToken = await this.accessTokenRepository.getAccessToken(); - await this.client.head('/', { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - return true; - } catch (error: any) { - this.logger.error({ - description: 'NHS API Unavailable', - err: error, - }); - return false; - } - } } From 44ee10c541e8032f08a41eadc56ae640d6a7141c Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 22 Dec 2025 16:12:44 +0000 Subject: [PATCH 07/38] CCM-12614: add component test and some clean up --- .../src/__tests__/apis/sqs-handler.test.ts | 16 +++++ .../pdm-poll-lambda/src/apis/sqs-handler.ts | 6 ++ .../pdm-poll.component.spec.ts | 68 +++++++++++++++++++ .../pdm-uploader.component.spec.ts | 3 - .../__tests__/pdm-client/pdm-client.test.ts | 27 +++----- utils/utils/src/pdm-client/pdm-client.ts | 6 +- 6 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 8efac833..a8d9919c 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -47,7 +47,14 @@ describe('SQS Handler', () => { id: '550e8400-e29b-41d4-a716-446655440001', time: '2023-06-20T12:00:00.250Z', recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + ...pdmResourceSubmittedEvent.data, + nhsNumber: '9999999999', + odsCode: 'AB1234', + }, }, ], expect.any(Function), @@ -73,6 +80,8 @@ describe('SQS Handler', () => { id: '550e8400-e29b-41d4-a716-446655440001', time: '2023-06-20T12:00:00.250Z', recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', data: { ...pdmResourceSubmittedEvent.data, @@ -107,7 +116,14 @@ describe('SQS Handler', () => { id: '550e8400-e29b-41d4-a716-446655440001', time: '2023-06-20T12:00:00.250Z', recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + ...pdmResourceUnavailableEvent.data, + nhsNumber: '9999999999', + odsCode: 'AB1234', + }, }, ], expect.any(Function), diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index 4f288728..ca187adb 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -116,6 +116,8 @@ export const createHandler = ({ id: randomUUID(), time: eventTime, recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', type: eventType, data: { ...event.data, @@ -133,9 +135,13 @@ export const createHandler = ({ id: randomUUID(), time: eventTime, recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', data: { ...event.data, + nhsNumber: '9999999999', + odsCode: 'AB1234', }, }, ], diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts new file mode 100644 index 00000000..d5e0fba4 --- /dev/null +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { EVENT_BUS_LOG_GROUP_NAME } from 'constants/backend-constants'; +import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; +import eventPublisher from 'helpers/event-bus-helpers'; +import expectToPassEventually from 'helpers/expectations'; +import { v4 as uuidv4 } from 'uuid'; + +const baseEvent = { + profileversion: '1.0.0', + profilepublished: '2025-10', + 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.submitted.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-submitted-data.schema.json', + dataschemaversion: '1.0', + severitytext: 'INFO', +}; + +test.describe('PDM Poll', () => { + test.beforeAll(async () => { + test.setTimeout(250_000); + }); + + test('should send a pdm.resource.available event when available in PDM', async () => { + const eventId = uuidv4(); + const documentResourceId = 'fc450f3d-e6fe-3436-9e06-4c83cc38b707'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + data: { + resourceId: documentResourceId, + messageReference, + senderId, + }, + }, + ], + pdmResourceSubmittedValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); +}); diff --git a/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts index 885dc1c4..447672ed 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts @@ -34,8 +34,6 @@ const pdmRequest = { }; const baseEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', specversion: '1.0', source: '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', @@ -49,7 +47,6 @@ const baseEvent = { datacontenttype: 'application/json', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', - dataschemaversion: '1.0', severitytext: 'INFO', }; diff --git a/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts index 5badb94c..aa7858f8 100644 --- a/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts +++ b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts @@ -16,6 +16,9 @@ describe('PdmClient', () => { head: jest.Mock; post: jest.Mock; }; + const mockDocumentResourceId = 'doc-123'; + const mockResponse = { data: { id: mockDocumentResourceId } }; + const mockRequestId = 'req-123'; beforeEach(() => { jest.clearAllMocks(); @@ -61,10 +64,8 @@ describe('PdmClient', () => { const mockFhirRequest = JSON.stringify({ resourceType: 'DocumentReference', }); - const mockRequestId = 'req-123'; it('should successfully create document reference', async () => { - const mockResponse = { data: { id: 'doc-123' } }; mockAxiosInstance.post.mockResolvedValue(mockResponse); const result = await pdmClient.createDocumentReference( @@ -94,7 +95,6 @@ describe('PdmClient', () => { it('should omit Authorization header when access token is empty', async () => { mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); - const mockResponse = { data: { id: 'doc-123' } }; mockAxiosInstance.post.mockResolvedValue(mockResponse); await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); @@ -116,7 +116,6 @@ describe('PdmClient', () => { isAxiosError: true, response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, }; - const mockResponse = { data: { id: 'doc-123' } }; mockAxiosInstance.post .mockRejectedValueOnce(mockError) @@ -205,22 +204,18 @@ describe('PdmClient', () => { }); describe('getDocumentReference', () => { - const mockDocumentReferenceId = 'doc-123'; - const mockResponse = { data: { id: mockDocumentReferenceId } }; - const mockRequestId = 'req-123'; - it('should successfully fetch document reference', async () => { mockAxiosInstance.get.mockResolvedValue(mockResponse); const result = await pdmClient.getDocumentReference( - mockDocumentReferenceId, + mockDocumentResourceId, mockRequestId, ); expect(result).toEqual(mockResponse.data); expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); expect(mockAxiosInstance.get).toHaveBeenCalledWith( - `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentReferenceId}`, + `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentResourceId}`, { headers: { 'X-Request-ID': mockRequestId, @@ -240,12 +235,12 @@ describe('PdmClient', () => { mockAxiosInstance.get.mockResolvedValue(mockResponse); await pdmClient.getDocumentReference( - mockDocumentReferenceId, + mockDocumentResourceId, mockRequestId, ); expect(mockAxiosInstance.get).toHaveBeenCalledWith( - `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentReferenceId}`, + `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentResourceId}`, { headers: { 'X-Request-ID': mockRequestId, @@ -266,7 +261,7 @@ describe('PdmClient', () => { .mockResolvedValueOnce(mockResponse); const result = await pdmClient.getDocumentReference( - mockDocumentReferenceId, + mockDocumentResourceId, mockRequestId, ); @@ -293,7 +288,7 @@ describe('PdmClient', () => { mockAxiosInstance.get.mockRejectedValue(mockError); await expect( - pdmClient.getDocumentReference(mockDocumentReferenceId, mockRequestId), + pdmClient.getDocumentReference(mockDocumentResourceId, mockRequestId), ).rejects.toEqual(mockError); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); @@ -309,7 +304,7 @@ describe('PdmClient', () => { mockAxiosInstance.get.mockRejectedValue(mockError); await expect( - pdmClient.getDocumentReference(mockDocumentReferenceId, mockRequestId), + pdmClient.getDocumentReference(mockDocumentResourceId, mockRequestId), ).rejects.toThrow('Network error'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); @@ -336,7 +331,7 @@ describe('PdmClient', () => { await expect( pdmClientWithCustomRetry.getDocumentReference( - mockDocumentReferenceId, + mockDocumentResourceId, mockRequestId, ), ).rejects.toEqual(mockError); diff --git a/utils/utils/src/pdm-client/pdm-client.ts b/utils/utils/src/pdm-client/pdm-client.ts index 30db887e..ef661df0 100644 --- a/utils/utils/src/pdm-client/pdm-client.ts +++ b/utils/utils/src/pdm-client/pdm-client.ts @@ -14,7 +14,7 @@ export interface IPdmClient { requestId: string, ): Promise; getDocumentReference( - documentReferenceId: string, + documentResourceId: string, requestId: string, ): Promise; } @@ -90,7 +90,7 @@ export class PdmClient implements IPdmClient { } public async getDocumentReference( - documentReferenceId: string, + documentResourceId: string, requestId: string, ): Promise { try { @@ -113,7 +113,7 @@ export class PdmClient implements IPdmClient { }), }; const response = await this.client.get( - `/patient-data-manager/FHIR/R4/DocumentReference/${documentReferenceId}`, + `/patient-data-manager/FHIR/R4/DocumentReference/${documentResourceId}`, { headers }, ); From 432c93c92035d09b1c4995c05f3df8f1616ae27c Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 2 Jan 2026 15:16:54 +0000 Subject: [PATCH 08/38] CCM-12614: allow retryCount to be 0 --- .../domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml index ccf3a2a9..ca33a916 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml @@ -11,7 +11,7 @@ properties: - "f5524783-e5d7-473e-b2a0-29582ff231da" retryCount: type: integer - minimum: 1 + minimum: 0 description: Number of times that PDM has been polled while waiting for document processing to complete examples: - 2 From a795299f332415e3333bbd58e9851491963d3464 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 2 Jan 2026 15:17:47 +0000 Subject: [PATCH 09/38] CCM-12614: fix typo in schema definition --- ...gital.letters.pdm.resource.retries.exceeded.v1.schema.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml index 58d83a0b..ccbeb08b 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml @@ -15,9 +15,9 @@ properties: dataschema: type: string - const: ../data/digital-letters-pdm-resource-retries-exceeded-data.yaml + const: ../data/digital-letters-pdm-resource-retries-exceeded-data.schema.yaml description: Canonical URI of the event's data schema. examples: - - digital-letters-pdm-resource-retries-exceeded-data.yaml + - digital-letters-pdm-resource-retries-exceeded-data.schema.yaml data: $ref: ../data/digital-letters-pdm-resource-retries-exceeded-data.schema.yaml From e67950fb1cd8a1664ec7421a05fa0b6f6f90f256 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 2 Jan 2026 15:41:28 +0000 Subject: [PATCH 10/38] CCM-12614: add some of the missing logic --- .../src/__tests__/apis/sqs-handler.test.ts | 4 +- .../pdm-poll-lambda/src/apis/sqs-handler.ts | 159 ++++++++++++------ 2 files changed, 111 insertions(+), 52 deletions(-) diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index a8d9919c..0178f8e7 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -189,9 +189,11 @@ describe('SQS Handler', () => { id: '550e8400-e29b-41d4-a716-446655440001', time: '2023-06-20T12:00:00.250Z', recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-retries-exceeded-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', data: { - ...pdmResourceSubmittedEvent.data, + ...pdmResourceUnavailableEvent.data, retryCount: 10, }, }, diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index ca187adb..286ff05b 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -5,12 +5,15 @@ import type { SQSEvent, } from 'aws-lambda'; import { + PDMResourceAvailable, + PDMResourceRetriesExceeded, PDMResourceSubmitted, PDMResourceUnavailable, } from 'digital-letters-events'; import pdmResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js'; import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceUnavailable.js'; +import pdmResourceRetriesExceededValidator from 'digital-letters-events/PDMResourceRetriesExceeded.js'; import { randomUUID } from 'node:crypto'; import { EventPublisher, Logger } from 'utils'; @@ -21,10 +24,12 @@ export interface HandlerDependencies { pollMaxRetries: number; } -interface ValidatedRecord { +type PollableEvent = PDMResourceSubmitted | PDMResourceUnavailable; + +type ValidatedRecord = { messageId: string; - event: PDMResourceSubmitted | PDMResourceUnavailable; -} + event: PollableEvent; +}; function validateRecord( { body, messageId }: { body: string; messageId: string }, @@ -71,6 +76,67 @@ function validateRecord( } } +function generateAvailableEvent(event: PollableEvent): PDMResourceAvailable { + const eventTime = new Date().toISOString(); + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + ...event.data, + nhsNumber: '9999999999', + odsCode: 'AB1234', + }, + }; +} + +function generateUnavailableEvent( + event: PollableEvent, + retries: number, +): PDMResourceUnavailable { + const eventTime = new Date().toISOString(); + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + ...event.data, + retryCount: retries, + }, + }; +} + +function generateRetriesExceededEvent( + event: PollableEvent, + retries: number, +): PDMResourceRetriesExceeded { + const eventTime = new Date().toISOString(); + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-retries-exceeded-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', + data: { + ...event.data, + retryCount: retries, + }, + }; +} + export const createHandler = ({ eventPublisher, logger, @@ -79,12 +145,14 @@ export const createHandler = ({ }: HandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { const receivedItemCount = sqsEvent.Records.length; + const batchItemFailures: SQSBatchItemFailure[] = []; + const validatedRecords: ValidatedRecord[] = []; + const availableEvents: PDMResourceAvailable[] = []; + const unavailableEvents: PDMResourceUnavailable[] = []; + const retriesExceededEvents: PDMResourceRetriesExceeded[] = []; logger.info(`Received SQS Event of ${receivedItemCount} record(s)`); - const batchItemFailures: SQSBatchItemFailure[] = []; - - const validatedRecords: ValidatedRecord[] = []; for (const record of sqsEvent.Records) { const validated = validateRecord(record, logger); if (validated) { @@ -98,55 +166,24 @@ export const createHandler = ({ validatedRecords.map(async (validatedRecord: ValidatedRecord) => { try { const { event } = validatedRecord; + const result = await pdm.poll(event); - const retries = - ('retryCount' in event.data ? event.data.retryCount : -1) + 1; - const eventTime = new Date().toISOString(); + + let retries = 0; // First attempt for submitted events + if ('retryCount' in event.data) { + retries = event.data.retryCount + 1; // Increment attempt for unavailable events + } if (result === 'unavailable') { - const eventType = - retries >= pollMaxRetries - ? 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1' - : 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1'; - - await eventPublisher.sendEvents( - [ - { - ...event, - id: randomUUID(), - time: eventTime, - recordedtime: eventTime, - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', - type: eventType, - data: { - ...event.data, - retryCount: retries, - }, - }, - ], - pdmResourceUnavailableValidator, - ); + if (retries >= pollMaxRetries) { + retriesExceededEvents.push( + generateRetriesExceededEvent(event, retries), + ); + } else { + unavailableEvents.push(generateUnavailableEvent(event, retries)); + } } else { - await eventPublisher.sendEvents( - [ - { - ...event, - id: randomUUID(), - time: eventTime, - recordedtime: eventTime, - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', - type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', - data: { - ...event.data, - nhsNumber: '9999999999', - odsCode: 'AB1234', - }, - }, - ], - pdmResourceAvailableValidator, - ); + availableEvents.push(generateAvailableEvent(event)); } } catch (error: any) { logger.warn({ @@ -158,6 +195,26 @@ export const createHandler = ({ }), ); + await Promise.all( + [ + availableEvents.length > 0 && + eventPublisher.sendEvents( + availableEvents, + pdmResourceAvailableValidator, + ), + unavailableEvents.length > 0 && + eventPublisher.sendEvents( + unavailableEvents, + pdmResourceUnavailableValidator, + ), + retriesExceededEvents.length > 0 && + eventPublisher.sendEvents( + retriesExceededEvents, + pdmResourceRetriesExceededValidator, + ), + ].filter(Boolean), + ); + const processedItemCount = receivedItemCount - batchItemFailures.length; logger.info( `${processedItemCount} of ${receivedItemCount} records processed successfully`, From d9c34c5c9b09ff471d71ea196b15d8c5a63d2b01 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Fri, 2 Jan 2026 15:41:50 +0000 Subject: [PATCH 11/38] CCM-12614: add extra component tests --- .../pdm-poll.component.spec.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index d5e0fba4..dc99c577 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { EVENT_BUS_LOG_GROUP_NAME } from 'constants/backend-constants'; import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; +import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceUnavailable.js'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; @@ -65,4 +66,84 @@ test.describe('PDM Poll', () => { expect(eventLogEntry.length).toEqual(1); }, 120); }); + + test('should send a pdm.resource.unavailable event when not available in PDM', async () => { + const eventId = uuidv4(); + const documentResourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + resourceId: documentResourceId, + messageReference, + senderId, + retryCount: 0, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":1*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + + test('should send a pdm.resource.retries.exceeded event when not available in PDM after 10 retries', async () => { + const eventId = uuidv4(); + const documentResourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + resourceId: documentResourceId, + messageReference, + senderId, + retryCount: 9, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":10*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); }); From bfb53677fbaac08dd3c54e4b26bdc9cf045bbf53 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 5 Jan 2026 09:07:45 +0000 Subject: [PATCH 12/38] CCM-12614: add poll dlq component test --- .../playwright/constants/backend-constants.ts | 2 + .../pdm-poll.component.spec.ts | 49 ++++++++++++++++++- .../pdm-uploader.component.spec.ts | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index a8f30c46..fd2fae57 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -16,6 +16,7 @@ export const TTL_POLL_LAMBDA_NAME = `${CSI}-ttl-poll`; 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`; // Queue Url Prefix export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`; @@ -33,3 +34,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`; diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index dc99c577..30091f24 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -1,10 +1,15 @@ import { expect, test } from '@playwright/test'; -import { EVENT_BUS_LOG_GROUP_NAME } from 'constants/backend-constants'; +import { + EVENT_BUS_LOG_GROUP_NAME, + PDM_POLL_DLQ_NAME, + PDM_POLL_LAMBDA_LOG_GROUP_NAME, +} from 'constants/backend-constants'; import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceUnavailable.js'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; +import { expectMessageContainingString } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; const baseEvent = { @@ -146,4 +151,46 @@ test.describe('PDM Poll', () => { expect(eventLogEntry.length).toEqual(1); }, 120); }); + + test('should send invalid event to poll dlq', async () => { + // Sadly it takes longer than expected to go through the 3 retries before it's sent to the DLQ. + test.setTimeout(550_000); + + const eventId = uuidv4(); + const documentResourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + // Send pdm.resource.unavailable event with no retryCount + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + resourceId: documentResourceId, + messageReference, + senderId, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + PDM_POLL_LAMBDA_LOG_GROUP_NAME, + [ + `$.message.err[0].message = "must have required property 'retryCount'"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + + await expectMessageContainingString(PDM_POLL_DLQ_NAME, eventId, 420); + }); }); diff --git a/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts index 447672ed..9e58217a 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts @@ -167,7 +167,7 @@ test.describe('Digital Letters - Upload to PDM', () => { }, 120); }); - test('should send invalid event to dlq', async () => { + test('should send invalid event to uploader dlq', async () => { // Sadly it takes longer than expected to go through the 3 retries before it's sent to the DLQ. test.setTimeout(550_000); From 6143fce16e07abf328cc9e6d438422677f4ad3ea Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 5 Jan 2026 13:06:47 +0000 Subject: [PATCH 13/38] CCM-12614: fix for flaky build docs test --- package-lock.json | 4 +++- .../digital-letters/2025-10-draft/defs/core.schema.yaml | 1 - src/cloudevents/package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 63bcc39e..1adb8ebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14164,6 +14164,8 @@ }, "node_modules/json-schema-faker": { "version": "0.5.9", + "resolved": "https://registry.npmjs.org/json-schema-faker/-/json-schema-faker-0.5.9.tgz", + "integrity": "sha512-fNKLHgDvfGNNTX1zqIjqFMJjCLzJ2kvnJ831x4aqkAoeE4jE2TxvpJdhOnk3JU3s42vFzmXvkpbYzH5H3ncAzg==", "license": "MIT", "dependencies": { "json-schema-ref-parser": "^6.1.0", @@ -17741,7 +17743,7 @@ "ajv-formats": "^3.0.1", "fast-glob": "^3.3.2", "js-yaml": "^4.1.0", - "json-schema-faker": "^0.5.0-rc23", + "json-schema-faker": "^0.5.9", "json-schema-ref-parser": "^9.0.9" }, "devDependencies": { diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml index 45d8c45a..7dc5cd94 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/core.schema.yaml @@ -25,5 +25,4 @@ properties: "2025-10-01T10:15:30.000Z" ] type: "string" - format: "date-time" pattern: "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" diff --git a/src/cloudevents/package.json b/src/cloudevents/package.json index 01d66626..b92ac478 100644 --- a/src/cloudevents/package.json +++ b/src/cloudevents/package.json @@ -5,7 +5,7 @@ "ajv-formats": "^3.0.1", "fast-glob": "^3.3.2", "js-yaml": "^4.1.0", - "json-schema-faker": "^0.5.0-rc23", + "json-schema-faker": "^0.5.9", "json-schema-ref-parser": "^9.0.9" }, "devDependencies": { From 3ee927d25183f436e83e556a2a6321019e3709e3 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 5 Jan 2026 15:19:22 +0000 Subject: [PATCH 14/38] CCM-12614: get nhs number and ods code from pdm response --- .../src/__tests__/apis/sqs-handler.test.ts | 30 +++++++++-- .../src/__tests__/app/pdm.test.ts | 51 +++++++++++++++++-- .../pdm-poll-lambda/src/apis/sqs-handler.ts | 20 +++++--- lambdas/pdm-poll-lambda/src/app/pdm.ts | 29 +++++++++-- .../pdm-poll.component.spec.ts | 4 +- utils/utils/src/types/pdm-types.ts | 6 +++ 6 files changed, 118 insertions(+), 22 deletions(-) diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 0178f8e7..1c2a6342 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -36,7 +36,11 @@ describe('SQS Handler', () => { describe('pdm.resource.submitted', () => { it('should send pdm.resource.available event when the document is ready', async () => { - pdm.poll.mockResolvedValueOnce('available'); + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'available', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); const response = await handler(recordEvent([pdmResourceSubmittedEvent])); @@ -69,7 +73,11 @@ describe('SQS Handler', () => { }); it('should send pdm.resource.unavailable event when the document is not ready', async () => { - pdm.poll.mockResolvedValueOnce('unavailable'); + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'unavailable', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); const response = await handler(recordEvent([pdmResourceSubmittedEvent])); @@ -103,7 +111,11 @@ describe('SQS Handler', () => { describe('pdm.resource.unavailable', () => { it('should send pdm.resource.available event when the document is ready', async () => { - pdm.poll.mockResolvedValueOnce('available'); + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'available', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); const response = await handler( recordEvent([pdmResourceUnavailableEvent]), @@ -138,7 +150,11 @@ describe('SQS Handler', () => { }); it('should send pdm.resource.unavailable event when the document is not ready', async () => { - pdm.poll.mockResolvedValueOnce('unavailable'); + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'unavailable', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); const response = await handler( recordEvent([pdmResourceUnavailableEvent]), @@ -170,7 +186,11 @@ describe('SQS Handler', () => { }); it('should send pdm.resource.retries.exceeded event when the document is not ready after 10 retries', async () => { - pdm.poll.mockResolvedValueOnce('unavailable'); + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'unavailable', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); const testEvent = { ...pdmResourceUnavailableEvent, diff --git a/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts index d5d6a303..b4f96109 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts @@ -18,6 +18,14 @@ const availableResponse = { lastUpdated: '2025-12-10T09:00:47.068021Z', }, status: 'current', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y05868', + }, + }, + ], subject: { identifier: { system: 'https://fhir.nhs.uk/Id/nhs-number', @@ -68,7 +76,11 @@ describe('Pdm', () => { const result = await pdm.poll(pdmResourceSubmittedEvent); - expect(result).toBe('available'); + expect(result).toEqual({ + pdmAvailability: 'available', + nhsNumber: '9912003071', + odsCode: 'Y05868', + }); }); it('returns unavailable when the document is not ready', async () => { @@ -90,7 +102,11 @@ describe('Pdm', () => { const result = await pdm.poll(pdmResourceSubmittedEvent); - expect(result).toBe('unavailable'); + expect(result).toEqual({ + pdmAvailability: 'unavailable', + nhsNumber: '9912003071', + odsCode: 'Y05868', + }); }); it('logs and throws error when error from PDM', async () => { @@ -106,7 +122,36 @@ describe('Pdm', () => { expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: 'Error getting document resource from PDM', - err: new Error('pdm failure'), + err: thrown, + }), + ); + }); + + it('logs and throws error when no ODS Code is found', async () => { + const cfg = validConfig(); + const thrown = new Error('No ODS organization code found'); + const noOdsCodeResponse = { + ...availableResponse, + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/some-other-code', + value: '1111', + }, + }, + ], + }; + pdmClient.getDocumentReference.mockResolvedValue(noOdsCodeResponse); + + const pdm = new Pdm(cfg); + + await expect(pdm.poll(pdmResourceSubmittedEvent)).rejects.toThrow(thrown); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error getting document resource from PDM', + err: thrown, }), ); }); diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index 286ff05b..6c6d5848 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -76,7 +76,11 @@ function validateRecord( } } -function generateAvailableEvent(event: PollableEvent): PDMResourceAvailable { +function generateAvailableEvent( + event: PollableEvent, + nhsNumber: string, + odsCode: string, +): PDMResourceAvailable { const eventTime = new Date().toISOString(); return { @@ -89,8 +93,8 @@ function generateAvailableEvent(event: PollableEvent): PDMResourceAvailable { type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', data: { ...event.data, - nhsNumber: '9999999999', - odsCode: 'AB1234', + nhsNumber, + odsCode, }, }; } @@ -166,15 +170,13 @@ export const createHandler = ({ validatedRecords.map(async (validatedRecord: ValidatedRecord) => { try { const { event } = validatedRecord; - - const result = await pdm.poll(event); - + const { nhsNumber, odsCode, pdmAvailability } = await pdm.poll(event); let retries = 0; // First attempt for submitted events if ('retryCount' in event.data) { retries = event.data.retryCount + 1; // Increment attempt for unavailable events } - if (result === 'unavailable') { + if (pdmAvailability === 'unavailable') { if (retries >= pollMaxRetries) { retriesExceededEvents.push( generateRetriesExceededEvent(event, retries), @@ -183,7 +185,9 @@ export const createHandler = ({ unavailableEvents.push(generateUnavailableEvent(event, retries)); } } else { - availableEvents.push(generateAvailableEvent(event)); + availableEvents.push( + generateAvailableEvent(event, nhsNumber, odsCode), + ); } } catch (error: any) { logger.warn({ diff --git a/lambdas/pdm-poll-lambda/src/app/pdm.ts b/lambdas/pdm-poll-lambda/src/app/pdm.ts index ce1a6cf9..7fe6d1ce 100644 --- a/lambdas/pdm-poll-lambda/src/app/pdm.ts +++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts @@ -1,6 +1,12 @@ import { IPdmClient, Logger } from 'utils'; -export type PdmOutcome = 'available' | 'unavailable'; +export type PdmAvailability = 'available' | 'unavailable'; + +export type PdmPollResult = { + pdmAvailability: PdmAvailability; + nhsNumber: string; + odsCode: string; +}; export interface PdmDependencies { pdmClient: IPdmClient; @@ -24,7 +30,7 @@ export class Pdm { this.logger = config.logger; } - async poll(item: any): Promise { + async poll(item: any): Promise { try { this.logger.info(item); @@ -35,10 +41,23 @@ export class Pdm { this.logger.info(response); - if (response.content[0].attachment.data) { - return 'available'; + const { data } = response.content[0].attachment; + const nhsNumber = response.subject.identifier.value; + const odsCode = response.author.find( + (author) => + author.identifier.system === + 'https://fhir.nhs.uk/Id/ods-organization-code', + )?.identifier.value; + + if (!odsCode) { + throw new Error('No ODS organization code found'); } - return 'unavailable'; + + return { + pdmAvailability: data ? 'available' : 'unavailable', + nhsNumber, + odsCode, + }; } catch (error) { this.logger.error({ description: 'Error getting document resource from PDM', diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index 30091f24..5477e2ad 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -39,7 +39,7 @@ test.describe('PDM Poll', () => { test('should send a pdm.resource.available event when available in PDM', async () => { const eventId = uuidv4(); - const documentResourceId = 'fc450f3d-e6fe-3436-9e06-4c83cc38b707'; + const documentResourceId = '9ae75410-c067-35ae-9410-153fa849a4dd'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -65,6 +65,8 @@ test.describe('PDM Poll', () => { '$.message_type = "EVENT_RECEIPT"', '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"odsCode\\":\\"Y05868\\"*"`, + `$.details.event_detail = "*\\"nhsNumber\\":\\"9912003071\\"*"`, ], ); diff --git a/utils/utils/src/types/pdm-types.ts b/utils/utils/src/types/pdm-types.ts index 597abef0..ff1dbf29 100644 --- a/utils/utils/src/types/pdm-types.ts +++ b/utils/utils/src/types/pdm-types.ts @@ -19,4 +19,10 @@ export type PdmResponse = { data?: string; }; }[]; + author: { + identifier: { + system: string; + value: string; + }; + }[]; }; From 7d76c2a9ce0772970ce0d288fab677b5b76a9002 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Mon, 5 Jan 2026 15:38:06 +0000 Subject: [PATCH 15/38] CCM-12614: get nhs number and ods code from pdm response --- .../src/__tests__/app/upload-to-pdm.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts index 2e446a61..3adae4dd 100644 --- a/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts +++ b/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts @@ -43,6 +43,14 @@ describe('UploadToPdm', () => { lastUpdated: '2023-06-20T12:00:00Z', }, status: 'current', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y05868', + }, + }, + ], subject: { identifier: { system: 'https://fhir.nhs.uk/Id/nhs-number', From c934f1f49207d1be85573229575dbca56f493a34 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 12:20:32 +0000 Subject: [PATCH 16/38] CCM-12614: update pdm mock with an unavailable resource response --- .../src/__tests__/handlers.test.ts | 38 +++++++++++++++++++ lambdas/pdm-mock-lambda/src/handlers.ts | 11 +++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts index e4cfc6da..d7035724 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts @@ -74,6 +74,10 @@ describe('GET Resource Handler', () => { expect(body.meta).toBeDefined(); expect(body.meta.versionId).toBe('1'); expect(body.meta.lastUpdated).toBeDefined(); + expect(body.author[0].identifier.system).toBe( + 'https://fhir.nhs.uk/Id/ods-organization-code', + ); + expect(body.author[0].identifier.value).toBe('Y05868'); expect(body.subject.identifier.system).toBe( 'https://fhir.nhs.uk/Id/nhs-number', ); @@ -84,6 +88,40 @@ describe('GET Resource Handler', () => { expect(body.content[0].attachment.title).toBe('Dummy PDF'); }); + it('should return response with no attachment.data for unavailable-response', async () => { + const handler = createGetResourceHandler(mockLogger); + const event = createMockEvent({ + pathParameters: { id: 'unavailable-response' }, + headers: { + 'X-Request-ID': 'get-test-1234-5678-9abc-def012345678', + }, + }); + + const response = await handler(event); + + expect(response.statusCode).toBe(200); + expect(response.headers?.['Content-Type']).toBe('application/fhir+json'); + + const body = JSON.parse(response.body); + expect(body.resourceType).toBe('DocumentReference'); + expect(body.id).toBe('unavailable-response'); + expect(body.status).toBe('current'); + expect(body.meta).toBeDefined(); + expect(body.meta.versionId).toBe('1'); + expect(body.meta.lastUpdated).toBeDefined(); + expect(body.author[0].identifier.system).toBe( + 'https://fhir.nhs.uk/Id/ods-organization-code', + ); + expect(body.author[0].identifier.value).toBe('Y05868'); + expect(body.subject.identifier.system).toBe( + 'https://fhir.nhs.uk/Id/nhs-number', + ); + expect(body.subject.identifier.value).toBe('9912003071'); + expect(body.content[0].attachment.contentType).toBe('application/pdf'); + expect(body.content[0].attachment.data).not.toBeDefined(); + expect(body.content[0].attachment.title).toBe('Dummy PDF'); + }); + it('should return 400 error when resource ID is missing', async () => { const handler = createGetResourceHandler(mockLogger); const event = createMockEvent({ diff --git a/lambdas/pdm-mock-lambda/src/handlers.ts b/lambdas/pdm-mock-lambda/src/handlers.ts index 800d9a6d..69c3f303 100644 --- a/lambdas/pdm-mock-lambda/src/handlers.ts +++ b/lambdas/pdm-mock-lambda/src/handlers.ts @@ -158,6 +158,14 @@ const generateMockResource = ( lastUpdated: new Date().toISOString(), }, status: 'current', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y05868', + }, + }, + ], subject: { identifier: { system: 'https://fhir.nhs.uk/Id/nhs-number', @@ -236,7 +244,8 @@ export const createGetResourceHandler = (logger: Logger) => { return createEmptySuccessResponse(); } - const resource = generateMockResource(resourceId, true); + const includeData = resourceId === 'unavailable-response' ? false : true; + const resource = generateMockResource(resourceId, includeData); logger.info('Returning mock resource', { resourceId, requestId }); return createResourceResponse(resource); }; From c02e087e5b0e5ea7a297ea81ffd909d3b1b6b34d Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 12:22:11 +0000 Subject: [PATCH 17/38] CCM-12614: update pdm mock so it does not use iam_auth --- .../components/dl/aws_api_gateway_deployment_pdm_mock.tf | 2 ++ .../dl/aws_api_gateway_method_create_document_reference.tf | 2 +- .../dl/aws_api_gateway_method_get_document_reference.tf | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf b/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf index 6468311b..c6c39b1b 100644 --- a/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf +++ b/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf @@ -16,7 +16,9 @@ resource "aws_api_gateway_deployment" "pdm_mock" { aws_api_gateway_resource.document_reference[0].id, aws_api_gateway_resource.document_reference_id[0].id, aws_api_gateway_method.create_document_reference[0].id, + aws_api_gateway_method.create_document_reference[0].authorization, aws_api_gateway_method.get_document_reference[0].id, + aws_api_gateway_method.get_document_reference[0].authorization, aws_api_gateway_integration.create_document_reference[0].id, aws_api_gateway_integration.get_document_reference[0].id, ])) diff --git a/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf b/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf index 5058b284..c2d1d30c 100644 --- a/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf +++ b/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf @@ -4,5 +4,5 @@ resource "aws_api_gateway_method" "create_document_reference" { rest_api_id = aws_api_gateway_rest_api.pdm_mock[0].id resource_id = aws_api_gateway_resource.document_reference[0].id http_method = "POST" - authorization = "AWS_IAM" + authorization = "NONE" } diff --git a/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf b/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf index 7d107af6..56ac1b43 100644 --- a/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf +++ b/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf @@ -4,5 +4,5 @@ resource "aws_api_gateway_method" "get_document_reference" { rest_api_id = aws_api_gateway_rest_api.pdm_mock[0].id resource_id = aws_api_gateway_resource.document_reference_id[0].id http_method = "GET" - authorization = "AWS_IAM" + authorization = "NONE" } From 6a1a08f17f7a17e6caae1478079f05ce5c6741f1 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 12:23:14 +0000 Subject: [PATCH 18/38] CCM-12614: update pdm poll to use pdm mock when deployed --- .../terraform/components/dl/module_lambda_pdm_poll.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf index 552f7d2a..40138d0c 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -36,7 +36,7 @@ module "pdm_poll" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - "APIM_BASE_URL" = var.apim_base_url + "APIM_BASE_URL" = local.deploy_pdm_mock ? aws_api_gateway_stage.pdm_mock[0].invoke_url : var.apim_base_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 From 8b5e2c19474ba89a5110a68a1fc7d5c1c2a9416a Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 12:30:44 +0000 Subject: [PATCH 19/38] CCM-12614: simplify the pdm mock authentication --- .../components/dl/module_lambda_pdm_mock.tf | 6 - lambdas/pdm-mock-lambda/README.md | 40 +++---- .../src/__tests__/authenticator.test.ts | 111 ------------------ .../src/__tests__/config.test.ts | 67 ----------- .../src/__tests__/container.test.ts | 76 ------------ lambdas/pdm-mock-lambda/src/authenticator.ts | 38 +----- lambdas/pdm-mock-lambda/src/config.ts | 26 ---- lambdas/pdm-mock-lambda/src/container.ts | 32 +---- 8 files changed, 22 insertions(+), 374 deletions(-) delete mode 100644 lambdas/pdm-mock-lambda/src/__tests__/config.test.ts delete mode 100644 lambdas/pdm-mock-lambda/src/config.ts diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf index ab5475c5..42f9a971 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf @@ -35,12 +35,6 @@ module "pdm_mock" { send_to_firehose = true log_destination_arn = local.log_destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - - lambda_env_vars = { - MOCK_ACCESS_TOKEN = var.pdm_mock_access_token - ACCESS_TOKEN_SSM_PATH = local.apim_access_token_ssm_parameter_name - USE_NON_MOCK_TOKEN = var.pdm_use_non_mock_token - } } data "aws_iam_policy_document" "pdm_mock" { diff --git a/lambdas/pdm-mock-lambda/README.md b/lambdas/pdm-mock-lambda/README.md index da9d8c5c..c0ec5b0c 100644 --- a/lambdas/pdm-mock-lambda/README.md +++ b/lambdas/pdm-mock-lambda/README.md @@ -27,8 +27,8 @@ curl -X POST https:///patient-data-manager/FHIR/R4/DocumentRefe **Headers:** -- `Authorization: Bearer ` - Required authentication token (default: `mock-pdm-token`) -- `Content-Type: application/fhir+json` - Required content type +- `Authorization: Bearer ` - Authentication token is not validated and can be any string value. +- `Content-Type: application/fhir+json` - Required content type. - `X-Request-ID: ` - This uuid will be used as the DocumentReference `id` in the response. **Response (201 Created):** @@ -76,8 +76,8 @@ curl https:///patient-data-manager/FHIR/R4/DocumentReference/te **Headers:** -- `Authorization: Bearer ` - Required authentication token (default: `mock-pdm-token`) -- `Content-Type: application/fhir+json` - Required content type +- `Authorization: Bearer ` - Authentication token is not validated and can be any string value. +- `Content-Type: application/fhir+json` - Required content type. - `X-Request-ID: ` - Used for request tracking and correlation. This isn't part of the ID or response that gets returned. **Response (200 OK):** @@ -136,17 +136,18 @@ Both GET and POST endpoints require the `X-Request-ID` header. If it's missing, The mock API supports triggering specific error responses for testing in both endpoints. Use these special resource IDs: -| Resource ID | Status Code | Error Code | Description | -| ------------------------ | ----------- | ------------------- | ------------------------------- | -| `error-400-invalid` | 400 | INVALID_VALUE | Invalid resource value | -| `error-401-unauthorized` | 401 | UNAUTHORISED | Unauthorized access | -| `error-403-forbidden` | 403 | FORBIDDEN | Access forbidden | -| `error-404-notfound` | 404 | RESOURCE_NOT_FOUND | Resource not found | -| `error-409-conflict` | 409 | CONFLICT | Resource already exists | -| `error-429-ratelimit` | 429 | TOO_MANY_REQUESTS | Rate limit exceeded | -| `error-500-internal` | 500 | INTERNAL_ERROR | Internal server error | -| `error-503-unavailable` | 503 | SERVICE_UNAVAILABLE | Service temporarily unavailable | -| `empty-response` | 200 | - | Empty success response | +| Resource ID | Status Code | Error Code | Description | +| ------------------------ | ----------- | ------------------- | ---------------------------------------- | +| `error-400-invalid` | 400 | INVALID_VALUE | Invalid resource value | +| `error-401-unauthorized` | 401 | UNAUTHORISED | Unauthorized access | +| `error-403-forbidden` | 403 | FORBIDDEN | Access forbidden | +| `error-404-notfound` | 404 | RESOURCE_NOT_FOUND | Resource not found | +| `error-409-conflict` | 409 | CONFLICT | Resource already exists | +| `error-429-ratelimit` | 429 | TOO_MANY_REQUESTS | Rate limit exceeded | +| `error-500-internal` | 500 | INTERNAL_ERROR | Internal server error | +| `error-503-unavailable` | 503 | SERVICE_UNAVAILABLE | Service temporarily unavailable | +| `empty-response` | 200 | - | Empty success response | +| `unavailable-response` | 200 | - | Success response with no attachment.data | **Example - Trigger 404 Error:** @@ -177,9 +178,6 @@ curl https:///resource/error-404-notfound \ The lambda is configured via environment variables: -| Variable | Description | Default | -| ----------------------- | ---------------------------------------- | -------------------------- | -| `MOCK_ACCESS_TOKEN` | Token to use in local/dev environments | `mock-pdm-token` | -| `ACCESS_TOKEN_SSM_PATH` | SSM parameter path for the access token | `/dl/main/apim/access_token`| -| `USE_NON_MOCK_TOKEN` | Use SSM token instead of mock token | `false` | -| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARN, ERROR) | `INFO` | +| Variable | Description | Default | +| ----------------------- | ---------------------------------------- | ------------------------ | +| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARN, ERROR) | `INFO` | diff --git a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts index 5ab8dc55..d9352863 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts @@ -10,21 +10,14 @@ const mockLogger: Logger = { } as any; describe('Authenticator', () => { - let mockGetAccessToken: jest.Mock; beforeEach(() => { jest.clearAllMocks(); - mockGetAccessToken = jest.fn(); }); describe('with mock token', () => { it('should authenticate successfully with valid Bearer token', async () => { const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, mockLogger, ); @@ -33,16 +26,10 @@ describe('Authenticator', () => { }); expect(result.isValid).toBe(true); - expect(mockGetAccessToken).not.toHaveBeenCalled(); }); it('should reject request with missing Authorization header', async () => { const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, mockLogger, ); @@ -64,11 +51,6 @@ describe('Authenticator', () => { it('should reject request with invalid token type', async () => { const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, mockLogger, ); @@ -86,37 +68,8 @@ describe('Authenticator', () => { ); }); - it('should reject request with invalid token value', async () => { - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - const result = await authenticator({ - headers: { Authorization: 'Bearer wrong-token' }, - }); - - expect(result.isValid).toBe(false); - expect(result).toHaveProperty('error'); - expect((result as { isValid: false; error: any }).error.statusCode).toBe( - 401, - ); - expect((result as { isValid: false; error: any }).error.body).toContain( - 'Invalid Access Token', - ); - }); - it('should handle lowercase authorization header', async () => { const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, mockLogger, ); @@ -127,68 +80,4 @@ describe('Authenticator', () => { expect(result.isValid).toBe(true); }); }); - - describe('with non-mock token', () => { - it('should authenticate successfully with SSM token', async () => { - mockGetAccessToken.mockResolvedValue('ssm-token'); - - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: true, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - const result = await authenticator({ - headers: { Authorization: 'Bearer ssm-token' }, - }); - - expect(result.isValid).toBe(true); - expect(mockGetAccessToken).toHaveBeenCalledTimes(1); - }); - - it('should reject request with mock token when non-mock token is required', async () => { - mockGetAccessToken.mockResolvedValue('ssm-token'); - - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: true, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - const result = await authenticator({ - headers: { Authorization: 'Bearer test-token' }, - }); - - expect(result.isValid).toBe(false); - expect(result).toHaveProperty('error'); - expect((result as { isValid: false; error: any }).error.statusCode).toBe( - 401, - ); - }); - - it('should handle SSM token retrieval errors gracefully', async () => { - mockGetAccessToken.mockRejectedValue(new Error('SSM error')); - - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: true, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - await expect( - authenticator({ - headers: { Authorization: 'Bearer test-token' }, - }), - ).rejects.toThrow('SSM error'); - }); - }); }); diff --git a/lambdas/pdm-mock-lambda/src/__tests__/config.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/config.test.ts deleted file mode 100644 index 17d57516..00000000 --- a/lambdas/pdm-mock-lambda/src/__tests__/config.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { loadConfig } from 'config'; - -describe('Config', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...originalEnv }; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - describe('loadConfig', () => { - it('should load config with default values', () => { - delete process.env.MOCK_ACCESS_TOKEN; - delete process.env.ACCESS_TOKEN_SSM_PATH; - delete process.env.USE_NON_MOCK_TOKEN; - delete process.env.LOG_LEVEL; - - const config = loadConfig(); - - expect(config.mockAccessToken).toBe('mock-pdm-token'); - expect(config.accessTokenSsmPath).toBe('/dl/main/apim/access_token'); - expect(config.useNonMockToken).toBe(false); - expect(config.logLevel).toBe('INFO'); - }); - - it('should load config from environment variables', () => { - process.env.MOCK_ACCESS_TOKEN = 'custom-token'; - process.env.ACCESS_TOKEN_SSM_PATH = '/custom/path'; - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.LOG_LEVEL = 'DEBUG'; - - const config = loadConfig(); - - expect(config.mockAccessToken).toBe('custom-token'); - expect(config.accessTokenSsmPath).toBe('/custom/path'); - expect(config.useNonMockToken).toBe(true); - expect(config.logLevel).toBe('DEBUG'); - }); - - it('should parse boolean environment variables correctly', () => { - process.env.USE_NON_MOCK_TOKEN = 'TRUE'; - let config = loadConfig(); - expect(config.useNonMockToken).toBe(true); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - config = loadConfig(); - expect(config.useNonMockToken).toBe(true); - - process.env.USE_NON_MOCK_TOKEN = 'false'; - config = loadConfig(); - expect(config.useNonMockToken).toBe(false); - - process.env.USE_NON_MOCK_TOKEN = 'FALSE'; - config = loadConfig(); - expect(config.useNonMockToken).toBe(false); - }); - - it('should not throw when all required env vars have default values', () => { - const config = loadConfig(); - expect(config).toBeDefined(); - }); - }); -}); diff --git a/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts index 963a7adf..4cfda0ac 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts @@ -1,5 +1,4 @@ import { createContainer } from 'container'; -import { parameterStore } from 'utils'; jest.mock('utils', () => { const actual = jest.requireActual('utils'); @@ -25,9 +24,6 @@ describe('Container', () => { beforeEach(() => { jest.clearAllMocks(); process.env = { ...originalEnv }; - process.env.MOCK_ACCESS_TOKEN = 'test-token'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/path'; - process.env.USE_NON_MOCK_TOKEN = 'false'; process.env.LOG_LEVEL = 'INFO'; container = createContainer(); @@ -88,76 +84,4 @@ describe('Container', () => { expect(result).toBeDefined(); expect(result.isValid).toBeDefined(); }); - - it('should handle USE_NON_MOCK_TOKEN configuration', () => { - process.env.USE_NON_MOCK_TOKEN = 'true'; - const containerWithSSM = createContainer(); - - expect(containerWithSSM).toBeDefined(); - expect(containerWithSSM.authenticator).toBeDefined(); - expect(typeof containerWithSSM.authenticator).toBe('function'); - }); - - it('should wire getAccessToken to authenticator when using SSM token', async () => { - const mockTokenValue = JSON.stringify({ - access_token: 'ssm-stored-token', - expires_at: 1_765_187_843, - token_type: 'Bearer', - }); - - (parameterStore.getParameter as jest.Mock).mockResolvedValue({ - Value: mockTokenValue, - }); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/token/path'; - process.env.MOCK_ACCESS_TOKEN = 'unused-mock-token'; - - const testContainer = createContainer(); - - const result = await testContainer.authenticator({ - headers: { Authorization: 'Bearer ssm-stored-token' }, - }); - - expect(result.isValid).toBe(true); - expect(parameterStore.getParameter).toHaveBeenCalledWith( - '/test/token/path', - ); - }); - - it('should handle invalid JSON format in SSM parameter', async () => { - (parameterStore.getParameter as jest.Mock).mockResolvedValue({ - Value: 'invalid-json', - }); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/token/path'; - - const testContainer = createContainer(); - - await expect( - testContainer.authenticator({ - headers: { Authorization: 'Bearer any-token' }, - }), - ).rejects.toThrow('Invalid access token format in SSM parameter'); - }); - - it('should handle missing SSM parameter', async () => { - (parameterStore.getParameter as jest.Mock).mockResolvedValue({ - Value: undefined, - }); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/token/path'; - - const testContainer = createContainer(); - - await expect( - testContainer.authenticator({ - headers: { Authorization: 'Bearer any-token' }, - }), - ).rejects.toThrow( - 'Access token parameter "/test/token/path" not found in SSM', - ); - }); }); diff --git a/lambdas/pdm-mock-lambda/src/authenticator.ts b/lambdas/pdm-mock-lambda/src/authenticator.ts index b6987c91..000de2db 100644 --- a/lambdas/pdm-mock-lambda/src/authenticator.ts +++ b/lambdas/pdm-mock-lambda/src/authenticator.ts @@ -1,17 +1,11 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import type { Logger } from 'utils'; -export interface AuthConfig { - mockAccessToken: string; - useNonMockToken: boolean; - getAccessToken: () => Promise; -} - export type AuthResult = | { isValid: true } | { isValid: false; error: APIGatewayProxyResult }; -export const createAuthenticator = (authConfig: AuthConfig, logger: Logger) => { +export const createAuthenticator = (logger: Logger) => { return async ( event: Pick, ): Promise => { @@ -41,7 +35,7 @@ export const createAuthenticator = (authConfig: AuthConfig, logger: Logger) => { }; } - const [tokenType, token] = authHeader.split(' '); + const [tokenType] = authHeader.split(' '); if (tokenType !== 'Bearer') { logger.warn(tokenType, 'Invalid token type'); @@ -66,34 +60,6 @@ export const createAuthenticator = (authConfig: AuthConfig, logger: Logger) => { }; } - const validToken = authConfig.useNonMockToken - ? await authConfig.getAccessToken() - : authConfig.mockAccessToken; - - // eslint-disable-next-line security/detect-possible-timing-attacks - if (token !== validToken) { - logger.warn('Token validation failed'); - return { - isValid: false, - error: { - statusCode: 401, - body: JSON.stringify({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'forbidden', - details: { - coding: [{ code: 'ACCESS_DENIED' }], - }, - diagnostics: 'Invalid Access Token', - }, - ], - }), - }, - }; - } - logger.debug('Authentication successful'); return { isValid: true }; }; diff --git a/lambdas/pdm-mock-lambda/src/config.ts b/lambdas/pdm-mock-lambda/src/config.ts deleted file mode 100644 index 977e6762..00000000 --- a/lambdas/pdm-mock-lambda/src/config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defaultConfigReader } from 'utils'; - -export interface Config { - mockAccessToken: string; - accessTokenSsmPath: string; - useNonMockToken: boolean; - logLevel: string; -} - -export const loadConfig = (): Config => { - const mockAccessToken = - defaultConfigReader.tryGetValue('MOCK_ACCESS_TOKEN') || 'mock-pdm-token'; - const accessTokenSsmPath = - defaultConfigReader.tryGetValue('ACCESS_TOKEN_SSM_PATH') || - '/dl/main/apim/access_token'; - const useNonMockToken = - defaultConfigReader.tryGetBoolean('USE_NON_MOCK_TOKEN') || false; - const logLevel = defaultConfigReader.tryGetValue('LOG_LEVEL') || 'INFO'; - - return { - mockAccessToken, - accessTokenSsmPath, - useNonMockToken, - logLevel, - }; -}; diff --git a/lambdas/pdm-mock-lambda/src/container.ts b/lambdas/pdm-mock-lambda/src/container.ts index fea6e382..3c15ac62 100644 --- a/lambdas/pdm-mock-lambda/src/container.ts +++ b/lambdas/pdm-mock-lambda/src/container.ts @@ -1,5 +1,4 @@ -import { logger, parameterStore } from 'utils'; -import { loadConfig } from 'config'; +import { logger } from 'utils'; import { createAuthenticator } from 'authenticator'; import { createCreateResourceHandler, @@ -14,36 +13,7 @@ export interface Container { } export const createContainer = (): Container => { - const config = loadConfig(); - - const getAccessToken = async () => { - const parameter = await parameterStore.getParameter( - config.accessTokenSsmPath, - ); - if (!parameter?.Value) { - throw new Error( - `Access token parameter "${config.accessTokenSsmPath}" not found in SSM`, - ); - } - - try { - const parsed = JSON.parse(parameter.Value); - return parsed.access_token; - } catch (error) { - logger.error('Failed to parse access token from SSM', { - error, - value: parameter.Value, - }); - throw new Error('Invalid access token format in SSM parameter'); - } - }; - const authenticator = createAuthenticator( - { - mockAccessToken: config.mockAccessToken, - useNonMockToken: config.useNonMockToken, - getAccessToken, - }, logger, ); From 721d8bb55f25f0b80f7f9ef3fd20d1985bac23ab Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 12:31:26 +0000 Subject: [PATCH 20/38] CCM-12614: update component test to use mock resource id --- .../pdm-poll.component.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index 5477e2ad..7e9f7551 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -76,7 +76,7 @@ test.describe('PDM Poll', () => { test('should send a pdm.resource.unavailable event when not available in PDM', async () => { const eventId = uuidv4(); - const documentResourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const documentResourceId = 'unavailable-response'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -116,7 +116,7 @@ test.describe('PDM Poll', () => { test('should send a pdm.resource.retries.exceeded event when not available in PDM after 10 retries', async () => { const eventId = uuidv4(); - const documentResourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const documentResourceId = 'unavailable-response'; const messageReference = uuidv4(); const senderId = uuidv4(); From 40843043d41951e73e013452d1f83b7194ff348f Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 12:38:58 +0000 Subject: [PATCH 21/38] CCM-12614: update to use local.csi --- infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf | 2 +- .../terraform/components/dl/module_sqs_pdm_uploader.tf | 2 +- infrastructure/terraform/components/dl/module_sqs_ttl.tf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf index ffe157a3..f7cb3842 100644 --- a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf @@ -29,7 +29,7 @@ data "aws_iam_policy_document" "sqs_pdm_poll" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-pdm-poll-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-pdm-poll-queue" ] } } diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf index a9b1b33a..cb45762a 100644 --- a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf @@ -32,7 +32,7 @@ data "aws_iam_policy_document" "sqs_pdm_uploader" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-pdm-uploader-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-pdm-uploader-queue" ] } } diff --git a/infrastructure/terraform/components/dl/module_sqs_ttl.tf b/infrastructure/terraform/components/dl/module_sqs_ttl.tf index 055c0ff9..38638a2b 100644 --- a/infrastructure/terraform/components/dl/module_sqs_ttl.tf +++ b/infrastructure/terraform/components/dl/module_sqs_ttl.tf @@ -32,7 +32,7 @@ data "aws_iam_policy_document" "sqs_ttl" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-ttl-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-ttl-queue" ] } } From 0dc5ad3d2520da2200bdda0246dd6c4392edd78d Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 13:13:22 +0000 Subject: [PATCH 22/38] CCM-12614: linting --- lambdas/pdm-mock-lambda/src/container.ts | 4 +--- lambdas/pdm-mock-lambda/src/handlers.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lambdas/pdm-mock-lambda/src/container.ts b/lambdas/pdm-mock-lambda/src/container.ts index 3c15ac62..2824c465 100644 --- a/lambdas/pdm-mock-lambda/src/container.ts +++ b/lambdas/pdm-mock-lambda/src/container.ts @@ -13,9 +13,7 @@ export interface Container { } export const createContainer = (): Container => { - const authenticator = createAuthenticator( - logger, - ); + const authenticator = createAuthenticator(logger); const getResourceHandler = createGetResourceHandler(logger); const createResourceHandler = createCreateResourceHandler(logger); diff --git a/lambdas/pdm-mock-lambda/src/handlers.ts b/lambdas/pdm-mock-lambda/src/handlers.ts index 69c3f303..a7b5ad1d 100644 --- a/lambdas/pdm-mock-lambda/src/handlers.ts +++ b/lambdas/pdm-mock-lambda/src/handlers.ts @@ -244,7 +244,7 @@ export const createGetResourceHandler = (logger: Logger) => { return createEmptySuccessResponse(); } - const includeData = resourceId === 'unavailable-response' ? false : true; + const includeData = resourceId !== 'unavailable-response'; const resource = generateMockResource(resourceId, includeData); logger.info('Returning mock resource', { resourceId, requestId }); return createResourceResponse(resource); From 23b5a11235c488ca3b9d49da4d7ea80a108ec108 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 13:26:18 +0000 Subject: [PATCH 23/38] CCM-12614: linting --- .../src/__tests__/authenticator.test.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts index d9352863..86db5e8c 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts @@ -10,16 +10,13 @@ const mockLogger: Logger = { } as any; describe('Authenticator', () => { - beforeEach(() => { jest.clearAllMocks(); }); describe('with mock token', () => { it('should authenticate successfully with valid Bearer token', async () => { - const authenticator = createAuthenticator( - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: { Authorization: 'Bearer test-token' }, @@ -29,9 +26,7 @@ describe('Authenticator', () => { }); it('should reject request with missing Authorization header', async () => { - const authenticator = createAuthenticator( - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: {} }); @@ -50,9 +45,7 @@ describe('Authenticator', () => { }); it('should reject request with invalid token type', async () => { - const authenticator = createAuthenticator( - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: { Authorization: 'Basic test-token' }, @@ -69,9 +62,7 @@ describe('Authenticator', () => { }); it('should handle lowercase authorization header', async () => { - const authenticator = createAuthenticator( - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: { authorization: 'Bearer test-token' }, From 3d0c413323a2d1e9db1fd8f6dea3ce4c702618a4 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 13:47:16 +0000 Subject: [PATCH 24/38] CCM-12614: remove logging of sensitive data --- lambdas/pdm-poll-lambda/src/app/pdm.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lambdas/pdm-poll-lambda/src/app/pdm.ts b/lambdas/pdm-poll-lambda/src/app/pdm.ts index 7fe6d1ce..a26e1e03 100644 --- a/lambdas/pdm-poll-lambda/src/app/pdm.ts +++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts @@ -39,8 +39,6 @@ export class Pdm { item.data.messageReference, ); - this.logger.info(response); - const { data } = response.content[0].attachment; const nhsNumber = response.subject.identifier.value; const odsCode = response.author.find( From c8c7f093d5270a98925463714647b376b879ee4e Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 13:51:40 +0000 Subject: [PATCH 25/38] CCM-12614: remove unused npm packages --- lambdas/pdm-poll-lambda/package.json | 5 ----- package-lock.json | 5 ----- 2 files changed, 10 deletions(-) diff --git a/lambdas/pdm-poll-lambda/package.json b/lambdas/pdm-poll-lambda/package.json index 7d1b591d..8a1d0894 100644 --- a/lambdas/pdm-poll-lambda/package.json +++ b/lambdas/pdm-poll-lambda/package.json @@ -2,17 +2,12 @@ "dependencies": { "aws-lambda": "^1.0.7", "digital-letters-events": "^0.0.1", - "lodash": "^4.17.21", - "p-limit": "^3.1.0", "utils": "^0.0.1" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", - "@types/lodash": "^4.17.20", - "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" diff --git a/package-lock.json b/package-lock.json index 1adb8ebc..bed97dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -297,17 +297,12 @@ "dependencies": { "aws-lambda": "^1.0.7", "digital-letters-events": "^0.0.1", - "lodash": "^4.17.21", - "p-limit": "^3.1.0", "utils": "^0.0.1" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", - "@types/lodash": "^4.17.20", - "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" From ed5ba2db1ee5721059d87c61220e46a7f196e057 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 13:54:53 +0000 Subject: [PATCH 26/38] CCM-12614: update to retryCount description --- .../domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml index ca33a916..a1804697 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml @@ -12,6 +12,6 @@ properties: retryCount: type: integer minimum: 0 - description: Number of times that PDM has been polled while waiting for document processing to complete + description: Number of times that PDM has been retried while waiting for document processing to complete examples: - 2 From 67125a3ba08719c1ebcbaad5a2236030b117af92 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 15:18:55 +0000 Subject: [PATCH 27/38] CCM-12614: update and addition to component tests --- .../pdm-poll.component.spec.ts | 245 +++++++++++------- 1 file changed, 145 insertions(+), 100 deletions(-) diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index 7e9f7551..46fce3f2 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -20,138 +20,183 @@ const baseEvent = { '/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.submitted.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-submitted-data.schema.json', dataschemaversion: '1.0', severitytext: 'INFO', }; +const submittedEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-submitted-data.schema.json', +}; + +const unavailableEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', +}; + test.describe('PDM Poll', () => { test.beforeAll(async () => { test.setTimeout(250_000); }); - test('should send a pdm.resource.available event when available in PDM', async () => { - const eventId = uuidv4(); - const documentResourceId = '9ae75410-c067-35ae-9410-153fa849a4dd'; - const messageReference = uuidv4(); - const senderId = uuidv4(); - - await eventPublisher.sendEvents( - [ - { - ...baseEvent, - id: eventId, - data: { - resourceId: documentResourceId, - messageReference, - senderId, - }, - }, - ], - pdmResourceSubmittedValidator, - ); + test.describe('pdm.resource.submitted', () => { + test('should send a pdm.resource.available event when available in PDM', async () => { + const eventId = uuidv4(); + const documentResourceId = '9ae75410-c067-35ae-9410-153fa849a4dd'; + const messageReference = uuidv4(); + const senderId = uuidv4(); - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - EVENT_BUS_LOG_GROUP_NAME, + await eventPublisher.sendEvents( [ - '$.message_type = "EVENT_RECEIPT"', - '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', - `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, - `$.details.event_detail = "*\\"odsCode\\":\\"Y05868\\"*"`, - `$.details.event_detail = "*\\"nhsNumber\\":\\"9912003071\\"*"`, + { + ...submittedEvent, + id: eventId, + data: { + resourceId: documentResourceId, + messageReference, + senderId, + }, + }, ], + pdmResourceSubmittedValidator, ); - expect(eventLogEntry.length).toEqual(1); - }, 120); - }); - - test('should send a pdm.resource.unavailable event when not available in PDM', async () => { - const eventId = uuidv4(); - const documentResourceId = 'unavailable-response'; - const messageReference = uuidv4(); - const senderId = uuidv4(); - - await eventPublisher.sendEvents( - [ - { - ...baseEvent, - id: eventId, - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', - type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', - data: { - resourceId: documentResourceId, - messageReference, - senderId, - retryCount: 0, - }, - }, - ], - pdmResourceUnavailableValidator, - ); - - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - EVENT_BUS_LOG_GROUP_NAME, + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"odsCode\\":\\"Y05868\\"*"`, + `$.details.event_detail = "*\\"nhsNumber\\":\\"9912003071\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + + test('should send a pdm.resource.unavailable event when unavailable in PDM', async () => { + const eventId = uuidv4(); + const documentResourceId = 'unavailable-response'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( [ - '$.message_type = "EVENT_RECEIPT"', - '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"', - `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, - `$.details.event_detail = "*\\"retryCount\\":1*"`, + { + ...submittedEvent, + id: eventId, + data: { + resourceId: documentResourceId, + messageReference, + senderId, + }, + }, ], + pdmResourceSubmittedValidator, ); - expect(eventLogEntry.length).toEqual(1); - }, 120); + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":0*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); }); - test('should send a pdm.resource.retries.exceeded event when not available in PDM after 10 retries', async () => { - const eventId = uuidv4(); - const documentResourceId = 'unavailable-response'; - const messageReference = uuidv4(); - const senderId = uuidv4(); + test.describe('pdm.resource.unavailable', () => { + test('should send a pdm.resource.unavailable event when still unavailable in PDM', async () => { + const eventId = uuidv4(); + const documentResourceId = 'unavailable-response'; + const messageReference = uuidv4(); + const senderId = uuidv4(); - await eventPublisher.sendEvents( - [ - { - ...baseEvent, - id: eventId, - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', - type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', - data: { - resourceId: documentResourceId, - messageReference, - senderId, - retryCount: 9, + await eventPublisher.sendEvents( + [ + { + ...unavailableEvent, + id: eventId, + data: { + resourceId: documentResourceId, + messageReference, + senderId, + retryCount: 0, + }, }, - }, - ], - pdmResourceUnavailableValidator, - ); + ], + pdmResourceUnavailableValidator, + ); - await expectToPassEventually(async () => { - const eventLogEntry = await getLogsFromCloudwatch( - EVENT_BUS_LOG_GROUP_NAME, + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":1*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + + test('should send a pdm.resource.retries.exceeded event when unavailable in PDM after 10 retries', async () => { + const eventId = uuidv4(); + const documentResourceId = 'unavailable-response'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( [ - '$.message_type = "EVENT_RECEIPT"', - '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1"', - `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, - `$.details.event_detail = "*\\"retryCount\\":10*"`, + { + ...unavailableEvent, + id: eventId, + data: { + resourceId: documentResourceId, + messageReference, + senderId, + retryCount: 9, + }, + }, ], + pdmResourceUnavailableValidator, ); - expect(eventLogEntry.length).toEqual(1); - }, 120); + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":10*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); }); test('should send invalid event to poll dlq', async () => { From deac2d4bfbf5d952554a57c83b883a486eba8f9a Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 16:34:21 +0000 Subject: [PATCH 28/38] CCM-12614: update event target resource names --- .../dl/cloudwatch_event_rule_pdm_resource_submitted.tf | 3 +-- .../dl/cloudwatch_event_rule_pdm_resource_unavailable.tf | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf index 0ae3c949..3dc98b1d 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf @@ -12,9 +12,8 @@ resource "aws_cloudwatch_event_rule" "pdm_resource_submitted" { }) } -resource "aws_cloudwatch_event_target" "pdm_resource_submitted" { +resource "aws_cloudwatch_event_target" "pdm_resource_submitted_pdm_poll_target" { rule = aws_cloudwatch_event_rule.pdm_resource_submitted.name arn = module.sqs_pdm_poll.sqs_queue_arn - target_id = "pdm-resource-submitted-target" event_bus_name = aws_cloudwatch_event_bus.main.name } diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf index 3188f0ec..9452388d 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf @@ -12,9 +12,8 @@ resource "aws_cloudwatch_event_rule" "pdm_resource_unavailable" { }) } -resource "aws_cloudwatch_event_target" "pdm_resource_unavailable" { +resource "aws_cloudwatch_event_target" "pdm_resource_unavailable_pdm_poll_target" { rule = aws_cloudwatch_event_rule.pdm_resource_unavailable.name arn = module.sqs_pdm_poll.sqs_queue_arn - target_id = "pdm-resource-unavailable-target" event_bus_name = aws_cloudwatch_event_bus.main.name } From 45696fa92fbf196366a918107583006f2fb5ef47 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 16:40:11 +0000 Subject: [PATCH 29/38] CCM-12614: update event target resource names --- .../dl/cloudwatch_event_rule_pdm_resource_submitted.tf | 2 +- .../dl/cloudwatch_event_rule_pdm_resource_unavailable.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf index 3dc98b1d..9c5394fc 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf @@ -12,7 +12,7 @@ resource "aws_cloudwatch_event_rule" "pdm_resource_submitted" { }) } -resource "aws_cloudwatch_event_target" "pdm_resource_submitted_pdm_poll_target" { +resource "aws_cloudwatch_event_target" "pdm_resource_submitted_pdm_poll" { rule = aws_cloudwatch_event_rule.pdm_resource_submitted.name arn = module.sqs_pdm_poll.sqs_queue_arn event_bus_name = aws_cloudwatch_event_bus.main.name diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf index 9452388d..ed63ebbe 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf @@ -12,7 +12,7 @@ resource "aws_cloudwatch_event_rule" "pdm_resource_unavailable" { }) } -resource "aws_cloudwatch_event_target" "pdm_resource_unavailable_pdm_poll_target" { +resource "aws_cloudwatch_event_target" "pdm_resource_unavailable_pdm_poll" { rule = aws_cloudwatch_event_rule.pdm_resource_unavailable.name arn = module.sqs_pdm_poll.sqs_queue_arn event_bus_name = aws_cloudwatch_event_bus.main.name From c3682c1eff5868da6e48c78967d3486da22d89f1 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Tue, 6 Jan 2026 16:45:50 +0000 Subject: [PATCH 30/38] CCM-12614: update all axios to latest version --- .../refresh-apim-access-token/package.json | 6 ++--- package-lock.json | 24 ++++++------------- utils/sender-management/package.json | 1 + utils/utils/package.json | 2 +- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/lambdas/refresh-apim-access-token/package.json b/lambdas/refresh-apim-access-token/package.json index 9130950f..88b85610 100644 --- a/lambdas/refresh-apim-access-token/package.json +++ b/lambdas/refresh-apim-access-token/package.json @@ -2,11 +2,11 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.840.0", "aws-lambda": "^1.0.7", - "axios": "1.10.0", + "axios": "^1.13.2", "esbuild": "^0.25.9", "jsonwebtoken": "^9.0.2", - "utils": "*", - "qs": "^6.14.0" + "qs": "^6.14.0", + "utils": "*" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", diff --git a/package-lock.json b/package-lock.json index bed97dbd..7c3018f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -388,17 +388,6 @@ "pretty-format": "^29.0.0" } }, - "lambdas/pdm-uploader-lambda/node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "lambdas/pdm-uploader-lambda/node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -453,7 +442,7 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.840.0", "aws-lambda": "^1.0.7", - "axios": "1.10.0", + "axios": "^1.13.2", "esbuild": "^0.25.9", "jsonwebtoken": "^9.0.2", "qs": "^6.14.0", @@ -8401,13 +8390,13 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -20582,6 +20571,7 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.914.0", "@types/yargs": "^17.0.33", + "axios": "^1.13.2", "tsx": "^4.20.6", "utils": "^0.0.1", "yargs": "^17.7.2", @@ -20661,7 +20651,7 @@ "@aws-sdk/lib-dynamodb": "^3.914.0", "@aws-sdk/lib-storage": "^3.914.0", "async-mutex": "^0.4.0", - "axios": "^1.10.0", + "axios": "^1.13.2", "date-fns": "^4.1.0", "node-jose": "^2.2.0", "winston": "^3.17.0", diff --git a/utils/sender-management/package.json b/utils/sender-management/package.json index e008904a..8544a5ba 100644 --- a/utils/sender-management/package.json +++ b/utils/sender-management/package.json @@ -2,6 +2,7 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.914.0", "@types/yargs": "^17.0.33", + "axios": "^1.13.2", "tsx": "^4.20.6", "utils": "^0.0.1", "yargs": "^17.7.2", diff --git a/utils/utils/package.json b/utils/utils/package.json index c477e9ec..5669cae8 100644 --- a/utils/utils/package.json +++ b/utils/utils/package.json @@ -9,7 +9,7 @@ "@aws-sdk/lib-dynamodb": "^3.914.0", "@aws-sdk/lib-storage": "^3.914.0", "async-mutex": "^0.4.0", - "axios": "^1.10.0", + "axios": "^1.13.2", "date-fns": "^4.1.0", "node-jose": "^2.2.0", "winston": "^3.17.0", From f5169187eb7fec4ee9601e99d6b61e26a6a7fb99 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 7 Jan 2026 08:39:29 +0000 Subject: [PATCH 31/38] CCM-12614: linting --- utils/utils/src/pdm-client/pdm-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/utils/src/pdm-client/pdm-client.ts b/utils/utils/src/pdm-client/pdm-client.ts index 3a93dec4..7b50fc3d 100644 --- a/utils/utils/src/pdm-client/pdm-client.ts +++ b/utils/utils/src/pdm-client/pdm-client.ts @@ -122,8 +122,8 @@ export class PdmClient implements IPdmClient { (err) => Boolean( isAxiosError(err) && - err.response?.status === - HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS, + err.response?.status === + HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS, ), this.backoffConfig, ); From 4bf5f0733844d6d5863c981ccd0e05c616523f86 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 7 Jan 2026 16:06:43 +0000 Subject: [PATCH 32/38] CCM-12614: remove send_to_firehose --- infrastructure/terraform/components/dl/README.md | 2 ++ .../terraform/components/dl/module_lambda_pdm_poll.tf | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 4f1fca61..3ba20eec 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -46,12 +46,14 @@ No requirements. | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | 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\_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\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | +| [sqs\_print\_status\_handler](#module\_sqs\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf index 40138d0c..8a9bf319 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -31,7 +31,6 @@ module "pdm_poll" { force_lambda_code_deploy = var.force_lambda_code_deploy enable_lambda_insights = false - send_to_firehose = true log_destination_arn = local.log_destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn From f9e9c264c620529481790c561642833008e38d51 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 7 Jan 2026 16:18:13 +0000 Subject: [PATCH 33/38] CCM-12614: remove invalid fields --- .../digital-letters-component-tests/pdm-poll.component.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index 46fce3f2..243b8e6e 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -13,8 +13,6 @@ import { expectMessageContainingString } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; const baseEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', specversion: '1.0', source: '/nhs/england/notify/production/primary/data-plane/digitalletters/pdm', @@ -25,7 +23,6 @@ const baseEvent = { severitynumber: 2, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', datacontenttype: 'application/json', - dataschemaversion: '1.0', severitytext: 'INFO', }; From 1f386fc30cc508e6ba718640869c319fdaa695fb Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 7 Jan 2026 16:18:43 +0000 Subject: [PATCH 34/38] CCM-12614: remove axios dependency --- package-lock.json | 1 - utils/sender-management/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a1d1511..5180598a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18844,7 +18844,6 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.914.0", "@types/yargs": "^17.0.33", - "axios": "^1.13.2", "tsx": "^4.20.6", "utils": "^0.0.1", "yargs": "^17.7.2", diff --git a/utils/sender-management/package.json b/utils/sender-management/package.json index 8544a5ba..e008904a 100644 --- a/utils/sender-management/package.json +++ b/utils/sender-management/package.json @@ -2,7 +2,6 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.914.0", "@types/yargs": "^17.0.33", - "axios": "^1.13.2", "tsx": "^4.20.6", "utils": "^0.0.1", "yargs": "^17.7.2", From cd5fb9b3097caa4ca8ab31c6a76253c6d4d62abb Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Wed, 7 Jan 2026 18:38:04 +0000 Subject: [PATCH 35/38] CCM-12614: update .gitleaksignore with a new ignore --- .gitleaksignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitleaksignore b/.gitleaksignore index 5def75c1..8042ee19 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -16,3 +16,4 @@ d1c0a37078cbed4fbedae044e5cbafac71717af0:utils/utils/src/__tests__/key-generatio d1c0a37078cbed4fbedae044e5cbafac71717af0:utils/utils/src/__tests__/key-generation/get-private-key.test.ts:private-key:23 d1c0a37078cbed4fbedae044e5cbafac71717af0:utils/utils/src/__tests__/key-generation/get-private-key.test.ts:private-key:30 d1c0a37078cbed4fbedae044e5cbafac71717af0:utils/utils/src/__tests__/key-generation/get-private-key.test.ts:private-key:46 +f0eebf1356a699213340a45f64c6b990afcbb869:infrastructure/terraform/components/dl/ssm_parameter_mesh.tf:hashicorp-tf-password:11 From bbffe19d6504b741f7b661138931c8b889417396 Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 8 Jan 2026 09:12:58 +0000 Subject: [PATCH 36/38] CCM-12614: fix unavailable to available bug --- .../terraform/components/dl/README.md | 2 - .../src/__tests__/apis/sqs-handler.test.ts | 9 ++- .../pdm-poll-lambda/src/apis/sqs-handler.ts | 4 +- .../pdm-poll.component.spec.ts | 58 +++++++++++++++---- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 3ba20eec..4f1fca61 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -46,14 +46,12 @@ No requirements. | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | 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\_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\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_print\_status\_handler](#module\_sqs\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 1c2a6342..3b309c1f 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -55,7 +55,9 @@ describe('SQS Handler', () => { 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', data: { - ...pdmResourceSubmittedEvent.data, + messageReference: pdmResourceSubmittedEvent.data.messageReference, + senderId: pdmResourceSubmittedEvent.data.senderId, + resourceId: pdmResourceSubmittedEvent.data.resourceId, nhsNumber: '9999999999', odsCode: 'AB1234', }, @@ -132,7 +134,10 @@ describe('SQS Handler', () => { 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', data: { - ...pdmResourceUnavailableEvent.data, + messageReference: + pdmResourceUnavailableEvent.data.messageReference, + senderId: pdmResourceUnavailableEvent.data.senderId, + resourceId: pdmResourceUnavailableEvent.data.resourceId, nhsNumber: '9999999999', odsCode: 'AB1234', }, diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index 6c6d5848..6cd0c4d8 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -92,7 +92,9 @@ function generateAvailableEvent( 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', data: { - ...event.data, + messageReference: event.data.messageReference, + senderId: event.data.senderId, + resourceId: event.data.resourceId, nhsNumber, odsCode, }, diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index 243b8e6e..fe5e001b 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -48,7 +48,7 @@ test.describe('PDM Poll', () => { test.describe('pdm.resource.submitted', () => { test('should send a pdm.resource.available event when available in PDM', async () => { const eventId = uuidv4(); - const documentResourceId = '9ae75410-c067-35ae-9410-153fa849a4dd'; + const resourceId = '9ae75410-c067-35ae-9410-153fa849a4dd'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -58,7 +58,7 @@ test.describe('PDM Poll', () => { ...submittedEvent, id: eventId, data: { - resourceId: documentResourceId, + resourceId, messageReference, senderId, }, @@ -85,7 +85,7 @@ test.describe('PDM Poll', () => { test('should send a pdm.resource.unavailable event when unavailable in PDM', async () => { const eventId = uuidv4(); - const documentResourceId = 'unavailable-response'; + const resourceId = 'unavailable-response'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -95,7 +95,7 @@ test.describe('PDM Poll', () => { ...submittedEvent, id: eventId, data: { - resourceId: documentResourceId, + resourceId, messageReference, senderId, }, @@ -121,9 +121,47 @@ test.describe('PDM Poll', () => { }); test.describe('pdm.resource.unavailable', () => { + test('should send a pdm.resource.available event when an unavailable resource becomes available in PDM', async () => { + const eventId = uuidv4(); + const resourceId = uuidv4(); + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...unavailableEvent, + id: eventId, + data: { + resourceId, + messageReference, + senderId, + retryCount: 0, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"odsCode\\":\\"Y05868\\"*"`, + `$.details.event_detail = "*\\"nhsNumber\\":\\"9912003071\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + test('should send a pdm.resource.unavailable event when still unavailable in PDM', async () => { const eventId = uuidv4(); - const documentResourceId = 'unavailable-response'; + const resourceId = 'unavailable-response'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -133,7 +171,7 @@ test.describe('PDM Poll', () => { ...unavailableEvent, id: eventId, data: { - resourceId: documentResourceId, + resourceId, messageReference, senderId, retryCount: 0, @@ -160,7 +198,7 @@ test.describe('PDM Poll', () => { test('should send a pdm.resource.retries.exceeded event when unavailable in PDM after 10 retries', async () => { const eventId = uuidv4(); - const documentResourceId = 'unavailable-response'; + const resourceId = 'unavailable-response'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -170,7 +208,7 @@ test.describe('PDM Poll', () => { ...unavailableEvent, id: eventId, data: { - resourceId: documentResourceId, + resourceId, messageReference, senderId, retryCount: 9, @@ -201,7 +239,7 @@ test.describe('PDM Poll', () => { test.setTimeout(550_000); const eventId = uuidv4(); - const documentResourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const resourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; const messageReference = uuidv4(); const senderId = uuidv4(); @@ -215,7 +253,7 @@ test.describe('PDM Poll', () => { 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', data: { - resourceId: documentResourceId, + resourceId, messageReference, senderId, }, From 5d00f87e97c4db7579bf6ee7ed6785940167e44e Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 8 Jan 2026 09:28:03 +0000 Subject: [PATCH 37/38] CCM-12614: fix unavailable to available bug --- .../src/__tests__/apis/sqs-handler.test.ts | 14 +++++++++++--- lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts index 3b309c1f..74c1ac35 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -94,7 +94,9 @@ describe('SQS Handler', () => { 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', data: { - ...pdmResourceSubmittedEvent.data, + messageReference: pdmResourceSubmittedEvent.data.messageReference, + senderId: pdmResourceSubmittedEvent.data.senderId, + resourceId: pdmResourceSubmittedEvent.data.resourceId, retryCount: 0, }, }, @@ -174,7 +176,10 @@ describe('SQS Handler', () => { recordedtime: '2023-06-20T12:00:00.250Z', type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', data: { - ...pdmResourceSubmittedEvent.data, + messageReference: + pdmResourceUnavailableEvent.data.messageReference, + senderId: pdmResourceUnavailableEvent.data.senderId, + resourceId: pdmResourceUnavailableEvent.data.resourceId, retryCount: 2, }, }, @@ -218,7 +223,10 @@ describe('SQS Handler', () => { 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-retries-exceeded-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', data: { - ...pdmResourceUnavailableEvent.data, + messageReference: + pdmResourceUnavailableEvent.data.messageReference, + senderId: pdmResourceUnavailableEvent.data.senderId, + resourceId: pdmResourceUnavailableEvent.data.resourceId, retryCount: 10, }, }, diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts index 6cd0c4d8..39ae9c72 100644 --- a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -116,7 +116,9 @@ function generateUnavailableEvent( 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', data: { - ...event.data, + messageReference: event.data.messageReference, + senderId: event.data.senderId, + resourceId: event.data.resourceId, retryCount: retries, }, }; @@ -137,7 +139,9 @@ function generateRetriesExceededEvent( 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-retries-exceeded-data.schema.json', type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', data: { - ...event.data, + messageReference: event.data.messageReference, + senderId: event.data.senderId, + resourceId: event.data.resourceId, retryCount: retries, }, }; From db26feb78677a7ac4d8d0e2bb76b5ac9241305ea Mon Sep 17 00:00:00 2001 From: Ian Hodges Date: Thu, 8 Jan 2026 12:11:57 +0000 Subject: [PATCH 38/38] CCM-12614: update lambda terraform to v2.0.29 --- infrastructure/terraform/components/dl/README.md | 2 +- .../terraform/components/dl/module_lambda_pdm_poll.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 4f1fca61..25f460fb 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -44,7 +44,7 @@ No requirements. | [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 | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf index 8a9bf319..5ee748b1 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -1,5 +1,5 @@ module "pdm_poll" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" function_name = "pdm-poll" description = "A function for polling PDM document status"