Skip to content
Open
66 changes: 66 additions & 0 deletions lambdas/src/lib/db/notification-audit-db-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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();

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: NotifyEventCode.OrderDispatched,
correlationId: "123e4567-e89b-12d3-a456-426614174001",
status: NotificationAuditStatus.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: NotifyEventCode.OrderDispatched,
correlationId: "123e4567-e89b-12d3-a456-426614174001",
status: NotificationAuditStatus.SENT,
}),
).rejects.toThrow("Failed to insert notification audit entry");
});
});
66 changes: 66 additions & 0 deletions lambdas/src/lib/db/notification-audit-db-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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: NotifyEventCode;
correlationId: string;
status: NotificationAuditStatus;
notifyMessageId?: string | null;
routingPlanId?: string | null;
}

export class NotificationAuditDbClient {
constructor(private readonly dbClient: DBClient) {}

async insertNotificationAuditEntry(params: NotificationAuditEntryParams): Promise<void> {
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,
},
);
}
}
}
42 changes: 39 additions & 3 deletions lambdas/src/lib/db/order-status-db.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DBClient } from "./db-client";
import {
OrderRow,
OrderStatusCodes,
Expand All @@ -18,11 +19,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", () => {
Expand Down Expand Up @@ -153,4 +155,38 @@ describe("OrderStatusService", () => {
).rejects.toThrow("Failed to update order status");
});
});

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);
});
});
});
24 changes: 24 additions & 0 deletions lambdas/src/lib/db/order-status-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,28 @@ export class OrderStatusService {
});
}
}

async isFirstStatusOccurrence(orderId: string, statusCode: OrderStatusCode): Promise<boolean> {
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,
},
);
}
}
}
55 changes: 55 additions & 0 deletions lambdas/src/lib/db/patient-db-client.test.ts
Original file line number Diff line number Diff line change
@@ -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("get", () => {
it("should return notify recipient data", async () => {
mockQuery.mockResolvedValue({
rows: [
{
nhs_number: "1234567890",
birth_date: "1990-04-20",
},
],
rowCount: 1,
});

const result = await patientDbClient.get("some-mocked-patient-id");

expect(result).toEqual({
nhsNumber: "1234567890",
birthDate: "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.get("missing-patient-id")).rejects.toThrow(
"Failed to fetch notify recipient data",
);
});
});
});
41 changes: 41 additions & 0 deletions lambdas/src/lib/db/patient-db-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { type DBClient } from "./db-client";

export interface Patient {
nhsNumber: string;
birthDate: string;
}

export class PatientDbClient {
constructor(private readonly dbClient: DBClient) {}

async get(patientId: string): Promise<Patient> {
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 },
[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,
birthDate: row.birth_date,
};
} catch (error) {
throw new Error(`Failed to fetch notify recipient data for patientId ${patientId}`, {
cause: error,
});
}
}
}
1 change: 1 addition & 0 deletions lambdas/src/lib/types/notify-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export interface NotifyRecipient {

export enum NotifyEventCode {
OrderConfirmed = "ORDER_CONFIRMED",
OrderDispatched = "ORDER_DISPATCHED",
}
Loading