Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions lambdas/src/order-status-lambda/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,17 @@ describe("Order Status Lambda Handler", () => {

expect(result.statusCode).toBe(201);
});

it(`should accept ${IncomingBusinessStatus.CONFIRMED} business status`, async () => {
mockEvent.body = JSON.stringify({
...validTaskBody,
businessStatus: { text: IncomingBusinessStatus.CONFIRMED },
} satisfies Partial<OrderStatusFHIRTask>);

const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context);

expect(result.statusCode).toBe(201);
});
});

describe("Idempotency via Correlation ID", () => {
Expand Down Expand Up @@ -446,6 +457,26 @@ describe("Order Status Lambda Handler", () => {
);
});

it("should delegate confirmed status to the notification service", async () => {
mockEvent.body = JSON.stringify({
...validTaskBody,
businessStatus: {
text: IncomingBusinessStatus.CONFIRMED,
},
} satisfies Partial<OrderStatusFHIRTask>);

const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context);

expect(result.statusCode).toBe(201);
expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith(
expect.objectContaining({
statusUpdate: expect.objectContaining({
statusCode: businessStatusMapping[IncomingBusinessStatus.CONFIRMED],
}),
}),
);
});

it("should return 500 when notification service fails", async () => {
mockHandleOrderStatusUpdated.mockRejectedValueOnce(new Error("Unexpected side effect error"));
mockEvent.body = JSON.stringify(validTaskBody);
Expand Down
4 changes: 3 additions & 1 deletion lambdas/src/order-status-lambda/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,12 @@ export const lambdaHandler = async (
return createFhirResponse(200, validatedTask);
}

const mappedStatusCode = businessStatusMapping[validatedTask.businessStatus.text];

// Process the update
const statusOrderUpdateParams: OrderStatusUpdateParams = {
orderId,
statusCode: businessStatusMapping[validatedTask.businessStatus.text],
statusCode: mappedStatusCode,
createdAt: validatedTask.lastModified,
correlationId,
};
Expand Down
10 changes: 10 additions & 0 deletions lambdas/src/order-status-lambda/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PostgresDbClient } from "../lib/db/db-client";
import { postgresConfigFromEnv } from "../lib/db/db-config";
import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client";
import { OrderDbClient } from "../lib/db/order-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";
Expand All @@ -12,6 +13,7 @@ import { NotifyMessageBuilder } from "./notify-message-builder";
import { OrderStatusNotifyService } from "./notify-service";

jest.mock("../lib/db/order-status-db");
jest.mock("../lib/db/order-db-client");
jest.mock("../lib/db/patient-db-client");
jest.mock("../lib/db/notification-audit-db-client");
jest.mock("../lib/db/db-client");
Expand Down Expand Up @@ -109,6 +111,8 @@ describe("init", () => {

expect(result).toEqual({
orderStatusDb: expect.any(OrderStatusService),
patientDbClient: expect.any(PatientDbClient),
orderDbClient: expect.any(OrderDbClient),
orderStatusNotifyService: expect.any(OrderStatusNotifyService),
});
});
Expand Down Expand Up @@ -151,6 +155,11 @@ describe("init", () => {
times: 1,
calledWith: expect.any(PostgresDbClient),
},
{
mock: OrderDbClient as jest.Mock,
times: 1,
calledWith: expect.any(PostgresDbClient),
},
{
mock: NotificationAuditDbClient as jest.Mock,
times: 1,
Expand All @@ -177,6 +186,7 @@ describe("init", () => {

expect(NotifyMessageBuilder).toHaveBeenCalledWith(
expect.any(PatientDbClient),
expect.any(OrderDbClient),
"https://hometest.example.nhs.uk",
);
});
Expand Down
12 changes: 11 additions & 1 deletion lambdas/src/order-status-lambda/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PostgresDbClient } from "../lib/db/db-client";
import { postgresConfigFromEnv } from "../lib/db/db-config";
import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client";
import { OrderDbClient } from "../lib/db/order-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";
Expand All @@ -11,6 +12,8 @@ import { OrderStatusNotifyService } from "./notify-service";

export interface Environment {
orderStatusDb: OrderStatusService;
patientDbClient: PatientDbClient;
orderDbClient: OrderDbClient;
orderStatusNotifyService: OrderStatusNotifyService;
}

Expand All @@ -23,9 +26,14 @@ export function buildEnvironment(): Environment {
const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient));
const orderStatusDb = new OrderStatusService(dbClient);
const patientDbClient = new PatientDbClient(dbClient);
const orderDbClient = new OrderDbClient(dbClient);
const notificationAuditDbClient = new NotificationAuditDbClient(dbClient);
const sqsClient = new AWSSQSClient();
const notifyMessageBuilder = new NotifyMessageBuilder(patientDbClient, homeTestBaseUrl);
const notifyMessageBuilder = new NotifyMessageBuilder(
patientDbClient,
orderDbClient,
homeTestBaseUrl,
);
const orderStatusNotifyService = new OrderStatusNotifyService({
orderStatusDb,
notificationAuditDbClient,
Expand All @@ -36,6 +44,8 @@ export function buildEnvironment(): Environment {

return {
orderStatusDb,
patientDbClient,
orderDbClient,
orderStatusNotifyService,
};
}
Expand Down
50 changes: 50 additions & 0 deletions lambdas/src/order-status-lambda/notify-message-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import type { OrderDbClient } from "../lib/db/order-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 mockGetPatient = jest.fn<Promise<Patient>, [string]>();
const mockGetOrder = jest.fn();

const mockPatientDbClient: Pick<PatientDbClient, "get"> = {
get: mockGetPatient,
};

const mockOrderDbClient: Pick<OrderDbClient, "getOrder"> = {
getOrder: mockGetOrder,
};

let builder: NotifyMessageBuilder;

beforeEach(() => {
Expand All @@ -17,9 +23,13 @@ describe("NotifyMessageBuilder", () => {
nhsNumber: "1234567890",
birthDate: "1990-01-02",
});
mockGetOrder.mockResolvedValue({
created_at: new Date("2026-08-06T08:30:00Z"),
});

builder = new NotifyMessageBuilder(
mockPatientDbClient as PatientDbClient,
mockOrderDbClient as OrderDbClient,
"https://hometest.example.nhs.uk",
);
});
Expand Down Expand Up @@ -49,6 +59,7 @@ describe("NotifyMessageBuilder", () => {
it("should normalize trailing slash in base url", async () => {
const trailingSlashBuilder = new NotifyMessageBuilder(
mockPatientDbClient as PatientDbClient,
mockOrderDbClient as OrderDbClient,
"https://hometest.example.nhs.uk/",
);

Expand Down Expand Up @@ -95,4 +106,43 @@ describe("NotifyMessageBuilder", () => {
"https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking",
});
});

it("should build confirmed notify message with orderedDate in personalisation", async () => {
const result = await builder.buildOrderConfirmedNotifyMessage({
patientId: "550e8400-e29b-41d4-a716-446655440111",
correlationId: "123e4567-e89b-12d3-a456-426614174000",
orderId: "550e8400-e29b-41d4-a716-446655440000",
orderedAt: "2026-08-01T10:00:00Z",
});

expect(result.correlationId).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(result.eventCode).toBe(NotifyEventCode.OrderConfirmed);
expect(result.personalisation).toEqual({
orderedDate: "6 August 2026",
orderLinkUrl:
"https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking",
});
expect(mockGetOrder).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440000",
"1234567890",
expect.any(Date),
);
});

it("should fall back to provided orderedAt when order lookup returns null", async () => {
mockGetOrder.mockResolvedValueOnce(null);

const result = await builder.buildOrderConfirmedNotifyMessage({
patientId: "550e8400-e29b-41d4-a716-446655440111",
correlationId: "123e4567-e89b-12d3-a456-426614174000",
orderId: "550e8400-e29b-41d4-a716-446655440000",
orderedAt: "2026-08-01T10:00:00Z",
});

expect(result.personalisation).toEqual({
orderedDate: "1 August 2026",
orderLinkUrl:
"https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking",
});
});
});
65 changes: 57 additions & 8 deletions lambdas/src/order-status-lambda/notify-message-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from "uuid";

import type { OrderDbClient } from "../lib/db/order-db-client";
import type { PatientDbClient } from "../lib/db/patient-db-client";
import { NotifyEventCode, NotifyMessage, NotifyRecipient } from "../lib/types/notify-message";

Expand All @@ -10,6 +11,13 @@ export interface BuildOrderDispatchedNotifyMessageInput {
dispatchedAt: string;
}

export interface BuildOrderConfirmedNotifyMessageInput {
patientId: string;
correlationId: string;
orderId: string;
orderedAt: string;
}

export interface BuildOrderReceivedNotifyMessageInput {
patientId: string;
correlationId: string;
Expand All @@ -30,6 +38,7 @@ export class NotifyMessageBuilder {

constructor(
private readonly patientDbClient: PatientDbClient,
private readonly orderDbClient: OrderDbClient,
homeTestBaseUrl: string,
) {
this.normalizedHomeTestBaseUrl = homeTestBaseUrl.replaceAll(/\/$/g, "");
Expand All @@ -53,6 +62,36 @@ export class NotifyMessageBuilder {
});
}

async buildOrderConfirmedNotifyMessage(
input: BuildOrderConfirmedNotifyMessageInput,
): Promise<NotifyMessage> {
const { patientId, correlationId, orderId, orderedAt } = input;

const patient = await this.patientDbClient.get(patientId);
const order = await this.orderDbClient.getOrder(
orderId,
patient.nhsNumber,
new Date(patient.birthDate),
);
const orderedAtValue = order?.created_at.toISOString() ?? orderedAt;

const trackingUrl = `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`;

return this.buildOrderStatusNotifyMessage({
recipient: {
nhsNumber: patient.nhsNumber,
dateOfBirth: patient.birthDate,
},
correlationId,
orderId,
eventCode: NotifyEventCode.OrderConfirmed,
personalisation: {
orderedDate: formatStatusDate(orderedAtValue),
orderLinkUrl: trackingUrl,
},
});
}

async buildOrderReceivedNotifyMessage(
input: BuildOrderReceivedNotifyMessageInput,
): Promise<NotifyMessage> {
Expand All @@ -73,26 +112,36 @@ export class NotifyMessageBuilder {
}

private async buildOrderStatusNotifyMessage(input: {
patientId: string;
patientId?: string;
recipient?: NotifyRecipient;
correlationId: string;
orderId: string;
eventCode: NotifyEventCode;
personalisation: Record<string, string>;
}): Promise<NotifyMessage> {
const { patientId, correlationId, eventCode, personalisation } = input;
const { patientId, recipient, correlationId, eventCode, personalisation } = input;

const patient = await this.patientDbClient.get(patientId);
const recipient: NotifyRecipient = {
nhsNumber: patient.nhsNumber,
dateOfBirth: patient.birthDate,
};
const notifyRecipient = recipient ?? (await this.getRecipientFromPatientId(patientId));
Comment on lines 114 to +124
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildOrderStatusNotifyMessage accepts both patientId? and recipient?, but the implementation requires at least one and otherwise throws at runtime; model this constraint in the type (e.g., a union of { patientId: string; recipient?: never } | { recipient: NotifyRecipient; patientId?: never }) so invalid internal calls are caught at compile time.

Copilot uses AI. Check for mistakes.

return {
correlationId,
messageReference: uuidv4(),
eventCode,
recipient,
recipient: notifyRecipient,
personalisation,
};
}

private async getRecipientFromPatientId(patientId?: string): Promise<NotifyRecipient> {
if (!patientId) {
throw new Error("patientId is required when recipient is not provided");
}

const patient = await this.patientDbClient.get(patientId);

return {
nhsNumber: patient.nhsNumber,
dateOfBirth: patient.birthDate,
};
}
}
Loading