diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index f734055cc..082f687b5 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -25,16 +25,23 @@ runs: using: "composite" steps: + - name: Get Node version + id: nodejs_version + shell: bash + run: | + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + - uses: ./.github/actions/node-install + with: + node-version: ${{ steps.nodejs_version.outputs.nodejs_version }} + GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} - name: "Repo setup" shell: bash run: | npm ci - - name: "Generate dependencies" shell: bash run: | npm run generate-dependencies - - name: Run test - ${{ inputs.testType }} shell: bash run: | @@ -51,7 +58,6 @@ runs: env: TEST_TYPE: ${{ inputs.testType }} ENVIRONMENT: ${{ inputs.targetEnvironment }} - - name: Archive integration test results if: ${{ inputs.testType == 'integration' }} uses: actions/upload-artifact@v4 diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index a005d111c..53f740330 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -4,14 +4,21 @@ inputs: version: description: "Version number" required: true + node-version: + description: 'Node.js version' + required: true + GITHUB_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: "composite" steps: - name: Checkout uses: actions/checkout@v5 - - uses: actions/setup-node@v6 + - uses: ./.github/actions/node-install with: - node-version: 24 + node-version: ${{ inputs.node-version }} + GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }} - name: Npm cli install working-directory: ./docs run: npm ci diff --git a/.github/actions/node-install/action.yaml b/.github/actions/node-install/action.yaml new file mode 100644 index 000000000..6ab529a58 --- /dev/null +++ b/.github/actions/node-install/action.yaml @@ -0,0 +1,25 @@ +name: 'Node install and setup' +description: 'Setup node and authenticate github package repository' + +inputs: + node-version: + description: 'Node.js version' + required: true + GITHUB_TOKEN: + description: "Token for access to github package registry" + required: true + +runs: + using: 'composite' + steps: + - name: 'Use Node.js' + uses: actions/setup-node@v6 + with: + node-version: '${{ inputs.node-version }}' + + - name: "Configure npm for GitHub Packages" + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }} + run: | + scripts/set-github-token.sh diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 1f4b77211..ed943fc3a 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -15,12 +15,15 @@ on: permissions: id-token: write contents: write + packages: read jobs: metadata: name: "Set CI/CD metadata" runs-on: ubuntu-latest timeout-minutes: 1 + permissions: + contents: read outputs: build_datetime_london: ${{ steps.variables.outputs.build_datetime_london }} build_datetime: ${{ steps.variables.outputs.build_datetime }} @@ -152,6 +155,9 @@ jobs: name: Trigger dynamic environment creation needs: [metadata, build-stage] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')) steps: - uses: actions/checkout@v5.0.0 diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index d5436d675..d8f3bb816 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -152,12 +152,15 @@ jobs: uses: ./.github/actions/lint-terraform trivy-iac: name: "Trivy IaC Scan" - permissions: - contents: read runs-on: ubuntu-latest timeout-minutes: 10 needs: detect-terraform-changes if: needs.detect-terraform-changes.outputs.terraform_changed == 'true' + permissions: + contents: read + packages: read + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout code" uses: actions/checkout@v4 @@ -168,10 +171,13 @@ jobs: trivy-package: if: ${{ !inputs.skip_trivy_package }} name: "Trivy Package Scan" - permissions: - contents: read runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + packages: read + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout code" uses: actions/checkout@v4 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 3d4a8fa26..bc01b549f 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -40,21 +40,21 @@ env: AWS_REGION: eu-west-2 TERM: xterm-256color -permissions: - id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout - jobs: check-generated-dependencies: name: "Check generated dependencies" runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + packages: read steps: - name: "Checkout code" uses: actions/checkout@v5 - - uses: actions/setup-node@v6 + - uses: ./.github/actions/node-install with: - node-version: 24.10.0 + node-version: ${{ inputs.nodejs_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: "Repo setup" run: | npm ci @@ -66,12 +66,16 @@ jobs: name: "Unit tests" runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + packages: read steps: - name: "Checkout code" uses: actions/checkout@v5 - - uses: actions/setup-node@v6 + - uses: ./.github/actions/node-install with: - node-version: 24.10.0 + node-version: ${{ inputs.nodejs_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: "Setup Python" uses: actions/setup-python@v6 with: @@ -103,6 +107,9 @@ jobs: name: "Linting" runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + packages: read steps: - name: "Checkout code" uses: actions/checkout@v5 @@ -110,9 +117,10 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ inputs.python_version }} - - uses: actions/setup-node@v6 + - uses: ./.github/actions/node-install with: - node-version: 24.10.0 + node-version: ${{ inputs.nodejs_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: "Run linting" run: | make test-lint @@ -120,12 +128,18 @@ jobs: name: "Typecheck" runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + packages: read + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: "Checkout code" uses: actions/checkout@v5 - - uses: actions/setup-node@v6 + - uses: ./.github/actions/node-install with: - node-version: 24.10.0 + node-version: ${{ inputs.nodejs_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: "Run typecheck" run: | make test-typecheck @@ -134,6 +148,8 @@ jobs: needs: [test-unit] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read steps: - name: "Checkout code" uses: actions/checkout@v5 diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index bc28487c1..e9d531949 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -44,3 +44,5 @@ jobs: uses: ./.github/actions/build-docs with: version: "${{ inputs.version }}" + node-version: ${{ inputs.nodejs_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..aab2808a0 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Package is scoped under @org, set registry for that scope +@nhsdigital:registry=https://npm.pkg.github.com diff --git a/Makefile b/Makefile index 348ca3470..806321435 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ dependencies:: # Install dependencies needed to build and test the project @Pipe $(MAKE) -C utils/metric-publishers install $(MAKE) -C utils/event-publisher-py install $(MAKE) -C utils/py-mock-mesh install + ./scripts/set-github-token.sh npm install --workspaces $(MAKE) generate diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 26aeafacd..f811d47a7 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -50,6 +50,7 @@ No requirements. | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.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 | +| [print\_status\_handler](#module\_print\_status\_handler) | 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\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | @@ -60,6 +61,7 @@ No requirements. | [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | +| [sqs\_print\_status\_handler](#module\_sqs\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_print_status_changed.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_print_status_changed.tf new file mode 100644 index 000000000..d5344efed --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_print_status_changed.tf @@ -0,0 +1,19 @@ +resource "aws_cloudwatch_event_rule" "print_status_changed" { + name = "${local.csi}-print-status-changed" + description = "Print status changed event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [{ + "prefix" : "uk.nhs.notify.supplier-api.letter." + }] + } + }) +} + +resource "aws_cloudwatch_event_target" "print_status_changed_print_status_handler" { + rule = aws_cloudwatch_event_rule.print_status_changed.name + arn = module.sqs_print_status_handler.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_print_status_handler.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_print_status_handler.tf new file mode 100644 index 000000000..847476148 --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_print_status_handler.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "print_status_handler" { + event_source_arn = module.sqs_print_status_handler.sqs_queue_arn + function_name = module.print_status_handler.function_name + batch_size = var.queue_batch_size + maximum_batching_window_in_seconds = var.queue_batch_window_seconds + + function_response_types = [ + "ReportBatchItemFailures" + ] +} diff --git a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf new file mode 100644 index 000000000..498b72852 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf @@ -0,0 +1,86 @@ +module "print_status_handler" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" + + function_name = "print-status-handler" + description = "A function for processing letter printing statuses" + + 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.print_status_handler.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "print-status-handler/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 = { + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + } +} + +data "aws_iam_policy_document" "print_status_handler" { + 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 = "SQSPermissionsPrintStatusHandlerQueue" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ] + + resources = [ + module.sqs_print_status_handler.sqs_queue_arn, + ] + } +} diff --git a/infrastructure/terraform/components/dl/module_sqs_print_status_handler.tf b/infrastructure/terraform/components/dl/module_sqs_print_status_handler.tf new file mode 100644 index 000000000..075b59500 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_sqs_print_status_handler.tf @@ -0,0 +1,36 @@ +module "sqs_print_status_handler" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + name = "print-status-handler" + sqs_kms_key_arn = module.kms.key_arn + visibility_timeout_seconds = 60 + delay_seconds = 5 + create_dlq = true + max_receive_count = 1 + sqs_policy_overload = data.aws_iam_policy_document.sqs_print_status_handler.json +} + +data "aws_iam_policy_document" "sqs_print_status_handler" { + 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}-print-status-handler-queue" + ] + } +} diff --git a/infrastructure/terraform/components/dl/pre.sh b/infrastructure/terraform/components/dl/pre.sh index 373d14b05..4ef25bb37 100755 --- a/infrastructure/terraform/components/dl/pre.sh +++ b/infrastructure/terraform/components/dl/pre.sh @@ -4,6 +4,16 @@ # It ensures all Node.js dependencies are installed, generates any required dependencies, # and builds all Lambda functions in the workspace before Terraform provisions infrastructure. +echo "Running Pre.sh" + +ROOT_DIR="$(git rev-parse --show-toplevel)" + +echo "Running set-github-token.sh" + +$ROOT_DIR/scripts/set-github-token.sh + +echo "Completed." + npm ci npm run generate-dependencies diff --git a/lambdas/print-status-handler/jest.config.ts b/lambdas/print-status-handler/jest.config.ts new file mode 100644 index 000000000..c1c729f3f --- /dev/null +++ b/lambdas/print-status-handler/jest.config.ts @@ -0,0 +1,9 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = baseJestConfig; + +config.transformIgnorePatterns = [ + 'node_modules/(?!@nhsdigital/nhs-notify-event-schemas-supplier-api)', +]; + +export default config; diff --git a/lambdas/print-status-handler/package.json b/lambdas/print-status-handler/package.json new file mode 100644 index 000000000..b5bd7f43a --- /dev/null +++ b/lambdas/print-status-handler/package.json @@ -0,0 +1,27 @@ +{ + "dependencies": { + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6", + "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1", + "zod": "^4.1.12" + }, + "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-print-status-handler", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/print-status-handler/src/__tests__/apis/sqs-handler.test.ts b/lambdas/print-status-handler/src/__tests__/apis/sqs-handler.test.ts new file mode 100644 index 000000000..65c344a7a --- /dev/null +++ b/lambdas/print-status-handler/src/__tests__/apis/sqs-handler.test.ts @@ -0,0 +1,178 @@ +import { mock } from 'jest-mock-extended'; +import { randomUUID } from 'node:crypto'; +import { createHandler } from 'apis/sqs-handler'; +import { EventPublisher, Logger } from 'utils'; +import { acceptedLetterEvent, recordEvent } from '__tests__/test-data'; + +const logger = mock(); +const eventPublisher = 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, +}); + +describe('SQS Handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('letter status transitions', () => { + it('should send print.letter.transitioned event when letter.ACCEPTED received', async () => { + const response = await handler(recordEvent([acceptedLetterEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...acceptedLetterEvent, + 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-print-letter-transitioned-data.schema.json', + type: 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/print', + data: { + senderId: acceptedLetterEvent.data.origin.subject.split('/')[1], + messageReference: + acceptedLetterEvent.data.origin.subject.split('/')[3], + specificationId: acceptedLetterEvent.data.specificationId, + status: acceptedLetterEvent.data.status, + supplierId: acceptedLetterEvent.data.supplierId, + time: acceptedLetterEvent.time, + }, + }, + ], + 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 processing them', async () => { + const event = recordEvent([acceptedLetterEvent]); + 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 letter.ACCEPTED event is received', async () => { + const invalidAcceptedLetterEvent = { + ...acceptedLetterEvent, + source: 'invalid letter.ACCEPTED source', + }; + const event = recordEvent([invalidAcceptedLetterEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.objectContaining({ + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ['source'], + }), + ]), + }), + description: 'Error parsing queue item', + }); + + 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 origin.subject event is received', async () => { + const invalidAcceptedLetterEvent = { + ...acceptedLetterEvent, + data: { + ...acceptedLetterEvent.data, + origin: { + ...acceptedLetterEvent.data.origin, + subject: 'invalid origin.subject', + }, + }, + }; + const event = recordEvent([invalidAcceptedLetterEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.objectContaining({ + issues: expect.arrayContaining([ + expect.objectContaining({ + message: + 'Subject must be in format: client/{senderId}/digital-letters/{messageReference}', + }), + ]), + }), + description: 'Invalid origin.subject format', + }); + + 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 event transformation fails', async () => { + mockRandomUUID.mockImplementationOnce(() => { + throw new Error('A forced error scenario'); + }); + + const event = recordEvent([acceptedLetterEvent]); + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: 'A forced error scenario', + description: 'Failed processing message', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + }); +}); diff --git a/lambdas/print-status-handler/src/__tests__/container.test.ts b/lambdas/print-status-handler/src/__tests__/container.test.ts new file mode 100644 index 000000000..64f1a694d --- /dev/null +++ b/lambdas/print-status-handler/src/__tests__/container.test.ts @@ -0,0 +1,22 @@ +import { createContainer } from 'container'; + +jest.mock('infra/config', () => ({ + loadConfig: jest.fn(() => ({ + eventPublisherDlqUrl: 'test-url', + eventPublisherEventBusArn: 'test-arn', + })), +})); + +jest.mock('utils', () => ({ + eventBridgeClient: {}, + EventPublisher: jest.fn(() => ({})), + logger: {}, + sqsClient: {}, +})); + +describe('container', () => { + it('should create container', () => { + const container = createContainer(); + expect(container).toBeDefined(); + }); +}); diff --git a/lambdas/print-status-handler/src/__tests__/index.test.ts b/lambdas/print-status-handler/src/__tests__/index.test.ts new file mode 100644 index 000000000..b5465321a --- /dev/null +++ b/lambdas/print-status-handler/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/print-status-handler/src/__tests__/infra/config.test.ts b/lambdas/print-status-handler/src/__tests__/infra/config.test.ts new file mode 100644 index 000000000..2902c80f9 --- /dev/null +++ b/lambdas/print-status-handler/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/print-status-handler/src/__tests__/test-data.ts b/lambdas/print-status-handler/src/__tests__/test-data.ts new file mode 100644 index 000000000..4dcd09f54 --- /dev/null +++ b/lambdas/print-status-handler/src/__tests__/test-data.ts @@ -0,0 +1,64 @@ +import { SQSEvent, SQSRecord } from 'aws-lambda'; +import { LetterEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events'; + +export const acceptedLetterEvent = { + id: '550e8400-e29b-41d4-a716-446655440001', + specversion: '1.0', + source: '/data-plane/supplier-api/prod/update-status', + subject: + 'letter-origin/digital-letters/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479', + type: 'uk.nhs.notify.supplier-api.letter.ACCEPTED.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.ACCEPTED.1.0.0.schema.json', + dataschemaversion: '1.0.0', + 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', + plane: 'data', + data: { + domainId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + groupId: 'client_template', + origin: { + domain: 'letter-rendering', + event: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + source: '/data-plane/letter-rendering/prod/render-pdf', + subject: + 'client/00f3b388-bbe9-41c9-9e76-052d37ee8988/digital-letters/b9c0c7f8-8204-400d-8348-7e7ddf775dae', + }, + specificationId: '1y3q9v1zzzz', + supplierId: 'supplier-1', + status: 'ACCEPTED', + }, +} as LetterEvent; + +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: LetterEvent[]): SQSEvent => ({ + Records: events.map((event, i) => ({ + ...sqsRecord, + messageId: String(i + 1), + body: JSON.stringify({ ...busEvent, detail: event }), + })), +}); diff --git a/lambdas/print-status-handler/src/apis/sqs-handler.ts b/lambdas/print-status-handler/src/apis/sqs-handler.ts new file mode 100644 index 000000000..141fc6c26 --- /dev/null +++ b/lambdas/print-status-handler/src/apis/sqs-handler.ts @@ -0,0 +1,165 @@ +import type { + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from 'aws-lambda'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { + $LetterEvent, + LetterEvent, +} from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events'; +import { PrintLetterTransitioned } from 'digital-letters-events'; +import printLetterTransitionedValidator from 'digital-letters-events/PrintLetterTransitioned.js'; +import { EventPublisher, Logger } from 'utils'; + +export interface HandlerDependencies { + eventPublisher: EventPublisher; + logger: Logger; +} + +type ValidatedRecord = { + messageId: string; + event: LetterEvent; +}; + +const originSubjectSchema = z + .string() + .regex( + /^client\/[^/]+\/digital-letters\/[^/]+$/, + 'Subject must be in format: client/{senderId}/digital-letters/{messageReference}', + ); + +function validateRecord( + { body, messageId }: { body: string; messageId: string }, + logger: Logger, +): ValidatedRecord | null { + try { + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; + + const { + data: item, + error: parseError, + success: parseSuccess, + } = $LetterEvent.safeParse(sqsEventDetail); + + if (!parseSuccess) { + logger.warn({ + err: parseError, + description: 'Error parsing queue item', + }); + + return null; + } + + const subjectValidation = originSubjectSchema.safeParse( + item.data.origin.subject, + ); + + if (!subjectValidation.success) { + logger.warn({ + err: subjectValidation.error, + description: 'Invalid origin.subject format', + }); + + return null; + } + + return { messageId, event: item }; + } catch (error) { + logger.warn({ + err: error, + description: 'Error parsing SQS record', + }); + + return null; + } +} + +function generateUpdatedEvent(event: LetterEvent): PrintLetterTransitioned { + const eventTime = new Date().toISOString(); + + const { + data: { + origin: { subject }, + specificationId, + status, + supplierId, + }, + time, + } = event; + + const senderId = subject.split('/')[1]; + const messageReference = subject.split('/')[3]; + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.json', + type: 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1', + // NOTE: CCM-13892 Generate event digital letters source property from scratch + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/print', + data: { + senderId, + messageReference, + specificationId, + status, + supplierId, + time, + }, + }; +} + +export const createHandler = ({ + eventPublisher, + logger, +}: HandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const receivedItemCount = sqsEvent.Records.length; + const batchItemFailures: SQSBatchItemFailure[] = []; + const validatedRecords: ValidatedRecord[] = []; + const validEvents: PrintLetterTransitioned[] = []; + + 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; + validEvents.push(generateUpdatedEvent(event)); + } catch (error: any) { + logger.warn({ + err: error.message, + description: 'Failed processing message', + }); + batchItemFailures.push({ itemIdentifier: validatedRecord.messageId }); + } + }), + ); + + await eventPublisher.sendEvents( + validEvents, + printLetterTransitionedValidator, + ); + + const processedItemCount = receivedItemCount - batchItemFailures.length; + logger.info( + `${processedItemCount} of ${receivedItemCount} records processed successfully`, + ); + + return { batchItemFailures }; + }; diff --git a/lambdas/print-status-handler/src/container.ts b/lambdas/print-status-handler/src/container.ts new file mode 100644 index 000000000..7fe49378d --- /dev/null +++ b/lambdas/print-status-handler/src/container.ts @@ -0,0 +1,19 @@ +import { HandlerDependencies } from 'apis/sqs-handler'; +import { loadConfig } from 'infra/config'; +import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; + +export const createContainer = (): HandlerDependencies => { + const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); + + const eventPublisher = new EventPublisher({ + eventBusArn: eventPublisherEventBusArn, + dlqUrl: eventPublisherDlqUrl, + logger, + sqsClient, + eventBridgeClient, + }); + + return { eventPublisher, logger }; +}; + +export default createContainer; diff --git a/lambdas/print-status-handler/src/index.ts b/lambdas/print-status-handler/src/index.ts new file mode 100644 index 000000000..f25a80861 --- /dev/null +++ b/lambdas/print-status-handler/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/print-status-handler/src/infra/config.ts b/lambdas/print-status-handler/src/infra/config.ts new file mode 100644 index 000000000..855e66108 --- /dev/null +++ b/lambdas/print-status-handler/src/infra/config.ts @@ -0,0 +1,17 @@ +import { defaultConfigReader } from 'utils'; + +export type Config = { + eventPublisherEventBusArn: string; + eventPublisherDlqUrl: string; +}; + +export function loadConfig(): Config { + return { + eventPublisherEventBusArn: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ), + eventPublisherDlqUrl: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_DLQ_URL', + ), + }; +} diff --git a/lambdas/print-status-handler/tsconfig.json b/lambdas/print-status-handler/tsconfig.json new file mode 100644 index 000000000..f7bcaa1ff --- /dev/null +++ b/lambdas/print-status-handler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./src/", + "isolatedModules": true + }, + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 07ad56f2b..8bb825262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "lambdas/ttl-poll-lambda", "lambdas/pdm-uploader-lambda", "lambdas/core-notifier-lambda", + "lambdas/print-status-handler", "utils/utils", "utils/sender-management", "src/cloudevents", @@ -428,6 +429,78 @@ "lambdas/print-sender-lambda": { "extraneous": true }, + "lambdas/print-status-handler": { + "name": "nhs-notify-digital-letters-print-status-handler", + "version": "0.0.1", + "dependencies": { + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6", + "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + } + }, + "lambdas/print-status-handler/node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "lambdas/print-status-handler/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "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/print-status-handler/node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "lambdas/refresh-apim-access-token": { "version": "0.0.1", "dependencies": { @@ -722,6 +795,36 @@ "dev": true, "license": "ISC" }, + "node_modules/@asyncapi/bundler": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@asyncapi/bundler/-/bundler-0.6.4.tgz", + "integrity": "sha512-lKZo2FF2TKt4n6Qm8vP/JOEEGE04gdH/D9oHmBt/NfOylMaw8XoFsI+k+IJyzpVMzREjZfxGf9gNzfW0CWRf5g==", + "license": "Apache-2.0", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.4", + "@types/json-schema": "^7.0.11", + "@ungap/structured-clone": "^1.2.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@asyncapi/bundler/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "license": "Apache-2.0", @@ -3918,6 +4021,16 @@ "node": ">= 6" } }, + "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": { + "version": "1.0.6", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-api/1.0.6/075ecd7a7a79fd34c80ec7f79f9c885a125ce2a4", + "integrity": "sha512-kGqY3ANW/eHhrt21ZIRubkTENvBJiB0PL1zOmSJ+3fuJz/K6q3sNZuQEyJipreq/bmy06Jntgu1mboLRQa8Ilw==", + "license": "MIT", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.11" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -5347,7 +5460,8 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "dev": true, + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { @@ -12356,6 +12470,10 @@ "resolved": "lambdas/pdm-uploader-lambda", "link": true }, + "node_modules/nhs-notify-digital-letters-print-status-handler": { + "resolved": "lambdas/print-status-handler", + "link": true + }, "node_modules/nhs-notify-digital-letters-ttl-create-lambda": { "resolved": "lambdas/ttl-create-lambda", "link": true @@ -15256,6 +15374,7 @@ "@aws-sdk/lib-dynamodb": "3.844.0", "@aws-sdk/util-dynamodb": "^3.933.0", "@faker-js/faker": "^9.6.0", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6", "@playwright/test": "^1.51.1", "digital-letters-events": "^0.0.1", "sender-management": "^0.0.1", diff --git a/package.json b/package.json index 4e4c5e124..125f8fb2c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "lambdas/ttl-poll-lambda", "lambdas/pdm-uploader-lambda", "lambdas/core-notifier-lambda", + "lambdas/print-status-handler", "utils/utils", "utils/sender-management", "src/cloudevents", diff --git a/scripts/set-github-token.sh b/scripts/set-github-token.sh new file mode 100755 index 000000000..5e4179a48 --- /dev/null +++ b/scripts/set-github-token.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + +npm config ls -l | grep '/npm.pkg.github.com/:_authToken' -q && echo "Github token already exists" && exit 0 + +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + read -p "Enter GitHub token: " GITHUB_TOKEN + export GITHUB_TOKEN +fi + +npm config --location user set //npm.pkg.github.com/:_authToken $GITHUB_TOKEN diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml index 0550f42f7..b4dbfebb4 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml @@ -8,6 +8,8 @@ properties: $ref: ../defs/requests.schema.yaml#/properties/senderId messageReference: $ref: ../defs/requests.schema.yaml#/properties/messageReference + specificationId: + $ref: ../defs/print.schema.yaml#/properties/specificationId status: $ref: ../defs/print.schema.yaml#/properties/status supplierId: diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml index a718f0ebd..c5fb2160e 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml @@ -13,6 +13,11 @@ properties: - "DISPATCHED" - "FAILED" - "RETURNED" + - "PENDING" + - "ENCLOSED" + - "CANCELLED" + - "FORWARDED" + - "DELIVERED" examples: - ACCEPTED - DISPATCHED @@ -22,6 +27,11 @@ properties: description: Identifier of the print supplier handling the print job examples: - "supplier-12345" + specificationId: + type: string + description: Reference to the letter specification which was used to produce a letter pack for this request + examples: + - "1y3q9v1zzzz" pageCount: type: integer description: The number of pages in the analysed letter PDF. diff --git a/tests/playwright/config/playwright.config.ts b/tests/playwright/config/playwright.config.ts index d625cb362..9d85ebcb1 100644 --- a/tests/playwright/config/playwright.config.ts +++ b/tests/playwright/config/playwright.config.ts @@ -3,12 +3,12 @@ import path from 'node:path'; export default defineConfig({ testDir: path.resolve(__dirname, '../'), - fullyParallel: false, + fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, retries: 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 4 : undefined, + workers: process.env.CI ? 8 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [ ['line'], diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index 0af36d564..ebe9c1f9c 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -19,6 +19,8 @@ export const TTL_DLQ_NAME = `${CSI}-ttl-dlq`; export const PDM_UPLOADER_DLQ_NAME = `${CSI}-pdm-uploader-dlq`; export const PDM_POLL_DLQ_NAME = `${CSI}-pdm-poll-dlq`; export const CORE_NOTIFIER_DLQ_NAME = `${CSI}-core-notifier-dlq`; +export const PRINT_STATUS_HANDLER_DLQ_NAME = `${CSI}-print-status-handler-dlq`; +export const HANDLE_TTL_DLQ_NAME = `${CSI}-ttl-handle-expiry-errors-queue`; // Queue Url Prefix export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`; @@ -38,3 +40,4 @@ export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGIO export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`; export const PDM_POLL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-poll`; export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-notifier`; +export const PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-status-handler`; diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts index fe5e001bf..c1c413b77 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -9,7 +9,7 @@ import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceU import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; -import { expectMessageContainingString } from 'helpers/sqs-helpers'; +import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; const baseEvent = { @@ -43,6 +43,7 @@ const unavailableEvent = { test.describe('PDM Poll', () => { test.beforeAll(async () => { test.setTimeout(250_000); + await purgeQueue(PDM_POLL_DLQ_NAME); }); test.describe('pdm.resource.submitted', () => { diff --git a/tests/playwright/digital-letters-component-tests/print-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/print-status-handler.component.spec.ts new file mode 100644 index 000000000..bade8f061 --- /dev/null +++ b/tests/playwright/digital-letters-component-tests/print-status-handler.component.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from '@playwright/test'; +import { LetterEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events'; +import { + ENV, + PRINT_STATUS_HANDLER_DLQ_NAME, + PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, +} from 'constants/backend-constants'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; +import eventPublisher from 'helpers/event-bus-helpers'; +import expectToPassEventually from 'helpers/expectations'; +import { v4 as uuidv4 } from 'uuid'; +import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; + +const baseLetterEvent = { + id: '550e8400-e29b-41d4-a716-446655440001', + specversion: '1.0', + source: '/data-plane/supplier-api/prod/update-status', + subject: + 'letter-origin/digital-letters/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479', + dataschemaversion: '1.0.0', + 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', + plane: 'data', + data: { + domainId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + groupId: 'client_template', + origin: { + domain: 'letter-rendering', + event: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + source: '/data-plane/letter-rendering/prod/render-pdf', + }, + specificationId: '1y3q9v1zzzz', + supplierId: 'supplier-1', + }, +} as LetterEvent; + +const letterStatuses = [ + 'ACCEPTED', + 'REJECTED', + 'PRINTED', + 'DISPATCHED', + 'FAILED', + 'RETURNED', + 'PENDING', + 'ENCLOSED', + 'CANCELLED', + 'FORWARDED', + 'DELIVERED', +] as const; + +test.describe('Print status handler', () => { + test.beforeAll(async () => { + test.setTimeout(150_000); + await purgeQueue(PRINT_STATUS_HANDLER_DLQ_NAME); + }); + + for (const status of letterStatuses) { + test(`should create print.letter.transitioned ${status} event for a letters.${status} event`, async () => { + const messageReference = uuidv4(); + const letterEvent = { + ...baseLetterEvent, + type: `uk.nhs.notify.supplier-api.letter.${status}.v1`, + dataschema: `https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.${status}.1.0.0.schema.json`, + data: { + ...baseLetterEvent.data, + status, + origin: { + ...baseLetterEvent.data.origin, + subject: `client/00f3b388-bbe9-41c9-9e76-052d37ee8988/digital-letters/${messageReference}`, + }, + }, + }; + + await eventPublisher.sendEvents([letterEvent], () => true); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + `/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.print.letter.transitioned.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"status\\":\\"${status}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + } + + test('should send invalid event to print status handler dlq', async () => { + test.setTimeout(250_000); + + const messageReference = uuidv4(); + + // Send letter.ACCEPTED event with no data.status + await eventPublisher.sendEvents( + [ + { + ...baseLetterEvent, + type: `uk.nhs.notify.supplier-api.letter.ACCEPTED.v1`, + dataschema: `https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.ACCEPTED.1.0.0.schema.json`, + data: { + ...baseLetterEvent.data, + origin: { + ...baseLetterEvent.data.origin, + subject: `client/00f3b388-bbe9-41c9-9e76-052d37ee8988/digital-letters/${messageReference}`, + }, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, + [ + String.raw`$.message.err.message = "*Invalid option: expected one of \\\"PENDING\\\"*"`, + '$.message.description = "Error parsing queue item"', + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + + await expectMessageContainingString( + PRINT_STATUS_HANDLER_DLQ_NAME, + messageReference, + 120, + ); + }); +}); diff --git a/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts b/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts index 295f011d4..66c031319 100644 --- a/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { ENV } from 'constants/backend-constants'; +import { ENV, HANDLE_TTL_DLQ_NAME } from 'constants/backend-constants'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import { deleteTtl, putTtl } from 'helpers/dynamodb-helpers'; @@ -8,10 +8,8 @@ import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - Handle TTL', () => { - const handleTtlDlqName = `nhs-${ENV}-dl-ttl-handle-expiry-errors-queue`; - test.beforeAll(async () => { - await purgeQueue(handleTtlDlqName); + await purgeQueue(HANDLE_TTL_DLQ_NAME); }); const baseEvent: MESHInboxMessageDownloaded = { @@ -146,6 +144,6 @@ test.describe('Digital Letters - Handle TTL', () => { const deleteResponseCode = await deleteTtl(messageUri); expect(deleteResponseCode).toBe(200); - await expectMessageContainingString(handleTtlDlqName, letterId); + await expectMessageContainingString(HANDLE_TTL_DLQ_NAME, letterId); }); }); diff --git a/tests/playwright/package.json b/tests/playwright/package.json index a1c7e3524..8113a26b8 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -8,6 +8,7 @@ "@aws-sdk/lib-dynamodb": "3.844.0", "@aws-sdk/util-dynamodb": "^3.933.0", "@faker-js/faker": "^9.6.0", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6", "@playwright/test": "^1.51.1", "digital-letters-events": "^0.0.1", "sender-management": "^0.0.1",