diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md
index 3cc47f95..25f460fb 100644
--- a/infrastructure/terraform/components/dl/README.md
+++ b/infrastructure/terraform/components/dl/README.md
@@ -31,7 +31,7 @@ No requirements.
| [pdm\_use\_non\_mock\_token](#input\_pdm\_use\_non\_mock\_token) | Whether to use the shared APIM access token from SSM (/component/environment/apim/access\_token) instead of the mock token | `bool` | `false` | 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 |
@@ -44,11 +44,13 @@ 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.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 |
| [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\_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 |
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"
}
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..9c5394fc
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf
@@ -0,0 +1,19 @@
+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_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
new file mode 100644
index 00000000..ed63ebbe
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf
@@ -0,0 +1,19 @@
+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_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
+}
diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf
new file mode 100644
index 00000000..acd13036
--- /dev/null
+++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf
@@ -0,0 +1,10 @@
+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
+
+ function_response_types = [
+ "ReportBatchItemFailures"
+ ]
+}
diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf
index 0734c000..b4ce6313 100644
--- a/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf
+++ b/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf
@@ -34,12 +34,6 @@ module "pdm_mock" {
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/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf
new file mode 100644
index 00000000..5ee748b1
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf
@@ -0,0 +1,102 @@
+module "pdm_poll" {
+ 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"
+
+ 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.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 = "pdm-poll-lambda/dist"
+ function_include_common = true
+ handler_function_name = "handler"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 60
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.log_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = {
+ "APIM_BASE_URL" = 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
+ "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"
+
+ 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,
+ ]
+ }
+ statement {
+ sid = "SQSPermissionsPollPdmQueue"
+ effect = "Allow"
+
+ actions = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes",
+ "sqs:GetQueueUrl",
+ ]
+
+ resources = [
+ module.sqs_pdm_poll.sqs_queue_arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf
new file mode 100644
index 00000000..f7cb3842
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf
@@ -0,0 +1,35 @@
+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
+ component = local.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ 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_pdm_poll.json
+}
+
+data "aws_iam_policy_document" "sqs_pdm_poll" {
+ statement {
+ sid = "AllowEventBridgeToSendMessage"
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["events.amazonaws.com"]
+ }
+
+ actions = [
+ "sqs:SendMessage"
+ ]
+
+ resources = [
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-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"
]
}
}
diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf
index d3f63e02..028ec53e 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-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..86db5e8c 100644
--- a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts
+++ b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts
@@ -10,41 +10,23 @@ 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,
- );
+ const authenticator = createAuthenticator(mockLogger);
const result = await authenticator({
headers: { Authorization: 'Bearer test-token' },
});
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,
- );
+ const authenticator = createAuthenticator(mockLogger);
const result = await authenticator({ headers: {} });
@@ -63,14 +45,7 @@ describe('Authenticator', () => {
});
it('should reject request with invalid token type', async () => {
- const authenticator = createAuthenticator(
- {
- mockAccessToken: 'test-token',
- useNonMockToken: false,
- getAccessToken: mockGetAccessToken,
- },
- mockLogger,
- );
+ const authenticator = createAuthenticator(mockLogger);
const result = await authenticator({
headers: { Authorization: 'Basic test-token' },
@@ -86,39 +61,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,
- );
+ const authenticator = createAuthenticator(mockLogger);
const result = await authenticator({
headers: { authorization: 'Bearer test-token' },
@@ -127,68 +71,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/__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/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..2824c465 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,38 +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,
- );
+ 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 800d9a6d..a7b5ad1d 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';
+ const resource = generateMockResource(resourceId, includeData);
logger.info('Returning mock resource', { resourceId, requestId });
return createResourceResponse(resource);
};
diff --git a/lambdas/pdm-poll-lambda/jest.config.ts b/lambdas/pdm-poll-lambda/jest.config.ts
new file mode 100644
index 00000000..c02601ae
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/jest.config.ts
@@ -0,0 +1,5 @@
+import { baseJestConfig } from '../../jest.config.base';
+
+const config = baseJestConfig;
+
+export default config;
diff --git a/lambdas/pdm-poll-lambda/package.json b/lambdas/pdm-poll-lambda/package.json
new file mode 100644
index 00000000..8a1d0894
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/package.json
@@ -0,0 +1,25 @@
+{
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "digital-letters-events": "^0.0.1",
+ "utils": "^0.0.1"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ },
+ "name": "nhs-notify-digital-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",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "version": "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
new file mode 100644
index 00000000..74c1ac35
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts
@@ -0,0 +1,343 @@
+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 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,
+ logger,
+ pdm,
+ pollMaxRetries: 10,
+});
+
+describe('SQS Handler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('pdm.resource.submitted', () => {
+ it('should send pdm.resource.available event when the document is ready', async () => {
+ pdm.poll.mockResolvedValueOnce({
+ pdmAvailability: 'available',
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ });
+
+ 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',
+ 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: {
+ messageReference: pdmResourceSubmittedEvent.data.messageReference,
+ senderId: pdmResourceSubmittedEvent.data.senderId,
+ resourceId: pdmResourceSubmittedEvent.data.resourceId,
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ },
+ },
+ ],
+ expect.any(Function),
+ );
+ 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({
+ pdmAvailability: 'unavailable',
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ });
+
+ 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',
+ 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: {
+ messageReference: pdmResourceSubmittedEvent.data.messageReference,
+ senderId: pdmResourceSubmittedEvent.data.senderId,
+ resourceId: pdmResourceSubmittedEvent.data.resourceId,
+ retryCount: 0,
+ },
+ },
+ ],
+ expect.any(Function),
+ );
+ 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('pdm.resource.unavailable', () => {
+ it('should send pdm.resource.available event when the document is ready', async () => {
+ pdm.poll.mockResolvedValueOnce({
+ pdmAvailability: 'available',
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ });
+
+ 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',
+ 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: {
+ messageReference:
+ pdmResourceUnavailableEvent.data.messageReference,
+ senderId: pdmResourceUnavailableEvent.data.senderId,
+ resourceId: pdmResourceUnavailableEvent.data.resourceId,
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ },
+ },
+ ],
+ expect.any(Function),
+ );
+ 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({
+ pdmAvailability: 'unavailable',
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ });
+
+ 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.unavailable.v1',
+ data: {
+ messageReference:
+ pdmResourceUnavailableEvent.data.messageReference,
+ senderId: pdmResourceUnavailableEvent.data.senderId,
+ resourceId: pdmResourceUnavailableEvent.data.resourceId,
+ retryCount: 2,
+ },
+ },
+ ],
+ expect.any(Function),
+ );
+ 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.retries.exceeded event when the document is not ready after 10 retries', async () => {
+ pdm.poll.mockResolvedValueOnce({
+ pdmAvailability: 'unavailable',
+ nhsNumber: '9999999999',
+ odsCode: 'AB1234',
+ });
+
+ 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',
+ 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: {
+ messageReference:
+ pdmResourceUnavailableEvent.data.messageReference,
+ senderId: pdmResourceUnavailableEvent.data.senderId,
+ resourceId: pdmResourceUnavailableEvent.data.resourceId,
+ retryCount: 10,
+ },
+ },
+ ],
+ expect.any(Function),
+ );
+ 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 SQS records to the queue if an error occurs while calling PDM', async () => {
+ pdm.poll.mockRejectedValueOnce(new Error('PDM error'));
+ const event = recordEvent([pdmResourceSubmittedEvent]);
+
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: 'PDM error',
+ description: 'Failed processing message',
+ });
+
+ 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(
+ '0 of 1 records processed successfully',
+ );
+
+ 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..b4f96109
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts
@@ -0,0 +1,159 @@
+import { mock } from 'jest-mock-extended';
+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 => ({
+ pdmClient,
+ logger,
+});
+
+const availableResponse = {
+ resourceType: 'DocumentReference',
+ id: '4c5af7c3-ca21-31b8-924b-fa526db5379b',
+ meta: {
+ versionId: '1',
+ 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',
+ 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', () => {
+ const cfg = validConfig();
+ expect(() => new Pdm(cfg)).not.toThrow();
+ });
+
+ it('throws if pdmClient is not provided', () => {
+ const cfg = {
+ logger,
+ } as unknown as PdmDependencies;
+
+ expect(() => new Pdm(cfg)).toThrow('pdmClient has not been specified');
+ });
+
+ it('throws if logger is not provided', () => {
+ const cfg = {
+ pdmClient,
+ } as unknown 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();
+ pdmClient.getDocumentReference.mockResolvedValue(availableResponse);
+
+ const pdm = new Pdm(cfg);
+
+ const result = await pdm.poll(pdmResourceSubmittedEvent);
+
+ expect(result).toEqual({
+ pdmAvailability: 'available',
+ nhsNumber: '9912003071',
+ odsCode: 'Y05868',
+ });
+ });
+
+ it('returns unavailable when the document is not ready', async () => {
+ const cfg = validConfig();
+ const unavailableResponse = {
+ ...availableResponse,
+ content: [
+ {
+ attachment: {
+ contentType: 'application/pdf',
+ title: 'Dummy PDF',
+ },
+ },
+ ],
+ };
+ pdmClient.getDocumentReference.mockResolvedValue(unavailableResponse);
+
+ const pdm = new Pdm(cfg);
+
+ const result = await pdm.poll(pdmResourceSubmittedEvent);
+
+ expect(result).toEqual({
+ pdmAvailability: 'unavailable',
+ nhsNumber: '9912003071',
+ odsCode: 'Y05868',
+ });
+ });
+
+ it('logs and throws error when error from PDM', async () => {
+ const cfg = validConfig();
+ const thrown = new Error('pdm failure');
+ pdmClient.getDocumentReference.mockRejectedValueOnce(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,
+ }),
+ );
+ });
+
+ 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/__tests__/container.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts
new file mode 100644
index 00000000..8833bc85
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts
@@ -0,0 +1,28 @@
+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',
+ maxPollCount: 10,
+ })),
+}));
+
+jest.mock('utils', () => ({
+ createGetApimAccessToken: jest.fn(() => ({})),
+ eventBridgeClient: {},
+ EventPublisher: jest.fn(() => ({})),
+ logger: {},
+ ParameterStoreCache: jest.fn(() => ({})),
+ PdmClient: jest.fn(() => ({})),
+ sqsClient: {},
+}));
+
+describe('container', () => {
+ it('should create container', () => {
+ const container = createContainer();
+ expect(container).toBeDefined();
+ });
+});
diff --git a/lambdas/pdm-poll-lambda/src/__tests__/index.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/index.test.ts
new file mode 100644
index 00000000..b5465321
--- /dev/null
+++ b/lambdas/pdm-poll-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/pdm-poll-lambda/src/__tests__/infra/config.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/infra/config.test.ts
new file mode 100644
index 00000000..2902c80f
--- /dev/null
+++ b/lambdas/pdm-poll-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/pdm-poll-lambda/src/__tests__/test-data.ts b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts
new file mode 100644
index 00000000..ec47a851
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts
@@ -0,0 +1,77 @@
+import { SQSEvent, SQSRecord } from 'aws-lambda';
+import {
+ PDMResourceSubmitted,
+ PDMResourceUnavailable,
+} from 'digital-letters-events';
+
+const baseEvent = {
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ 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/digital-letter-base-data.schema.json',
+ severitytext: 'INFO',
+ data: {
+ resourceId: 'a2bcbb42-ab7e-42b6-88d6-74f8d3ca4a09',
+ 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-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-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json',
+ data: {
+ ...baseEvent.data,
+ retryCount: 1,
+ },
+} as PDMResourceUnavailable;
+
+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: (PDMResourceSubmitted | PDMResourceUnavailable)[],
+): 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
new file mode 100644
index 00000000..39ae9c72
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts
@@ -0,0 +1,234 @@
+import { Pdm } from 'app/pdm';
+import type {
+ SQSBatchItemFailure,
+ SQSBatchResponse,
+ 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';
+
+export interface HandlerDependencies {
+ eventPublisher: EventPublisher;
+ logger: Logger;
+ pdm: Pdm;
+ pollMaxRetries: number;
+}
+
+type PollableEvent = PDMResourceSubmitted | PDMResourceUnavailable;
+
+type ValidatedRecord = {
+ messageId: string;
+ event: PollableEvent;
+};
+
+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;
+ }
+}
+
+function generateAvailableEvent(
+ event: PollableEvent,
+ nhsNumber: string,
+ odsCode: string,
+): 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: {
+ messageReference: event.data.messageReference,
+ senderId: event.data.senderId,
+ resourceId: event.data.resourceId,
+ nhsNumber,
+ odsCode,
+ },
+ };
+}
+
+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: {
+ messageReference: event.data.messageReference,
+ senderId: event.data.senderId,
+ resourceId: event.data.resourceId,
+ 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: {
+ messageReference: event.data.messageReference,
+ senderId: event.data.senderId,
+ resourceId: event.data.resourceId,
+ retryCount: retries,
+ },
+ };
+}
+
+export const createHandler = ({
+ eventPublisher,
+ logger,
+ pdm,
+ pollMaxRetries,
+}: 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)`);
+
+ 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(
+ validatedRecords.map(async (validatedRecord: ValidatedRecord) => {
+ try {
+ const { event } = validatedRecord;
+ 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 (pdmAvailability === 'unavailable') {
+ if (retries >= pollMaxRetries) {
+ retriesExceededEvents.push(
+ generateRetriesExceededEvent(event, retries),
+ );
+ } else {
+ unavailableEvents.push(generateUnavailableEvent(event, retries));
+ }
+ } else {
+ availableEvents.push(
+ generateAvailableEvent(event, nhsNumber, odsCode),
+ );
+ }
+ } catch (error: any) {
+ logger.warn({
+ err: error.message,
+ description: 'Failed processing message',
+ });
+ batchItemFailures.push({ itemIdentifier: validatedRecord.messageId });
+ }
+ }),
+ );
+
+ 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`,
+ );
+
+ return { batchItemFailures };
+ };
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..a26e1e03
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts
@@ -0,0 +1,68 @@
+import { IPdmClient, Logger } from 'utils';
+
+export type PdmAvailability = 'available' | 'unavailable';
+
+export type PdmPollResult = {
+ pdmAvailability: PdmAvailability;
+ nhsNumber: string;
+ odsCode: string;
+};
+
+export interface PdmDependencies {
+ pdmClient: IPdmClient;
+ logger: Logger;
+}
+
+export class Pdm {
+ private readonly pdmClient: IPdmClient;
+
+ private readonly logger: Logger;
+
+ constructor(config: PdmDependencies) {
+ if (!config.pdmClient) {
+ throw new Error('pdmClient has not been specified');
+ }
+ if (!config.logger) {
+ throw new Error('logger has not been provided');
+ }
+
+ this.pdmClient = config.pdmClient;
+ this.logger = config.logger;
+ }
+
+ async poll(item: any): Promise {
+ try {
+ this.logger.info(item);
+
+ const response = await this.pdmClient.getDocumentReference(
+ item.data.resourceId,
+ item.data.messageReference,
+ );
+
+ 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 {
+ pdmAvailability: data ? 'available' : 'unavailable',
+ nhsNumber,
+ odsCode,
+ };
+ } 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
new file mode 100644
index 00000000..d8514aa8
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/container.ts
@@ -0,0 +1,51 @@
+import { HandlerDependencies } from 'apis/sqs-handler';
+import { Pdm } from 'app/pdm';
+import { loadConfig } from 'infra/config';
+import {
+ EventPublisher,
+ ParameterStoreCache,
+ PdmClient,
+ createGetApimAccessToken,
+ eventBridgeClient,
+ logger,
+ sqsClient,
+} from 'utils';
+
+export const createContainer = (): HandlerDependencies => {
+ const {
+ apimAccessTokenSsmParameterName,
+ apimBaseUrl,
+ eventPublisherDlqUrl,
+ eventPublisherEventBusArn,
+ pollMaxRetries,
+ } = loadConfig();
+
+ const eventPublisher = new EventPublisher({
+ eventBusArn: eventPublisherEventBusArn,
+ dlqUrl: eventPublisherDlqUrl,
+ logger,
+ sqsClient,
+ eventBridgeClient,
+ });
+
+ const parameterStore = new ParameterStoreCache();
+
+ const accessTokenRepository = {
+ getAccessToken: createGetApimAccessToken(
+ apimAccessTokenSsmParameterName,
+ logger,
+ parameterStore,
+ ),
+ };
+
+ const pdmClient = new PdmClient(apimBaseUrl, accessTokenRepository, logger);
+
+ const pdm = new Pdm({
+ pdmClient,
+ logger,
+ });
+
+ return { eventPublisher, logger, pdm, pollMaxRetries };
+};
+
+export default createContainer;
diff --git a/lambdas/pdm-poll-lambda/src/index.ts b/lambdas/pdm-poll-lambda/src/index.ts
new file mode 100644
index 00000000..f25a8086
--- /dev/null
+++ b/lambdas/pdm-poll-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/pdm-poll-lambda/src/infra/config.ts b/lambdas/pdm-poll-lambda/src/infra/config.ts
new file mode 100644
index 00000000..e40455ff
--- /dev/null
+++ b/lambdas/pdm-poll-lambda/src/infra/config.ts
@@ -0,0 +1,25 @@
+import { defaultConfigReader } from 'utils';
+
+export type PdmCreateConfig = {
+ apimBaseUrl: string;
+ pollMaxRetries: number;
+ apimAccessTokenSsmParameterName: string;
+ eventPublisherEventBusArn: string;
+ eventPublisherDlqUrl: string;
+};
+
+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',
+ ),
+ eventPublisherEventBusArn: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_EVENT_BUS_ARN',
+ ),
+ eventPublisherDlqUrl: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_DLQ_URL',
+ ),
+ };
+}
diff --git a/lambdas/pdm-poll-lambda/tsconfig.json b/lambdas/pdm-poll-lambda/tsconfig.json
new file mode 100644
index 00000000..f7bcaa1f
--- /dev/null
+++ b/lambdas/pdm-poll-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/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..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
@@ -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'),
@@ -44,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',
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/refresh-apim-access-token/package.json b/lambdas/refresh-apim-access-token/package.json
index 766771a6..59450065 100644
--- a/lambdas/refresh-apim-access-token/package.json
+++ b/lambdas/refresh-apim-access-token/package.json
@@ -2,7 +2,7 @@
"dependencies": {
"@aws-sdk/client-ssm": "^3.840.0",
"aws-lambda": "^1.0.7",
- "axios": "1.12.0",
+ "axios": "^1.13.2",
"esbuild": "^0.25.9",
"jsonwebtoken": "^9.0.2",
"qs": "^6.14.1",
diff --git a/package-lock.json b/package-lock.json
index f7a440f1..5180598a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"lambdas/refresh-apim-access-token",
"lambdas/mesh-poll",
"lambdas/pdm-mock-lambda",
+ "lambdas/pdm-poll-lambda",
"lambdas/ttl-create-lambda",
"lambdas/ttl-handle-expiry-lambda",
"lambdas/ttl-poll-lambda",
@@ -283,11 +284,11 @@
"dev": true,
"license": "MIT"
},
- "lambdas/pdm-uploader-lambda": {
- "name": "nhs-notify-digital-letters-pdm-uploader",
+ "lambdas/pdm-poll-lambda": {
+ "name": "nhs-notify-digital-letters-pdm-poll-lambda",
"version": "0.0.1",
"dependencies": {
- "axios": "^1.13.2",
+ "aws-lambda": "^1.0.7",
"digital-letters-events": "^0.0.1",
"utils": "^0.0.1"
},
@@ -296,10 +297,11 @@
"@types/aws-lambda": "^8.10.155",
"@types/jest": "^29.5.14",
"jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
"typescript": "^5.9.3"
}
},
- "lambdas/pdm-uploader-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==",
@@ -310,15 +312,73 @@
"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==",
+ "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==",
+ "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
- "proxy-from-env": "^1.1.0"
+ "@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/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==",
+ "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/pdm-uploader-lambda": {
+ "name": "nhs-notify-digital-letters-pdm-uploader",
+ "version": "0.0.1",
+ "dependencies": {
+ "axios": "^1.13.2",
+ "digital-letters-events": "^0.0.1",
+ "utils": "^0.0.1"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "typescript": "^5.9.3"
+ }
+ },
+ "lambdas/pdm-uploader-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/pdm-uploader-lambda/node_modules/jest": {
@@ -348,12 +408,34 @@
}
}
},
+ "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": {
"@aws-sdk/client-ssm": "^3.840.0",
"aws-lambda": "^1.0.7",
- "axios": "1.12.0",
+ "axios": "^1.13.2",
"esbuild": "^0.25.9",
"jsonwebtoken": "^9.0.2",
"qs": "^6.14.1",
@@ -7271,9 +7353,9 @@
}
},
"node_modules/axios": {
- "version": "1.12.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
- "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
+ "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",
@@ -14754,6 +14836,10 @@
"resolved": "lambdas/pdm-mock-lambda",
"link": true
},
+ "node_modules/nhs-notify-digital-letters-pdm-poll-lambda": {
+ "resolved": "lambdas/pdm-poll-lambda",
+ "link": true
+ },
"node_modules/nhs-notify-digital-letters-pdm-uploader": {
"resolved": "lambdas/pdm-uploader-lambda",
"link": true
@@ -18386,7 +18472,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": {
@@ -18837,6 +18923,7 @@
"@aws-sdk/lib-dynamodb": "^3.914.0",
"@aws-sdk/lib-storage": "^3.914.0",
"async-mutex": "^0.4.0",
+ "axios": "^1.13.2",
"date-fns": "^4.1.0",
"node-jose": "^2.2.0",
"winston": "^3.17.0",
diff --git a/package.json b/package.json
index 27c15e9e..d993bbda 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
"lambdas/refresh-apim-access-token",
"lambdas/mesh-poll",
"lambdas/pdm-mock-lambda",
+ "lambdas/pdm-poll-lambda",
"lambdas/ttl-create-lambda",
"lambdas/ttl-handle-expiry-lambda",
"lambdas/ttl-poll-lambda",
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..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
@@ -11,7 +11,7 @@ properties:
- "f5524783-e5d7-473e-b2a0-29582ff231da"
retryCount:
type: integer
- minimum: 1
- description: Number of times that PDM has been polled while waiting for document processing to complete
+ minimum: 0
+ description: Number of times that PDM has been retried while waiting for document processing to complete
examples:
- 2
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
diff --git a/src/cloudevents/package.json b/src/cloudevents/package.json
index 173c2afa..798398a6 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": {
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
new file mode 100644
index 00000000..fe5e001b
--- /dev/null
+++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts
@@ -0,0 +1,278 @@
+import { expect, test } from '@playwright/test';
+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 = {
+ 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',
+ time: '2023-06-20T12:00:00Z',
+ recordedtime: '2023-06-20T12:00:00.250Z',
+ severitynumber: 2,
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ datacontenttype: 'application/json',
+ 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.describe('pdm.resource.submitted', () => {
+ test('should send a pdm.resource.available event when available in PDM', async () => {
+ const eventId = uuidv4();
+ const resourceId = '9ae75410-c067-35ae-9410-153fa849a4dd';
+ const messageReference = uuidv4();
+ const senderId = uuidv4();
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...submittedEvent,
+ id: eventId,
+ data: {
+ resourceId,
+ 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}\\"*"`,
+ `$.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 resourceId = 'unavailable-response';
+ const messageReference = uuidv4();
+ const senderId = uuidv4();
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...submittedEvent,
+ id: eventId,
+ data: {
+ resourceId,
+ 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.unavailable.v1"',
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"retryCount\\":0*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 120);
+ });
+ });
+
+ 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 resourceId = 'unavailable-response';
+ 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.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 resourceId = 'unavailable-response';
+ const messageReference = uuidv4();
+ const senderId = uuidv4();
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...unavailableEvent,
+ id: eventId,
+ data: {
+ resourceId,
+ 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);
+ });
+ });
+
+ 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 resourceId = '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,
+ 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 ee734c2e..5a2d99c4 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',
};
@@ -170,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);
diff --git a/utils/utils/package.json b/utils/utils/package.json
index 112ae9bb..5669cae8 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.13.2",
"date-fns": "^4.1.0",
"node-jose": "^2.2.0",
"winston": "^3.17.0",
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..aa7858f8
--- /dev/null
+++ b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts
@@ -0,0 +1,343 @@
+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;
+ };
+ const mockDocumentResourceId = 'doc-123';
+ const mockResponse = { data: { id: mockDocumentResourceId } };
+ const mockRequestId = 'req-123';
+
+ 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',
+ });
+
+ it('should successfully create document reference', async () => {
+ 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('');
+ 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 },
+ };
+
+ 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', () => {
+ it('should successfully fetch document reference', async () => {
+ mockAxiosInstance.get.mockResolvedValue(mockResponse);
+
+ const result = await pdmClient.getDocumentReference(
+ mockDocumentResourceId,
+ mockRequestId,
+ );
+
+ expect(result).toEqual(mockResponse.data);
+ expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1);
+ expect(mockAxiosInstance.get).toHaveBeenCalledWith(
+ `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentResourceId}`,
+ {
+ 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(
+ mockDocumentResourceId,
+ mockRequestId,
+ );
+
+ expect(mockAxiosInstance.get).toHaveBeenCalledWith(
+ `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentResourceId}`,
+ {
+ 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(
+ mockDocumentResourceId,
+ 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(mockDocumentResourceId, 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(mockDocumentResourceId, 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(
+ mockDocumentResourceId,
+ mockRequestId,
+ ),
+ ).rejects.toEqual(mockError);
+
+ // Should attempt 3 times then give up
+ expect(mockAxiosInstance.get).toHaveBeenCalledTimes(3);
+ });
+ });
+});
diff --git a/utils/utils/src/index.ts b/utils/utils/src/index.ts
index 46ef7231..8da5a845 100644
--- a/utils/utils/src/index.ts
+++ b/utils/utils/src/index.ts
@@ -14,3 +14,4 @@ export * from './event-publisher';
export * from './event-bridge-utils';
export * from './key-generation-utils';
export * from './schema-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/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts b/utils/utils/src/pdm-client/pdm-client.ts
similarity index 58%
rename from lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts
rename to utils/utils/src/pdm-client/pdm-client.ts
index c7fb997c..7b50fc3d 100644
--- a/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts
+++ b/utils/utils/src/pdm-client/pdm-client.ts
@@ -1,6 +1,8 @@
import axios, { AxiosInstance, isAxiosError } from 'axios';
import { constants as HTTP2_CONSTANTS } from 'node:http2';
-import { 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;
@@ -11,6 +13,10 @@ export interface IPdmClient {
fhirRequest: string,
requestId: string,
): Promise;
+ getDocumentReference(
+ documentResourceId: string,
+ requestId: string,
+ ): Promise;
}
export class PdmClient implements IPdmClient {
@@ -82,4 +88,53 @@ export class PdmClient implements IPdmClient {
throw error;
}
}
+
+ public async getDocumentReference(
+ documentResourceId: 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 = {
+ 'X-Request-ID': requestId,
+ ...(accessToken === ''
+ ? {}
+ : {
+ Authorization: `Bearer ${accessToken}`,
+ }),
+ };
+ const response = await this.client.get(
+ `/patient-data-manager/FHIR/R4/DocumentReference/${documentResourceId}`,
+ { 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/utils/utils/src/types/pdm-types.ts b/utils/utils/src/types/pdm-types.ts
index 28c112b5..ff1dbf29 100644
--- a/utils/utils/src/types/pdm-types.ts
+++ b/utils/utils/src/types/pdm-types.ts
@@ -16,6 +16,13 @@ export type PdmResponse = {
attachment: {
contentType: string;
title: string;
+ data?: string;
+ };
+ }[];
+ author: {
+ identifier: {
+ system: string;
+ value: string;
};
}[];
};