From 6620888a0cbecc58ae2dc26ba65e1e580084be06 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Tue, 21 Apr 2026 09:21:29 +0100 Subject: [PATCH 1/4] CCM-13519 Add idempotency layer in front of upsert --- .../terraform/components/api/README.md | 1 + .../api/ddb_table_upsert_idempotency.tf | 24 +++++++ .../module_ddb_alarms_upsert_idempotency.tf | 7 ++ .../api/module_lambda_upsert_letter.tf | 4 +- lambdas/upsert-letter/package.json | 1 + .../src/config/__tests__/env.test.ts | 2 + lambdas/upsert-letter/src/config/deps.ts | 17 +++-- lambdas/upsert-letter/src/config/env.ts | 1 + .../handler/__tests__/upsert-handler.test.ts | 31 +++++++- .../src/handler/upsert-handler.ts | 70 +++++++++++++------ package-lock.json | 59 +++++++++++++++- package.json | 2 +- tests/helpers/event-fixtures.ts | 21 +++--- 13 files changed, 195 insertions(+), 45 deletions(-) create mode 100644 infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf create mode 100644 infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 97653bcac..ca423f3d9 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -58,6 +58,7 @@ No requirements. | [ddb\_alarms\_letters](#module\_ddb\_alarms\_letters) | ../../modules/alarms-ddb | n/a | | [ddb\_alarms\_mi](#module\_ddb\_alarms\_mi) | ../../modules/alarms-ddb | n/a | | [ddb\_alarms\_suppliers](#module\_ddb\_alarms\_suppliers) | ../../modules/alarms-ddb | n/a | +| [ddb\_alarms\_upsert\_idempotency](#module\_ddb\_alarms\_upsert\_idempotency) | ../../modules/alarms-ddb | n/a | | [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | | [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-eventpub.zip | n/a | | [eventsub](#module\_eventsub) | ../../modules/eventsub | n/a | diff --git a/infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf b/infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf new file mode 100644 index 000000000..5891c22e9 --- /dev/null +++ b/infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf @@ -0,0 +1,24 @@ +resource "aws_dynamodb_table" "upsert_idempotency" { + name = "${local.csi}-upsert-idempotency" + billing_mode = "PAY_PER_REQUEST" + hash_key = "id" + attribute { + name = "id" + type = "S" + } + ttl { + attribute_name = "expiration" + enabled = true + } + + point_in_time_recovery { + enabled = true + } + + tags = merge( + local.default_tags, + { + NHSE-Enable-Dynamo-Backup-Acct = "True" + } + ) +} diff --git a/infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf b/infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf new file mode 100644 index 000000000..f5ede6328 --- /dev/null +++ b/infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf @@ -0,0 +1,7 @@ +module "ddb_alarms_upsert_idempotency" { + count = local.alarms_enabled ? 1 : 0 + source = "../../modules/alarms-ddb" + alarm_prefix = local.csi + table_name = aws_dynamodb_table.upsert_idempotency.name + tags = local.default_tags +} diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index 201e10186..a175808b6 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -35,7 +35,8 @@ module "upsert_letter" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = merge(local.common_lambda_env_vars, { - VARIANT_MAP = jsonencode(var.letter_variant_map) + VARIANT_MAP = jsonencode(var.letter_variant_map), + UPSERT_IDEMPOTENCY_TABLE_NAME = aws_dynamodb_table.upsert_idempotency.name }) } @@ -67,6 +68,7 @@ data "aws_iam_policy_document" "upsert_letter_lambda" { resources = [ aws_dynamodb_table.letters.arn, + aws_dynamodb_table.upsert_idempotency.arn, "${aws_dynamodb_table.letters.arn}/index/supplierStatus-index" ] } diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index 01211d571..906cf5bd8 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@aws-lambda-powertools/idempotency": "^2.33.0", "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.1008.0", "@internal/datastore": "*", diff --git a/lambdas/upsert-letter/src/config/__tests__/env.test.ts b/lambdas/upsert-letter/src/config/__tests__/env.test.ts index 7f3462798..57b0e295d 100644 --- a/lambdas/upsert-letter/src/config/__tests__/env.test.ts +++ b/lambdas/upsert-letter/src/config/__tests__/env.test.ts @@ -16,12 +16,14 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.LETTERS_TABLE_NAME = "letters-table"; + process.env.UPSERT_IDEMPOTENCY_TABLE_NAME = "idempotency-table"; process.env.LETTER_TTL_HOURS = "12960"; const { envVars } = require("../env"); expect(envVars).toEqual({ LETTERS_TABLE_NAME: "letters-table", + UPSERT_IDEMPOTENCY_TABLE_NAME: "idempotency-table", LETTER_TTL_HOURS: 12_960, }); }); diff --git a/lambdas/upsert-letter/src/config/deps.ts b/lambdas/upsert-letter/src/config/deps.ts index 9303e1152..0ccde8163 100644 --- a/lambdas/upsert-letter/src/config/deps.ts +++ b/lambdas/upsert-letter/src/config/deps.ts @@ -1,5 +1,6 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/dynamodb"; import { Logger } from "pino"; import { LetterRepository } from "@internal/datastore"; import { createLogger } from "@internal/helpers"; @@ -7,6 +8,7 @@ import { EnvVars, envVars } from "./env"; export type Deps = { letterRepo: LetterRepository; + idempotencyLayer: DynamoDBPersistenceLayer; logger: Logger; env: EnvVars; }; @@ -16,11 +18,7 @@ function createDocumentClient(): DynamoDBDocumentClient { return DynamoDBDocumentClient.from(ddbClient); } -function createLetterRepository( - log: Logger, - // eslint-disable-next-line @typescript-eslint/no-shadow - envVars: EnvVars, -): LetterRepository { +function createLetterRepository(log: Logger): LetterRepository { const config = { lettersTableName: envVars.LETTERS_TABLE_NAME, lettersTtlHours: envVars.LETTER_TTL_HOURS, @@ -29,11 +27,18 @@ function createLetterRepository( return new LetterRepository(createDocumentClient(), log, config); } +function createIdempotencyLayer(): DynamoDBPersistenceLayer { + return new DynamoDBPersistenceLayer({ + tableName: envVars.UPSERT_IDEMPOTENCY_TABLE_NAME, + }); +} + export function createDependenciesContainer(): Deps { const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL }); return { - letterRepo: createLetterRepository(log, envVars), + letterRepo: createLetterRepository(log), + idempotencyLayer: createIdempotencyLayer(), logger: log, env: envVars, }; diff --git a/lambdas/upsert-letter/src/config/env.ts b/lambdas/upsert-letter/src/config/env.ts index fd205813a..797ee9548 100644 --- a/lambdas/upsert-letter/src/config/env.ts +++ b/lambdas/upsert-letter/src/config/env.ts @@ -4,6 +4,7 @@ const EnvVarsSchema = z.object({ LETTERS_TABLE_NAME: z.string(), LETTER_TTL_HOURS: z.coerce.number().int(), PINO_LOG_LEVEL: z.coerce.string().optional(), + UPSERT_IDEMPOTENCY_TABLE_NAME: z.string(), }); export type EnvVars = z.infer; diff --git a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts index 2ead57328..201f29999 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -6,6 +6,7 @@ import { } from "@internal/datastore"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { makeIdempotent } from "@aws-lambda-powertools/idempotency"; import { $LetterStatusChangeEvent, LetterStatusChangeEvent, @@ -15,6 +16,14 @@ import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; import packageJson from "../../../package.json"; +jest.mock("@aws-lambda-powertools/idempotency", () => { + const original = jest.requireActual("@aws-lambda-powertools/idempotency"); + return { + ...original, + makeIdempotent: jest.fn((fn, _) => fn), + }; +}); + const renderingSchemaVersion: string = packageJson.dependencies[ "@nhsdigital/nhs-notify-event-schemas-letter-rendering" @@ -313,7 +322,7 @@ describe("createUpsertLetterHandler", () => { ); }); - it("does not treat a replayed insert as a failure", async () => { + it("does not treat a second insert for the same letter as a failure", async () => { const v1message = { letterEvent: createPreparedV1Event(), supplierSpec: { @@ -338,6 +347,26 @@ describe("createUpsertLetterHandler", () => { expect(result!.batchItemFailures).toEqual([]); }); + it("does not insert a letter if the same message is replayed", async () => { + const v1message = { + letterEvent: createPreparedV1Event(), + supplierSpec: { + supplierId: "supplier1", + specId: "spec1", + priority: 10, + billingId: "billing1", + }, + }; + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg2", JSON.stringify(v1message)), + ]); + (makeIdempotent as jest.Mock).mockImplementationOnce((_fn) => "supplier1"); + + await createUpsertLetterHandler(mockedDeps)(evt, {} as any, {} as any); + + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + }); + test("unknown supplier has metric emitted with 'unknown' supplier dimension", async () => { const letterEvent = createSupplierStatusChangeEventWithoutSupplier(); diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 677048ba5..61b6feb0d 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -1,4 +1,4 @@ -import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; +import { Context, SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; import { $LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { InsertLetter, @@ -11,6 +11,10 @@ import { } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; import { $LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; +import { + IdempotencyConfig, + makeIdempotent, +} from "@aws-lambda-powertools/idempotency"; import { Deps } from "../config/deps"; import { PreparedEvents, @@ -20,6 +24,10 @@ import { UpsertOperation, } from "./schemas"; +const idempotencyConfig = new IdempotencyConfig({ + eventKeyJmesPath: "id", +}); + function getOperationFromType(type: string): UpsertOperation { if ( type.startsWith("uk.nhs.notify.letter-rendering.letter-request.prepared") @@ -180,8 +188,13 @@ function parseQueueMessage(queueMessage: string): QueueMessage { } export default function createUpsertLetterHandler(deps: Deps): SQSHandler { + const processRecordIdempotently = makeIdempotent(processRecord, { + persistenceStore: deps.idempotencyLayer, + config: idempotencyConfig, + }); + return metricScope((metrics: MetricsLogger) => { - return async (event: SQSEvent) => { + return async (event: SQSEvent, context: Context) => { const batchItemFailures: SQSBatchItemFailure[] = []; const perSupplierSuccess: Map = new Map(); const perSupplierFailure: Map = new Map(); @@ -216,29 +229,13 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { supplier: supplierSpec, }); - supplier = - !supplierSpec || !supplierSpec.supplierId - ? getSupplierIdFromEvent(letterEvent) - : supplierSpec.supplierId; - - const operation = getOperationFromType(letterEvent.type); - - await runUpsert( - operation, + idempotencyConfig.registerLambdaContext(context); + supplier = await processRecordIdempotently( letterEvent, - supplierSpec ?? { - supplierId: "unknown", - specId: "unknown", - priority: 10, - billingId: "unknown", - }, + supplierSpec, + perSupplierSuccess, deps, ); - - perSupplierSuccess.set( - supplier, - (perSupplierSuccess.get(supplier) || 0) + 1, - ); } catch (error) { deps.logger.error({ description: "Error processing upsert of record", @@ -261,3 +258,32 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { }; }); } + +async function processRecord( + letterEvent: LetterStatusChangeEvent | PreparedEvents, + supplierSpec: SupplierSpec | undefined, + perSupplierSuccess: Map, + deps: Deps, +) { + const supplier = + !supplierSpec || !supplierSpec.supplierId + ? getSupplierIdFromEvent(letterEvent) + : supplierSpec.supplierId; + + const operation = getOperationFromType(letterEvent.type); + + await runUpsert( + operation, + letterEvent, + supplierSpec ?? { + supplierId: "unknown", + specId: "unknown", + priority: 10, + billingId: "unknown", + }, + deps, + ); + + perSupplierSuccess.set(supplier, (perSupplierSuccess.get(supplier) || 0) + 1); + return supplier; +} diff --git a/package-lock.json b/package-lock.json index 32d4e293e..b43a56c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -310,6 +310,7 @@ "name": "nhs-notify-supplier-api-upsert-letter", "version": "0.0.1", "dependencies": { + "@aws-lambda-powertools/idempotency": "^2.33.0", "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.1008.0", "@internal/datastore": "*", @@ -588,6 +589,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-lambda-powertools/commons": { + "version": "2.33.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/commons/-/commons-2.33.0.tgz", + "integrity": "sha512-cXGfWT4FXtLO7Ny/shep6V/xYzgVN7hvukOA0bTa4nlg2GWiQsPoBoKvCEcfQ9I8GIS0tPyDfRkdx80SvA7LpA==", + "license": "MIT-0", + "dependencies": { + "@aws/lambda-invoke-store": "0.2.4" + } + }, + "node_modules/@aws-lambda-powertools/idempotency": { + "version": "2.33.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/idempotency/-/idempotency-2.33.0.tgz", + "integrity": "sha512-2eMo4dkdXxqizcC+w4CjT2vVfTPQeKCceM17WzdatrGhWREuiZd2AYXNAE1Q/LWaiwjikz0fH8hf1sq3P2/q4Q==", + "license": "MIT-0", + "dependencies": { + "@aws-lambda-powertools/commons": "2.33.0", + "@aws-lambda-powertools/jmespath": "2.33.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": ">=3.x", + "@aws-sdk/lib-dynamodb": ">=3.x", + "@middy/core": "4.x || 5.x || 6.x || 7.x", + "@redis/client": "^5.0.1", + "@valkey/valkey-glide": "^2.3.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-dynamodb": { + "optional": true + }, + "@aws-sdk/lib-dynamodb": { + "optional": true + }, + "@middy/core": { + "optional": true + }, + "@redis/client": { + "optional": true + }, + "@valkey/valkey-glide": { + "optional": true + } + } + }, + "node_modules/@aws-lambda-powertools/jmespath": { + "version": "2.33.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/jmespath/-/jmespath-2.33.0.tgz", + "integrity": "sha512-FkCHfv30mEyYQ984IurYx81TNzpggeORP7XAibNyj4qdNQboAZaKoyHxvqR8re2NgkxyaN5Qf9bodqdpVWbb2g==", + "license": "MIT-0", + "dependencies": { + "@aws-lambda-powertools/commons": "2.33.0" + } + }, "node_modules/@aws-sdk/client-api-gateway": { "version": "3.1002.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-api-gateway/-/client-api-gateway-3.1002.0.tgz", @@ -3079,9 +3132,9 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 211a2cf97..747493a93 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "@aws-sdk/client-s3": "^3.925.0", "@aws-sdk/client-sns": "^3.936.0", "@openapitools/openapi-generator-cli": "^2.21.4", + "@playwright/test": "^1.57.0", "@redocly/cli": "1.31.0", "@stoplight/spectral-cli": "^6.15.0", "@stylistic/eslint-plugin": "^5.9.0", @@ -14,7 +15,6 @@ "@types/node": "^25.2.2", "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.27.0", - "@playwright/test": "^1.57.0", "ajv": "^8.17.1", "allure-commandline": "^2.34.1", "allure-playwright": "^3.4.3", diff --git a/tests/helpers/event-fixtures.ts b/tests/helpers/event-fixtures.ts index 5862cca20..e679ce950 100644 --- a/tests/helpers/event-fixtures.ts +++ b/tests/helpers/event-fixtures.ts @@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto"; export function createPreparedV1Event(overrides: Record = {}) { const now = new Date().toISOString(); + const { id, ...dataOverrides } = overrides; return { specversion: "1.0", - id: (overrides.id as string) ?? "7b9a03ca-342a-4150-b56b-989109c45613", + id: (id as string) ?? randomUUID(), source: "/data-plane/letter-rendering/test", subject: "client/client1/letter-request/letterRequest1", type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", @@ -14,23 +15,21 @@ export function createPreparedV1Event(overrides: Record = {}) { "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", dataschemaversion: "1.0.0", data: { - domainId: - (overrides.domainId as string) ?? - "fe658e11-0ffc-44f4-8ad6-0fafe75bfeee", - letterVariantId: - (overrides.letterVariantId as string) ?? "digitrials-aspiring", + domainId: "fe658e11-0ffc-44f4-8ad6-0fafe75bfeee", + letterVariantId: "digitrials-aspiring", requestId: "request1", requestItemId: "requestItem1", requestItemPlanId: "requestItemPlan1", clientId: "client1", campaignId: "campaign1", templateId: "template1", - url: (overrides.url as string) ?? "s3://letterDataBucket/letter1.pdf", + url: "s3://letterDataBucket/letter1.pdf", sha256Hash: "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", createdAt: now, pageCount: 1, - status: (overrides.status as string) ?? "PREPARED", + status: "PREPARED", + ...dataOverrides, }, traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", recordedtime: now, @@ -44,8 +43,8 @@ export function createPreparedEventBatchWithSameDomainId( overrides: Record = {}, ) { return [ - createPreparedV1Event({ id: randomUUID(), ...overrides }), - createPreparedV1Event({ id: randomUUID(), ...overrides }), - createPreparedV1Event({ id: randomUUID(), ...overrides }), + createPreparedV1Event(overrides), + createPreparedV1Event(overrides), + createPreparedV1Event(overrides), ]; } From 52b2165f54b8c0ac0bdd991cc63f0a8757b37cf2 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 24 Apr 2026 15:17:39 +0100 Subject: [PATCH 2/4] Flaky test fix --- .../integration-tests/urgent-letter-priority.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts b/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts index c91d288cd..2a2f2858c 100644 --- a/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts +++ b/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts @@ -11,6 +11,7 @@ import { } from "tests/helpers/urgent-letter-priority-helper"; import { createValidRequestHeaders } from "tests/constants/request-headers"; import { SUPPLIER_LETTERS } from "tests/constants/api-constants"; +import { supplierDataSetup } from "tests/helpers/suppliers-setup-helper"; import { GetLettersResponse, GetLettersResponseSchema, @@ -20,6 +21,7 @@ let baseUrl: string; test.beforeAll(async () => { baseUrl = await getRestApiGatewayBaseUrl(); + await supplierDataSetup(supplier); }); test.describe("Urgent Letter Priority Tests", () => { From ed23be2de966fb48c3785470be8d8cf803522432 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 24 Apr 2026 15:48:36 +0100 Subject: [PATCH 3/4] Another test fix --- .../events-tests/event-subscription.spec.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/component-tests/events-tests/event-subscription.spec.ts b/tests/component-tests/events-tests/event-subscription.spec.ts index 4d6044f60..82efbce31 100644 --- a/tests/component-tests/events-tests/event-subscription.spec.ts +++ b/tests/component-tests/events-tests/event-subscription.spec.ts @@ -104,16 +104,13 @@ test.describe("Event Subscription SNS Tests", () => { ); }); - test("Verify that the duplicate event throws an error", async () => { + test("Verify that an error is logged for a duplicate letter id", async () => { const domainId = randomUUID(); logger.info(`Testing event subscription with domainId: ${domainId}`); - const preparedEvent = createPreparedV1Event({ - domainId, - status: "PREPARED", - }); - const response = await sendSnsEvent(preparedEvent); + const preparedEvent1 = createPreparedV1Event({ domainId }); + const response1 = await sendSnsEvent(preparedEvent1); - expect(response.MessageId).toBeTruthy(); + expect(response1.MessageId).toBeTruthy(); // poll supplier allocator to check if supplier has been allocated const message = await pollSupplierAllocatorLogForResolvedSpec(domainId); @@ -129,11 +126,11 @@ test.describe("Event Subscription SNS Tests", () => { throw new Error("supplierId was not found in supplier allocator log"); } - // send same event again to simulate duplicate event - const duplicateResponse = await sendSnsEvent(preparedEvent); - expect(duplicateResponse.MessageId).toBeTruthy(); + const preparedEvent2 = createPreparedV1Event({ domainId }); + const response2 = await sendSnsEvent(preparedEvent2); + expect(response2.MessageId).toBeTruthy(); - // poll supplier upsert to check if duplicate event was processed + // poll supplier upsert to check if duplicate letter id was processed await pollUpsertLetterLogForWarning("Letter already exists", domainId); }); }); From cd863e44524cb22562d3f18950d77bfba1ed1a7c Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Mon, 27 Apr 2026 11:17:20 +0100 Subject: [PATCH 4/4] Rename table to be more generic --- infrastructure/terraform/components/api/README.md | 2 +- ...table_upsert_idempotency.tf => ddb_table_idempotency.tf} | 4 ++-- ...sert_idempotency.tf => module_ddb_alarms_idempotency.tf} | 4 ++-- .../terraform/components/api/module_lambda_upsert_letter.tf | 6 +++--- lambdas/upsert-letter/src/config/__tests__/env.test.ts | 4 ++-- lambdas/upsert-letter/src/config/deps.ts | 2 +- lambdas/upsert-letter/src/config/env.ts | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) rename infrastructure/terraform/components/api/{ddb_table_upsert_idempotency.tf => ddb_table_idempotency.tf} (76%) rename infrastructure/terraform/components/api/{module_ddb_alarms_upsert_idempotency.tf => module_ddb_alarms_idempotency.tf} (60%) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index ca423f3d9..00196a0bc 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -54,11 +54,11 @@ No requirements. | [amendment\_event\_transformer](#module\_amendment\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [amendments\_queue](#module\_amendments\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [ddb\_alarms\_idempotency](#module\_ddb\_alarms\_idempotency) | ../../modules/alarms-ddb | n/a | | [ddb\_alarms\_letter\_queue](#module\_ddb\_alarms\_letter\_queue) | ../../modules/alarms-ddb | n/a | | [ddb\_alarms\_letters](#module\_ddb\_alarms\_letters) | ../../modules/alarms-ddb | n/a | | [ddb\_alarms\_mi](#module\_ddb\_alarms\_mi) | ../../modules/alarms-ddb | n/a | | [ddb\_alarms\_suppliers](#module\_ddb\_alarms\_suppliers) | ../../modules/alarms-ddb | n/a | -| [ddb\_alarms\_upsert\_idempotency](#module\_ddb\_alarms\_upsert\_idempotency) | ../../modules/alarms-ddb | n/a | | [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | | [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-eventpub.zip | n/a | | [eventsub](#module\_eventsub) | ../../modules/eventsub | n/a | diff --git a/infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf b/infrastructure/terraform/components/api/ddb_table_idempotency.tf similarity index 76% rename from infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf rename to infrastructure/terraform/components/api/ddb_table_idempotency.tf index 5891c22e9..4e7c4cd6e 100644 --- a/infrastructure/terraform/components/api/ddb_table_upsert_idempotency.tf +++ b/infrastructure/terraform/components/api/ddb_table_idempotency.tf @@ -1,5 +1,5 @@ -resource "aws_dynamodb_table" "upsert_idempotency" { - name = "${local.csi}-upsert-idempotency" +resource "aws_dynamodb_table" "idempotency" { + name = "${local.csi}-idempotency" billing_mode = "PAY_PER_REQUEST" hash_key = "id" attribute { diff --git a/infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf b/infrastructure/terraform/components/api/module_ddb_alarms_idempotency.tf similarity index 60% rename from infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf rename to infrastructure/terraform/components/api/module_ddb_alarms_idempotency.tf index f5ede6328..755833ffe 100644 --- a/infrastructure/terraform/components/api/module_ddb_alarms_upsert_idempotency.tf +++ b/infrastructure/terraform/components/api/module_ddb_alarms_idempotency.tf @@ -1,7 +1,7 @@ -module "ddb_alarms_upsert_idempotency" { +module "ddb_alarms_idempotency" { count = local.alarms_enabled ? 1 : 0 source = "../../modules/alarms-ddb" alarm_prefix = local.csi - table_name = aws_dynamodb_table.upsert_idempotency.name + table_name = aws_dynamodb_table.idempotency.name tags = local.default_tags } diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index a175808b6..bac0f3a23 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -35,8 +35,8 @@ module "upsert_letter" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = merge(local.common_lambda_env_vars, { - VARIANT_MAP = jsonencode(var.letter_variant_map), - UPSERT_IDEMPOTENCY_TABLE_NAME = aws_dynamodb_table.upsert_idempotency.name + VARIANT_MAP = jsonencode(var.letter_variant_map), + IDEMPOTENCY_TABLE_NAME = aws_dynamodb_table.idempotency.name }) } @@ -68,7 +68,7 @@ data "aws_iam_policy_document" "upsert_letter_lambda" { resources = [ aws_dynamodb_table.letters.arn, - aws_dynamodb_table.upsert_idempotency.arn, + aws_dynamodb_table.idempotency.arn, "${aws_dynamodb_table.letters.arn}/index/supplierStatus-index" ] } diff --git a/lambdas/upsert-letter/src/config/__tests__/env.test.ts b/lambdas/upsert-letter/src/config/__tests__/env.test.ts index 57b0e295d..fce4a108d 100644 --- a/lambdas/upsert-letter/src/config/__tests__/env.test.ts +++ b/lambdas/upsert-letter/src/config/__tests__/env.test.ts @@ -16,14 +16,14 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.LETTERS_TABLE_NAME = "letters-table"; - process.env.UPSERT_IDEMPOTENCY_TABLE_NAME = "idempotency-table"; + process.env.IDEMPOTENCY_TABLE_NAME = "idempotency-table"; process.env.LETTER_TTL_HOURS = "12960"; const { envVars } = require("../env"); expect(envVars).toEqual({ LETTERS_TABLE_NAME: "letters-table", - UPSERT_IDEMPOTENCY_TABLE_NAME: "idempotency-table", + IDEMPOTENCY_TABLE_NAME: "idempotency-table", LETTER_TTL_HOURS: 12_960, }); }); diff --git a/lambdas/upsert-letter/src/config/deps.ts b/lambdas/upsert-letter/src/config/deps.ts index 0ccde8163..09292c6a3 100644 --- a/lambdas/upsert-letter/src/config/deps.ts +++ b/lambdas/upsert-letter/src/config/deps.ts @@ -29,7 +29,7 @@ function createLetterRepository(log: Logger): LetterRepository { function createIdempotencyLayer(): DynamoDBPersistenceLayer { return new DynamoDBPersistenceLayer({ - tableName: envVars.UPSERT_IDEMPOTENCY_TABLE_NAME, + tableName: envVars.IDEMPOTENCY_TABLE_NAME, }); } diff --git a/lambdas/upsert-letter/src/config/env.ts b/lambdas/upsert-letter/src/config/env.ts index 797ee9548..1f3a3bdf8 100644 --- a/lambdas/upsert-letter/src/config/env.ts +++ b/lambdas/upsert-letter/src/config/env.ts @@ -4,7 +4,7 @@ const EnvVarsSchema = z.object({ LETTERS_TABLE_NAME: z.string(), LETTER_TTL_HOURS: z.coerce.number().int(), PINO_LOG_LEVEL: z.coerce.string().optional(), - UPSERT_IDEMPOTENCY_TABLE_NAME: z.string(), + IDEMPOTENCY_TABLE_NAME: z.string(), }); export type EnvVars = z.infer;