diff --git a/lambdas/goose-migrator-lambda/migrations/000018_add_result_processed_status.sql b/lambdas/goose-migrator-lambda/migrations/000018_add_result_processed_status.sql new file mode 100644 index 000000000..92a94e383 --- /dev/null +++ b/lambdas/goose-migrator-lambda/migrations/000018_add_result_processed_status.sql @@ -0,0 +1,8 @@ +-- +goose Up +INSERT INTO result_type (result_code, description) +VALUES ('RESULT_PROCESSED', 'Test has been processed at the lab but results are not yet available') +ON CONFLICT DO NOTHING; + +-- +goose Down +DELETE FROM result_type +WHERE result_code = 'RESULT_PROCESSED'; diff --git a/lambdas/src/lib/types/status.ts b/lambdas/src/lib/types/status.ts index 2afd241cb..ad355e569 100644 --- a/lambdas/src/lib/types/status.ts +++ b/lambdas/src/lib/types/status.ts @@ -11,4 +11,5 @@ export enum OrderStatus { export enum ResultStatus { Result_Available = "RESULT_AVAILABLE", Result_Withheld = "RESULT_WITHHELD", + Result_Processed = "RESULT_PROCESSED", } diff --git a/lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts b/lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts new file mode 100644 index 000000000..7e8ba2ae0 --- /dev/null +++ b/lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts @@ -0,0 +1,60 @@ +import { type DBClient } from "../../../lib/db/db-client"; +import { ResultStatus } from "../../../lib/types/status"; +import { InsertResultStatusCommand } from "./insert-result-status"; + +const normalizeWhitespace = (sql: string): string => sql.replace(/\s+/g, " ").trim(); + +describe("InsertResultStatusCommand", () => { + let dbClient: jest.Mocked>; + let command: InsertResultStatusCommand; + + beforeEach(() => { + jest.clearAllMocks(); + + dbClient = { + query: jest.fn(), + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + command = new InsertResultStatusCommand(dbClient as DBClient); + }); + + const expectedQuery = ` + INSERT INTO result_status (order_uid, status, correlation_id) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT (correlation_id) DO NOTHING; + `; + + it("executes the correct SQL with all parameters", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 1 }); + + await command.execute( + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + ResultStatus.Result_Processed, + "c1a2b3c4-d5e6-7890-abcd-ef1234567890", + ); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + expect(normalizeWhitespace(dbClient.query.mock.calls[0][0])).toBe( + normalizeWhitespace(expectedQuery), + ); + expect(dbClient.query.mock.calls[0][1]).toEqual([ + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + ResultStatus.Result_Processed, + "c1a2b3c4-d5e6-7890-abcd-ef1234567890", + ]); + }); + + it("inserts with ON CONFLICT DO NOTHING for duplicate correlation IDs", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 0 }); + + await command.execute( + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + ResultStatus.Result_Processed, + "c1a2b3c4-d5e6-7890-abcd-ef1234567890", + ); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/src/order-status-lambda/db/commands/insert-result-status.ts b/lambdas/src/order-status-lambda/db/commands/insert-result-status.ts new file mode 100644 index 000000000..b2609c2e8 --- /dev/null +++ b/lambdas/src/order-status-lambda/db/commands/insert-result-status.ts @@ -0,0 +1,20 @@ +import { type DBClient } from "../../../lib/db/db-client"; +import { ResultStatus } from "../../../lib/types/status"; + +export class InsertResultStatusCommand { + constructor(private readonly dbClient: DBClient) {} + + async execute(orderId: string, status: ResultStatus, correlationId: string): Promise { + const query = ` + INSERT INTO result_status (order_uid, status, correlation_id) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT (correlation_id) DO NOTHING; + `; + + await this.dbClient.query(query, [ + orderId, + status, + correlationId, + ]); + } +} diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 6a1cb0fc8..974b43b9a 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -1,9 +1,10 @@ import { APIGatewayProxyEvent, Context } from "aws-lambda"; import { IdempotencyCheckResult } from "../lib/db/order-status-db"; -import { OrderStatusFHIRTask } from "./index"; -import { IncomingBusinessStatus } from "./types"; -import { businessStatusMapping } from "./utils"; +import { OrderStatus } from "../lib/types/status"; +import { errorResult, successResult } from "../lib/validation"; +import { OrderStatusFHIRTask } from "./models/schemas"; +import { IncomingBusinessStatus } from "./models/types"; const mockInit = jest.fn(); @@ -12,16 +13,26 @@ const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); const mockNotify = jest.fn(); const mockHandleReminderOrderStatusUpdated = jest.fn(); +const mockInsertResultStatusCommand = jest.fn(); -const mockGetCorrelationIdFromEventHeaders = jest.fn(); +const mockValidateAndExtractCorrelationId = jest.fn(); +const mockValidateAndExtractTask = jest.fn(); +const mockValidatePatientOwnership = jest.fn(); jest.mock("./init", () => ({ init: mockInit, })); -jest.mock("../lib/utils/utils", () => ({ - ...jest.requireActual("../lib/utils/utils"), - getCorrelationIdFromEventHeaders: () => mockGetCorrelationIdFromEventHeaders(), +jest.mock("./validation/correlation-id-validation", () => ({ + validateAndExtractCorrelationId: mockValidateAndExtractCorrelationId, +})); + +jest.mock("./validation/task-validation", () => ({ + validateAndExtractTask: mockValidateAndExtractTask, +})); + +jest.mock("./validation/patient-validation", () => ({ + validatePatientOwnership: mockValidatePatientOwnership, })); const MOCK_CORRELATION_ID = "123e4567-e89b-12d3-a456-426614174000"; @@ -33,18 +44,40 @@ describe("Order Status Lambda Handler", () => { let handler: (event: APIGatewayProxyEvent, context: Context) => Promise; let mockEvent: Partial; + const validTask: OrderStatusFHIRTask = { + resourceType: "Task", + status: "in-progress", + intent: "order", + identifier: [ + { + value: MOCK_ORDER_UID, + }, + ], + for: { + reference: `Patient/${MOCK_PATIENT_UID}`, + }, + lastModified: "2024-01-15T10:00:00Z", + businessStatus: { + text: MOCK_BUSINESS_STATUS, + }, + }; + beforeEach(async () => { jest.resetModules(); jest.clearAllMocks(); mockEvent = {}; - mockGetCorrelationIdFromEventHeaders.mockReturnValue(MOCK_CORRELATION_ID); + mockValidateAndExtractCorrelationId.mockReturnValue(successResult(MOCK_CORRELATION_ID)); + mockValidateAndExtractTask.mockReturnValue(successResult(validTask)); + mockValidatePatientOwnership.mockReturnValue(successResult()); + mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); mockNotify.mockResolvedValue(undefined); mockHandleReminderOrderStatusUpdated.mockResolvedValue(undefined); + mockInsertResultStatusCommand.mockResolvedValue(undefined); mockInit.mockReturnValue({ orderStatusDb: { @@ -58,6 +91,9 @@ describe("Order Status Lambda Handler", () => { orderStatusReminderService: { handleOrderStatusUpdated: mockHandleReminderOrderStatusUpdated, }, + insertResultStatusCommand: { + execute: mockInsertResultStatusCommand, + }, }); const module = await import("./index"); @@ -65,91 +101,66 @@ describe("Order Status Lambda Handler", () => { handler = module.lambdaHandler; }); - const validTaskBody: OrderStatusFHIRTask = { - resourceType: "Task", - status: "in-progress", - intent: "order", - identifier: [ - { - value: MOCK_ORDER_UID, - }, - ], - for: { - reference: `Patient/${MOCK_PATIENT_UID}`, - }, - lastModified: "2024-01-15T10:00:00Z", - businessStatus: { - text: MOCK_BUSINESS_STATUS, - }, - }; - - describe("Request Parsing and Validation", () => { - it("should return 400 if request body is empty JSON object", async () => { - mockEvent.body = "{}"; + describe("Validation Delegation", () => { + it("should return FHIR error when correlation ID validation fails", async () => { + mockValidateAndExtractCorrelationId.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Correlation ID is missing or invalid", + severity: "error", + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(400); const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); expect(body.issue[0].code).toBe("invalid"); - - expect(body.issue[0].diagnostics).toMatch(/identifier|lastModified|businessStatus/); + expect(body.issue[0].diagnostics).toMatch(/correlation id/i); }); - it("should return 400 if request body is null", async () => { - mockEvent.body = null; + it("should return FHIR error when task validation fails", async () => { + mockValidateAndExtractTask.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Request body is required", + severity: "error", + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(400); const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); expect(body.issue[0].code).toBe("invalid"); - expect(body.issue[0].diagnostics).toMatch(/Request body is required/); - }); - - it("should return 400 if request body is invalid JSON", async () => { - mockEvent.body = "{invalid json"; - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.resourceType).toBe("OperationOutcome"); }); - it("should return 400 if Task schema validation fails", async () => { - mockEvent.body = JSON.stringify({ - resourceType: "Task", - status: "in-progress", - for: { - reference: `Patient/${MOCK_PATIENT_UID}`, - }, - businessStatus: { - text: "invalid-business-status", - }, - } satisfies Partial>); + it("should return FHIR error when patient ownership validation fails", async () => { + mockValidatePatientOwnership.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Patient ID does not match the order", + severity: "error", + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(400); - const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].code).toBe("invalid"); + expect(body.issue[0].diagnostics).toContain("Patient ID does not match"); }); }); describe("Order Existence", () => { it("should return 404 when order does not exist", async () => { mockGetPatientIdFromOrder.mockResolvedValueOnce(null); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -164,7 +175,6 @@ describe("Order Status Lambda Handler", () => { it("should proceed when order exists", async () => { mockGetPatientIdFromOrder.mockResolvedValueOnce(MOCK_PATIENT_UID); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -173,122 +183,12 @@ describe("Order Status Lambda Handler", () => { }); }); - describe("Patient Ownership", () => { - it("should return 400 when patient reference format is invalid", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - for: { reference: "invalid-ref" }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("Invalid patient reference"); - }); - - it("should return 400 when patient does not match order", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - for: { reference: "Patient/other-patient" }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].diagnostics).toContain("Patient ID does not match"); - }); - - it("should proceed when patient matches order", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - }); - - describe("Business Status Validation", () => { - it("should return 400 for invalid business status", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { - text: "INVALID_STATUS" as unknown as IncomingBusinessStatus, - }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("businessStatus"); - }); - - it("should return 400 for missing business status", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: undefined, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("businessStatus"); - }); - - it(`should accept ${IncomingBusinessStatus.DISPATCHED} business status`, async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { text: IncomingBusinessStatus.DISPATCHED }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - - it(`should accept ${IncomingBusinessStatus.ORDER_ACCEPTED} business status`, async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { text: IncomingBusinessStatus.ORDER_ACCEPTED }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - - it(`should accept ${IncomingBusinessStatus.RECEIVED_AT_LAB} business 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); - }); - }); - describe("Idempotency via Correlation ID", () => { it("should detect duplicate updates with same correlation ID", async () => { mockCheckIdempotency.mockResolvedValueOnce({ isDuplicate: true, } satisfies Partial); - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(200); @@ -297,15 +197,14 @@ describe("Order Status Lambda Handler", () => { expect(mockNotify).not.toHaveBeenCalled(); }); - it("should process new updates with different correlation ID", async () => { + it("should process new updates with a different correlation ID", async () => { const newCorrelationId = "mock-new-correlation-id-123"; - mockGetCorrelationIdFromEventHeaders.mockReturnValueOnce(newCorrelationId); + mockValidateAndExtractCorrelationId.mockReturnValueOnce(successResult(newCorrelationId)); mockCheckIdempotency.mockResolvedValueOnce({ isDuplicate: false, } satisfies IdempotencyCheckResult); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -316,33 +215,14 @@ describe("Order Status Lambda Handler", () => { }), ); }); - - it("should return 400 when there is no correlation id", async () => { - mockEvent.headers = {}; - - mockGetCorrelationIdFromEventHeaders.mockImplementation(() => { - throw new Error("Correlation ID is missing or invalid"); - }); - - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toMatch(/correlation id/i); - }); }); describe("Timestamp Handling", () => { - it("should accept when lastModified timestamp is older than latest update", async () => { + it("should use lastModified timestamp when it is older than the latest update", async () => { const mockedLastModifiedTimestamp = "2024-01-15T08:00:00Z"; - - mockEvent.body = JSON.stringify({ - ...validTaskBody, - lastModified: mockedLastModifiedTimestamp, // Older than latest - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ ...validTask, lastModified: mockedLastModifiedTimestamp }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -355,13 +235,11 @@ describe("Order Status Lambda Handler", () => { ); }); - it("should accept when lastModified timestamp is newer than latest update", async () => { + it("should use lastModified timestamp when it is newer than the latest update", async () => { const mockedLastModifiedTimestamp = "2024-01-15T11:00:00Z"; - - mockEvent.body = JSON.stringify({ - ...validTaskBody, - lastModified: mockedLastModifiedTimestamp, // Newer than latest - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ ...validTask, lastModified: mockedLastModifiedTimestamp }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -373,28 +251,10 @@ describe("Order Status Lambda Handler", () => { }), ); }); - - it("should reject when lastModified is missing", async () => { - const { lastModified: _lastModified, ...bodyWithoutLastModified } = validTaskBody; - - mockEvent.body = JSON.stringify({ - ...bodyWithoutLastModified, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("lastModified"); - }); }); describe("Successful Update", () => { it("should return 201 OK with updated Task when all validations pass", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -403,28 +263,24 @@ describe("Order Status Lambda Handler", () => { const body = JSON.parse(result.body); expect(body.resourceType).toBe("Task"); - expect(body.status).toBe(validTaskBody.status); + expect(body.status).toBe(validTask.status); expect(body.for.reference).toBe(`Patient/${MOCK_PATIENT_UID}`); }); it("should call addOrderStatusUpdate with correct parameters", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( expect.objectContaining({ orderId: MOCK_ORDER_UID, - statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], - createdAt: validTaskBody.lastModified, + statusCode: OrderStatus.Dispatched, + createdAt: validTask.lastModified, correlationId: MOCK_CORRELATION_ID, }), ); }); 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); @@ -433,50 +289,48 @@ describe("Order Status Lambda Handler", () => { patientId: MOCK_PATIENT_UID, correlationId: MOCK_CORRELATION_ID, orderId: MOCK_ORDER_UID, - statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], + statusCode: OrderStatus.Dispatched, }), ); }); it("should still delegate non-dispatched statuses to the notification service", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { - text: IncomingBusinessStatus.RECEIVED_AT_LAB, - }, - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.RECEIVED_AT_LAB }, + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], + statusCode: OrderStatus.Received, }), ); }); it("should delegate confirmed statuses to the notification service", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { - text: IncomingBusinessStatus.ORDER_ACCEPTED, - }, - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.ORDER_ACCEPTED }, + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: businessStatusMapping[IncomingBusinessStatus.ORDER_ACCEPTED], + statusCode: OrderStatus.Confirmed, }), ); }); it("should delegate reminder scheduling to the reminder service", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -484,15 +338,14 @@ describe("Order Status Lambda Handler", () => { expect.objectContaining({ orderId: MOCK_ORDER_UID, correlationId: MOCK_CORRELATION_ID, - statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], - triggeredAt: validTaskBody.lastModified, + statusCode: OrderStatus.Dispatched, + triggeredAt: validTask.lastModified, }), ); }); it("should return 201 when notification service fails after a successful status update", async () => { mockNotify.mockRejectedValueOnce(new Error("Unexpected side effect error")); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -500,26 +353,44 @@ describe("Order Status Lambda Handler", () => { }); }); - describe("Error Handling", () => { - it("should return OperationOutcome for validation errors", async () => { - mockEvent.body = JSON.stringify({ - resourceType: "Task", - // Invalid - missing required fields - } satisfies Partial); + describe("Result Status Update (TEST_PROCESSED)", () => { + beforeEach(() => { + mockValidateAndExtractTask.mockReturnValue( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.TEST_PROCESSED }, + }), + ); + }); + it("should return 201 and call insertResultStatusCommand when status is TEST_PROCESSED", async () => { const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - expect(result.statusCode).toBe(400); - const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].severity).toBe("error"); + expect(result.statusCode).toBe(201); + expect(mockInsertResultStatusCommand).toHaveBeenCalledWith( + MOCK_ORDER_UID, + expect.any(String), + MOCK_CORRELATION_ID, + ); + expect(mockNotify).not.toHaveBeenCalled(); + expect(mockHandleReminderOrderStatusUpdated).not.toHaveBeenCalled(); }); + it("should still return 201 when insertResultStatusCommand throws", async () => { + mockInsertResultStatusCommand.mockRejectedValueOnce(new Error("Result status insert failed")); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockNotify).not.toHaveBeenCalled(); + expect(mockHandleReminderOrderStatusUpdated).not.toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { it("should return 500 with OperationOutcome for database errors", async () => { mockGetPatientIdFromOrder.mockRejectedValueOnce(new Error("Database connection failed")); - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(500); @@ -534,8 +405,6 @@ describe("Order Status Lambda Handler", () => { it("should return 500 with OperationOutcome for unexpected errors", async () => { mockCheckIdempotency.mockRejectedValueOnce(new Error("Unexpected error")); - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(500); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 414c2ae37..9692e6641 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -3,104 +3,62 @@ 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 z from "zod"; +import { ValidationError } from "src/lib/validation"; import { OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; -import { - FHIRCodeableConceptSchema, - FHIRIdentifierSchema, - FHIRReferenceSchema, - FHIRTaskSchema, -} from "../lib/models/fhir/fhir-schemas"; -import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; import { corsOptions } from "./cors-configuration"; import { init } from "./init"; -import { IncomingBusinessStatus } from "./types"; -import { businessStatusMapping, extractIdFromReference } from "./utils"; +import { resolveStatus } from "./models/mappings"; +import { StatusKind } from "./models/types"; +import { validateAndExtractCorrelationId } from "./validation/correlation-id-validation"; +import { validatePatientOwnership } from "./validation/patient-validation"; +import { validateAndExtractTask } from "./validation/task-validation"; const name = "order-status-lambda"; -const orderStatusFHIRTaskSchema = FHIRTaskSchema.extend({ - identifier: z.array(FHIRIdentifierSchema).min(1).max(1), - for: FHIRReferenceSchema, - lastModified: z.iso.datetime(), - businessStatus: FHIRCodeableConceptSchema.extend({ - text: z.enum(IncomingBusinessStatus), - }), -}); - -export type OrderStatusFHIRTask = z.infer; +const fhirErrorFromValidation = (error: ValidationError): APIGatewayProxyResult => + createFhirErrorResponse(error.errorCode, error.errorType, error.errorMessage, error.severity); /** * Lambda handler for POST /test-order/status endpoint - * Adds a status record for a given order based on the incoming FHIR Task resource + * Validates and processes an incoming FHIR Task resource, updating either the order status or + * result status, dispatching notifications, and managing reminders accordingly. */ export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb, orderStatusReminderService, orderStatusNotifyService } = init(); + const { + orderStatusDb, + orderStatusReminderService, + orderStatusNotifyService, + insertResultStatusCommand, + } = init(); console.info(name, "Received order status update request", { path: event.path, method: event.httpMethod, }); - let task: unknown; - - if (!event.body) { - console.error(name, "Missing request body"); - - return createFhirErrorResponse(400, "invalid", "Request body is required", "error"); - } - - try { - task = JSON.parse(event.body); - } catch (error) { - console.error(name, "Invalid JSON in request body", { error }); - - return createFhirErrorResponse(400, "invalid", "Invalid JSON in request body", "error"); - } - - const validationResult = orderStatusFHIRTaskSchema.safeParse(task); - - if (!validationResult.success) { - const errorDetails = validationResult.error.issues - .map((err) => `${err.path.join(".")}: ${err.message}`) - .join("; "); - - console.error(name, "Task validation failed", { - error: errorDetails, - }); - - return createFhirErrorResponse(400, "invalid", errorDetails, "error"); - } - - const validatedTask = validationResult.data; - const orderId = validatedTask.identifier[0].value; - try { - let correlationId: string; - - try { - correlationId = getCorrelationIdFromEventHeaders(event); - } catch (error) { - console.error(name, "Failed to retrieve correlation ID", { error }); + const correlationIdValidationResult = validateAndExtractCorrelationId(event); + if (!correlationIdValidationResult.success) { + return fhirErrorFromValidation(correlationIdValidationResult.error); + } + const correlationId = correlationIdValidationResult.data; - return createFhirErrorResponse( - 400, - "invalid", - error instanceof Error ? error.message : "Invalid correlation ID", - "error", - ); + const taskValidationResult = validateAndExtractTask(event.body); + if (!taskValidationResult.success) { + return fhirErrorFromValidation(taskValidationResult.error); } + const task = taskValidationResult.data; + const orderId = task.identifier[0].value; + const logContext = { orderId, correlationId }; - // Verify order exists and retrieve associated patient ID const orderPatientId = await orderStatusDb.getPatientIdFromOrder(orderId); - if (!orderPatientId) { - console.error(name, "Order not found", { orderId }); + console.error(name, "Order not found", logContext); return createFhirErrorResponse( 404, @@ -110,83 +68,86 @@ export const lambdaHandler = async ( ); } - // Verify patient ownership - const patientIdFromTask = extractIdFromReference(validatedTask.for.reference); - - if (!patientIdFromTask) { - console.error(name, "Invalid patient reference format", { - reference: validatedTask.for.reference, - }); + const patientOwnershipValidationResult = validatePatientOwnership( + task.for.reference, + orderPatientId, + orderId, + ); - return createFhirErrorResponse(400, "invalid", "Invalid patient reference format", "error"); + if (!patientOwnershipValidationResult.success) { + return fhirErrorFromValidation(patientOwnershipValidationResult.error); } - if (patientIdFromTask !== orderPatientId) { - console.error(name, "Patient mismatch for order", { - orderId, - expectedPatient: orderPatientId, - providedPatient: patientIdFromTask, - }); - - return createFhirErrorResponse( - 400, - "invalid", - "Patient ID does not match the order", - "error", - ); - } - - // Check for idempotency via Correlation ID const idempotencyCheck = await orderStatusDb.checkIdempotency(orderId, correlationId); - if (idempotencyCheck.isDuplicate) { - console.info(name, "Duplicate update detected via correlation ID", { - orderId, - correlationId, - }); + console.info(name, "Duplicate update detected via correlation ID", logContext); - return createFhirResponse(200, validatedTask); + return createFhirResponse(200, task); } - // Process the update - const statusOrderUpdateParams: OrderStatusUpdateParams = { - orderId, - statusCode: businessStatusMapping[validatedTask.businessStatus.text], - createdAt: validatedTask.lastModified, - correlationId, - }; - - await orderStatusDb.addOrderStatusUpdate(statusOrderUpdateParams); - - console.info(name, "Order status update added successfully", statusOrderUpdateParams); - - try { - await orderStatusNotifyService.dispatch({ - orderId, - patientId: orderPatientId, - correlationId, - statusCode: statusOrderUpdateParams.statusCode, - }); - } catch (error) { - console.error(name, "Failed to dispatch order status notification", { - correlationId, - orderId, - error, - }); + const incomingStatus = task.businessStatus.text; + const resolved = resolveStatus(incomingStatus); + + switch (resolved.kind) { + case StatusKind.Result: { + try { + await insertResultStatusCommand.execute(orderId, resolved.status, correlationId); + } catch (error) { + console.error(name, "Failed to update result status", { + ...logContext, + resultStatus: resolved.status, + error, + }); + } + + console.info(name, "Result status update added successfully", { + ...logContext, + resultStatus: resolved.status, + }); + + return createFhirResponse(201, task); + } + + case StatusKind.Order: { + const statusOrderUpdateParams: OrderStatusUpdateParams = { + orderId, + statusCode: resolved.status, + createdAt: task.lastModified, + correlationId, + }; + + await orderStatusDb.addOrderStatusUpdate(statusOrderUpdateParams); + console.info(name, "Order status update added successfully", statusOrderUpdateParams); + + try { + await orderStatusNotifyService.dispatch({ + orderId, + patientId: orderPatientId, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + }); + } catch (error) { + console.error(name, "Failed to dispatch order status notification", { + ...logContext, + error, + }); + } + + await orderStatusReminderService.handleOrderStatusUpdated({ + orderId, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + triggeredAt: statusOrderUpdateParams.createdAt, + }); + + return createFhirResponse(201, task); + } + + default: + return createFhirErrorResponse(400, "invalid", "Unrecognised business status", "error"); } - - await orderStatusReminderService.handleOrderStatusUpdated({ - orderId, - correlationId, - statusCode: statusOrderUpdateParams.statusCode, - triggeredAt: statusOrderUpdateParams.createdAt, - }); - - return createFhirResponse(201, validatedTask); } catch (error) { - console.error(name, "Error processing order status update", { - error, - }); + console.error(name, "Error processing order status update", { error }); return createFhirErrorResponse(500, "exception", "An internal error occurred", "fatal"); } }; diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index 7d145314f..1a341a634 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -13,6 +13,7 @@ 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 { InsertResultStatusCommand } from "./db/commands/insert-result-status"; import { buildEnvironment as init } from "./init"; jest.mock("../lib/db/order-status-db"); @@ -26,6 +27,7 @@ jest.mock("../lib/db/db-config"); jest.mock("../lib/notify/services/order-status-notify-service"); jest.mock("../lib/notify/message-builders/order-status/order-confirmed-message-builder"); jest.mock("../lib/reminder/order-status-reminder-service"); +jest.mock("./db/commands/insert-result-status"); describe("init", () => { const originalEnv = process.env; @@ -106,6 +108,7 @@ describe("init", () => { orderStatusDb: expect.any(OrderStatusService), orderStatusReminderService: expect.any(OrderStatusReminderService), orderStatusNotifyService: expect.any(OrderStatusNotifyService), + insertResultStatusCommand: expect.any(InsertResultStatusCommand), }); }); }); diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index d2855300c..351a4c9be 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -13,11 +13,13 @@ import { OrderStatusReminderService } from "../lib/reminder/order-status-reminde import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; +import { InsertResultStatusCommand } from "./db/commands/insert-result-status"; export interface Environment { orderStatusDb: OrderStatusService; orderStatusReminderService: OrderStatusReminderService; orderStatusNotifyService: OrderStatusNotifyService; + insertResultStatusCommand: InsertResultStatusCommand; } export function buildEnvironment(): Environment { @@ -46,11 +48,13 @@ export function buildEnvironment(): Environment { sqsClient, notifyMessagesQueueUrl, }); + const insertResultStatusCommand = new InsertResultStatusCommand(dbClient); return { orderStatusDb, orderStatusReminderService, orderStatusNotifyService, + insertResultStatusCommand, }; } diff --git a/lambdas/src/order-status-lambda/models/mappings.ts b/lambdas/src/order-status-lambda/models/mappings.ts new file mode 100644 index 000000000..50cc63fed --- /dev/null +++ b/lambdas/src/order-status-lambda/models/mappings.ts @@ -0,0 +1,28 @@ +import { OrderStatus, ResultStatus } from "../../lib/types/status"; +import { IncomingBusinessStatus, StatusKind } from "./types"; + +export type ResolvedStatus = + | { kind: StatusKind.Order; status: OrderStatus } + | { kind: StatusKind.Result; status: ResultStatus }; + +const statusResolutionMap: Record = { + [IncomingBusinessStatus.ORDER_ACCEPTED]: { + kind: StatusKind.Order, + status: OrderStatus.Confirmed, + }, + [IncomingBusinessStatus.DISPATCHED]: { + kind: StatusKind.Order, + status: OrderStatus.Dispatched, + }, + [IncomingBusinessStatus.RECEIVED_AT_LAB]: { + kind: StatusKind.Order, + status: OrderStatus.Received, + }, + [IncomingBusinessStatus.TEST_PROCESSED]: { + kind: StatusKind.Result, + status: ResultStatus.Result_Processed, + }, +}; + +export const resolveStatus = (incoming: IncomingBusinessStatus): ResolvedStatus => + statusResolutionMap[incoming]; diff --git a/lambdas/src/order-status-lambda/models/schemas.ts b/lambdas/src/order-status-lambda/models/schemas.ts new file mode 100644 index 000000000..9c279cd58 --- /dev/null +++ b/lambdas/src/order-status-lambda/models/schemas.ts @@ -0,0 +1,20 @@ +import z from "zod"; + +import { + FHIRCodeableConceptSchema, + FHIRIdentifierSchema, + FHIRReferenceSchema, + FHIRTaskSchema, +} from "../../lib/models/fhir/fhir-schemas"; +import { IncomingBusinessStatus } from "./types"; + +export const orderStatusFHIRTaskSchema = FHIRTaskSchema.extend({ + identifier: z.array(FHIRIdentifierSchema).min(1).max(1), + for: FHIRReferenceSchema, + lastModified: z.iso.datetime(), + businessStatus: FHIRCodeableConceptSchema.extend({ + text: z.enum(IncomingBusinessStatus), + }), +}); + +export type OrderStatusFHIRTask = z.infer; diff --git a/lambdas/src/order-status-lambda/types.ts b/lambdas/src/order-status-lambda/models/types.ts similarity index 53% rename from lambdas/src/order-status-lambda/types.ts rename to lambdas/src/order-status-lambda/models/types.ts index d43d366bd..bc0b23561 100644 --- a/lambdas/src/order-status-lambda/types.ts +++ b/lambdas/src/order-status-lambda/models/types.ts @@ -1,11 +1,11 @@ +export enum StatusKind { + Order = "order", + Result = "result", +} + export enum IncomingBusinessStatus { ORDER_ACCEPTED = "order-accepted", DISPATCHED = "dispatched", RECEIVED_AT_LAB = "received-at-lab", -} - -export enum AllowedInternalBusinessStatuses { - CONFIRMED = "CONFIRMED", - DISPATCHED = "DISPATCHED", - RECEIVED = "RECEIVED", + TEST_PROCESSED = "test-processed", } diff --git a/lambdas/src/order-status-lambda/utils.ts b/lambdas/src/order-status-lambda/utils.ts deleted file mode 100644 index a1ff167d6..000000000 --- a/lambdas/src/order-status-lambda/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AllowedInternalBusinessStatuses, IncomingBusinessStatus } from "./types"; - -/** - * Extract UUID from a FHIR reference (e.g., "ServiceRequest/550e8400-e29b-41d4-a716-446655440000") - */ -export const extractIdFromReference = (reference: string): string | null => { - const parts = reference.split("/"); - - return parts.length === 2 ? parts[1] : null; -}; - -/** - * Mapping of incoming business status values to allowed internal business statuses - */ -export const businessStatusMapping: Record< - IncomingBusinessStatus, - AllowedInternalBusinessStatuses -> = { - [IncomingBusinessStatus.ORDER_ACCEPTED]: AllowedInternalBusinessStatuses.CONFIRMED, - [IncomingBusinessStatus.DISPATCHED]: AllowedInternalBusinessStatuses.DISPATCHED, - [IncomingBusinessStatus.RECEIVED_AT_LAB]: AllowedInternalBusinessStatuses.RECEIVED, -}; diff --git a/lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts b/lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts new file mode 100644 index 000000000..5fa3484b8 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts @@ -0,0 +1,59 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +import { validateAndExtractCorrelationId } from "./correlation-id-validation"; + +const mockGetCorrelationIdFromEventHeaders = jest.fn(); + +jest.mock("../../lib/utils/utils", () => ({ + ...jest.requireActual("../../lib/utils/utils"), + getCorrelationIdFromEventHeaders: () => mockGetCorrelationIdFromEventHeaders(), +})); + +const MOCK_CORRELATION_ID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("validateAndExtractCorrelationId", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return success with the correlation ID when the header is present and valid", () => { + mockGetCorrelationIdFromEventHeaders.mockReturnValue(MOCK_CORRELATION_ID); + + const result = validateAndExtractCorrelationId({} as APIGatewayProxyEvent); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(MOCK_CORRELATION_ID); + } + }); + + it("should return error with the thrown error message when the correlation ID header is missing or invalid", () => { + const errorMessage = "Correlation ID is missing or invalid"; + mockGetCorrelationIdFromEventHeaders.mockImplementation(() => { + throw new Error(errorMessage); + }); + + const result = validateAndExtractCorrelationId({} as APIGatewayProxyEvent); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toBe(errorMessage); + } + }); + + it("should return a generic error message when a non-Error value is thrown", () => { + mockGetCorrelationIdFromEventHeaders.mockImplementation(() => { + throw "unexpected string error"; + }); + + const result = validateAndExtractCorrelationId({} as APIGatewayProxyEvent); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorMessage).toBe("Invalid correlation ID"); + } + }); +}); diff --git a/lambdas/src/order-status-lambda/validation/correlation-id-validation.ts b/lambdas/src/order-status-lambda/validation/correlation-id-validation.ts new file mode 100644 index 000000000..1cd2bc09c --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/correlation-id-validation.ts @@ -0,0 +1,23 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +import { getCorrelationIdFromEventHeaders } from "../../lib/utils/utils"; +import { ValidationResult, errorResult, successResult } from "../../lib/validation"; + +const name = "order-status-lambda"; + +export const validateAndExtractCorrelationId = ( + event: APIGatewayProxyEvent, +): ValidationResult => { + try { + const correlationId = getCorrelationIdFromEventHeaders(event); + return successResult(correlationId); + } catch (error) { + console.error(name, "Failed to retrieve correlation ID", { error }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: error instanceof Error ? error.message : "Invalid correlation ID", + severity: "error", + }); + } +}; diff --git a/lambdas/src/order-status-lambda/validation/patient-validation.test.ts b/lambdas/src/order-status-lambda/validation/patient-validation.test.ts new file mode 100644 index 000000000..bf6753847 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/patient-validation.test.ts @@ -0,0 +1,51 @@ +import { validatePatientOwnership } from "./patient-validation"; + +const MOCK_ORDER_ID = "550e8400-e29b-41d4-a716-446655440000"; +const MOCK_PATIENT_ID = "patient-123"; + +describe("validatePatientOwnership", () => { + it("should return success when the patient reference matches the order's patient", () => { + const result = validatePatientOwnership( + `Patient/${MOCK_PATIENT_ID}`, + MOCK_PATIENT_ID, + MOCK_ORDER_ID, + ); + + expect(result.success).toBe(true); + }); + + it("should return error when the reference format is invalid (no slash)", () => { + const result = validatePatientOwnership("invalid-reference", MOCK_PATIENT_ID, MOCK_ORDER_ID); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toContain("Invalid patient reference"); + } + }); + + it("should return error when the reference has more than two path segments", () => { + const result = validatePatientOwnership("Patient/123/extra", MOCK_PATIENT_ID, MOCK_ORDER_ID); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toContain("Invalid patient reference"); + } + }); + + it("should return error when the patient ID in the reference does not match the order", () => { + const result = validatePatientOwnership( + "Patient/different-patient", + MOCK_PATIENT_ID, + MOCK_ORDER_ID, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toContain("Patient ID does not match"); + } + }); +}); diff --git a/lambdas/src/order-status-lambda/validation/patient-validation.ts b/lambdas/src/order-status-lambda/validation/patient-validation.ts new file mode 100644 index 000000000..ea6ae32ca --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/patient-validation.ts @@ -0,0 +1,43 @@ +import { ValidationResult, errorResult, successResult } from "../../lib/validation"; + +const name = "order-status-lambda"; + +export const validatePatientOwnership = ( + reference: string, + orderPatientId: string, + orderId: string, +): ValidationResult => { + const patientIdFromTask = extractIdFromReference(reference); + + if (!patientIdFromTask) { + console.error(name, "Invalid patient reference format", { reference }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Invalid patient reference format", + severity: "error", + }); + } + + if (patientIdFromTask !== orderPatientId) { + console.error(name, "Patient mismatch for order", { + orderId, + expectedPatient: orderPatientId, + providedPatient: patientIdFromTask, + }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Patient ID does not match the order", + severity: "error", + }); + } + + return successResult(); +}; + +function extractIdFromReference(reference: string): string | null { + const parts = reference.split("/"); + + return parts.length === 2 ? parts[1] : null; +} diff --git a/lambdas/src/order-status-lambda/validation/task-validation.test.ts b/lambdas/src/order-status-lambda/validation/task-validation.test.ts new file mode 100644 index 000000000..602597743 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/task-validation.test.ts @@ -0,0 +1,133 @@ +import { IncomingBusinessStatus } from "../models/types"; +import { validateAndExtractTask } from "./task-validation"; + +const MOCK_ORDER_UID = "550e8400-e29b-41d4-a716-446655440000"; +const MOCK_PATIENT_UID = "patient-123"; + +const validBody = { + resourceType: "Task", + status: "in-progress", + intent: "order", + identifier: [{ value: MOCK_ORDER_UID }], + for: { reference: `Patient/${MOCK_PATIENT_UID}` }, + lastModified: "2024-01-15T10:00:00Z", + businessStatus: { text: IncomingBusinessStatus.DISPATCHED }, +}; + +describe("validateAndExtractTask", () => { + describe("Body presence", () => { + it("should return error when body is null", () => { + const result = validateAndExtractTask(null); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toMatch(/Request body is required/); + } + }); + + it("should return error when body is an empty string", () => { + const result = validateAndExtractTask(""); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/Request body is required/); + } + }); + }); + + describe("JSON parsing", () => { + it("should return error when body is invalid JSON", () => { + const result = validateAndExtractTask("{invalid json"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorMessage).toMatch(/Invalid JSON/); + } + }); + }); + + describe("Schema validation", () => { + it("should return error for an empty JSON object", () => { + const result = validateAndExtractTask("{}"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toMatch(/identifier|lastModified|businessStatus/); + } + }); + + it("should return error when identifier is missing", () => { + const { identifier: _identifier, ...bodyWithoutIdentifier } = validBody; + + const result = validateAndExtractTask(JSON.stringify(bodyWithoutIdentifier)); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/identifier/); + } + }); + + it("should return error when lastModified is missing", () => { + const { lastModified: _lastModified, ...bodyWithoutLastModified } = validBody; + + const result = validateAndExtractTask(JSON.stringify(bodyWithoutLastModified)); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/lastModified/); + } + }); + + it("should return error when businessStatus is missing", () => { + const { businessStatus: _businessStatus, ...bodyWithoutBusinessStatus } = validBody; + + const result = validateAndExtractTask(JSON.stringify(bodyWithoutBusinessStatus)); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/businessStatus/); + } + }); + + it("should return error when businessStatus.text is an unrecognised value", () => { + const result = validateAndExtractTask( + JSON.stringify({ ...validBody, businessStatus: { text: "INVALID_STATUS" } }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/businessStatus/); + } + }); + + it.each(Object.values(IncomingBusinessStatus))( + "should return success for valid businessStatus '%s'", + (status) => { + const result = validateAndExtractTask( + JSON.stringify({ ...validBody, businessStatus: { text: status } }), + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.businessStatus.text).toBe(status); + } + }, + ); + + it("should return success with parsed task for a fully valid body", () => { + const result = validateAndExtractTask(JSON.stringify(validBody)); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.identifier[0].value).toBe(MOCK_ORDER_UID); + expect(result.data.for.reference).toBe(`Patient/${MOCK_PATIENT_UID}`); + expect(result.data.businessStatus.text).toBe(IncomingBusinessStatus.DISPATCHED); + } + }); + }); +}); diff --git a/lambdas/src/order-status-lambda/validation/task-validation.ts b/lambdas/src/order-status-lambda/validation/task-validation.ts new file mode 100644 index 000000000..ed48006dd --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/task-validation.ts @@ -0,0 +1,48 @@ +import { generateReadableError } from "../../lib/utils/validation-utils"; +import { ValidationResult, errorResult, successResult } from "../../lib/validation"; +import { OrderStatusFHIRTask, orderStatusFHIRTaskSchema } from "../models/schemas"; + +const name = "order-status-lambda"; + +export const validateAndExtractTask = ( + body: string | null, +): ValidationResult => { + if (!body) { + console.error(name, "Missing request body"); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Request body is required", + severity: "error", + }); + } + + let task: unknown; + + try { + task = JSON.parse(body); + } catch (error) { + console.error(name, "Invalid JSON in request body", { error }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Invalid JSON in request body", + severity: "error", + }); + } + + const validationResult = orderStatusFHIRTaskSchema.safeParse(task); + + if (!validationResult.success) { + const errorDetails = generateReadableError(validationResult.error); + console.error(name, "Task validation failed", { error: errorDetails }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: errorDetails, + severity: "error", + }); + } + + return successResult(validationResult.data); +}; diff --git a/tests/models/TestResult.ts b/tests/models/TestResult.ts index 62e019d3c..3e95f6eca 100644 --- a/tests/models/TestResult.ts +++ b/tests/models/TestResult.ts @@ -1,4 +1,4 @@ -export type ResultStatus = 'RESULT_AVAILABLE' | 'RESULT_WITHHELD'; +export type ResultStatus = "RESULT_AVAILABLE" | "RESULT_WITHHELD" | "RESULT_PROCESSED"; export interface TestResult { order_uid: string; diff --git a/tests/test-data/OrderStatusTypes.ts b/tests/test-data/OrderStatusTypes.ts index 9e9987f9f..1c8f7d069 100644 --- a/tests/test-data/OrderStatusTypes.ts +++ b/tests/test-data/OrderStatusTypes.ts @@ -15,6 +15,7 @@ export class OrderStatusTestData { static readonly BUSINESS_STATUS_ORDER_ACCEPTED = "order-accepted"; static readonly BUSINESS_STATUS_DISPATCHED = "dispatched"; static readonly BUSINESS_STATUS_RECEIVED_AT_LAB = "received-at-lab"; + static readonly BUSINESS_STATUS_TEST_PROCESSED = "test-processed"; static readonly EXPECTED_STATUS_CODE_CONFIRMED = "CONFIRMED"; static readonly EXPECTED_STATUS_CODE_DISPATCHED = "DISPATCHED"; static readonly EXPECTED_STATUS_CODE_RECEIVED = "RECEIVED"; diff --git a/tests/tests/api/OrderStatusUpdate.spec.ts b/tests/tests/api/OrderStatusUpdate.spec.ts index 05d05fcc9..5d49d0d75 100644 --- a/tests/tests/api/OrderStatusUpdate.spec.ts +++ b/tests/tests/api/OrderStatusUpdate.spec.ts @@ -35,17 +35,18 @@ test.describe("Order Status Update API", { tag: ["@API", "@db"] }, () => { await testOrderDb.insertConsent(orderUid); }); - test.afterEach(async ({ testOrderDb }) => { + test.afterEach(async ({ testOrderDb, testResultDb }) => { await testOrderDb.deleteOrderStatusByUid(orderUid); await testOrderDb.deleteConsentByOrderUid(orderUid); await testOrderDb.deleteOrderByUid(orderUid); + await testResultDb.deleteResultStatusByUid(orderUid); await testOrderDb.deletePatientMapping(nhsNumber, birthDate); }); test( "success (201) persists order status updates", { tag: ["@API"] }, - async ({ orderStatusApi, testOrderDb }) => { + async ({ orderStatusApi, testOrderDb, testResultDb }) => { const confirmedResponse = await orderStatusApi.updateOrderStatus( orderStatusPayload(orderUid, patientUid, defaultStatus, defaultIntent, { businessStatus: { text: OrderStatusTestData.BUSINESS_STATUS_ORDER_ACCEPTED }, @@ -84,6 +85,20 @@ test.describe("Order Status Update API", { tag: ["@API", "@db"] }, () => { const { statusCode: receivedStatusCode } = await testOrderDb.getLatestOrderStatusWithCountByOrderUid(orderUid); expect(receivedStatusCode).toBe(OrderStatusTestData.EXPECTED_STATUS_CODE_RECEIVED); + + const processedResponse = await orderStatusApi.updateOrderStatus( + orderStatusPayload(orderUid, patientUid, defaultStatus, defaultIntent, { + businessStatus: { text: OrderStatusTestData.BUSINESS_STATUS_TEST_PROCESSED }, + }), + buildHeaders(randomUUID()), + ); + + orderStatusApi.validateResponse(processedResponse, 201); + + const resultStatus = await testResultDb.getLatestResultStatusByOrderUid(orderUid); + expect(resultStatus).toBe("RESULT_PROCESSED"); + const orderStatus = await testOrderDb.getLatestOrderStatusWithCountByOrderUid(orderUid); + expect(orderStatus.statusCode).toBe(OrderStatusTestData.EXPECTED_STATUS_CODE_RECEIVED); }, ); });