From d7d3c926ee353454db041225f52b5bade5ffcc7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Wed, 1 Apr 2026 13:05:43 +0200 Subject: [PATCH 01/10] add initial code with comments to fullfill --- lambdas/src/lib/types/notify-message.ts | 1 + lambdas/src/order-status-lambda/index.ts | 27 ++++++++++++++++++++++-- lambdas/src/order-status-lambda/init.ts | 8 +++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lambdas/src/lib/types/notify-message.ts b/lambdas/src/lib/types/notify-message.ts index 9c82cbb9..5622e8ad 100644 --- a/lambdas/src/lib/types/notify-message.ts +++ b/lambdas/src/lib/types/notify-message.ts @@ -13,4 +13,5 @@ export interface NotifyRecipient { export enum NotifyEventCode { OrderConfirmed = "ORDER_CONFIRMED", + OrderDispatched = "ORDER_DISPATCHED", } diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 6384bd0d..a4b48ac8 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -3,10 +3,11 @@ import cors from "@middy/http-cors"; import httpErrorHandler from "@middy/http-error-handler"; import httpSecurityHeaders from "@middy/http-security-headers"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { OrderStatusUpdateParams } from "src/lib/db/order-status-db"; +import { v4 as uuidv4 } from "uuid"; import z from "zod"; import { ConsoleCommons } from "../lib/commons"; +import { OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; import { @@ -15,6 +16,7 @@ import { FHIRReferenceSchema, FHIRTaskSchema, } from "../lib/models/fhir/fhir-schemas"; +import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; import { corsOptions } from "./cors-configuration"; import { init } from "./init"; @@ -42,7 +44,7 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb } = init(); + const { orderStatusDb, sqsClient, notifyMessagesQueueUrl } = init(); commons.logInfo(name, "Received order status update request", { path: event.path, method: event.httpMethod, @@ -161,6 +163,27 @@ export const lambdaHandler = async ( commons.logInfo(name, "Order status update added successfully", statusOrderUpdateParams); + //todo get user data from database + //todo get dispatched date + //todo fix status url with proper uri parts + //todo move the code to builder/service + //todo insert notification audit entry + const notifyMessage: NotifyMessage = { + correlationId: correlationId, + messageReference: uuidv4(), + eventCode: NotifyEventCode.OrderDispatched, + recipient: { + nhsNumber: "", + dateOfBirth: "", + }, + personalisation: { + dispatched_date: "6 August 2026", + status_url: + "[View kit order update and see more information](https:///orders//tracking)", + }, + }; + await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); + return createFhirResponse(201, validatedTask); } catch (error) { commons.logError(name, "Error processing order status update", { diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index 1909e9b3..a6251135 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -2,19 +2,27 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; import { OrderStatusService } from "../lib/db/order-status-db"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { AWSSQSClient } from "../lib/sqs/sqs-client"; +import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; export interface Environment { orderStatusDb: OrderStatusService; + sqsClient: AWSSQSClient; + notifyMessagesQueueUrl: string; } export function buildEnvironment(): Environment { const awsRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "eu-west-2"; + const notifyMessagesQueueUrl = retrieveMandatoryEnvVariable("NOTIFY_MESSAGES_QUEUE_URL"); const secretsClient = new AwsSecretsClient(awsRegion); const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); const orderStatusDb = new OrderStatusService(dbClient); + const sqsClient = new AWSSQSClient(); return { orderStatusDb, + sqsClient, + notifyMessagesQueueUrl, }; } From 46c6832fa3da39b2e60d8c0b7be3eb47951c1d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Thu, 2 Apr 2026 10:12:44 +0200 Subject: [PATCH 02/10] introduced logic to send message, extracted new services --- lambdas/src/lib/db/order-status-db.test.ts | 122 +++++++++++++++++- lambdas/src/lib/db/order-status-db.ts | 117 +++++++++++++++++ lambdas/src/order-status-lambda/index.test.ts | 92 +++++++++++++ lambdas/src/order-status-lambda/index.ts | 50 +++---- lambdas/src/order-status-lambda/init.test.ts | 26 ++++ lambdas/src/order-status-lambda/init.ts | 5 + .../notify-message-builder.test.ts | 81 ++++++++++++ .../notify-message-builder.ts | 53 ++++++++ local-environment/infra/main.tf | 20 +-- 9 files changed, 530 insertions(+), 36 deletions(-) create mode 100644 lambdas/src/order-status-lambda/notify-message-builder.test.ts create mode 100644 lambdas/src/order-status-lambda/notify-message-builder.ts diff --git a/lambdas/src/lib/db/order-status-db.test.ts b/lambdas/src/lib/db/order-status-db.test.ts index 27f86222..c503d3eb 100644 --- a/lambdas/src/lib/db/order-status-db.test.ts +++ b/lambdas/src/lib/db/order-status-db.test.ts @@ -1,4 +1,7 @@ +import { DBClient } from "./db-client"; import { + NotificationAuditEntryParams, + NotifyRecipientData, OrderRow, OrderStatusCodes, OrderStatusService, @@ -18,11 +21,12 @@ describe("OrderStatusService", () => { beforeEach(() => { jest.clearAllMocks(); - const mockDbClient = { + const mockDbClient: DBClient = { query: mockQuery, - close: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + withTransaction: jest.fn(), }; - service = new OrderStatusService(mockDbClient as any); + service = new OrderStatusService(mockDbClient); }); describe("getPatientIdFromOrder", () => { @@ -153,4 +157,116 @@ describe("OrderStatusService", () => { ).rejects.toThrow("Failed to update order status"); }); }); + + describe("getNotifyRecipientData", () => { + it("should return notify recipient data", async () => { + const mockRecipientRow = { + nhs_number: "1234567890", + birth_date: "1990-04-20", + }; + + mockQuery.mockResolvedValue({ + rows: [mockRecipientRow], + rowCount: 1, + }); + + const result = await service.getNotifyRecipientData("some-mocked-patient-id"); + + expect(result).toEqual({ + nhsNumber: mockRecipientRow.nhs_number, + dateOfBirth: mockRecipientRow.birth_date, + } satisfies NotifyRecipientData); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("patient_mapping"), [ + "some-mocked-patient-id", + ]); + }); + + it("should throw when patient record does not exist", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect(service.getNotifyRecipientData("missing-patient-id")).rejects.toThrow( + "Failed to fetch notify recipient data", + ); + }); + }); + + describe("isFirstStatusOccurrence", () => { + it("should return true for first occurrence", async () => { + mockQuery.mockResolvedValue({ + rows: [{ count: 1 }], + rowCount: 1, + }); + + const result = await service.isFirstStatusOccurrence( + "some-mocked-order-id", + OrderStatusCodes.DISPATCHED, + ); + + expect(result).toBe(true); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("COUNT(*)::int"), [ + "some-mocked-order-id", + OrderStatusCodes.DISPATCHED, + ]); + }); + + it("should return false when status already exists", async () => { + mockQuery.mockResolvedValue({ + rows: [{ count: 2 }], + rowCount: 1, + }); + + const result = await service.isFirstStatusOccurrence( + "some-mocked-order-id", + OrderStatusCodes.DISPATCHED, + ); + + expect(result).toBe(false); + }); + }); + + describe("insertNotificationAuditEntry", () => { + it("should insert notification audit entry", async () => { + const params: NotificationAuditEntryParams = { + messageReference: "123e4567-e89b-12d3-a456-426614174000", + eventCode: "ORDER_DISPATCHED", + correlationId: "123e4567-e89b-12d3-a456-426614174001", + status: "SENT", + }; + + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 1, + }); + + await expect(service.insertNotificationAuditEntry(params)).resolves.toBeUndefined(); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("notification_audit"), [ + params.messageReference, + null, + params.eventCode, + null, + params.correlationId, + params.status, + ]); + }); + + it("should throw when notification audit insert affects no rows", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect( + service.insertNotificationAuditEntry({ + messageReference: "123e4567-e89b-12d3-a456-426614174000", + eventCode: "ORDER_DISPATCHED", + correlationId: "123e4567-e89b-12d3-a456-426614174001", + status: "SENT", + } satisfies NotificationAuditEntryParams), + ).rejects.toThrow("Failed to insert notification audit entry"); + }); + }); }); diff --git a/lambdas/src/lib/db/order-status-db.ts b/lambdas/src/lib/db/order-status-db.ts index cb3e4ea5..953aefff 100644 --- a/lambdas/src/lib/db/order-status-db.ts +++ b/lambdas/src/lib/db/order-status-db.ts @@ -33,6 +33,20 @@ export interface IdempotencyCheckResult { isDuplicate: boolean; } +export interface NotifyRecipientData { + nhsNumber: string; + dateOfBirth: string; +} + +export interface NotificationAuditEntryParams { + messageReference: string; + eventCode: string; + correlationId: string; + status: string; + notifyMessageId?: string | null; + routingPlanId?: string | null; +} + export class OrderStatusService { private readonly dbClient: DBClient; @@ -115,4 +129,107 @@ export class OrderStatusService { }); } } + + async getNotifyRecipientData(patientId: string): Promise { + const query = ` + SELECT nhs_number, birth_date + FROM patient_mapping + WHERE patient_uid = $1::uuid + LIMIT 1; + `; + + try { + const result = await this.dbClient.query< + { nhs_number: string; birth_date: string | Date }, + [string] + >(query, [patientId]); + + if (result.rowCount === 0 || !result.rows[0]) { + throw new Error(`Notify recipient not found for patientId ${patientId}`); + } + + const row = result.rows[0]; + + return { + nhsNumber: row.nhs_number, + dateOfBirth: + row.birth_date instanceof Date + ? row.birth_date.toISOString().slice(0, 10) + : row.birth_date, + }; + } catch (error) { + throw new Error(`Failed to fetch notify recipient data for patientId ${patientId}`, { + cause: error, + }); + } + } + + async isFirstStatusOccurrence(orderId: string, statusCode: OrderStatusCode): Promise { + const query = ` + SELECT COUNT(*)::int AS count + FROM order_status + WHERE order_uid = $1::uuid AND status_code = $2; + `; + + try { + const result = await this.dbClient.query<{ count: number }, [string, OrderStatusCode]>( + query, + [orderId, statusCode], + ); + + return result.rows[0]?.count === 1; + } catch (error) { + throw new Error( + `Failed to verify first occurrence for orderId ${orderId} and statusCode ${statusCode}`, + { + cause: error, + }, + ); + } + } + + async insertNotificationAuditEntry(params: NotificationAuditEntryParams): Promise { + const { + messageReference, + notifyMessageId = null, + eventCode, + routingPlanId = null, + correlationId, + status, + } = params; + + const query = ` + INSERT INTO notification_audit ( + message_reference, + notify_message_id, + event_code, + routing_plan_id, + correlation_id, + status + ) + VALUES ($1::uuid, $2, $3, $4::uuid, $5::uuid, $6) + `; + + try { + const result = await this.dbClient.query(query, [ + messageReference, + notifyMessageId, + eventCode, + routingPlanId, + correlationId, + status, + ]); + + if (result.rowCount === 0) { + throw new Error("Failed to insert notification audit entry"); + } + } catch (error) { + throw new Error( + `Failed to insert notification audit entry for messageReference ${messageReference}`, + { + cause: error, + }, + ); + } + } } diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 272c470d..d0d775b5 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -10,6 +10,10 @@ const mockInit = jest.fn(); const mockGetPatientIdFromOrder = jest.fn(); const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); +const mockIsFirstStatusOccurrence = jest.fn(); +const mockBuildOrderDispatchedNotifyMessage = jest.fn(); +const mockInsertNotificationAuditEntry = jest.fn(); +const mockSendMessage = jest.fn(); const mockGetCorrelationIdFromEventHeaders = jest.fn(); @@ -41,13 +45,33 @@ describe("Order Status Lambda Handler", () => { mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); + mockIsFirstStatusOccurrence.mockResolvedValue(true); + mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ + messageReference: "123e4567-e89b-12d3-a456-426614174099", + eventCode: "ORDER_DISPATCHED", + correlationId: MOCK_CORRELATION_ID, + nhsNumber: "1234567890", + dateOfBirth: "1990-01-02", + }); + mockInsertNotificationAuditEntry.mockResolvedValue(undefined); + mockSendMessage.mockResolvedValue(undefined); mockInit.mockReturnValue({ orderStatusDb: { getPatientIdFromOrder: mockGetPatientIdFromOrder, checkIdempotency: mockCheckIdempotency, addOrderStatusUpdate: mockAddOrderStatusUpdate, + isFirstStatusOccurrence: mockIsFirstStatusOccurrence, + insertNotificationAuditEntry: mockInsertNotificationAuditEntry, + }, + sqsClient: { + sendMessage: mockSendMessage, + }, + notifyMessageBuilder: { + buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage, }, + notifyMessagesQueueUrl: "https://example.queue.local/notify", + homeTestBaseUrl: "https://hometest.example.nhs.uk", }); const module = await import("./index"); @@ -272,6 +296,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(200); expect(mockCheckIdempotency).toHaveBeenCalledWith(MOCK_ORDER_UID, MOCK_CORRELATION_ID); + expect(mockSendMessage).not.toHaveBeenCalled(); }); it("should process new updates with different correlation ID", async () => { @@ -398,6 +423,73 @@ describe("Order Status Lambda Handler", () => { }), ); }); + + it("should send dispatched notification for first DISPATCHED status", async () => { + mockIsFirstStatusOccurrence.mockResolvedValueOnce(true); + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockBuildOrderDispatchedNotifyMessage).toHaveBeenCalledWith( + expect.objectContaining({ + patientId: MOCK_PATIENT_UID, + correlationId: MOCK_CORRELATION_ID, + orderId: MOCK_ORDER_UID, + dispatchedAt: validTaskBody.lastModified, + }), + ); + expect(mockSendMessage).toHaveBeenCalledWith( + "https://example.queue.local/notify", + expect.any(String), + ); + expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + eventCode: "ORDER_DISPATCHED", + correlationId: MOCK_CORRELATION_ID, + status: "SENT", + }), + ); + }); + + it("should not send notification for non-DISPATCHED status", async () => { + mockEvent.body = JSON.stringify({ + ...validTaskBody, + businessStatus: { + text: IncomingBusinessStatus.RECEIVED_AT_LAB, + }, + } satisfies Partial); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockIsFirstStatusOccurrence).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it("should not send notification when DISPATCHED is not first occurrence", async () => { + mockIsFirstStatusOccurrence.mockResolvedValueOnce(false); + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockIsFirstStatusOccurrence).toHaveBeenCalledWith(MOCK_ORDER_UID, "DISPATCHED"); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should return 500 when sending notify message fails", async () => { + mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(500); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalledWith( + expect.objectContaining({ status: "FAILED" }), + ); + }); }); describe("Error Handling", () => { diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index a4b48ac8..5cfd1c4c 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -3,11 +3,10 @@ import cors from "@middy/http-cors"; import httpErrorHandler from "@middy/http-error-handler"; import httpSecurityHeaders from "@middy/http-security-headers"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { v4 as uuidv4 } from "uuid"; import z from "zod"; import { ConsoleCommons } from "../lib/commons"; -import { OrderStatusUpdateParams } from "../lib/db/order-status-db"; +import { OrderStatusCodes, OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; import { @@ -16,7 +15,6 @@ import { FHIRReferenceSchema, FHIRTaskSchema, } from "../lib/models/fhir/fhir-schemas"; -import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; import { corsOptions } from "./cors-configuration"; import { init } from "./init"; @@ -44,7 +42,7 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb, sqsClient, notifyMessagesQueueUrl } = init(); + const { orderStatusDb, sqsClient, notifyMessageBuilder, notifyMessagesQueueUrl } = init(); commons.logInfo(name, "Received order status update request", { path: event.path, method: event.httpMethod, @@ -163,26 +161,30 @@ export const lambdaHandler = async ( commons.logInfo(name, "Order status update added successfully", statusOrderUpdateParams); - //todo get user data from database - //todo get dispatched date - //todo fix status url with proper uri parts - //todo move the code to builder/service - //todo insert notification audit entry - const notifyMessage: NotifyMessage = { - correlationId: correlationId, - messageReference: uuidv4(), - eventCode: NotifyEventCode.OrderDispatched, - recipient: { - nhsNumber: "", - dateOfBirth: "", - }, - personalisation: { - dispatched_date: "6 August 2026", - status_url: - "[View kit order update and see more information](https:///orders//tracking)", - }, - }; - await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); + if (statusOrderUpdateParams.statusCode === OrderStatusCodes.DISPATCHED) { + const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence( + orderId, + OrderStatusCodes.DISPATCHED, + ); + + if (isFirstDispatched) { + const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ + patientId: orderPatientId, + correlationId, + orderId, + dispatchedAt: statusOrderUpdateParams.createdAt, + }); + + await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); + + await orderStatusDb.insertNotificationAuditEntry({ + messageReference: notifyMessage.messageReference, + eventCode: notifyMessage.eventCode, + correlationId, + status: "SENT", + }); + } + } return createFhirResponse(201, validatedTask); } catch (error) { diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index 203bbede..8e8d3608 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -2,14 +2,18 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; import { OrderStatusService } from "../lib/db/order-status-db"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { testComponentCreationOrder } from "../lib/test-utils/component-integration-helpers"; import { restoreEnvironment, setupEnvironment } from "../lib/test-utils/environment-test-helpers"; import { buildEnvironment as init } from "./init"; +import { NotifyMessageBuilder } from "./notify-message-builder"; jest.mock("../lib/db/order-status-db"); jest.mock("../lib/db/db-client"); jest.mock("../lib/secrets/secrets-manager-client"); +jest.mock("../lib/sqs/sqs-client"); jest.mock("../lib/db/db-config"); +jest.mock("./notify-message-builder"); describe("init", () => { const originalEnv = process.env; @@ -21,6 +25,8 @@ describe("init", () => { DB_NAME: "test-database", DB_SCHEMA: "test-schema", DB_SECRET_NAME: "test-secret-name", + NOTIFY_MESSAGES_QUEUE_URL: "https://example.queue.local/notify", + HOME_TEST_BASE_URL: "https://hometest.example.nhs.uk", }; const mockPostgresConfig = { @@ -97,6 +103,9 @@ describe("init", () => { expect(result).toEqual({ orderStatusDb: expect.any(OrderStatusService), + sqsClient: expect.any(AWSSQSClient), + notifyMessageBuilder: expect.any(NotifyMessageBuilder), + notifyMessagesQueueUrl: "https://example.queue.local/notify", }); }); }); @@ -133,9 +142,26 @@ describe("init", () => { times: 1, calledWith: expect.any(PostgresDbClient), }, + { + mock: AWSSQSClient as jest.Mock, + times: 1, + }, + { + mock: NotifyMessageBuilder as jest.Mock, + times: 1, + }, ], }); }); + + it("should create NotifyMessageBuilder with OrderStatusService and home test base url", () => { + init(); + + expect(NotifyMessageBuilder).toHaveBeenCalledWith( + expect.any(OrderStatusService), + "https://hometest.example.nhs.uk", + ); + }); }); describe("singleton protection", () => { diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index a6251135..9120bd0b 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -4,24 +4,29 @@ import { OrderStatusService } from "../lib/db/order-status-db"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; +import { NotifyMessageBuilder } from "./notify-message-builder"; export interface Environment { orderStatusDb: OrderStatusService; sqsClient: AWSSQSClient; + notifyMessageBuilder: NotifyMessageBuilder; notifyMessagesQueueUrl: string; } export function buildEnvironment(): Environment { const awsRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "eu-west-2"; const notifyMessagesQueueUrl = retrieveMandatoryEnvVariable("NOTIFY_MESSAGES_QUEUE_URL"); + const homeTestBaseUrl = retrieveMandatoryEnvVariable("HOME_TEST_BASE_URL"); const secretsClient = new AwsSecretsClient(awsRegion); const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); const orderStatusDb = new OrderStatusService(dbClient); const sqsClient = new AWSSQSClient(); + const notifyMessageBuilder = new NotifyMessageBuilder(orderStatusDb, homeTestBaseUrl); return { orderStatusDb, sqsClient, + notifyMessageBuilder, notifyMessagesQueueUrl, }; } diff --git a/lambdas/src/order-status-lambda/notify-message-builder.test.ts b/lambdas/src/order-status-lambda/notify-message-builder.test.ts new file mode 100644 index 00000000..23e481f5 --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-message-builder.test.ts @@ -0,0 +1,81 @@ +import type { OrderStatusService } from "../lib/db/order-status-db"; +import { NotifyEventCode } from "../lib/types/notify-message"; +import { NotifyMessageBuilder } from "./notify-message-builder"; + +describe("NotifyMessageBuilder", () => { + const mockGetNotifyRecipientData = jest.fn(); + + const mockOrderStatusDb: Pick = { + getNotifyRecipientData: mockGetNotifyRecipientData, + }; + + let builder: NotifyMessageBuilder; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetNotifyRecipientData.mockResolvedValue({ + nhsNumber: "1234567890", + dateOfBirth: "1990-01-02", + }); + + builder = new NotifyMessageBuilder( + mockOrderStatusDb as OrderStatusService, + "https://hometest.example.nhs.uk", + ); + }); + + it("should build dispatched notify message with formatted date and tracking url", async () => { + const result = await builder.buildOrderDispatchedNotifyMessage({ + patientId: "550e8400-e29b-41d4-a716-446655440111", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + orderId: "550e8400-e29b-41d4-a716-446655440000", + dispatchedAt: "2026-08-06T10:00:00Z", + }); + + expect(result.correlationId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(result.eventCode).toBe(NotifyEventCode.OrderDispatched); + expect(result.recipient).toEqual({ + nhsNumber: "1234567890", + dateOfBirth: "1990-01-02", + }); + + expect(result.personalisation).toEqual({ + dispatched_date: "6 August 2026", + status_url: + "[View kit order update and see more information](https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking)", + }); + }); + + it("should normalize trailing slash in base url", async () => { + const trailingSlashBuilder = new NotifyMessageBuilder( + mockOrderStatusDb as OrderStatusService, + "https://hometest.example.nhs.uk/", + ); + + const result = await trailingSlashBuilder.buildOrderDispatchedNotifyMessage({ + patientId: "550e8400-e29b-41d4-a716-446655440111", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + orderId: "550e8400-e29b-41d4-a716-446655440000", + dispatchedAt: "2026-08-06T10:00:00Z", + }); + + const statusUrl = result.personalisation?.status_url; + + expect(typeof statusUrl).toBe("string"); + expect(statusUrl).toContain( + "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", + ); + expect(statusUrl).not.toContain(".uk//orders"); + }); + + it("should call recipient lookup with patient id", async () => { + await builder.buildOrderDispatchedNotifyMessage({ + patientId: "550e8400-e29b-41d4-a716-446655440111", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + orderId: "550e8400-e29b-41d4-a716-446655440000", + dispatchedAt: "2026-08-06T10:00:00Z", + }); + + expect(mockGetNotifyRecipientData).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440111"); + }); +}); diff --git a/lambdas/src/order-status-lambda/notify-message-builder.ts b/lambdas/src/order-status-lambda/notify-message-builder.ts new file mode 100644 index 00000000..3d72956a --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-message-builder.ts @@ -0,0 +1,53 @@ +import { v4 as uuidv4 } from "uuid"; + +import type { OrderStatusService } from "../lib/db/order-status-db"; +import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; + +export interface BuildOrderDispatchedNotifyMessageInput { + patientId: string; + correlationId: string; + orderId: string; + dispatchedAt: string; +} + +const ORDER_TRACKING_LINK_TEXT = "View kit order update and see more information"; + +const normalizeBaseUrl = (baseUrl: string): string => baseUrl.replaceAll(/\/+$/g, ""); + +const formatDispatchedDate = (isoDateTime: string): string => + new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(new Date(isoDateTime)); + +export class NotifyMessageBuilder { + private readonly normalizedHomeTestBaseUrl: string; + + constructor( + private readonly orderStatusDb: OrderStatusService, + homeTestBaseUrl: string, + ) { + this.normalizedHomeTestBaseUrl = normalizeBaseUrl(homeTestBaseUrl); + } + + async buildOrderDispatchedNotifyMessage( + input: BuildOrderDispatchedNotifyMessageInput, + ): Promise { + const { patientId, correlationId, orderId, dispatchedAt } = input; + const recipient = await this.orderStatusDb.getNotifyRecipientData(patientId); + const trackingUrl = `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`; + + return { + correlationId, + messageReference: uuidv4(), + eventCode: NotifyEventCode.OrderDispatched, + recipient, + personalisation: { + dispatched_date: formatDispatchedDate(dispatchedAt), + status_url: `[${ORDER_TRACKING_LINK_TEXT}](${trackingUrl})`, + }, + }; + } +} diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 881e9bc2..cb5825bd 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -519,15 +519,17 @@ module "order_status_lambda" { lambda_role_policy_attachment = aws_iam_role_policy_attachment.lambda_basic environment_variables = { - NODE_OPTIONS = "--enable-source-maps" - ALLOW_ORIGIN = "http://localhost:3000" - DB_USERNAME = "app_user" - DB_ADDRESS = "postgres-db" - DB_PORT = "5432" - DB_NAME = "local_hometest_db" - DB_SCHEMA = "hometest" - DB_SECRET_NAME = "postgres-db-password" - DB_SSL = "false" + NODE_OPTIONS = "--enable-source-maps" + ALLOW_ORIGIN = "http://localhost:3000" + DB_USERNAME = "app_user" + DB_ADDRESS = "postgres-db" + DB_PORT = "5432" + DB_NAME = "local_hometest_db" + DB_SCHEMA = "hometest" + DB_SECRET_NAME = "postgres-db-password" + DB_SSL = "false" + NOTIFY_MESSAGES_QUEUE_URL = aws_sqs_queue.notify_messages.url + HOME_TEST_BASE_URL = "http://localhost:3000" } } From e622c7b0f66b3ff8d9384bfd4f18d0eb084c3e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Thu, 2 Apr 2026 10:19:52 +0200 Subject: [PATCH 03/10] refactoring code, spliting to new classes --- .../db/notification-audit-db-client.test.ts | 64 +++++++++++++ .../lib/db/notification-audit-db-client.ts | 59 ++++++++++++ lambdas/src/lib/db/order-status-db.test.ts | 80 ---------------- lambdas/src/lib/db/order-status-db.ts | 93 ------------------- lambdas/src/lib/db/patient-db-client.test.ts | 55 +++++++++++ lambdas/src/lib/db/patient-db-client.ts | 40 ++++++++ lambdas/src/order-status-lambda/index.test.ts | 3 +- lambdas/src/order-status-lambda/index.ts | 10 +- lambdas/src/order-status-lambda/init.test.ts | 20 +++- lambdas/src/order-status-lambda/init.ts | 10 +- .../notify-message-builder.test.ts | 8 +- .../notify-message-builder.ts | 6 +- 12 files changed, 262 insertions(+), 186 deletions(-) create mode 100644 lambdas/src/lib/db/notification-audit-db-client.test.ts create mode 100644 lambdas/src/lib/db/notification-audit-db-client.ts create mode 100644 lambdas/src/lib/db/patient-db-client.test.ts create mode 100644 lambdas/src/lib/db/patient-db-client.ts diff --git a/lambdas/src/lib/db/notification-audit-db-client.test.ts b/lambdas/src/lib/db/notification-audit-db-client.test.ts new file mode 100644 index 00000000..066fddd3 --- /dev/null +++ b/lambdas/src/lib/db/notification-audit-db-client.test.ts @@ -0,0 +1,64 @@ +import { type DBClient } from "./db-client"; +import { + NotificationAuditDbClient, + type NotificationAuditEntryParams, +} from "./notification-audit-db-client"; + +const mockQuery = jest.fn(); + +describe("NotificationAuditDbClient", () => { + let client: NotificationAuditDbClient; + + beforeEach(() => { + jest.clearAllMocks(); + + const dbClient: DBClient = { + query: mockQuery, + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + client = new NotificationAuditDbClient(dbClient); + }); + + it("should insert notification audit entry", async () => { + const params: NotificationAuditEntryParams = { + messageReference: "123e4567-e89b-12d3-a456-426614174000", + eventCode: "ORDER_DISPATCHED", + correlationId: "123e4567-e89b-12d3-a456-426614174001", + status: "SENT", + }; + + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 1, + }); + + await expect(client.insertNotificationAuditEntry(params)).resolves.toBeUndefined(); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("notification_audit"), [ + params.messageReference, + null, + params.eventCode, + null, + params.correlationId, + params.status, + ]); + }); + + it("should throw when notification audit insert affects no rows", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect( + client.insertNotificationAuditEntry({ + messageReference: "123e4567-e89b-12d3-a456-426614174000", + eventCode: "ORDER_DISPATCHED", + correlationId: "123e4567-e89b-12d3-a456-426614174001", + status: "SENT", + }), + ).rejects.toThrow("Failed to insert notification audit entry"); + }); +}); diff --git a/lambdas/src/lib/db/notification-audit-db-client.ts b/lambdas/src/lib/db/notification-audit-db-client.ts new file mode 100644 index 00000000..7d6a0350 --- /dev/null +++ b/lambdas/src/lib/db/notification-audit-db-client.ts @@ -0,0 +1,59 @@ +import { type DBClient } from "./db-client"; + +export interface NotificationAuditEntryParams { + messageReference: string; + eventCode: string; + correlationId: string; + status: string; + notifyMessageId?: string | null; + routingPlanId?: string | null; +} + +export class NotificationAuditDbClient { + constructor(private readonly dbClient: DBClient) {} + + async insertNotificationAuditEntry(params: NotificationAuditEntryParams): Promise { + const { + messageReference, + notifyMessageId = null, + eventCode, + routingPlanId = null, + correlationId, + status, + } = params; + + const query = ` + INSERT INTO notification_audit ( + message_reference, + notify_message_id, + event_code, + routing_plan_id, + correlation_id, + status + ) + VALUES ($1::uuid, $2, $3, $4::uuid, $5::uuid, $6) + `; + + try { + const result = await this.dbClient.query(query, [ + messageReference, + notifyMessageId, + eventCode, + routingPlanId, + correlationId, + status, + ]); + + if (result.rowCount === 0) { + throw new Error("Failed to insert notification audit entry"); + } + } catch (error) { + throw new Error( + `Failed to insert notification audit entry for messageReference ${messageReference}`, + { + cause: error, + }, + ); + } + } +} diff --git a/lambdas/src/lib/db/order-status-db.test.ts b/lambdas/src/lib/db/order-status-db.test.ts index c503d3eb..12047b51 100644 --- a/lambdas/src/lib/db/order-status-db.test.ts +++ b/lambdas/src/lib/db/order-status-db.test.ts @@ -1,7 +1,5 @@ import { DBClient } from "./db-client"; import { - NotificationAuditEntryParams, - NotifyRecipientData, OrderRow, OrderStatusCodes, OrderStatusService, @@ -158,41 +156,6 @@ describe("OrderStatusService", () => { }); }); - describe("getNotifyRecipientData", () => { - it("should return notify recipient data", async () => { - const mockRecipientRow = { - nhs_number: "1234567890", - birth_date: "1990-04-20", - }; - - mockQuery.mockResolvedValue({ - rows: [mockRecipientRow], - rowCount: 1, - }); - - const result = await service.getNotifyRecipientData("some-mocked-patient-id"); - - expect(result).toEqual({ - nhsNumber: mockRecipientRow.nhs_number, - dateOfBirth: mockRecipientRow.birth_date, - } satisfies NotifyRecipientData); - expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("patient_mapping"), [ - "some-mocked-patient-id", - ]); - }); - - it("should throw when patient record does not exist", async () => { - mockQuery.mockResolvedValue({ - rows: [], - rowCount: 0, - }); - - await expect(service.getNotifyRecipientData("missing-patient-id")).rejects.toThrow( - "Failed to fetch notify recipient data", - ); - }); - }); - describe("isFirstStatusOccurrence", () => { it("should return true for first occurrence", async () => { mockQuery.mockResolvedValue({ @@ -226,47 +189,4 @@ describe("OrderStatusService", () => { expect(result).toBe(false); }); }); - - describe("insertNotificationAuditEntry", () => { - it("should insert notification audit entry", async () => { - const params: NotificationAuditEntryParams = { - messageReference: "123e4567-e89b-12d3-a456-426614174000", - eventCode: "ORDER_DISPATCHED", - correlationId: "123e4567-e89b-12d3-a456-426614174001", - status: "SENT", - }; - - mockQuery.mockResolvedValue({ - rows: [], - rowCount: 1, - }); - - await expect(service.insertNotificationAuditEntry(params)).resolves.toBeUndefined(); - - expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("notification_audit"), [ - params.messageReference, - null, - params.eventCode, - null, - params.correlationId, - params.status, - ]); - }); - - it("should throw when notification audit insert affects no rows", async () => { - mockQuery.mockResolvedValue({ - rows: [], - rowCount: 0, - }); - - await expect( - service.insertNotificationAuditEntry({ - messageReference: "123e4567-e89b-12d3-a456-426614174000", - eventCode: "ORDER_DISPATCHED", - correlationId: "123e4567-e89b-12d3-a456-426614174001", - status: "SENT", - } satisfies NotificationAuditEntryParams), - ).rejects.toThrow("Failed to insert notification audit entry"); - }); - }); }); diff --git a/lambdas/src/lib/db/order-status-db.ts b/lambdas/src/lib/db/order-status-db.ts index 953aefff..0ac82e42 100644 --- a/lambdas/src/lib/db/order-status-db.ts +++ b/lambdas/src/lib/db/order-status-db.ts @@ -33,20 +33,6 @@ export interface IdempotencyCheckResult { isDuplicate: boolean; } -export interface NotifyRecipientData { - nhsNumber: string; - dateOfBirth: string; -} - -export interface NotificationAuditEntryParams { - messageReference: string; - eventCode: string; - correlationId: string; - status: string; - notifyMessageId?: string | null; - routingPlanId?: string | null; -} - export class OrderStatusService { private readonly dbClient: DBClient; @@ -130,40 +116,6 @@ export class OrderStatusService { } } - async getNotifyRecipientData(patientId: string): Promise { - const query = ` - SELECT nhs_number, birth_date - FROM patient_mapping - WHERE patient_uid = $1::uuid - LIMIT 1; - `; - - try { - const result = await this.dbClient.query< - { nhs_number: string; birth_date: string | Date }, - [string] - >(query, [patientId]); - - if (result.rowCount === 0 || !result.rows[0]) { - throw new Error(`Notify recipient not found for patientId ${patientId}`); - } - - const row = result.rows[0]; - - return { - nhsNumber: row.nhs_number, - dateOfBirth: - row.birth_date instanceof Date - ? row.birth_date.toISOString().slice(0, 10) - : row.birth_date, - }; - } catch (error) { - throw new Error(`Failed to fetch notify recipient data for patientId ${patientId}`, { - cause: error, - }); - } - } - async isFirstStatusOccurrence(orderId: string, statusCode: OrderStatusCode): Promise { const query = ` SELECT COUNT(*)::int AS count @@ -187,49 +139,4 @@ export class OrderStatusService { ); } } - - async insertNotificationAuditEntry(params: NotificationAuditEntryParams): Promise { - const { - messageReference, - notifyMessageId = null, - eventCode, - routingPlanId = null, - correlationId, - status, - } = params; - - const query = ` - INSERT INTO notification_audit ( - message_reference, - notify_message_id, - event_code, - routing_plan_id, - correlation_id, - status - ) - VALUES ($1::uuid, $2, $3, $4::uuid, $5::uuid, $6) - `; - - try { - const result = await this.dbClient.query(query, [ - messageReference, - notifyMessageId, - eventCode, - routingPlanId, - correlationId, - status, - ]); - - if (result.rowCount === 0) { - throw new Error("Failed to insert notification audit entry"); - } - } catch (error) { - throw new Error( - `Failed to insert notification audit entry for messageReference ${messageReference}`, - { - cause: error, - }, - ); - } - } } diff --git a/lambdas/src/lib/db/patient-db-client.test.ts b/lambdas/src/lib/db/patient-db-client.test.ts new file mode 100644 index 00000000..ef6a7150 --- /dev/null +++ b/lambdas/src/lib/db/patient-db-client.test.ts @@ -0,0 +1,55 @@ +import { type DBClient } from "./db-client"; +import { PatientDbClient } from "./patient-db-client"; + +const mockQuery = jest.fn(); + +describe("PatientDbClient", () => { + let patientDbClient: PatientDbClient; + + beforeEach(() => { + jest.clearAllMocks(); + + const dbClient: DBClient = { + query: mockQuery, + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + patientDbClient = new PatientDbClient(dbClient); + }); + + describe("getNotifyRecipientData", () => { + it("should return notify recipient data", async () => { + mockQuery.mockResolvedValue({ + rows: [ + { + nhs_number: "1234567890", + birth_date: "1990-04-20", + }, + ], + rowCount: 1, + }); + + const result = await patientDbClient.getNotifyRecipientData("some-mocked-patient-id"); + + expect(result).toEqual({ + nhsNumber: "1234567890", + dateOfBirth: "1990-04-20", + }); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("patient_mapping"), [ + "some-mocked-patient-id", + ]); + }); + + it("should throw when patient record does not exist", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect(patientDbClient.getNotifyRecipientData("missing-patient-id")).rejects.toThrow( + "Failed to fetch notify recipient data", + ); + }); + }); +}); diff --git a/lambdas/src/lib/db/patient-db-client.ts b/lambdas/src/lib/db/patient-db-client.ts new file mode 100644 index 00000000..97e552c5 --- /dev/null +++ b/lambdas/src/lib/db/patient-db-client.ts @@ -0,0 +1,40 @@ +import { type NotifyRecipient } from "../types/notify-message"; +import { type DBClient } from "./db-client"; + +export class PatientDbClient { + constructor(private readonly dbClient: DBClient) {} + + async getNotifyRecipientData(patientId: string): Promise { + const query = ` + SELECT nhs_number, birth_date + FROM patient_mapping + WHERE patient_uid = $1::uuid + LIMIT 1; + `; + + try { + const result = await this.dbClient.query< + { nhs_number: string; birth_date: string | Date }, + [string] + >(query, [patientId]); + + if (result.rowCount === 0 || !result.rows[0]) { + throw new Error(`Notify recipient not found for patientId ${patientId}`); + } + + const row = result.rows[0]; + + return { + nhsNumber: row.nhs_number, + dateOfBirth: + row.birth_date instanceof Date + ? row.birth_date.toISOString().slice(0, 10) + : row.birth_date, + }; + } catch (error) { + throw new Error(`Failed to fetch notify recipient data for patientId ${patientId}`, { + cause: error, + }); + } + } +} diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index d0d775b5..ee83dfa6 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -62,6 +62,8 @@ describe("Order Status Lambda Handler", () => { checkIdempotency: mockCheckIdempotency, addOrderStatusUpdate: mockAddOrderStatusUpdate, isFirstStatusOccurrence: mockIsFirstStatusOccurrence, + }, + notificationAuditDbClient: { insertNotificationAuditEntry: mockInsertNotificationAuditEntry, }, sqsClient: { @@ -71,7 +73,6 @@ describe("Order Status Lambda Handler", () => { buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage, }, notifyMessagesQueueUrl: "https://example.queue.local/notify", - homeTestBaseUrl: "https://hometest.example.nhs.uk", }); const module = await import("./index"); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 5cfd1c4c..99955a49 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -42,7 +42,13 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb, sqsClient, notifyMessageBuilder, notifyMessagesQueueUrl } = init(); + const { + orderStatusDb, + notificationAuditDbClient, + sqsClient, + notifyMessageBuilder, + notifyMessagesQueueUrl, + } = init(); commons.logInfo(name, "Received order status update request", { path: event.path, method: event.httpMethod, @@ -177,7 +183,7 @@ export const lambdaHandler = async ( await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); - await orderStatusDb.insertNotificationAuditEntry({ + await notificationAuditDbClient.insertNotificationAuditEntry({ messageReference: notifyMessage.messageReference, eventCode: notifyMessage.eventCode, correlationId, diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index 8e8d3608..c631446b 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -1,6 +1,8 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; +import { PatientDbClient } from "../lib/db/patient-db-client"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { testComponentCreationOrder } from "../lib/test-utils/component-integration-helpers"; @@ -9,6 +11,8 @@ import { buildEnvironment as init } from "./init"; import { NotifyMessageBuilder } from "./notify-message-builder"; jest.mock("../lib/db/order-status-db"); +jest.mock("../lib/db/patient-db-client"); +jest.mock("../lib/db/notification-audit-db-client"); jest.mock("../lib/db/db-client"); jest.mock("../lib/secrets/secrets-manager-client"); jest.mock("../lib/sqs/sqs-client"); @@ -103,6 +107,8 @@ describe("init", () => { expect(result).toEqual({ orderStatusDb: expect.any(OrderStatusService), + patientDbClient: expect.any(PatientDbClient), + notificationAuditDbClient: expect.any(NotificationAuditDbClient), sqsClient: expect.any(AWSSQSClient), notifyMessageBuilder: expect.any(NotifyMessageBuilder), notifyMessagesQueueUrl: "https://example.queue.local/notify", @@ -142,6 +148,16 @@ describe("init", () => { times: 1, calledWith: expect.any(PostgresDbClient), }, + { + mock: PatientDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: NotificationAuditDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, { mock: AWSSQSClient as jest.Mock, times: 1, @@ -154,11 +170,11 @@ describe("init", () => { }); }); - it("should create NotifyMessageBuilder with OrderStatusService and home test base url", () => { + it("should create NotifyMessageBuilder with PatientDbClient and home test base url", () => { init(); expect(NotifyMessageBuilder).toHaveBeenCalledWith( - expect.any(OrderStatusService), + expect.any(PatientDbClient), "https://hometest.example.nhs.uk", ); }); diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index 9120bd0b..8929cc12 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -1,6 +1,8 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; +import { PatientDbClient } from "../lib/db/patient-db-client"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; @@ -8,6 +10,8 @@ import { NotifyMessageBuilder } from "./notify-message-builder"; export interface Environment { orderStatusDb: OrderStatusService; + patientDbClient: PatientDbClient; + notificationAuditDbClient: NotificationAuditDbClient; sqsClient: AWSSQSClient; notifyMessageBuilder: NotifyMessageBuilder; notifyMessagesQueueUrl: string; @@ -20,11 +24,15 @@ export function buildEnvironment(): Environment { const secretsClient = new AwsSecretsClient(awsRegion); const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); const orderStatusDb = new OrderStatusService(dbClient); + const patientDbClient = new PatientDbClient(dbClient); + const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); - const notifyMessageBuilder = new NotifyMessageBuilder(orderStatusDb, homeTestBaseUrl); + const notifyMessageBuilder = new NotifyMessageBuilder(patientDbClient, homeTestBaseUrl); return { orderStatusDb, + patientDbClient, + notificationAuditDbClient, sqsClient, notifyMessageBuilder, notifyMessagesQueueUrl, diff --git a/lambdas/src/order-status-lambda/notify-message-builder.test.ts b/lambdas/src/order-status-lambda/notify-message-builder.test.ts index 23e481f5..e892ab2f 100644 --- a/lambdas/src/order-status-lambda/notify-message-builder.test.ts +++ b/lambdas/src/order-status-lambda/notify-message-builder.test.ts @@ -1,11 +1,11 @@ -import type { OrderStatusService } from "../lib/db/order-status-db"; +import type { PatientDbClient } from "../lib/db/patient-db-client"; import { NotifyEventCode } from "../lib/types/notify-message"; import { NotifyMessageBuilder } from "./notify-message-builder"; describe("NotifyMessageBuilder", () => { const mockGetNotifyRecipientData = jest.fn(); - const mockOrderStatusDb: Pick = { + const mockPatientDbClient: Pick = { getNotifyRecipientData: mockGetNotifyRecipientData, }; @@ -19,7 +19,7 @@ describe("NotifyMessageBuilder", () => { }); builder = new NotifyMessageBuilder( - mockOrderStatusDb as OrderStatusService, + mockPatientDbClient as PatientDbClient, "https://hometest.example.nhs.uk", ); }); @@ -48,7 +48,7 @@ describe("NotifyMessageBuilder", () => { it("should normalize trailing slash in base url", async () => { const trailingSlashBuilder = new NotifyMessageBuilder( - mockOrderStatusDb as OrderStatusService, + mockPatientDbClient as PatientDbClient, "https://hometest.example.nhs.uk/", ); diff --git a/lambdas/src/order-status-lambda/notify-message-builder.ts b/lambdas/src/order-status-lambda/notify-message-builder.ts index 3d72956a..1d958995 100644 --- a/lambdas/src/order-status-lambda/notify-message-builder.ts +++ b/lambdas/src/order-status-lambda/notify-message-builder.ts @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from "uuid"; -import type { OrderStatusService } from "../lib/db/order-status-db"; +import type { PatientDbClient } from "../lib/db/patient-db-client"; import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; export interface BuildOrderDispatchedNotifyMessageInput { @@ -26,7 +26,7 @@ export class NotifyMessageBuilder { private readonly normalizedHomeTestBaseUrl: string; constructor( - private readonly orderStatusDb: OrderStatusService, + private readonly patientDbClient: PatientDbClient, homeTestBaseUrl: string, ) { this.normalizedHomeTestBaseUrl = normalizeBaseUrl(homeTestBaseUrl); @@ -36,7 +36,7 @@ export class NotifyMessageBuilder { input: BuildOrderDispatchedNotifyMessageInput, ): Promise { const { patientId, correlationId, orderId, dispatchedAt } = input; - const recipient = await this.orderStatusDb.getNotifyRecipientData(patientId); + const recipient = await this.patientDbClient.getNotifyRecipientData(patientId); const trackingUrl = `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`; return { From 6856bd63a2ccb71fa1e8918a3fdf9f1d368e2cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Thu, 2 Apr 2026 10:24:11 +0200 Subject: [PATCH 04/10] replaced strings w enums --- .../src/lib/db/notification-audit-db-client.test.ts | 10 ++++++---- lambdas/src/lib/db/notification-audit-db-client.ts | 11 +++++++++-- lambdas/src/order-status-lambda/index.test.ts | 10 ++++++---- lambdas/src/order-status-lambda/index.ts | 3 ++- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/lambdas/src/lib/db/notification-audit-db-client.test.ts b/lambdas/src/lib/db/notification-audit-db-client.test.ts index 066fddd3..1e306135 100644 --- a/lambdas/src/lib/db/notification-audit-db-client.test.ts +++ b/lambdas/src/lib/db/notification-audit-db-client.test.ts @@ -1,7 +1,9 @@ +import { NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; import { NotificationAuditDbClient, type NotificationAuditEntryParams, + NotificationAuditStatus, } from "./notification-audit-db-client"; const mockQuery = jest.fn(); @@ -24,9 +26,9 @@ describe("NotificationAuditDbClient", () => { it("should insert notification audit entry", async () => { const params: NotificationAuditEntryParams = { messageReference: "123e4567-e89b-12d3-a456-426614174000", - eventCode: "ORDER_DISPATCHED", + eventCode: NotifyEventCode.OrderDispatched, correlationId: "123e4567-e89b-12d3-a456-426614174001", - status: "SENT", + status: NotificationAuditStatus.SENT, }; mockQuery.mockResolvedValue({ @@ -55,9 +57,9 @@ describe("NotificationAuditDbClient", () => { await expect( client.insertNotificationAuditEntry({ messageReference: "123e4567-e89b-12d3-a456-426614174000", - eventCode: "ORDER_DISPATCHED", + eventCode: NotifyEventCode.OrderDispatched, correlationId: "123e4567-e89b-12d3-a456-426614174001", - status: "SENT", + status: NotificationAuditStatus.SENT, }), ).rejects.toThrow("Failed to insert notification audit entry"); }); diff --git a/lambdas/src/lib/db/notification-audit-db-client.ts b/lambdas/src/lib/db/notification-audit-db-client.ts index 7d6a0350..9a4b904d 100644 --- a/lambdas/src/lib/db/notification-audit-db-client.ts +++ b/lambdas/src/lib/db/notification-audit-db-client.ts @@ -1,10 +1,17 @@ +import { type NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; +export enum NotificationAuditStatus { + QUEUED = "QUEUED", + SENT = "SENT", + FAILED = "FAILED", +} + export interface NotificationAuditEntryParams { messageReference: string; - eventCode: string; + eventCode: NotifyEventCode; correlationId: string; - status: string; + status: NotificationAuditStatus; notifyMessageId?: string | null; routingPlanId?: string | null; } diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index ee83dfa6..4710fe9f 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -1,6 +1,8 @@ import { APIGatewayProxyEvent, Context } from "aws-lambda"; +import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; import { IdempotencyCheckResult } from "../lib/db/order-status-db"; +import { NotifyEventCode } from "../lib/types/notify-message"; import { OrderStatusFHIRTask } from "./index"; import { IncomingBusinessStatus } from "./types"; import { businessStatusMapping } from "./utils"; @@ -48,7 +50,7 @@ describe("Order Status Lambda Handler", () => { mockIsFirstStatusOccurrence.mockResolvedValue(true); mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ messageReference: "123e4567-e89b-12d3-a456-426614174099", - eventCode: "ORDER_DISPATCHED", + eventCode: NotifyEventCode.OrderDispatched, correlationId: MOCK_CORRELATION_ID, nhsNumber: "1234567890", dateOfBirth: "1990-01-02", @@ -446,9 +448,9 @@ describe("Order Status Lambda Handler", () => { ); expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith( expect.objectContaining({ - eventCode: "ORDER_DISPATCHED", + eventCode: NotifyEventCode.OrderDispatched, correlationId: MOCK_CORRELATION_ID, - status: "SENT", + status: NotificationAuditStatus.SENT, }), ); }); @@ -488,7 +490,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(500); expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalledWith( - expect.objectContaining({ status: "FAILED" }), + expect.objectContaining({ status: NotificationAuditStatus.FAILED }), ); }); }); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 99955a49..fc9e813a 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -6,6 +6,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import z from "zod"; import { ConsoleCommons } from "../lib/commons"; +import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; import { OrderStatusCodes, OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; @@ -187,7 +188,7 @@ export const lambdaHandler = async ( messageReference: notifyMessage.messageReference, eventCode: notifyMessage.eventCode, correlationId, - status: "SENT", + status: NotificationAuditStatus.SENT, }); } } From a1b72e6ec8ff1a2d839f75a69a023cb2a8df17e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 3 Apr 2026 10:43:27 +0200 Subject: [PATCH 05/10] refactoring --- lambdas/src/lib/db/patient-db-client.test.ts | 6 ++--- lambdas/src/lib/db/patient-db-client.ts | 15 ++++++----- .../notify-message-builder.test.ts | 26 +++++++++---------- .../notify-message-builder.ts | 18 ++++++++----- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/lambdas/src/lib/db/patient-db-client.test.ts b/lambdas/src/lib/db/patient-db-client.test.ts index ef6a7150..a5d77b75 100644 --- a/lambdas/src/lib/db/patient-db-client.test.ts +++ b/lambdas/src/lib/db/patient-db-client.test.ts @@ -30,11 +30,11 @@ describe("PatientDbClient", () => { rowCount: 1, }); - const result = await patientDbClient.getNotifyRecipientData("some-mocked-patient-id"); + const result = await patientDbClient.get("some-mocked-patient-id"); expect(result).toEqual({ nhsNumber: "1234567890", - dateOfBirth: "1990-04-20", + birthDate: "1990-04-20", }); expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("patient_mapping"), [ "some-mocked-patient-id", @@ -47,7 +47,7 @@ describe("PatientDbClient", () => { rowCount: 0, }); - await expect(patientDbClient.getNotifyRecipientData("missing-patient-id")).rejects.toThrow( + await expect(patientDbClient.get("missing-patient-id")).rejects.toThrow( "Failed to fetch notify recipient data", ); }); diff --git a/lambdas/src/lib/db/patient-db-client.ts b/lambdas/src/lib/db/patient-db-client.ts index 97e552c5..a927f8fb 100644 --- a/lambdas/src/lib/db/patient-db-client.ts +++ b/lambdas/src/lib/db/patient-db-client.ts @@ -1,10 +1,14 @@ -import { type NotifyRecipient } from "../types/notify-message"; import { type DBClient } from "./db-client"; +export interface Patient { + nhsNumber: string; + birthDate: string; +} + export class PatientDbClient { constructor(private readonly dbClient: DBClient) {} - async getNotifyRecipientData(patientId: string): Promise { + async get(patientId: string): Promise { const query = ` SELECT nhs_number, birth_date FROM patient_mapping @@ -14,7 +18,7 @@ export class PatientDbClient { try { const result = await this.dbClient.query< - { nhs_number: string; birth_date: string | Date }, + { nhs_number: string; birth_date: string }, [string] >(query, [patientId]); @@ -26,10 +30,7 @@ export class PatientDbClient { return { nhsNumber: row.nhs_number, - dateOfBirth: - row.birth_date instanceof Date - ? row.birth_date.toISOString().slice(0, 10) - : row.birth_date, + birthDate: row.birth_date, }; } catch (error) { throw new Error(`Failed to fetch notify recipient data for patientId ${patientId}`, { diff --git a/lambdas/src/order-status-lambda/notify-message-builder.test.ts b/lambdas/src/order-status-lambda/notify-message-builder.test.ts index e892ab2f..bb3f8f5b 100644 --- a/lambdas/src/order-status-lambda/notify-message-builder.test.ts +++ b/lambdas/src/order-status-lambda/notify-message-builder.test.ts @@ -1,21 +1,21 @@ -import type { PatientDbClient } from "../lib/db/patient-db-client"; +import type { Patient, PatientDbClient } from "../lib/db/patient-db-client"; import { NotifyEventCode } from "../lib/types/notify-message"; import { NotifyMessageBuilder } from "./notify-message-builder"; describe("NotifyMessageBuilder", () => { - const mockGetNotifyRecipientData = jest.fn(); + const mockGetPatient = jest.fn, [string]>(); - const mockPatientDbClient: Pick = { - getNotifyRecipientData: mockGetNotifyRecipientData, + const mockPatientDbClient: Pick = { + get: mockGetPatient, }; let builder: NotifyMessageBuilder; beforeEach(() => { jest.clearAllMocks(); - mockGetNotifyRecipientData.mockResolvedValue({ + mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", - dateOfBirth: "1990-01-02", + birthDate: "1990-01-02", }); builder = new NotifyMessageBuilder( @@ -40,8 +40,8 @@ describe("NotifyMessageBuilder", () => { }); expect(result.personalisation).toEqual({ - dispatched_date: "6 August 2026", - status_url: + dispatchedDate: "6 August 2026", + statusLink: "[View kit order update and see more information](https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking)", }); }); @@ -59,13 +59,13 @@ describe("NotifyMessageBuilder", () => { dispatchedAt: "2026-08-06T10:00:00Z", }); - const statusUrl = result.personalisation?.status_url; + const statusLink = result.personalisation?.statusLink; - expect(typeof statusUrl).toBe("string"); - expect(statusUrl).toContain( + expect(typeof statusLink).toBe("string"); + expect(statusLink).toContain( "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", ); - expect(statusUrl).not.toContain(".uk//orders"); + expect(statusLink).not.toContain(".uk//orders"); }); it("should call recipient lookup with patient id", async () => { @@ -76,6 +76,6 @@ describe("NotifyMessageBuilder", () => { dispatchedAt: "2026-08-06T10:00:00Z", }); - expect(mockGetNotifyRecipientData).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440111"); + expect(mockGetPatient).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440111"); }); }); diff --git a/lambdas/src/order-status-lambda/notify-message-builder.ts b/lambdas/src/order-status-lambda/notify-message-builder.ts index 1d958995..3990e3d5 100644 --- a/lambdas/src/order-status-lambda/notify-message-builder.ts +++ b/lambdas/src/order-status-lambda/notify-message-builder.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import type { PatientDbClient } from "../lib/db/patient-db-client"; -import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; +import { NotifyEventCode, NotifyMessage, NotifyRecipient } from "../lib/types/notify-message"; export interface BuildOrderDispatchedNotifyMessageInput { patientId: string; @@ -12,8 +12,6 @@ export interface BuildOrderDispatchedNotifyMessageInput { const ORDER_TRACKING_LINK_TEXT = "View kit order update and see more information"; -const normalizeBaseUrl = (baseUrl: string): string => baseUrl.replaceAll(/\/+$/g, ""); - const formatDispatchedDate = (isoDateTime: string): string => new Intl.DateTimeFormat("en-GB", { day: "numeric", @@ -29,14 +27,20 @@ export class NotifyMessageBuilder { private readonly patientDbClient: PatientDbClient, homeTestBaseUrl: string, ) { - this.normalizedHomeTestBaseUrl = normalizeBaseUrl(homeTestBaseUrl); + this.normalizedHomeTestBaseUrl = homeTestBaseUrl.replaceAll(/\/$/g, ""); } async buildOrderDispatchedNotifyMessage( input: BuildOrderDispatchedNotifyMessageInput, ): Promise { const { patientId, correlationId, orderId, dispatchedAt } = input; - const recipient = await this.patientDbClient.getNotifyRecipientData(patientId); + + const patient = await this.patientDbClient.get(patientId); + const recipient: NotifyRecipient = { + nhsNumber: patient.nhsNumber, + dateOfBirth: patient.birthDate, + }; + const trackingUrl = `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`; return { @@ -45,8 +49,8 @@ export class NotifyMessageBuilder { eventCode: NotifyEventCode.OrderDispatched, recipient, personalisation: { - dispatched_date: formatDispatchedDate(dispatchedAt), - status_url: `[${ORDER_TRACKING_LINK_TEXT}](${trackingUrl})`, + dispatchedDate: formatDispatchedDate(dispatchedAt), + statusLink: `[${ORDER_TRACKING_LINK_TEXT}](${trackingUrl})`, }, }; } From 3eef93af8379a1a9da7886bb2627096c15f39ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 3 Apr 2026 10:57:45 +0200 Subject: [PATCH 06/10] added try catch for error --- lambdas/src/order-status-lambda/index.test.ts | 19 ++++++++-- lambdas/src/order-status-lambda/index.ts | 38 +++++++++++-------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 4710fe9f..4f6b1fb0 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -450,7 +450,7 @@ describe("Order Status Lambda Handler", () => { expect.objectContaining({ eventCode: NotifyEventCode.OrderDispatched, correlationId: MOCK_CORRELATION_ID, - status: NotificationAuditStatus.SENT, + status: NotificationAuditStatus.QUEUED, }), ); }); @@ -482,17 +482,30 @@ describe("Order Status Lambda Handler", () => { expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); }); - it("should return 500 when sending notify message fails", async () => { + it("should return 201 when sending notify message fails", async () => { mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - expect(result.statusCode).toBe(500); + expect(result.statusCode).toBe(201); expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalledWith( expect.objectContaining({ status: NotificationAuditStatus.FAILED }), ); }); + + it("should return 201 when building notify message fails", async () => { + mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce( + new Error("Notify payload build failed"), + ); + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); }); describe("Error Handling", () => { diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index fc9e813a..807b0cb4 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -175,21 +175,29 @@ export const lambdaHandler = async ( ); if (isFirstDispatched) { - const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ - patientId: orderPatientId, - correlationId, - orderId, - dispatchedAt: statusOrderUpdateParams.createdAt, - }); - - await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); - - await notificationAuditDbClient.insertNotificationAuditEntry({ - messageReference: notifyMessage.messageReference, - eventCode: notifyMessage.eventCode, - correlationId, - status: NotificationAuditStatus.SENT, - }); + try { + const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ + patientId: orderPatientId, + correlationId, + orderId, + dispatchedAt: statusOrderUpdateParams.createdAt, + }); + + await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); + + await notificationAuditDbClient.insertNotificationAuditEntry({ + messageReference: notifyMessage.messageReference, + eventCode: notifyMessage.eventCode, + correlationId, + status: NotificationAuditStatus.QUEUED, + }); + } catch (error) { + commons.logError(name, "Failed to send dispatched notification", { + correlationId, + orderId, + error, + }); + } } } From 985312f1c85b6bd922a18dccef7ad37a911b7c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 3 Apr 2026 11:15:52 +0200 Subject: [PATCH 07/10] fix tests --- lambdas/src/lib/db/patient-db-client.test.ts | 2 +- lambdas/src/order-status-lambda/index.test.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lambdas/src/lib/db/patient-db-client.test.ts b/lambdas/src/lib/db/patient-db-client.test.ts index a5d77b75..cb1c873f 100644 --- a/lambdas/src/lib/db/patient-db-client.test.ts +++ b/lambdas/src/lib/db/patient-db-client.test.ts @@ -18,7 +18,7 @@ describe("PatientDbClient", () => { patientDbClient = new PatientDbClient(dbClient); }); - describe("getNotifyRecipientData", () => { + describe("get", () => { it("should return notify recipient data", async () => { mockQuery.mockResolvedValue({ rows: [ diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 4f6b1fb0..f64a38a4 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -2,8 +2,9 @@ import { APIGatewayProxyEvent, Context } from "aws-lambda"; import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; import { IdempotencyCheckResult } from "../lib/db/order-status-db"; -import { NotifyEventCode } from "../lib/types/notify-message"; +import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; import { OrderStatusFHIRTask } from "./index"; +import { BuildOrderDispatchedNotifyMessageInput } from "./notify-message-builder"; import { IncomingBusinessStatus } from "./types"; import { businessStatusMapping } from "./utils"; @@ -13,7 +14,10 @@ const mockGetPatientIdFromOrder = jest.fn(); const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); const mockIsFirstStatusOccurrence = jest.fn(); -const mockBuildOrderDispatchedNotifyMessage = jest.fn(); +const mockBuildOrderDispatchedNotifyMessage = jest.fn< + Promise, + [BuildOrderDispatchedNotifyMessageInput] +>(); const mockInsertNotificationAuditEntry = jest.fn(); const mockSendMessage = jest.fn(); @@ -52,8 +56,11 @@ describe("Order Status Lambda Handler", () => { messageReference: "123e4567-e89b-12d3-a456-426614174099", eventCode: NotifyEventCode.OrderDispatched, correlationId: MOCK_CORRELATION_ID, - nhsNumber: "1234567890", - dateOfBirth: "1990-01-02", + recipient: { + nhsNumber: "1234567890", + dateOfBirth: "1990-01-02", + }, + personalisation: {}, }); mockInsertNotificationAuditEntry.mockResolvedValue(undefined); mockSendMessage.mockResolvedValue(undefined); From 7474c85bf73c40f5d5dda5a246b18c551f88cc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 3 Apr 2026 11:30:07 +0200 Subject: [PATCH 08/10] introduced notify service to handle updated status --- lambdas/src/order-status-lambda/index.test.ts | 105 +++--------- lambdas/src/order-status-lambda/index.ts | 49 +----- lambdas/src/order-status-lambda/init.test.ts | 24 ++- lambdas/src/order-status-lambda/init.ts | 17 +- .../notify-service.test.ts | 149 ++++++++++++++++++ .../src/order-status-lambda/notify-service.ts | 90 +++++++++++ 6 files changed, 298 insertions(+), 136 deletions(-) create mode 100644 lambdas/src/order-status-lambda/notify-service.test.ts create mode 100644 lambdas/src/order-status-lambda/notify-service.ts diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index f64a38a4..9ba8c161 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -1,10 +1,7 @@ import { APIGatewayProxyEvent, Context } from "aws-lambda"; -import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; import { IdempotencyCheckResult } from "../lib/db/order-status-db"; -import { NotifyEventCode, NotifyMessage } from "../lib/types/notify-message"; import { OrderStatusFHIRTask } from "./index"; -import { BuildOrderDispatchedNotifyMessageInput } from "./notify-message-builder"; import { IncomingBusinessStatus } from "./types"; import { businessStatusMapping } from "./utils"; @@ -13,13 +10,7 @@ const mockInit = jest.fn(); const mockGetPatientIdFromOrder = jest.fn(); const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); -const mockIsFirstStatusOccurrence = jest.fn(); -const mockBuildOrderDispatchedNotifyMessage = jest.fn< - Promise, - [BuildOrderDispatchedNotifyMessageInput] ->(); -const mockInsertNotificationAuditEntry = jest.fn(); -const mockSendMessage = jest.fn(); +const mockHandleOrderStatusUpdated = jest.fn(); const mockGetCorrelationIdFromEventHeaders = jest.fn(); @@ -51,37 +42,17 @@ describe("Order Status Lambda Handler", () => { mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); - mockIsFirstStatusOccurrence.mockResolvedValue(true); - mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ - messageReference: "123e4567-e89b-12d3-a456-426614174099", - eventCode: NotifyEventCode.OrderDispatched, - correlationId: MOCK_CORRELATION_ID, - recipient: { - nhsNumber: "1234567890", - dateOfBirth: "1990-01-02", - }, - personalisation: {}, - }); - mockInsertNotificationAuditEntry.mockResolvedValue(undefined); - mockSendMessage.mockResolvedValue(undefined); + mockHandleOrderStatusUpdated.mockResolvedValue(undefined); mockInit.mockReturnValue({ orderStatusDb: { getPatientIdFromOrder: mockGetPatientIdFromOrder, checkIdempotency: mockCheckIdempotency, addOrderStatusUpdate: mockAddOrderStatusUpdate, - isFirstStatusOccurrence: mockIsFirstStatusOccurrence, - }, - notificationAuditDbClient: { - insertNotificationAuditEntry: mockInsertNotificationAuditEntry, - }, - sqsClient: { - sendMessage: mockSendMessage, }, - notifyMessageBuilder: { - buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage, + orderStatusNotifyService: { + handleOrderStatusUpdated: mockHandleOrderStatusUpdated, }, - notifyMessagesQueueUrl: "https://example.queue.local/notify", }); const module = await import("./index"); @@ -306,7 +277,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(200); expect(mockCheckIdempotency).toHaveBeenCalledWith(MOCK_ORDER_UID, MOCK_CORRELATION_ID); - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockHandleOrderStatusUpdated).not.toHaveBeenCalled(); }); it("should process new updates with different correlation ID", async () => { @@ -434,35 +405,28 @@ describe("Order Status Lambda Handler", () => { ); }); - it("should send dispatched notification for first DISPATCHED status", async () => { - mockIsFirstStatusOccurrence.mockResolvedValueOnce(true); + it("should delegate post-update side effects to the notification service", async () => { mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); - expect(mockBuildOrderDispatchedNotifyMessage).toHaveBeenCalledWith( + expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( expect.objectContaining({ patientId: MOCK_PATIENT_UID, correlationId: MOCK_CORRELATION_ID, orderId: MOCK_ORDER_UID, - dispatchedAt: validTaskBody.lastModified, - }), - ); - expect(mockSendMessage).toHaveBeenCalledWith( - "https://example.queue.local/notify", - expect.any(String), - ); - expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith( - expect.objectContaining({ - eventCode: NotifyEventCode.OrderDispatched, - correlationId: MOCK_CORRELATION_ID, - status: NotificationAuditStatus.QUEUED, + statusUpdate: expect.objectContaining({ + orderId: MOCK_ORDER_UID, + statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], + createdAt: validTaskBody.lastModified, + correlationId: MOCK_CORRELATION_ID, + }), }), ); }); - it("should not send notification for non-DISPATCHED status", async () => { + it("should still delegate non-dispatched statuses to the notification service", async () => { mockEvent.body = JSON.stringify({ ...validTaskBody, businessStatus: { @@ -473,45 +437,22 @@ describe("Order Status Lambda Handler", () => { const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); - expect(mockIsFirstStatusOccurrence).not.toHaveBeenCalled(); - expect(mockSendMessage).not.toHaveBeenCalled(); - }); - - it("should not send notification when DISPATCHED is not first occurrence", async () => { - mockIsFirstStatusOccurrence.mockResolvedValueOnce(false); - mockEvent.body = JSON.stringify(validTaskBody); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - expect(mockIsFirstStatusOccurrence).toHaveBeenCalledWith(MOCK_ORDER_UID, "DISPATCHED"); - expect(mockSendMessage).not.toHaveBeenCalled(); - expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); - }); - - it("should return 201 when sending notify message fails", async () => { - mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); - mockEvent.body = JSON.stringify(validTaskBody); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalledWith( - expect.objectContaining({ status: NotificationAuditStatus.FAILED }), + expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + statusUpdate: expect.objectContaining({ + statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], + }), + }), ); }); - it("should return 201 when building notify message fails", async () => { - mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce( - new Error("Notify payload build failed"), - ); + it("should return 500 when notification service fails", async () => { + mockHandleOrderStatusUpdated.mockRejectedValueOnce(new Error("Unexpected side effect error")); mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - expect(result.statusCode).toBe(201); - expect(mockSendMessage).not.toHaveBeenCalled(); - expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + expect(result.statusCode).toBe(500); }); }); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 807b0cb4..b7a3c0ac 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -6,8 +6,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import z from "zod"; import { ConsoleCommons } from "../lib/commons"; -import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; -import { OrderStatusCodes, OrderStatusUpdateParams } from "../lib/db/order-status-db"; +import { OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; import { @@ -43,13 +42,7 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { - orderStatusDb, - notificationAuditDbClient, - sqsClient, - notifyMessageBuilder, - notifyMessagesQueueUrl, - } = init(); + const { orderStatusDb, orderStatusNotifyService } = init(); commons.logInfo(name, "Received order status update request", { path: event.path, method: event.httpMethod, @@ -168,38 +161,12 @@ export const lambdaHandler = async ( commons.logInfo(name, "Order status update added successfully", statusOrderUpdateParams); - if (statusOrderUpdateParams.statusCode === OrderStatusCodes.DISPATCHED) { - const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence( - orderId, - OrderStatusCodes.DISPATCHED, - ); - - if (isFirstDispatched) { - try { - const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ - patientId: orderPatientId, - correlationId, - orderId, - dispatchedAt: statusOrderUpdateParams.createdAt, - }); - - await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); - - await notificationAuditDbClient.insertNotificationAuditEntry({ - messageReference: notifyMessage.messageReference, - eventCode: notifyMessage.eventCode, - correlationId, - status: NotificationAuditStatus.QUEUED, - }); - } catch (error) { - commons.logError(name, "Failed to send dispatched notification", { - correlationId, - orderId, - error, - }); - } - } - } + await orderStatusNotifyService.handleOrderStatusUpdated({ + orderId, + patientId: orderPatientId, + correlationId, + statusUpdate: statusOrderUpdateParams, + }); return createFhirResponse(201, validatedTask); } catch (error) { diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index c631446b..c37fa91e 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -9,6 +9,7 @@ import { testComponentCreationOrder } from "../lib/test-utils/component-integrat import { restoreEnvironment, setupEnvironment } from "../lib/test-utils/environment-test-helpers"; import { buildEnvironment as init } from "./init"; import { NotifyMessageBuilder } from "./notify-message-builder"; +import { OrderStatusNotifyService } from "./notify-service"; jest.mock("../lib/db/order-status-db"); jest.mock("../lib/db/patient-db-client"); @@ -18,6 +19,7 @@ jest.mock("../lib/secrets/secrets-manager-client"); jest.mock("../lib/sqs/sqs-client"); jest.mock("../lib/db/db-config"); jest.mock("./notify-message-builder"); +jest.mock("./notify-service"); describe("init", () => { const originalEnv = process.env; @@ -107,11 +109,7 @@ describe("init", () => { expect(result).toEqual({ orderStatusDb: expect.any(OrderStatusService), - patientDbClient: expect.any(PatientDbClient), - notificationAuditDbClient: expect.any(NotificationAuditDbClient), - sqsClient: expect.any(AWSSQSClient), - notifyMessageBuilder: expect.any(NotifyMessageBuilder), - notifyMessagesQueueUrl: "https://example.queue.local/notify", + orderStatusNotifyService: expect.any(OrderStatusNotifyService), }); }); }); @@ -166,6 +164,10 @@ describe("init", () => { mock: NotifyMessageBuilder as jest.Mock, times: 1, }, + { + mock: OrderStatusNotifyService as jest.Mock, + times: 1, + }, ], }); }); @@ -178,6 +180,18 @@ describe("init", () => { "https://hometest.example.nhs.uk", ); }); + + it("should create OrderStatusNotifyService with notification dependencies", () => { + init(); + + expect(OrderStatusNotifyService).toHaveBeenCalledWith({ + orderStatusDb: expect.any(OrderStatusService), + notificationAuditDbClient: expect.any(NotificationAuditDbClient), + sqsClient: expect.any(AWSSQSClient), + notifyMessageBuilder: expect.any(NotifyMessageBuilder), + notifyMessagesQueueUrl: "https://example.queue.local/notify", + }); + }); }); describe("singleton protection", () => { diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index 8929cc12..e2a30284 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -7,20 +7,18 @@ import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; import { NotifyMessageBuilder } from "./notify-message-builder"; +import { OrderStatusNotifyService } from "./notify-service"; export interface Environment { orderStatusDb: OrderStatusService; - patientDbClient: PatientDbClient; - notificationAuditDbClient: NotificationAuditDbClient; - sqsClient: AWSSQSClient; - notifyMessageBuilder: NotifyMessageBuilder; - notifyMessagesQueueUrl: string; + orderStatusNotifyService: OrderStatusNotifyService; } export function buildEnvironment(): Environment { const awsRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "eu-west-2"; const notifyMessagesQueueUrl = retrieveMandatoryEnvVariable("NOTIFY_MESSAGES_QUEUE_URL"); const homeTestBaseUrl = retrieveMandatoryEnvVariable("HOME_TEST_BASE_URL"); + const secretsClient = new AwsSecretsClient(awsRegion); const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); const orderStatusDb = new OrderStatusService(dbClient); @@ -28,14 +26,17 @@ export function buildEnvironment(): Environment { const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); const notifyMessageBuilder = new NotifyMessageBuilder(patientDbClient, homeTestBaseUrl); - - return { + const orderStatusNotifyService = new OrderStatusNotifyService({ orderStatusDb, - patientDbClient, notificationAuditDbClient, sqsClient, notifyMessageBuilder, notifyMessagesQueueUrl, + }); + + return { + orderStatusDb, + orderStatusNotifyService, }; } diff --git a/lambdas/src/order-status-lambda/notify-service.test.ts b/lambdas/src/order-status-lambda/notify-service.test.ts new file mode 100644 index 00000000..b22d239b --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-service.test.ts @@ -0,0 +1,149 @@ +import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; +import { OrderStatusCodes, OrderStatusUpdateParams } from "../lib/db/order-status-db"; +import { NotifyEventCode } from "../lib/types/notify-message"; +import { OrderStatusNotifyService } from "./notify-service"; + +describe("OrderStatusNotifyService", () => { + const mockIsFirstStatusOccurrence = jest.fn, [string, string]>(); + const mockBuildOrderDispatchedNotifyMessage = jest.fn(); + const mockSendMessage = jest.fn(); + const mockInsertNotificationAuditEntry = jest.fn(); + + const statusUpdate: OrderStatusUpdateParams = { + orderId: "550e8400-e29b-41d4-a716-446655440000", + statusCode: OrderStatusCodes.DISPATCHED, + createdAt: "2024-01-15T10:00:00Z", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + }; + + let service: OrderStatusNotifyService; + + beforeEach(() => { + jest.clearAllMocks(); + + mockIsFirstStatusOccurrence.mockResolvedValue(true); + mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ + messageReference: "123e4567-e89b-12d3-a456-426614174099", + eventCode: NotifyEventCode.OrderDispatched, + correlationId: statusUpdate.correlationId, + recipient: { + nhsNumber: "1234567890", + dateOfBirth: "1990-01-02", + }, + personalisation: {}, + }); + mockSendMessage.mockResolvedValue({ messageId: "message-id" }); + mockInsertNotificationAuditEntry.mockResolvedValue(undefined); + + service = new OrderStatusNotifyService({ + orderStatusDb: { + isFirstStatusOccurrence: mockIsFirstStatusOccurrence, + } as never, + notificationAuditDbClient: { + insertNotificationAuditEntry: mockInsertNotificationAuditEntry, + } as never, + sqsClient: { + sendMessage: mockSendMessage, + }, + notifyMessageBuilder: { + buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage, + } as never, + notifyMessagesQueueUrl: "https://example.queue.local/notify", + }); + }); + + it("should do nothing for statuses without side effects", async () => { + await service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate: { + ...statusUpdate, + statusCode: OrderStatusCodes.RECEIVED, + }, + }); + + expect(mockIsFirstStatusOccurrence).not.toHaveBeenCalled(); + expect(mockBuildOrderDispatchedNotifyMessage).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should not send a dispatched notification when it is not the first occurrence", async () => { + mockIsFirstStatusOccurrence.mockResolvedValueOnce(false); + + await service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }); + + expect(mockIsFirstStatusOccurrence).toHaveBeenCalledWith( + statusUpdate.orderId, + OrderStatusCodes.DISPATCHED, + ); + expect(mockBuildOrderDispatchedNotifyMessage).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should send and audit the first dispatched notification", async () => { + await service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }); + + expect(mockBuildOrderDispatchedNotifyMessage).toHaveBeenCalledWith({ + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + orderId: statusUpdate.orderId, + dispatchedAt: statusUpdate.createdAt, + }); + expect(mockSendMessage).toHaveBeenCalledWith( + "https://example.queue.local/notify", + expect.any(String), + ); + expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith({ + messageReference: "123e4567-e89b-12d3-a456-426614174099", + eventCode: NotifyEventCode.OrderDispatched, + correlationId: statusUpdate.correlationId, + status: NotificationAuditStatus.QUEUED, + }); + }); + + it("should swallow errors when building the notify message fails", async () => { + mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce( + new Error("Notify payload build failed"), + ); + + await expect( + service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }), + ).resolves.toBeUndefined(); + + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should swallow errors when sending the notify message fails", async () => { + mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); + + await expect( + service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }), + ).resolves.toBeUndefined(); + + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/src/order-status-lambda/notify-service.ts b/lambdas/src/order-status-lambda/notify-service.ts new file mode 100644 index 00000000..16c78461 --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-service.ts @@ -0,0 +1,90 @@ +import { ConsoleCommons } from "../lib/commons"; +import { + NotificationAuditDbClient, + NotificationAuditStatus, +} from "../lib/db/notification-audit-db-client"; +import { + OrderStatusCodes, + OrderStatusService, + OrderStatusUpdateParams, +} from "../lib/db/order-status-db"; +import { SQSClientInterface } from "../lib/sqs/sqs-client"; +import { NotifyMessageBuilder } from "./notify-message-builder"; + +const commons = new ConsoleCommons(); +const name = "order-status-lambda"; + +export interface OrderStatusNotifyServiceDependencies { + orderStatusDb: OrderStatusService; + notificationAuditDbClient: NotificationAuditDbClient; + sqsClient: SQSClientInterface; + notifyMessageBuilder: NotifyMessageBuilder; + notifyMessagesQueueUrl: string; +} + +export interface HandleOrderStatusUpdatedInput { + orderId: string; + patientId: string; + correlationId: string; + statusUpdate: OrderStatusUpdateParams; +} + +export class OrderStatusNotifyService { + constructor(private readonly dependencies: OrderStatusNotifyServiceDependencies) {} + + async handleOrderStatusUpdated(input: HandleOrderStatusUpdatedInput): Promise { + const { statusUpdate } = input; + + switch (statusUpdate.statusCode) { + case OrderStatusCodes.DISPATCHED: + await this.handleDispatchedStatusUpdated(input); + return; + default: + return; + } + } + + private async handleDispatchedStatusUpdated(input: HandleOrderStatusUpdatedInput): Promise { + const { orderId, patientId, correlationId, statusUpdate } = input; + const { + orderStatusDb, + notificationAuditDbClient, + sqsClient, + notifyMessageBuilder, + notifyMessagesQueueUrl, + } = this.dependencies; + + const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence( + orderId, + OrderStatusCodes.DISPATCHED, + ); + + if (!isFirstDispatched) { + return; + } + + try { + const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ + patientId, + correlationId, + orderId, + dispatchedAt: statusUpdate.createdAt, + }); + + await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); + + await notificationAuditDbClient.insertNotificationAuditEntry({ + messageReference: notifyMessage.messageReference, + eventCode: notifyMessage.eventCode, + correlationId, + status: NotificationAuditStatus.QUEUED, + }); + } catch (error) { + commons.logError(name, "Failed to send dispatched notification", { + correlationId, + orderId, + error, + }); + } + } +} From 33b318d1d35e34db77cdc47e813c1217daf7d196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 7 Apr 2026 11:45:37 +0200 Subject: [PATCH 09/10] fix after review --- .../notify-message-builder.test.ts | 12 ++++++------ .../order-status-lambda/notify-message-builder.ts | 4 +--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lambdas/src/order-status-lambda/notify-message-builder.test.ts b/lambdas/src/order-status-lambda/notify-message-builder.test.ts index bb3f8f5b..db6f9364 100644 --- a/lambdas/src/order-status-lambda/notify-message-builder.test.ts +++ b/lambdas/src/order-status-lambda/notify-message-builder.test.ts @@ -41,8 +41,8 @@ describe("NotifyMessageBuilder", () => { expect(result.personalisation).toEqual({ dispatchedDate: "6 August 2026", - statusLink: - "[View kit order update and see more information](https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking)", + orderLinkUrl: + "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", }); }); @@ -59,13 +59,13 @@ describe("NotifyMessageBuilder", () => { dispatchedAt: "2026-08-06T10:00:00Z", }); - const statusLink = result.personalisation?.statusLink; + const orderLinkUrl = result.personalisation?.orderLinkUrl; - expect(typeof statusLink).toBe("string"); - expect(statusLink).toContain( + expect(typeof orderLinkUrl).toBe("string"); + expect(orderLinkUrl).toContain( "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", ); - expect(statusLink).not.toContain(".uk//orders"); + expect(orderLinkUrl).not.toContain(".uk//orders"); }); it("should call recipient lookup with patient id", async () => { diff --git a/lambdas/src/order-status-lambda/notify-message-builder.ts b/lambdas/src/order-status-lambda/notify-message-builder.ts index 3990e3d5..48651f34 100644 --- a/lambdas/src/order-status-lambda/notify-message-builder.ts +++ b/lambdas/src/order-status-lambda/notify-message-builder.ts @@ -10,8 +10,6 @@ export interface BuildOrderDispatchedNotifyMessageInput { dispatchedAt: string; } -const ORDER_TRACKING_LINK_TEXT = "View kit order update and see more information"; - const formatDispatchedDate = (isoDateTime: string): string => new Intl.DateTimeFormat("en-GB", { day: "numeric", @@ -50,7 +48,7 @@ export class NotifyMessageBuilder { recipient, personalisation: { dispatchedDate: formatDispatchedDate(dispatchedAt), - statusLink: `[${ORDER_TRACKING_LINK_TEXT}](${trackingUrl})`, + orderLinkUrl: trackingUrl, }, }; } From bc9caf22a81954d75df7894f3814147adcf77dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 7 Apr 2026 12:31:06 +0200 Subject: [PATCH 10/10] wrap status check with try --- .../src/order-status-lambda/notify-service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lambdas/src/order-status-lambda/notify-service.ts b/lambdas/src/order-status-lambda/notify-service.ts index 16c78461..9110e79f 100644 --- a/lambdas/src/order-status-lambda/notify-service.ts +++ b/lambdas/src/order-status-lambda/notify-service.ts @@ -54,16 +54,16 @@ export class OrderStatusNotifyService { notifyMessagesQueueUrl, } = this.dependencies; - const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence( - orderId, - OrderStatusCodes.DISPATCHED, - ); + try { + const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence( + orderId, + OrderStatusCodes.DISPATCHED, + ); - if (!isFirstDispatched) { - return; - } + if (!isFirstDispatched) { + return; + } - try { const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ patientId, correlationId,