diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index b6d5086e9..b01f0ba1e 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -8,8 +8,6 @@ on: - main pull_request: types: [opened, reopened, synchronize] - branches: - - main concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 22a3c28b3..19e9a622a 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -64,6 +64,14 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: + # Pull testcontainers images in the background so the download overlaps + # the setup steps. Keep these references in sync with the images used in + # internal/datastore/src/__test__/db.ts and + # internal/sql-test-helpers/src/trino.ts. + - name: "Pre-pull test container images" + run: | + docker pull amazon/dynamodb-local > /dev/null 2>&1 & + docker pull trinodb/trino:476 > /dev/null 2>&1 & - name: "Checkout code" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Repo setup" diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index f0d69354e..354fc99c9 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -92,6 +92,7 @@ No requirements. | [populate\_supplier\_config\_cache](#module\_populate\_supplier\_config\_cache) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | +| [s3bucket\_athena\_query\_results](#module\_s3bucket\_athena\_query\_results) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | | [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.6/terraform-s3bucket.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a | | [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a | diff --git a/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf new file mode 100644 index 000000000..e3939d786 --- /dev/null +++ b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf @@ -0,0 +1,19 @@ +resource "aws_athena_workgroup" "letter_sla_breaches" { + name = "${local.csi}-letter-sla-breaches" + description = "Athena workgroup for the letter SLA breaches state machine" + force_destroy = var.force_destroy + + configuration { + enforce_workgroup_configuration = true + publish_cloudwatch_metrics_enabled = true + + result_configuration { + output_location = "s3://${module.s3bucket_athena_query_results.bucket}/letter-sla-breaches/" + + encryption_configuration { + encryption_option = "SSE_KMS" + kms_key_arn = module.kms.key_arn + } + } + } +} diff --git a/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf index 25d483c6c..501d1aa05 100644 --- a/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf +++ b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf @@ -1,5 +1,6 @@ resource "aws_iam_role_policy" "letter_sla_breaches" { - count = local.eventpub_parquet_enabled ? 1 : 0 + count = local.eventpub_parquet_enabled ? 1 : 0 + name = "${local.csi}-letter-sla-breaches-sfn" role = aws_iam_role.letter_sla_breaches[0].id policy = data.aws_iam_policy_document.letter_sla_breaches_policy[0].json diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 1dbc78c51..8f8f9bf57 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -78,6 +78,10 @@ locals { event_cache_bucket_name = lookup(module.eventpub.s3_bucket_event_cache, "bucket", null) eventsub_event_cache_bucket_name = lookup(module.eventsub.s3_bucket_event_cache, "bucket", null) + reporting_bucket_name = lookup(module.s3bucket_reporting, "bucket", null) + + # The Glue crawler names tables after the crawled bucket, replacing characters that are invalid in table names + event_cache_glue_table_name = local.event_cache_bucket_name != null ? lower(replace(local.event_cache_bucket_name, "/[^a-zA-Z0-9_]/", "_")) : null csoc_api_gw_log_destination_arn = format("arn:aws:logs:%s:%s:destination:api_gateway_log_destination", var.region, diff --git a/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf index 083f5ddfe..d951c02c3 100644 --- a/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf +++ b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf @@ -1,5 +1,6 @@ module "populate_standard_breaches" { - count = local.eventpub_parquet_enabled ? 1 : 0 + count = local.eventpub_parquet_enabled ? 1 : 0 + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip" function_name = "populate_standard_breaches" @@ -26,7 +27,7 @@ module "populate_standard_breaches" { handler_function_name = "populateStandardBreachesHandler" runtime = "nodejs22.x" memory = 128 - timeout = 29 + timeout = 300 log_level = var.log_level force_lambda_code_deploy = var.force_lambda_code_deploy @@ -37,9 +38,12 @@ module "populate_standard_breaches" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - ATHENA_WORKGROUP = aws_athena_workgroup.reporting.name - ATHENA_DATABASE = aws_glue_catalog_database.supplier.name - EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name + ATHENA_WORKGROUP = aws_athena_workgroup.reporting.name + ATHENA_DATABASE = aws_glue_catalog_database.supplier.name + EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name + PACK_SPECIFICATION_TABLE_NAME = "${replace(local.csi, "-", "_")}_pack_specification" + DAYS_TO_LOOK_BACK = "14" + STANDARD_SLA_WORKING_DAYS = "2" } } diff --git a/infrastructure/terraform/components/api/s3_athena_query_results.tf b/infrastructure/terraform/components/api/s3_athena_query_results.tf new file mode 100644 index 000000000..68d514b78 --- /dev/null +++ b/infrastructure/terraform/components/api/s3_athena_query_results.tf @@ -0,0 +1,73 @@ +module "s3bucket_athena_query_results" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip" + + name = "athena-query-results" + + aws_account_id = var.aws_account_id + region = var.region + project = var.project + environment = var.environment + component = var.component + + acl = "private" + force_destroy = var.force_destroy + versioning = false + kms_key_arn = module.kms.key_arn + + lifecycle_rules = [ + { + enabled = true + + expiration = { + days = "7" + } + + abort_incomplete_multipart_upload = { + days = "1" + } + } + ] + + policy_documents = [ + data.aws_iam_policy_document.s3bucket_athena_query_results.json + ] + + bucket_logging_target = { + bucket = local.acct.s3_buckets["access_logs"]["id"] + } + + public_access = { + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + } + + default_tags = { + Name = "Athena Query Results" + } +} + +data "aws_iam_policy_document" "s3bucket_athena_query_results" { + statement { + sid = "DontAllowNonSecureConnection" + effect = "Deny" + actions = ["s3:*"] + + resources = [ + module.s3bucket_athena_query_results.arn, + "${module.s3bucket_athena_query_results.arn}/*", + ] + + principals { + type = "AWS" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} diff --git a/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf index ccb5a8ef9..5f3187fc7 100644 --- a/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf +++ b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf @@ -1,5 +1,6 @@ resource "aws_sfn_state_machine" "letter_sla_breaches" { - count = local.eventpub_parquet_enabled ? 1 : 0 + count = local.eventpub_parquet_enabled ? 1 : 0 + name = local.letter_sla_breaches_state_machine_name role_arn = aws_iam_role.letter_sla_breaches[0].arn diff --git a/infrastructure/terraform/modules/alarms-sla-breaches/README.md b/infrastructure/terraform/modules/alarms-sla-breaches/README.md new file mode 100644 index 000000000..df8c1f5c0 --- /dev/null +++ b/infrastructure/terraform/modules/alarms-sla-breaches/README.md @@ -0,0 +1,19 @@ + + + + +## Requirements + +No requirements. +## Inputs + +No inputs. +## Modules + +No modules. +## Outputs + +No outputs. + + + diff --git a/infrastructure/terraform/modules/alarms-sla-breaches/cloudwatch_metric_alarm_errors.tf b/infrastructure/terraform/modules/alarms-sla-breaches/cloudwatch_metric_alarm_errors.tf new file mode 100644 index 000000000..662715893 --- /dev/null +++ b/infrastructure/terraform/modules/alarms-sla-breaches/cloudwatch_metric_alarm_errors.tf @@ -0,0 +1,22 @@ +resource "aws_cloudwatch_metric_alarm" "errors" { + alarm_name = "${var.alarm_prefix}-lambda-${var.function_name}-errors" + alarm_description = "ERROR: Lambda errors" + + namespace = "AWS/Lambda" + metric_name = "Errors" + statistic = "Sum" + period = var.period_seconds + + evaluation_periods = var.evaluation_periods + threshold = var.errors_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { FunctionName = var.function_name } + + actions_enabled = false + alarm_actions = [] + ok_actions = [] + insufficient_data_actions = [] + tags = var.tags +} diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 7e5f18edf..76c245175 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -9,6 +9,7 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { DatastoreConfig } from "../config"; export async function setupDynamoDBContainer() { + // Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync. const container = await new GenericContainer("amazon/dynamodb-local") .withExposedPorts(8000) .start(); diff --git a/internal/helpers/src/__tests__/metrics.test.ts b/internal/helpers/src/__tests__/metrics.test.ts new file mode 100644 index 000000000..ce458e0ba --- /dev/null +++ b/internal/helpers/src/__tests__/metrics.test.ts @@ -0,0 +1,151 @@ +import type { MetricsLogger } from "aws-embedded-metrics"; +import { Unit } from "aws-embedded-metrics"; +import { + MetricStatus, + buildEMFObject, + emitForSingleSupplier, +} from "../metrics"; + +describe("emitForSingleSupplier", () => { + const originalFunctionName = process.env.AWS_LAMBDA_FUNCTION_NAME; + + afterEach(() => { + if (originalFunctionName === undefined) { + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + } else { + process.env.AWS_LAMBDA_FUNCTION_NAME = originalFunctionName; + } + }); + + it("sets the function namespace, supplier dimensions, and count metric", () => { + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + const metrics = { + setNamespace: jest.fn(), + putDimensions: jest.fn(), + putMetric: jest.fn(), + } as unknown as MetricsLogger; + + emitForSingleSupplier( + metrics, + "process-letter", + "supplier-1", + 3, + MetricStatus.Success, + ); + + expect(metrics.setNamespace).toHaveBeenCalledWith("process-letter"); + expect(metrics.putDimensions).toHaveBeenCalledWith({ + Supplier: "supplier-1", + }); + expect(metrics.putMetric).toHaveBeenCalledWith( + MetricStatus.Success, + 3, + Unit.Count, + ); + }); + + it("uses the Lambda function name and preserves additional dimensions", () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = "lambda-function"; + const metrics = { + setNamespace: jest.fn(), + putDimensions: jest.fn(), + putMetric: jest.fn(), + } as unknown as MetricsLogger; + + emitForSingleSupplier( + metrics, + "fallback-function", + "supplier-1", + 2, + MetricStatus.Failure, + { Environment: "test", Supplier: "overridden-supplier" }, + ); + + expect(metrics.setNamespace).toHaveBeenCalledWith("lambda-function"); + expect(metrics.putDimensions).toHaveBeenCalledWith({ + Environment: "test", + Supplier: "supplier-1", + }); + expect(metrics.putMetric).toHaveBeenCalledWith( + MetricStatus.Failure, + 2, + Unit.Count, + ); + }); +}); + +describe("buildEMFObject", () => { + const originalFunctionName = process.env.AWS_LAMBDA_FUNCTION_NAME; + + afterEach(() => { + jest.restoreAllMocks(); + if (originalFunctionName === undefined) { + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + } else { + process.env.AWS_LAMBDA_FUNCTION_NAME = originalFunctionName; + } + }); + + it("builds an EMF object using the supplied function name", () => { + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + jest.spyOn(Date, "now").mockReturnValue(123_456_789); + + expect( + buildEMFObject( + "process-letter", + { Supplier: "supplier-1", Environment: "test" }, + { key: MetricStatus.Success, value: 1, unit: Unit.Count }, + ), + ).toEqual({ + Supplier: "supplier-1", + Environment: "test", + _aws: { + Timestamp: 123_456_789, + CloudWatchMetrics: [ + { + Namespace: "process-letter", + Dimensions: [["Supplier", "Environment"]], + Metrics: [ + { + Name: MetricStatus.Success, + Value: 1, + Unit: Unit.Count, + }, + ], + }, + ], + }, + [MetricStatus.Success]: 1, + }); + }); + + it("prefers the Lambda function name for the EMF namespace", () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = "lambda-function"; + + const emfObject = buildEMFObject( + "fallback-function", + { Supplier: "supplier-1" }, + { key: "BreachCount", value: 4, unit: Unit.Count }, + ); + + expect(emfObject._aws.CloudWatchMetrics[0].Namespace).toBe( + "lambda-function", + ); + }); + + it("uses the optional dimension sets if provided", () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = "lambda-function"; + + const emfObject = buildEMFObject( + "process-letter", + { Supplier: "supplier-1" }, + { key: "BreachCount", value: 4, unit: Unit.Count }, + [[], ["Supplier"]], + ); + + expect(emfObject._aws.CloudWatchMetrics[0].Dimensions).toEqual([ + [], + ["Supplier"], + ]); + }); +}); diff --git a/internal/helpers/src/metrics.ts b/internal/helpers/src/metrics.ts index 74ed3622a..df79b62e0 100644 --- a/internal/helpers/src/metrics.ts +++ b/internal/helpers/src/metrics.ts @@ -32,6 +32,7 @@ export function buildEMFObject( functionName: string, dimensions: Record, metric: MetricEntry, + dimensionSets?: string[][], ) { const namespace = process.env.AWS_LAMBDA_FUNCTION_NAME || functionName; return { @@ -41,7 +42,7 @@ export function buildEMFObject( CloudWatchMetrics: [ { Namespace: namespace, - Dimensions: [Object.keys(dimensions)], + Dimensions: dimensionSets || [Object.keys(dimensions)], Metrics: [ { Name: metric.key, Value: metric.value, Unit: metric.unit }, ], diff --git a/internal/sql-test-helpers/README.md b/internal/sql-test-helpers/README.md new file mode 100644 index 000000000..d86694a32 --- /dev/null +++ b/internal/sql-test-helpers/README.md @@ -0,0 +1,46 @@ + + +# @internal/sql-test-helpers + +## Purpose + +Test-only helpers for running SQL queries against databases in [testcontainers](https://node.testcontainers.org/), so that query builders can be checked against a real SQL engine using known test data. + +## General Structure highlights + +- **`src/trino.ts`**: Trino helpers, which talk to Trino over its REST API using `fetch`: + - `setupTrinoContainer()` — starts a `trinodb/trino` `GenericContainer` and returns a `TrinoContext` (`container`, `endpoint`). + - `runTrinoStatement(context, sql)` — runs a statement against the built-in `memory` catalog, follows result pagination and returns rows as `Record` (NULL → `undefined`). + - `runParameterisedQueryOnTrino(context, query)` — runs an Athena-style `{ queryString, executionParameters }` query via `EXECUTE IMMEDIATE ... USING`. Execution parameters are SQL literal expressions, as in Athena (for example `"'2026-10-14T12:00:00.000Z'"`). + +## Usage + +Add the package as a **devDependency** of the consuming workspace and import it from test code only: + +```json +"devDependencies": { + "@internal/sql-test-helpers": "workspace:*" +} +``` + +```ts +import { + runParameterisedQueryOnTrino, + runTrinoStatement, + setupTrinoContainer, +} from "@internal/sql-test-helpers"; + +const context = await setupTrinoContainer(); +await runTrinoStatement(context, "CREATE TABLE event_cache (...)"); +const rows = await runParameterisedQueryOnTrino(context, buildQuery(...)); +await context.container.stop(); +``` + +## Nuances and Peculiarities + +- Must never be imported by production code. Because consumers list it under `devDependencies`, ESLint's `import-x/no-extraneous-dependencies` rule reports any import from non-test files. +- Has no unit tests of its own and is excluded from coverage (see `sonar.coverage.exclusions` in `scripts/config/sonar-scanner.properties`). It is checked through the tests that use it. +- Tests that use it require Docker. The first run pulls the Trino image (about 1 GB), so allow a generous `beforeAll` timeout (for example `180_000` ms). In CI, the image is pulled in the background at the start of the unit test job in `.github/workflows/stage-2-test.yaml`. If you change `TRINO_IMAGE`, update that step too. +- Trino SQL is close to, but not identical to, Athena's engine version. The helpers check query logic, not every Athena-specific behaviour. + + diff --git a/internal/sql-test-helpers/package.json b/internal/sql-test-helpers/package.json new file mode 100644 index 000000000..af9e7aa3f --- /dev/null +++ b/internal/sql-test-helpers/package.json @@ -0,0 +1,17 @@ +{ + "dependencies": { + "testcontainers": "catalog:test" + }, + "description": "Test-only helpers for running SQL queries against containerised databases", + "license": "MIT", + "main": "src/index.ts", + "name": "@internal/sql-test-helpers", + "private": true, + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "echo \"Test helpers package has no unit tests\"", + "typecheck": "tsc --noEmit" + }, + "version": "0.1.0" +} diff --git a/internal/sql-test-helpers/src/index.ts b/internal/sql-test-helpers/src/index.ts new file mode 100644 index 000000000..79ebbf71c --- /dev/null +++ b/internal/sql-test-helpers/src/index.ts @@ -0,0 +1,8 @@ +export { + type SqlQuery, + type SqlRow, + type TrinoContext, + runParameterisedQueryOnTrino, + runTrinoStatement, + setupTrinoContainer, +} from "./trino"; diff --git a/internal/sql-test-helpers/src/trino.ts b/internal/sql-test-helpers/src/trino.ts new file mode 100644 index 000000000..b86d17511 --- /dev/null +++ b/internal/sql-test-helpers/src/trino.ts @@ -0,0 +1,156 @@ +import { GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +// Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync. +const TRINO_IMAGE = "trinodb/trino:476"; +const TRINO_PORT = 8080; + +// Structurally compatible with Athena's query/row shapes, so Athena query +// builders can be exercised against Trino without depending on them here. +export type SqlQuery = { + queryString: string; + executionParameters?: string[]; +}; + +export type SqlRow = Record; + +type TrinoResponse = { + columns?: { name: string }[]; + data?: unknown[][]; + nextUri?: string; + error?: { message: string }; +}; + +export type TrinoContext = { + container: StartedTestContainer; + endpoint: string; +}; + +const NODE_READY_TIMEOUT_MS = 60_000; +const NODE_READY_POLL_MS = 500; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +// Trino can report "No nodes available to run query" when the coordinator has +// not (or no longer) registered a worker, e.g. just after "SERVER STARTED" or +// on a loaded CI runner. It is raised before execution starts, so retrying is +// safe even for non-idempotent statements. +const isNoNodesError = (error: unknown) => + error instanceof Error && error.message.includes("No nodes available"); + +export async function setupTrinoContainer(): Promise { + const container = await new GenericContainer(TRINO_IMAGE) + .withExposedPorts(TRINO_PORT) + .withWaitStrategy(Wait.forLogMessage("======== SERVER STARTED ========")) + .withStartupTimeout(120_000) + .start(); + + const endpoint = `http://${container.getHost()}:${container.getMappedPort(TRINO_PORT)}`; + const context = { container, endpoint }; + + await runTrinoStatement(context, "SELECT 1"); + + return context; +} + +async function readTrinoResponse(response: Response): Promise { + if (!response.ok) { + throw new Error( + `Trino request failed: ${response.status} ${await response.text()}`, + ); + } + + const body = (await response.json()) as TrinoResponse; + + if (body.error) { + throw new Error(`Trino query failed: ${body.error.message}`); + } + + return body; +} + +function toRow(columns: string[], values: unknown[]): SqlRow { + return Object.fromEntries( + columns.map((name, i) => { + const value = values.at(i); + return [name, value === null ? undefined : String(value)]; + }), + ); +} + +// Runs a statement via the Trino REST API against the in-memory catalog. +export async function runTrinoStatement( + context: TrinoContext, + sql: string, +): Promise { + const deadline = Date.now() + NODE_READY_TIMEOUT_MS; + + for (;;) { + try { + return await executeTrinoStatement(context, sql); + } catch (error) { + if (!isNoNodesError(error) || Date.now() >= deadline) { + throw error; + } + } + + await sleep(NODE_READY_POLL_MS); + } +} + +async function executeTrinoStatement( + context: TrinoContext, + sql: string, +): Promise { + let body = await readTrinoResponse( + await fetch(`${context.endpoint}/v1/statement`, { + method: "POST", + headers: { + "X-Trino-User": "test", + "X-Trino-Catalog": "memory", + "X-Trino-Schema": "default", + }, + body: sql, + }), + ); + + let columns: string[] = []; + const rows: SqlRow[] = []; + + while (true) { + if (body.columns) { + columns = body.columns.map((column) => column.name); + } + + const currentColumns = columns; + rows.push( + ...(body.data ?? []).map((values) => toRow(currentColumns, values)), + ); + + if (!body.nextUri) { + return rows; + } + + body = await readTrinoResponse( + await fetch(body.nextUri, { headers: { "X-Trino-User": "test" } }), + ); + } +} + +// Emulates Athena parameterised queries: each execution parameter is +// substituted into the query as a SQL literal expression. +export async function runParameterisedQueryOnTrino( + context: TrinoContext, + query: SqlQuery, +): Promise { + const escapedQuery = query.queryString.replaceAll("'", "''"); + const parameters = query.executionParameters ?? []; + const using = parameters.length > 0 ? ` USING ${parameters.join(", ")}` : ""; + + return runTrinoStatement( + context, + `EXECUTE IMMEDIATE '${escapedQuery}'${using}`, + ); +} diff --git a/internal/sql-test-helpers/tsconfig.json b/internal/sql-test-helpers/tsconfig.json new file mode 100644 index 000000000..70ae3804b --- /dev/null +++ b/internal/sql-test-helpers/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": {}, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*" + ] +} diff --git a/lambdas/letter-sla-breaches/common/package.json b/lambdas/letter-sla-breaches/common/package.json index 7e3ef13d3..1160a6a43 100644 --- a/lambdas/letter-sla-breaches/common/package.json +++ b/lambdas/letter-sla-breaches/common/package.json @@ -1,8 +1,9 @@ { "dependencies": { - "@aws-sdk/client-athena": "^3.1149.0", + "@aws-sdk/client-athena": "catalog:aws", "@internal/helpers": "workspace:*", - "pino": "^10.4.0" + "pino": "catalog:app", + "zod": "catalog:app" }, "description": "Common utilities for letter SLA breach lambdas", "license": "MIT", diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts index 485e6ab81..d33a405a0 100644 --- a/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts +++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts @@ -9,29 +9,27 @@ import { type Deps } from "../deps"; const query = { queryString: "SELECT ?", executionParameters: ["'a'"] }; -function makeDeps(responses: unknown[]) { - const send = jest.fn(); - for (const response of responses) { - send.mockResolvedValueOnce(response); - } - const deps = { - athenaClient: { send } as unknown as AthenaClient, - logger: { info: jest.fn(), debug: jest.fn() }, - env: { - ATHENA_WORKGROUP: "workgroup", - ATHENA_DATABASE: "database", - EVENT_CACHE_TABLE_NAME: "event_cache", - PACK_SPECIFICATION_TABLE_NAME: "pack_specification", - }, - } as unknown as Deps; - return { deps, send }; -} +const deps = { + athenaClient: { send: jest.fn() } as unknown as AthenaClient, + logger: { info: jest.fn(), debug: jest.fn() }, + env: { + ATHENA_WORKGROUP: "workgroup", + ATHENA_DATABASE: "database", + EVENT_CACHE_TABLE_NAME: "event_cache", + }, +} as unknown as Deps; const columnInfo = { ColumnInfo: [{ Name: "id" }, { Name: "status" }] }; +function mockResolvedValues(fn: any, returnValues: any[]) { + for (const value of returnValues) { + (fn as jest.Mock).mockResolvedValueOnce(value); + } +} + describe("runAthenaQuery", () => { it("starts the query, polls until complete and returns paginated rows", async () => { - const { deps, send } = makeDeps([ + mockResolvedValues(deps.athenaClient.send, [ { QueryExecutionId: "qid" }, { QueryExecution: { Status: { State: "QUEUED" } } }, { QueryExecution: { Status: { State: "RUNNING" } } }, @@ -60,7 +58,9 @@ describe("runAthenaQuery", () => { { id: "1", status: "PENDING" }, { id: "2", status: undefined }, ]); - const commands = send.mock.calls.map(([command]) => command); + const commands = (deps.athenaClient.send as jest.Mock).mock.calls.map( + ([command]) => command, + ); expect(commands[0]).toBeInstanceOf(StartQueryExecutionCommand); expect(commands[0].input).toEqual({ QueryString: "SELECT ?", @@ -77,7 +77,7 @@ describe("runAthenaQuery", () => { }); it("handles empty result sets and missing metadata", async () => { - const { deps } = makeDeps([ + mockResolvedValues(deps.athenaClient.send, [ { QueryExecutionId: "qid" }, { QueryExecution: { Status: { State: "SUCCEEDED" } } }, { @@ -91,7 +91,7 @@ describe("runAthenaQuery", () => { }); it("handles a response with no result set", async () => { - const { deps } = makeDeps([ + mockResolvedValues(deps.athenaClient.send, [ { QueryExecutionId: "qid" }, { QueryExecution: { Status: { State: "SUCCEEDED" } } }, {}, @@ -102,7 +102,7 @@ describe("runAthenaQuery", () => { it("uses the default poll interval when none is given", async () => { jest.useFakeTimers(); - const { deps } = makeDeps([ + mockResolvedValues(deps.athenaClient.send, [ { QueryExecutionId: "qid" }, {}, { QueryExecution: { Status: { State: "SUCCEEDED" } } }, @@ -117,7 +117,7 @@ describe("runAthenaQuery", () => { }); it("throws when no query execution id is returned", async () => { - const { deps } = makeDeps([{}]); + mockResolvedValues(deps.athenaClient.send, [{}]); await expect(runAthenaQuery(deps, query)).rejects.toThrow( "Athena did not return a query execution id", @@ -127,7 +127,7 @@ describe("runAthenaQuery", () => { it.each(["FAILED", "CANCELLED"])( "throws when the query is %s", async (state) => { - const { deps } = makeDeps([ + mockResolvedValues(deps.athenaClient.send, [ { QueryExecutionId: "qid" }, { QueryExecution: { @@ -143,7 +143,7 @@ describe("runAthenaQuery", () => { ); it("throws with a default reason when none is given", async () => { - const { deps } = makeDeps([ + mockResolvedValues(deps.athenaClient.send, [ { QueryExecutionId: "qid" }, { QueryExecution: { Status: { State: "FAILED" } } }, ]); diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/deps.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/deps.test.ts new file mode 100644 index 000000000..290f279e0 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/__tests__/deps.test.ts @@ -0,0 +1,69 @@ +import { ZodError } from "zod"; +import createDependenciesContainer from "../deps"; + +describe("createDependenciesContainer", () => { + const oldEnv = process.env; + + beforeEach(() => { + process.env = { + ...oldEnv, + ATHENA_WORKGROUP: "workgroup", + ATHENA_DATABASE: "database", + EVENT_CACHE_TABLE_NAME: "event_cache", + PACK_SPECIFICATION_TABLE_NAME: "pack_specification", + DAYS_TO_LOOK_BACK: "14", + STANDARD_SLA_WORKING_DAYS: "2", + }; + }); + + afterEach(() => { + process.env = oldEnv; + }); + + it("parses numeric configuration from the environment", () => { + const { env } = createDependenciesContainer(); + + expect(env.DAYS_TO_LOOK_BACK).toBe(14); + expect(env.STANDARD_SLA_WORKING_DAYS).toBe(2); + }); + + it("loads the full environment", () => { + const { env } = createDependenciesContainer(); + + expect(env).toEqual({ + ATHENA_WORKGROUP: "workgroup", + ATHENA_DATABASE: "database", + EVENT_CACHE_TABLE_NAME: "event_cache", + PACK_SPECIFICATION_TABLE_NAME: "pack_specification", + DAYS_TO_LOOK_BACK: 14, + STANDARD_SLA_WORKING_DAYS: 2, + }); + }); + + it("passes the optional log level through", () => { + process.env.PINO_LOG_LEVEL = "debug"; + + expect(createDependenciesContainer().env.PINO_LOG_LEVEL).toBe("debug"); + }); + + it("throws when a required string variable is missing", () => { + delete process.env.ATHENA_WORKGROUP; + + expect(() => createDependenciesContainer()).toThrow(ZodError); + }); + + it.each(["abc", "0", "1.5", ""])( + "rejects invalid STANDARD_SLA_WORKING_DAYS %p", + (value) => { + process.env.STANDARD_SLA_WORKING_DAYS = value; + + expect(() => createDependenciesContainer()).toThrow(); + }, + ); + + it("rejects a missing DAYS_TO_LOOK_BACK", () => { + delete process.env.DAYS_TO_LOOK_BACK; + + expect(() => createDependenciesContainer()).toThrow(); + }); +}); diff --git a/lambdas/letter-sla-breaches/common/src/deps.ts b/lambdas/letter-sla-breaches/common/src/deps.ts index 1a3ebc369..cc948000e 100644 --- a/lambdas/letter-sla-breaches/common/src/deps.ts +++ b/lambdas/letter-sla-breaches/common/src/deps.ts @@ -1,27 +1,20 @@ import { AthenaClient } from "@aws-sdk/client-athena"; import { createLogger } from "@internal/helpers"; import { Logger } from "pino"; +import { type EnvVars, parseEnvVars } from "./env"; export type Deps = { athenaClient: AthenaClient; logger: Logger; - env: { - ATHENA_WORKGROUP: string; - ATHENA_DATABASE: string; - EVENT_CACHE_TABLE_NAME: string; - PACK_SPECIFICATION_TABLE_NAME: string; - }; + env: EnvVars; }; export default function createDependenciesContainer(): Deps { + const env = parseEnvVars(process.env); + return { athenaClient: new AthenaClient(), - logger: createLogger({ logLevel: process.env.PINO_LOG_LEVEL }), - env: { - ATHENA_WORKGROUP: process.env.ATHENA_WORKGROUP!, - ATHENA_DATABASE: process.env.ATHENA_DATABASE!, - EVENT_CACHE_TABLE_NAME: process.env.EVENT_CACHE_TABLE_NAME!, - PACK_SPECIFICATION_TABLE_NAME: process.env.PACK_SPECIFICATION_TABLE_NAME!, - }, + logger: createLogger({ logLevel: env.PINO_LOG_LEVEL }), + env, }; } diff --git a/lambdas/letter-sla-breaches/common/src/env.ts b/lambdas/letter-sla-breaches/common/src/env.ts new file mode 100644 index 000000000..129b90af9 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/env.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const EnvVarsSchema = z.object({ + ATHENA_WORKGROUP: z.string(), + ATHENA_DATABASE: z.string(), + EVENT_CACHE_TABLE_NAME: z.string(), + PACK_SPECIFICATION_TABLE_NAME: z.string(), + DAYS_TO_LOOK_BACK: z.coerce.number().int().positive(), + STANDARD_SLA_WORKING_DAYS: z.coerce.number().int().positive(), + PINO_LOG_LEVEL: z.string().optional(), +}); + +export type EnvVars = z.infer; + +// Parsed on demand rather than at import so that importing this package +// (e.g. when automocked in tests) doesn't require the environment to be set +export const parseEnvVars = (env: NodeJS.ProcessEnv): EnvVars => + EnvVarsSchema.parse(env); diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/package.json b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json index 8875e4acc..15e760948 100644 --- a/lambdas/letter-sla-breaches/populate-standard-breaches/package.json +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json @@ -1,6 +1,13 @@ { "dependencies": { - "esbuild": "0.28.2" + "@date-fns/tz": "catalog:app", + "@internal/helpers": "workspace:*", + "@internal/letter-sla-breaches-common": "workspace:*", + "aws-embedded-metrics": "catalog:aws", + "date-fns": "catalog:app" + }, + "devDependencies": { + "@internal/sql-test-helpers": "workspace:*" }, "name": "nhs-notify-supplier-api-populate-standard-breaches", "private": true, diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts index f64093251..90a85a00c 100644 --- a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts @@ -1,12 +1,101 @@ -import populateStandardBreaches from "../handler/populate-standard-breaches"; +import { + type Deps, + runAthenaQuery, +} from "@internal/letter-sla-breaches-common"; +import { Unit } from "aws-embedded-metrics"; +import buildStandardBreachesQuery from "../handler/standard-breaches-query"; +import createHandler from "../handler/populate-standard-breaches"; + +jest.mock("@internal/letter-sla-breaches-common"); +jest.mock("../handler/standard-breaches-query"); + +const makeDeps = () => + ({ + logger: { info: jest.fn(), error: jest.fn() }, + env: { + EVENT_CACHE_TABLE_NAME: "event_cache", + PACK_SPECIFICATION_TABLE_NAME: "pack_specification", + DAYS_TO_LOOK_BACK: 10, + STANDARD_SLA_WORKING_DAYS: 3, + }, + }) as unknown as Deps; describe("populateStandardBreaches", () => { - it("returns a success result", async () => { - const result = await populateStandardBreaches({ date: "2026-10-08" }); + const oldEnv = process.env; + + beforeEach(() => { + process.env = { ...oldEnv }; + }); + + afterEach(() => { + process.env = oldEnv; + }); + + it("passes the configured SLA days and look-back to the query", async () => { + jest.mocked(runAthenaQuery).mockResolvedValue([]); + + await createHandler(makeDeps())({ date: "2026-10-08" }); + + expect(buildStandardBreachesQuery).toHaveBeenCalledWith( + "event_cache", + "pack_specification", + new Date("2026-10-08"), + 3, + 10, + ); + }); + + it("runs the standard breaches query and logs a metric per supplier", async () => { + const deps = makeDeps(); + jest.mocked(runAthenaQuery).mockResolvedValue([ + { supplierid: "supplier1", breach_count: "10" }, + { supplierid: "supplier2", breach_count: "5" }, + ]); + process.env.AWS_LAMBDA_FUNCTION_NAME = "populate-standard-breaches"; + + await createHandler(deps)({ date: "2026-10-08" }); - expect(result).toEqual({ - status: "SUCCESS", - message: "populate-standard-breaches invoked successfully", - }); + expect(deps.logger.info as jest.Mock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + SupplierId: "supplier1", + _aws: expect.objectContaining({ + CloudWatchMetrics: [ + { + Namespace: "populate-standard-breaches", + Dimensions: [[], ["SupplierId"]], + Metrics: [ + expect.objectContaining({ + Name: "BreachCount", + Unit: Unit.Count, + }), + ], + }, + ], + }), + BreachCount: 10, + }), + ); + expect(deps.logger.info as jest.Mock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + SupplierId: "supplier2", + _aws: expect.objectContaining({ + CloudWatchMetrics: [ + { + Namespace: "populate-standard-breaches", + Dimensions: [[], ["SupplierId"]], + Metrics: [ + expect.objectContaining({ + Name: "BreachCount", + Unit: Unit.Count, + }), + ], + }, + ], + }), + BreachCount: 5, + }), + ); }); }); diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts new file mode 100644 index 000000000..d33007281 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts @@ -0,0 +1,410 @@ +import { + TrinoContext, + runParameterisedQueryOnTrino, + runTrinoStatement, + setupTrinoContainer, +} from "@internal/sql-test-helpers"; +import { TZDate } from "@date-fns/tz"; +import buildStandardBreachesQuery from "../handler/standard-breaches-query"; + +type LetterEvent = { + domainid: string; + specificationid: string; + status: string; + supplierid: string; + time: string; +}; + +const EVENT_TABLE_NAME = "event_cache"; +const PACK_SPECIFICATION_TABLE_NAME = "pack_specification"; + +async function insertEvents(context: TrinoContext, events: LetterEvent[]) { + const values = events + .map(({ domainid, specificationid, status, supplierid, time }) => { + const [year, month, day] = time.slice(0, 10).split("-"); + return `('${domainid}', '${specificationid}', '${status}', '${supplierid}', '${time}', '${year}', '${month}', '${day}')`; + }) + .join(",\n"); + + await runTrinoStatement( + context, + `INSERT INTO ${EVENT_TABLE_NAME} VALUES ${values}`, + ); +} + +async function insertPackSpecifications( + context: TrinoContext, + specifications: { id: string; features: string[] }[], +) { + const packSpecifications = specifications.map((specification) => { + const featuresArray = `ARRAY['${specification.features.join("', '")}']`; + return `('${specification.id}', ROW(${featuresArray}))`; + }); + + await runTrinoStatement( + context, + `INSERT INTO ${PACK_SPECIFICATION_TABLE_NAME} VALUES ${packSpecifications}`, + ); +} + +function atZoneEuropeLondon(dateTime: string) { + const [year, month, day, hour, minute, second] = dateTime + .split(/[- :]/) + .map(Number); + return new TZDate( + year, + month - 1, + day, + hour, + minute, + second, + "Europe/London", + ); +} + +function sortBySupplier(rows: Record[]) { + return rows.toSorted((a, b) => + (a.supplierid ?? "").localeCompare(b.supplierid ?? ""), + ); +} + +describe("buildStandardBreachesQuery", () => { + let context: TrinoContext; + + beforeAll(async () => { + context = await setupTrinoContainer(); + + await runTrinoStatement( + context, + `CREATE TABLE ${PACK_SPECIFICATION_TABLE_NAME} ( + id VARCHAR, + assembly ROW(features ARRAY(VARCHAR)) + )`, + ); + await insertPackSpecifications(context, [ + { id: "standard", features: [] }, + { id: "braille", features: ["BRAILLE"] }, + { id: "audio", features: ["AUDIO"] }, + { id: "same_day", features: ["SAME_DAY"] }, + { id: "admail", features: ["ADMAIL"] }, + ]); + }, 180_000); + + afterAll(async () => { + await runTrinoStatement( + context, + `DROP TABLE IF EXISTS ${PACK_SPECIFICATION_TABLE_NAME}`, + ); + await context?.container.stop(); + }); + + beforeEach(async () => { + await runTrinoStatement( + context, + `CREATE TABLE ${EVENT_TABLE_NAME} ( + domainid VARCHAR, + specificationid VARCHAR, + status VARCHAR, + supplierid VARCHAR, + time VARCHAR, + __year VARCHAR, + __month VARCHAR, + __day VARCHAR + )`, + ); + }); + + afterEach(async () => { + await runTrinoStatement( + context, + `DROP TABLE IF EXISTS ${EVENT_TABLE_NAME}`, + ); + }); + + it("counts letters per supplier where the SLA has been breached", async () => { + // After 2400 BST on Wednesday (i.e. early on Thursday) + const now = new Date("2026-10-15T01:01:00.000Z"); + + await insertEvents(context, [ + // Breach: received on Monday, never completed + { + domainid: "letter-1", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: "2026-10-12T10:00:00.000Z", + }, + // Not a breach: completed + { + domainid: "letter-2", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: "2026-10-12T10:00:00.000Z", + }, + { + domainid: "letter-2", + supplierid: "supplier-a", + specificationid: "standard", + status: "DISPATCHED", + time: "2026-10-13T10:00:00.000Z", + }, + // Breach: a non-terminal status does not complete the letter + { + domainid: "letter-4", + supplierid: "supplier-b", + specificationid: "standard", + status: "PENDING", + time: "2026-10-12T10:00:00.000Z", + }, + { + domainid: "letter-4", + supplierid: "supplier-b", + specificationid: "standard", + status: "ACCEPTED", + time: "2026-10-13T10:00:00.000Z", + }, + // Breach: first PENDING event is used, even if repeated later + { + domainid: "letter-5", + supplierid: "supplier-b", + specificationid: "standard", + status: "PENDING", + time: "2026-10-12T11:00:00.000Z", + }, + { + domainid: "letter-5", + supplierid: "supplier-b", + specificationid: "standard", + status: "PENDING", + time: "2026-10-13T11:00:00.000Z", + }, + // Not a breach: each terminal status completes the letter + ...[ + "REJECTED", + "FAILED", + "CANCELLED", + "FORWARDED", + "RETURNED", + "DELIVERED", + ].flatMap((status, i) => [ + { + domainid: `letter-terminal-${i}`, + supplierid: "supplier-b", + status: "PENDING", + specificationid: "standard", + time: "2026-10-12T10:00:00.000Z", + }, + { + domainid: `letter-terminal-${i}`, + supplierid: "supplier-b", + specificationid: "standard", + status, + time: "2026-10-13T10:00:00.000Z", + }, + ]), + // Breaching, but ignored: before the 14-day window + { + domainid: "letter-6", + supplierid: "supplier-c", + specificationid: "standard", + status: "PENDING", + time: "2026-09-01T10:00:00.000Z", + }, + ]); + + const rows = await runParameterisedQueryOnTrino( + context, + buildStandardBreachesQuery( + EVENT_TABLE_NAME, + PACK_SPECIFICATION_TABLE_NAME, + now, + 2, + 14, + ), + ); + + expect(sortBySupplier(rows)).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + { supplierid: "supplier-b", breach_count: "2" }, + ]); + }); + + test("Letters breach after two working days, at the end of the day to allow time for Royal Mail collection", async () => { + await insertEvents(context, [ + // Received Wednesday - won't breach until after 2400 BST on Friday + { + domainid: "letter-1", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: atZoneEuropeLondon("2026-10-14 11:00:00") + .withTimeZone("UTC") + .toISOString(), + }, + // Received Thursday - won't breach until after 2400 BST on Monday + { + domainid: "letter-2", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: atZoneEuropeLondon("2026-10-15 11:00:00") + .withTimeZone("UTC") + .toISOString(), + }, + // Received Friday - won't breach until after 2400 BST on Tuesday + { + domainid: "letter-3", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: atZoneEuropeLondon("2026-10-16 11:00:00") + .withTimeZone("UTC") + .toISOString(), + }, + // Received Saturday - won't breach until after 2400 BST on Tuesday + { + domainid: "letter-4", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: atZoneEuropeLondon("2026-10-17 11:00:00") + .withTimeZone("UTC") + .toISOString(), + }, + // Received Sunday - won't breach until after 2400 BST on Tuesday + { + domainid: "letter-5", + supplierid: "supplier-a", + specificationid: "standard", + status: "PENDING", + time: atZoneEuropeLondon("2026-10-18 11:00:00") + .withTimeZone("UTC") + .toISOString(), + }, + ]); + + const [ + rowsBefore2400OnFriday, + rowsAfter2400OnFriday, + rowsAfter2400OnSaturday, + rowsAfter2400OnSunday, + rowsBefore2400OnMonday, + rowsAfter2400OnMonday, + rowsBefore2400OnTuesday, + rowsAfter2400OnTuesday, + ] = await Promise.all( + [ + atZoneEuropeLondon("2026-10-16 23:59:59"), + atZoneEuropeLondon("2026-10-17 00:00:01"), + atZoneEuropeLondon("2026-10-18 00:00:01"), + atZoneEuropeLondon("2026-10-19 00:00:01"), + atZoneEuropeLondon("2026-10-19 23:59:59"), + atZoneEuropeLondon("2026-10-20 00:00:01"), + atZoneEuropeLondon("2026-10-20 23:59:59"), + atZoneEuropeLondon("2026-10-21 00:00:01"), + ].map(async (day) => + runParameterisedQueryOnTrino( + context, + buildStandardBreachesQuery( + EVENT_TABLE_NAME, + PACK_SPECIFICATION_TABLE_NAME, + day, + 2, + 14, + ), + ), + ), + ); + expect(rowsBefore2400OnFriday).toHaveLength(0); + expect(rowsAfter2400OnFriday).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + ]); + expect(rowsAfter2400OnSaturday).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + ]); + expect(rowsAfter2400OnSunday).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + ]); + expect(rowsBefore2400OnMonday).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + ]); + expect(rowsAfter2400OnMonday).toEqual([ + { supplierid: "supplier-a", breach_count: "2" }, + ]); + expect(rowsBefore2400OnTuesday).toEqual([ + { supplierid: "supplier-a", breach_count: "2" }, + ]); + expect(rowsAfter2400OnTuesday).toEqual([ + { supplierid: "supplier-a", breach_count: "5" }, + ]); + }); + + it("ignores same-day letters and letters in an accessible format", async () => { + const now = new Date("2026-10-14T12:00:00.000Z"); + + await insertEvents(context, [ + { + domainid: "letter-1", + supplierid: "supplier-a", + specificationid: "same_day", + status: "PENDING", + time: "2026-10-11T10:00:00.000Z", + }, + { + domainid: "letter-2", + supplierid: "supplier-a", + specificationid: "braille", + status: "PENDING", + time: "2026-10-11T10:00:00.000Z", + }, + { + domainid: "letter-3", + supplierid: "supplier-a", + specificationid: "audio", + status: "PENDING", + time: "2026-10-11T10:00:00.000Z", + }, + ]); + + const rows = await runParameterisedQueryOnTrino( + context, + buildStandardBreachesQuery( + EVENT_TABLE_NAME, + PACK_SPECIFICATION_TABLE_NAME, + now, + 2, + 14, + ), + ); + + expect(rows).toHaveLength(0); + }); + + it("does not ignore admail", async () => { + const now = new Date("2026-10-14T12:00:00.000Z"); + + await insertEvents(context, [ + { + domainid: "letter-1", + supplierid: "supplier-a", + specificationid: "admail", + status: "PENDING", + time: "2026-10-11T10:00:00.000Z", + }, + ]); + + const rows = await runParameterisedQueryOnTrino( + context, + buildStandardBreachesQuery( + EVENT_TABLE_NAME, + PACK_SPECIFICATION_TABLE_NAME, + now, + 2, + 14, + ), + ); + + expect(rows).toEqual([{ supplierid: "supplier-a", breach_count: "1" }]); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts index 1bcc2318c..2135c1018 100644 --- a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts @@ -1,18 +1,38 @@ +import { + type Deps, + runAthenaQuery, +} from "@internal/letter-sla-breaches-common"; +import { Unit } from "aws-embedded-metrics"; +import { buildEMFObject } from "@internal/helpers"; +import buildStandardBreachesQuery from "./standard-breaches-query"; + export type PopulateStandardBreachesEvent = { date: string; }; -export type PopulateStandardBreachesResult = { - status: "SUCCESS"; - message: string; -}; +export default function createHandler(deps: Deps) { + return async (event: PopulateStandardBreachesEvent): Promise => { + const sql = buildStandardBreachesQuery( + deps.env.EVENT_CACHE_TABLE_NAME, + deps.env.PACK_SPECIFICATION_TABLE_NAME, + new Date(event.date), + deps.env.STANDARD_SLA_WORKING_DAYS, + deps.env.DAYS_TO_LOOK_BACK, + ); + const rows = await runAthenaQuery(deps, sql); -// Placeholder: cache population is not yet implemented. -export default async function populateStandardBreaches( - _event: PopulateStandardBreachesEvent, -): Promise { - return { - status: "SUCCESS", - message: "populate-standard-breaches invoked successfully", + for (const row of rows) { + const emfObject = buildEMFObject( + "", + { SupplierId: row.supplierid! }, + { + key: "BreachCount", + value: Number.parseInt(row.breach_count!, 10), + unit: Unit.Count, + }, + [[], ["SupplierId"]], + ); + deps.logger.info(emfObject); + } }; } diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts new file mode 100644 index 000000000..43c183307 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts @@ -0,0 +1,55 @@ +import { + type AthenaQuery, + buildAthenaDatePredicate, +} from "@internal/letter-sla-breaches-common"; +import { startOfDay, subBusinessDays } from "date-fns"; +import { TZDate } from "@date-fns/tz"; + +export default function buildStandardBreachesQuery( + eventsTableName: string, + packSpecificationTableName: string, + now: Date, + slaWorkingDays: number, + daysToLookBack: number, +): AthenaQuery { + const nowEuropeLondon = new TZDate(now, "Europe/London"); + const slaCutOff = subBusinessDays( + startOfDay(nowEuropeLondon), + slaWorkingDays, + ); + return { + queryString: `WITH parameters AS ( + SELECT from_iso8601_timestamp(?) AS sla_cutoff +), +recent_letter_events AS ( + SELECT domainid, supplierid, specificationid, status, from_iso8601_timestamp(time) AS event_time + FROM ${eventsTableName} + WHERE ${buildAthenaDatePredicate(now, daysToLookBack)} +), +allocated_letters AS ( + SELECT domainid, supplierid, specificationid + FROM recent_letter_events + WHERE status = 'PENDING' + GROUP BY domainid, supplierid, specificationid + HAVING min(event_time) < (select sla_cutoff from parameters) +), +completed_letters AS ( + SELECT domainid + FROM recent_letter_events + WHERE status in ('REJECTED', 'FAILED', 'CANCELLED', 'FORWARDED', 'DISPATCHED', 'RETURNED', 'DELIVERED') + GROUP BY domainid, supplierid +) +SELECT supplierid, count(*) AS breach_count +FROM ( + SELECT domainid, supplierid + FROM allocated_letters + WHERE domainid NOT IN (SELECT domainid FROM completed_letters) + AND cardinality(array_intersect( + (SELECT COALESCE(assembly.features, ARRAY[]) FROM ${packSpecificationTableName} spec WHERE spec.id = specificationid), + ARRAY['SAME_DAY', 'BRAILLE', 'AUDIO'] + )) = 0 +) +GROUP BY supplierid`, + executionParameters: [`'${slaCutOff.toISOString()}'`], + }; +} diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts index 5516fb886..f52283ddb 100644 --- a/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts @@ -1,2 +1,7 @@ +import { createDependenciesContainer } from "@internal/letter-sla-breaches-common"; +import createHandler from "./handler/populate-standard-breaches"; + // eslint-disable-next-line import-x/prefer-default-export -export { default as populateStandardBreachesHandler } from "./handler/populate-standard-breaches"; +export const populateStandardBreachesHandler = createHandler( + createDependenciesContainer(), +); diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json index a41d1b78d..db2aad1b6 100644 --- a/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json @@ -1,13 +1,13 @@ { "dependencies": { - "@aws-sdk/client-dynamodb": "^3.1138.0", - "@aws-sdk/client-s3": "^3.1138.0", - "@aws-sdk/lib-dynamodb": "^3.1138.0", + "@aws-sdk/client-dynamodb": "catalog:aws", + "@aws-sdk/client-s3": "catalog:aws", + "@aws-sdk/lib-dynamodb": "catalog:aws", "@internal/datastore": "workspace:*", "@internal/helpers": "workspace:*", - "esbuild": "0.28.2", - "pino": "^10.3.1", - "zod": "^4.6.5" + "esbuild": "catalog:tools", + "pino": "catalog:app", + "zod": "catalog:app" }, "name": "nhs-notify-supplier-api-populate-supplier-config-cache", "private": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa87dc58..082443169 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@asyncapi/bundler': specifier: ^0.6.4 version: 0.6.4 + '@date-fns/tz': + specifier: ^1.4.0 + version: 1.5.0 '@nhsdigital/nhs-notify-event-schemas-letter-rendering': specifier: ^2.0.2 version: 2.0.2 @@ -60,9 +63,6 @@ catalogs: yargs: specifier: ^18.2.0 version: 18.2.0 - zod: - specifier: ^4.6.7 - version: 4.6.7 zod-mermaid: specifier: ^1.0.9 version: 1.3.0 @@ -73,6 +73,9 @@ catalogs: '@aws-sdk/client-api-gateway': specifier: ^3.1138.0 version: 3.1149.0 + '@aws-sdk/client-athena': + specifier: ^3.1138.0 + version: 3.1152.0 '@aws-sdk/client-cloudwatch-logs': specifier: ^3.1138.0 version: 3.1149.0 @@ -299,8 +302,8 @@ catalogs: specifier: ^13.0.6 version: 13.0.6 js-yaml: - specifier: ^5.4.2 - version: 5.4.3 + specifier: ^5.5.0 + version: 5.5.0 openapi-typescript: specifier: ^7.13.0 version: 7.13.0 @@ -337,6 +340,8 @@ overrides: fast-xml-parser: ^5.5.6 flatted: ^3.4.0 ip-address: ^10.7.4 + js-yaml@3: ^3.15.2 + js-yaml@4: ^4.3.2 minimatch@10: ^10.2.4 minimatch@3: ^3.1.5 minimatch@5: ^5.1.9 @@ -346,6 +351,7 @@ overrides: react: ^19.0.0 undici: ^7.30.1 uuid: ^14.0.0 + zod: ^4.6.6 importers: @@ -513,7 +519,7 @@ importers: version: 4.0.2(@jest/globals@30.5.2)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3) js-yaml: specifier: catalog:tools - version: 5.4.3 + version: 5.5.0 lcov-result-merger: specifier: catalog:test version: 6.0.0 @@ -537,7 +543,7 @@ importers: version: 12.2.0 ts-jest: specifier: catalog:test - version: 29.4.14(@babel/core@8.0.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@8.0.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: catalog:tools version: 10.9.2(@types/node@26.6.5)(typescript@5.9.3) @@ -554,7 +560,7 @@ importers: specifier: catalog:tools version: 2.9.1 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 docs: @@ -581,7 +587,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 zod-mermaid: specifier: catalog:app @@ -602,7 +608,7 @@ importers: specifier: catalog:app version: 0.6.4 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 internal/helpers: @@ -617,9 +623,15 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 + internal/sql-test-helpers: + dependencies: + testcontainers: + specifier: catalog:test + version: 12.2.0 + lambdas/api-handler: dependencies: '@aws-sdk/client-dynamodb': @@ -665,7 +677,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/authorizer: @@ -695,7 +707,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/eventpub-parquet-transformer: @@ -710,14 +722,17 @@ importers: lambdas/letter-sla-breaches/common: dependencies: '@aws-sdk/client-athena': - specifier: ^3.1149.0 + specifier: catalog:aws version: 3.1152.0 '@internal/helpers': specifier: workspace:* version: link:../../../internal/helpers pino: - specifier: ^10.4.0 + specifier: catalog:app version: 10.4.0 + zod: + specifier: ^4.6.6 + version: 4.6.7 lambdas/letter-sla-breaches/populate-sameday-breaches: dependencies: @@ -736,20 +751,36 @@ importers: lambdas/letter-sla-breaches/populate-standard-breaches: dependencies: - esbuild: - specifier: 0.28.2 - version: 0.28.2 + '@date-fns/tz': + specifier: catalog:app + version: 1.5.0 + '@internal/helpers': + specifier: workspace:* + version: link:../../../internal/helpers + '@internal/letter-sla-breaches-common': + specifier: workspace:* + version: link:../common + aws-embedded-metrics: + specifier: catalog:aws + version: 4.2.1 + date-fns: + specifier: catalog:app + version: 4.4.0 + devDependencies: + '@internal/sql-test-helpers': + specifier: workspace:* + version: link:../../../internal/sql-test-helpers lambdas/letter-sla-breaches/populate-supplier-config-cache: dependencies: '@aws-sdk/client-dynamodb': - specifier: ^3.1138.0 + specifier: catalog:aws version: 3.1149.0 '@aws-sdk/client-s3': - specifier: ^3.1138.0 + specifier: catalog:aws version: 3.1149.0 '@aws-sdk/lib-dynamodb': - specifier: ^3.1138.0 + specifier: catalog:aws version: 3.1149.0(@aws-sdk/client-dynamodb@3.1149.0) '@internal/datastore': specifier: workspace:* @@ -758,13 +789,13 @@ importers: specifier: workspace:* version: link:../../../internal/helpers esbuild: - specifier: 0.28.2 + specifier: catalog:tools version: 0.28.2 pino: - specifier: ^10.3.1 + specifier: catalog:app version: 10.4.0 zod: - specifier: ^4.6.5 + specifier: ^4.6.6 version: 4.6.7 lambdas/letter-updates-transformer: @@ -800,7 +831,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/mi-updates-transformer: @@ -833,7 +864,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/supplier-allocator: @@ -890,7 +921,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/supplier-config-ingress: @@ -923,7 +954,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/supplier-mock: @@ -953,7 +984,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 lambdas/update-letter-queue: @@ -986,7 +1017,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 devDependencies: '@tsconfig/node22': @@ -1050,7 +1081,7 @@ importers: specifier: catalog:app version: 10.4.0 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 pact-contracts: {} @@ -1210,7 +1241,7 @@ importers: specifier: catalog:app version: 8.11.2 zod: - specifier: catalog:app + specifier: ^4.6.6 version: 4.6.7 devDependencies: '@aws-sdk/client-cloudwatch-logs': @@ -2146,6 +2177,9 @@ packages: '@datastructures-js/heap@4.3.7': resolution: {integrity: sha512-Dx4un7Uj0dVxkfoq4RkpzsY2OrvNJgQYZ3n3UlGdl88RxxdHd7oTi21/l3zoxUUe0sXFuNUrfmWqlHzqnoN6Ug==} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -3446,6 +3480,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.4: + resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -5053,8 +5090,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.7.4: - resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + ip-address@10.7.5: + resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -5438,6 +5475,10 @@ packages: resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} hasBin: true + js-yaml@5.5.0: + resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -7163,7 +7204,7 @@ packages: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.25.0 || ^4.0.0 + zod: ^4.6.6 zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -7361,7 +7402,7 @@ snapshots: '@aws-sdk/credential-provider-env@3.972.73': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 '@smithy/types': 4.19.0 @@ -7377,7 +7418,7 @@ snapshots: '@aws-sdk/credential-provider-http@3.972.75': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 '@smithy/fetch-http-handler': 5.8.0 @@ -7397,7 +7438,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.973.18': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/credential-provider-env': 3.972.73 '@aws-sdk/credential-provider-http': 3.972.75 '@aws-sdk/credential-provider-login': 3.972.80 @@ -7429,8 +7470,8 @@ snapshots: '@aws-sdk/credential-provider-login@3.972.80': dependencies: - '@aws-sdk/core': 3.978.2 - '@aws-sdk/nested-clients': 3.997.47 + '@aws-sdk/core': 3.978.3 + '@aws-sdk/nested-clients': 3.997.48 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 '@smithy/types': 4.19.0 @@ -7475,7 +7516,7 @@ snapshots: '@aws-sdk/credential-provider-process@3.972.73': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 '@smithy/types': 4.19.0 @@ -7491,7 +7532,7 @@ snapshots: '@aws-sdk/credential-provider-sso@3.973.17': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/nested-clients': 3.997.47 '@aws-sdk/token-providers': 3.1149.0 '@aws-sdk/types': 3.974.6 @@ -7511,7 +7552,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.972.79': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/nested-clients': 3.997.47 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 @@ -7529,7 +7570,7 @@ snapshots: '@aws-sdk/dynamodb-codec@3.973.47': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@smithy/core': 3.35.2 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7581,7 +7622,7 @@ snapshots: '@aws-sdk/nested-clients@3.997.47': dependencies: - '@aws-sdk/core': 3.978.2 + '@aws-sdk/core': 3.978.3 '@aws-sdk/signature-v4-multi-region': 3.996.48 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 @@ -7619,8 +7660,8 @@ snapshots: '@aws-sdk/token-providers@3.1149.0': dependencies: - '@aws-sdk/core': 3.978.2 - '@aws-sdk/nested-clients': 3.997.47 + '@aws-sdk/core': 3.978.3 + '@aws-sdk/nested-clients': 3.997.48 '@aws-sdk/types': 3.974.6 '@smithy/core': 3.35.2 '@smithy/types': 4.19.0 @@ -8507,6 +8548,8 @@ snapshots: '@datastructures-js/heap@4.3.7': {} + '@date-fns/tz@1.5.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -10046,6 +10089,8 @@ snapshots: argparse@2.0.1: {} + argparse@3.0.4: {} + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -12072,7 +12117,7 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 - ip-address@10.7.4: {} + ip-address@10.7.5: {} ipaddr.js@1.9.1: {} @@ -12652,6 +12697,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.5.0: + dependencies: + argparse: 3.0.4 + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 @@ -13735,7 +13784,7 @@ snapshots: socks@2.8.10: dependencies: - ip-address: 10.7.4 + ip-address: 10.7.5 smart-buffer: 4.2.0 sonic-boom@4.2.1: @@ -14062,6 +14111,27 @@ snapshots: optionalDependencies: typescript: 5.9.3 + ts-jest@29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.10 + jest: 30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + babel-jest: 30.5.2(@babel/core@7.29.7) + esbuild: 0.28.2 + jest-util: 30.5.1 + ts-jest@29.4.14(@babel/core@8.0.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@8.0.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39ce4cbc2..017c7dae8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalogs: "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.1" date-fns: "^4.4.0" date-fns-tz: "^3.0.0" + "@date-fns/tz": "^1.4.0" dotenv: "^18.0.3" express: "^4.22.2" graphql: "^17.0.2" @@ -45,6 +46,7 @@ catalogs: aws: "@aws-lambda-powertools/idempotency": "^2.36.0" "@aws-sdk/client-api-gateway": "^3.1138.0" + "@aws-sdk/client-athena": "^3.1138.0" "@aws-sdk/client-cloudwatch-logs": "^3.1138.0" "@aws-sdk/client-dynamodb": "^3.1138.0" "@aws-sdk/client-kinesis": "^3.1138.0" @@ -87,6 +89,7 @@ catalogs: test: "@babel/core": "^8.0.6" "@babel/preset-env": "^8.0.6" + "browserslist": "4.28.7" "@jest/globals": "^30.4.1" "@nhsdigital/notify-digital-letters-consumer-contracts": "^1.0.3" "@pact-foundation/pact": "^17.1.4" @@ -122,7 +125,7 @@ catalogs: esbuild: "0.28.2" get-east-asian-width: "^1.7.0" glob: "^13.0.6" - js-yaml: "^5.4.2" + js-yaml: "^5.5.0" openapi-typescript: "^7.13.0" serve: "^14.2.4" ts-node: "^10.9.2" @@ -150,6 +153,8 @@ overrides: fast-xml-parser: "^5.5.6" flatted: "^3.4.0" ip-address: "^10.7.4" + js-yaml@3: "^3.15.2" + js-yaml@4: "^4.3.2" "minimatch@10": "^10.2.4" "minimatch@3": "^3.1.5" "minimatch@5": "^5.1.9" @@ -159,3 +164,4 @@ overrides: react: "^19.0.0" undici: "^7.30.1" uuid: "^14.0.0" + zod: "^4.6.6" diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index cda56bc1d..6ad5ec975 100644 --- a/scripts/config/sonar-scanner.properties +++ b/scripts/config/sonar-scanner.properties @@ -9,7 +9,7 @@ sonar.exclusions=lambdas/**/src/__tests__/**/* sonar.test.inclusions=**/*.test.*, **/*.spec.* sonar.terraform.provider.aws.version=5.54.1 sonar.cpd.exclusions=**.test.* -sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, +sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, internal/sql-test-helpers/**, #sonar.python.coverage.reportPaths=.coverage/coverage.xml sonar.javascript.lcov.reportPaths=lcov.info