diff --git a/architecture/home-test-consumer-api.yaml b/architecture/home-test-consumer-api.yaml index 2cdd270b8..1d66950ce 100644 --- a/architecture/home-test-consumer-api.yaml +++ b/architecture/home-test-consumer-api.yaml @@ -17,7 +17,7 @@ paths: content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIRServiceRequest' + $ref: "#/components/schemas/FHIRServiceRequest" example: resourceType: "ServiceRequest" id: "550e8400-e29b-41d4-a716-446655440000" @@ -65,12 +65,12 @@ paths: type: "Organization" display: "Test Supplier Ltd" responses: - '201': + "201": description: Order created successfully content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIRServiceRequest' + $ref: "#/components/schemas/FHIRServiceRequest" example: resourceType: "ServiceRequest" id: "550e8400-e29b-41d4-a716-446655440000" @@ -98,10 +98,10 @@ paths: code: "order-received" display: "Order received and validated" text: "order-received" - '400': - $ref: '#/components/responses/BadRequest' - '422': - $ref: '#/components/responses/UnprocessableEntity' + "400": + $ref: "#/components/responses/BadRequest" + "422": + $ref: "#/components/responses/UnprocessableEntity" /get-order: get: summary: Retrieve Orders @@ -115,7 +115,7 @@ paths: description: NHS number to retrieve orders for schema: type: string - pattern: '^[0-9]{10}$' + pattern: "^[0-9]{10}$" example: "9876543210" - name: date_of_birth in: query @@ -134,12 +134,12 @@ paths: format: uuid example: "550e8400-e29b-41d4-a716-446655440000" responses: - '200': + "200": description: Orders retrieved successfully content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIRBundleSearchsetServiceRequest' + $ref: "#/components/schemas/FHIRBundleSearchsetServiceRequest" example: resourceType: "Bundle" type: "searchset" @@ -180,10 +180,10 @@ paths: code: "dispatched" display: "Kit dispatched to patient" text: "dispatched" - '400': - $ref: '#/components/responses/BadRequest' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" /results: get: @@ -214,7 +214,7 @@ paths: description: NHS number for identity verification schema: type: string - pattern: '^[0-9]{10}$' + pattern: "^[0-9]{10}$" example: "9876543210" - name: date_of_birth in: query @@ -225,12 +225,12 @@ paths: format: date example: "1990-01-01" responses: - '200': + "200": description: Results retrieved successfully content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIRObservation' + $ref: "#/components/schemas/FHIRObservation" example: resourceType: "Observation" id: "550e8400-e29b-41d4-a716-446655440001" @@ -261,12 +261,80 @@ paths: code: "N" display: "Normal" text: "Normal" - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /result/status: + post: + summary: Update Result Status + description: Update the status of a test result + tags: + - Result Service + requestBody: + required: true + content: + application/fhir+json: + schema: + $ref: "#/components/schemas/FHIRTask" + example: + resourceType: "Task" + identifier: + - system: "https://fhir.hometest.nhs.uk/Id/order-id" + value: "550e8400-e29b-41d4-a716-446655440000" + status: "completed" + intent: "order" + basedOn: + - reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" + type: "ServiceRequest" + requester: + reference: "Organization/ORG001" + for: + reference: "Patient/123e4567-e89b-12d3-a456-426614174000" + businessStatus: + coding: + - system: "https://fhir.hometest.nhs.uk/CodeSystem/result-business-status" + code: "result-available" + display: "Result available to patient" + text: "result-available" + lastModified: "2026-02-05T12:00:00Z" + + responses: + "201": + description: Result status updated successfully + content: + application/fhir+json: + schema: + $ref: "#/components/schemas/FHIRTask" + example: + resourceType: "Task" + identifier: + - system: "https://fhir.hometest.nhs.uk/Id/order-id" + value: "550e8400-e29b-41d4-a716-446655440000" + status: "completed" + intent: "order" + basedOn: + - reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" + type: "ServiceRequest" + requester: + reference: "Organization/ORG001" + for: + reference: "Patient/123e4567-e89b-12d3-a456-426614174000" + businessStatus: + coding: + - system: "https://fhir.hometest.nhs.uk/CodeSystem/result-business-status" + code: "result-available" + display: "Result available to patient" + text: "result-available" + lastModified: "2026-02-05T12:00:00Z" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" components: schemas: @@ -294,7 +362,7 @@ components: type: array description: List of identifiers for this order (including external order id) items: - $ref: '#/components/schemas/FHIRIdentifier' + $ref: "#/components/schemas/FHIRIdentifier" authoredOn: type: string format: date @@ -325,23 +393,23 @@ components: type: array description: NHS number and other identifiers items: - $ref: '#/components/schemas/FHIRIdentifier' + $ref: "#/components/schemas/FHIRIdentifier" name: type: array description: Patient name(s) items: - $ref: '#/components/schemas/FHIRHumanName' + $ref: "#/components/schemas/FHIRHumanName" telecom: type: array description: Contact details - must include both phone and email for delivery coordination and customer communication minItems: 2 items: - $ref: '#/components/schemas/FHIRContactPoint' + $ref: "#/components/schemas/FHIRContactPoint" address: type: array description: Patient address(es) items: - $ref: '#/components/schemas/FHIRAddress' + $ref: "#/components/schemas/FHIRAddress" birthDate: type: string format: date @@ -355,11 +423,22 @@ components: intent: type: string description: Intent of the service request - enum: [proposal, plan, directive, order, original-order, reflex-order, filler-order, instance-order, option] + enum: + [ + proposal, + plan, + directive, + order, + original-order, + reflex-order, + filler-order, + instance-order, + option, + ] example: "order" code: allOf: - - $ref: '#/components/schemas/FHIRCodeableConcept' + - $ref: "#/components/schemas/FHIRCodeableConcept" - description: What is being requested/ordered (SNOMED CT) example: coding: @@ -369,13 +448,13 @@ components: text: "HIV antigen test" subject: allOf: - - $ref: '#/components/schemas/FHIRReference' + - $ref: "#/components/schemas/FHIRReference" - description: Individual or Entity the service is ordered for example: reference: "#patient-1" requester: allOf: - - $ref: '#/components/schemas/FHIRReference' + - $ref: "#/components/schemas/FHIRReference" - description: Individual making the request example: reference: "Organization/ORG001" @@ -384,7 +463,7 @@ components: description: Requested performer (supplier organization) items: allOf: - - $ref: '#/components/schemas/FHIRReference' + - $ref: "#/components/schemas/FHIRReference" - example: reference: "Organization/SUPP001" type: "Organization" @@ -416,7 +495,7 @@ components: example: "2026-02-05" valueCodeableConcept: allOf: - - $ref: '#/components/schemas/FHIRCodeableConcept' + - $ref: "#/components/schemas/FHIRCodeableConcept" - description: Business workflow status example: coding: @@ -459,15 +538,25 @@ components: type: array description: Reference to the ServiceRequest this observation fulfills items: - $ref: '#/components/schemas/FHIRReference' + $ref: "#/components/schemas/FHIRReference" status: type: string description: Status of the observation result - enum: [registered, preliminary, final, amended, corrected, cancelled, entered-in-error, unknown] + enum: + [ + registered, + preliminary, + final, + amended, + corrected, + cancelled, + entered-in-error, + unknown, + ] example: "final" code: allOf: - - $ref: '#/components/schemas/FHIRCodeableConcept' + - $ref: "#/components/schemas/FHIRCodeableConcept" - description: Type of observation (SNOMED CT or LOINC) example: coding: @@ -477,7 +566,7 @@ components: text: "HIV antigen test" subject: allOf: - - $ref: '#/components/schemas/FHIRReference' + - $ref: "#/components/schemas/FHIRReference" - description: Who and/or what the observation is about example: reference: "Patient/123e4567-e89b-12d3-a456-426614174000" @@ -495,10 +584,10 @@ components: type: array description: Who is responsible for the observation items: - $ref: '#/components/schemas/FHIRReference' + $ref: "#/components/schemas/FHIRReference" valueCodeableConcept: allOf: - - $ref: '#/components/schemas/FHIRCodeableConcept' + - $ref: "#/components/schemas/FHIRCodeableConcept" - description: Actual result value (for coded results) example: coding: @@ -513,7 +602,7 @@ components: type: array description: High, low, normal, etc. items: - $ref: '#/components/schemas/FHIRCodeableConcept' + $ref: "#/components/schemas/FHIRCodeableConcept" referenceRange: type: array description: Provides guide for interpretation @@ -586,7 +675,7 @@ components: format: uri example: "urn:uuid:550e8400-e29b-41d4-a716-446655440000" resource: - $ref: '#/components/schemas/FHIRServiceRequest' + $ref: "#/components/schemas/FHIRServiceRequest" FHIRReference: type: object @@ -613,7 +702,7 @@ components: type: array description: Code defined by a terminology system items: - $ref: '#/components/schemas/FHIRCoding' + $ref: "#/components/schemas/FHIRCoding" text: type: string description: Plain text representation of the concept @@ -770,11 +859,44 @@ components: code: type: string description: Error or warning code - enum: [invalid, structure, required, value, invariant, security, login, unknown, expired, forbidden, suppressed, processing, not-supported, duplicate, multiple-matches, not-found, deleted, too-long, code-invalid, extension, too-costly, business-rule, conflict, transient, lock-error, no-store, exception, timeout, incomplete, throttled, informational] + enum: + [ + invalid, + structure, + required, + value, + invariant, + security, + login, + unknown, + expired, + forbidden, + suppressed, + processing, + not-supported, + duplicate, + multiple-matches, + not-found, + deleted, + too-long, + code-invalid, + extension, + too-costly, + business-rule, + conflict, + transient, + lock-error, + no-store, + exception, + timeout, + incomplete, + throttled, + informational, + ] example: "business-rule" details: allOf: - - $ref: '#/components/schemas/FHIRCodeableConcept' + - $ref: "#/components/schemas/FHIRCodeableConcept" - description: Additional details about the error example: coding: @@ -799,7 +921,7 @@ components: content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIROperationOutcome' + $ref: "#/components/schemas/FHIROperationOutcome" example: resourceType: "OperationOutcome" issue: @@ -814,7 +936,7 @@ components: content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIROperationOutcome' + $ref: "#/components/schemas/FHIROperationOutcome" example: resourceType: "OperationOutcome" issue: @@ -829,7 +951,7 @@ components: content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIROperationOutcome' + $ref: "#/components/schemas/FHIROperationOutcome" example: resourceType: "OperationOutcome" issue: @@ -844,7 +966,7 @@ components: content: application/fhir+json: schema: - $ref: '#/components/schemas/FHIROperationOutcome' + $ref: "#/components/schemas/FHIROperationOutcome" example: resourceType: "OperationOutcome" issue: diff --git a/lambdas/src/lib/db/order-db.test.ts b/lambdas/src/lib/db/order-db.test.ts index 738eb095c..e6e439acb 100644 --- a/lambdas/src/lib/db/order-db.test.ts +++ b/lambdas/src/lib/db/order-db.test.ts @@ -1,42 +1,50 @@ -import { OrderResultSummary, OrderService } from "./order-db"; import { OrderStatus, ResultStatus } from "../types/status"; - -import { Commons } from "../commons"; +import { OrderResultSummary, OrderService } from "./order-db"; const normalizeWhitespace = (sql: string): string => sql.replace(/\s+/g, " ").trim(); describe("OrderService", () => { let dbClient: any; - let commons: Pick; let orderService: OrderService; + const mockQuery = jest.fn(); + const mockWithTransaction = jest.fn(); + beforeEach(() => { + mockQuery.mockClear(); dbClient = { - query: jest.fn(), - withTransaction: jest.fn(), + query: mockQuery, + withTransaction: mockWithTransaction, }; - commons = { - logError: jest.fn(), - }; - orderService = new OrderService(dbClient, commons as any as Commons); + orderService = new OrderService(dbClient); }); describe("retrieveOrderDetails", () => { const expectedRetrieveOrderDetailsQuery = ` - SELECT - o.order_uid, - o.supplier_id, - o.patient_uid, - r.status AS result_status, - r.correlation_id, - os.status_code AS order_status_code - FROM test_order o - LEFT JOIN result_status r ON o.order_uid = r.order_uid - LEFT JOIN order_status os ON o.order_uid = os.order_uid - WHERE o.order_uid = $1::uuid - ORDER BY os.created_at DESC - LIMIT 1; - `; + SELECT + o.order_uid, + o.supplier_id, + o.patient_uid, + r.status AS result_status, + r.correlation_id, + os.status_code AS order_status_code + FROM test_order o + LEFT JOIN Lateral ( + SELECT + r.status, + r.correlation_id + FROM result_status r + WHERE o.order_uid = r.order_uid + ORDER BY r.created_at DESC LIMIT 1 + ) r ON true + LEFT JOIN Lateral ( + SELECT os.status_code + FROM order_status os + WHERE o.order_uid = os.order_uid + ORDER BY os.created_at DESC LIMIT 1 + ) os ON true + WHERE o.order_uid = $1::uuid; + `; it("should return order details when found", async () => { const mockSummary: OrderResultSummary = { @@ -72,7 +80,7 @@ describe("OrderService", () => { expect(result).toBeNull(); }); - it("should log and rethrow when retrieving order details fails", async () => { + it("should rethrow when retrieving order details fails", async () => { const error = new Error("query failed"); dbClient.query.mockRejectedValue(error); @@ -83,14 +91,6 @@ describe("OrderService", () => { normalizeWhitespace(expectedRetrieveOrderDetailsQuery), ); expect(dbClient.query.mock.calls[0][1]).toEqual(["order-500"]); - expect(commons.logError).toHaveBeenCalledWith( - "order-db", - "Failed to retrieve order details", - { - error, - orderUid: "order-500", - }, - ); }); }); @@ -116,7 +116,8 @@ describe("OrderService", () => { `; const expectedResultStatusQuery = ` INSERT INTO result_status (order_uid, status, correlation_id) - VALUES ($1::uuid, $2, $3::uuid);`; + VALUES ($1::uuid, $2, $3::uuid); + `; await orderService.updateOrderStatusAndResultStatus( "order-1", @@ -142,7 +143,7 @@ describe("OrderService", () => { ]); }); - it("should log and rethrow when the transaction fails", async () => { + it("should rethrow when the transaction fails", async () => { const error = new Error("transaction failed"); dbClient.withTransaction.mockRejectedValue(error); @@ -153,16 +154,7 @@ describe("OrderService", () => { ResultStatus.Result_Available, "corr-1", ), - ).rejects.toThrow(error); - - expect(commons.logError).toHaveBeenCalledWith( - "order-db", - "Failed to update order and result status", - { - error, - orderUid: "order-1", - }, - ); + ).rejects.toThrow("Failed to update order and result status"); }); }); }); diff --git a/lambdas/src/lib/db/order-db.ts b/lambdas/src/lib/db/order-db.ts index a002d0c35..e5e57b39d 100644 --- a/lambdas/src/lib/db/order-db.ts +++ b/lambdas/src/lib/db/order-db.ts @@ -1,6 +1,4 @@ import { OrderStatus, ResultStatus } from "../types/status"; - -import { Commons } from "../commons"; import { DBClient } from "./db-client"; export interface OrderResultSummary { @@ -14,10 +12,8 @@ export interface OrderResultSummary { export class OrderService { private readonly dbClient: DBClient; - private readonly commons: Commons; - constructor(dbClient: DBClient, commons: Commons) { + constructor(dbClient: DBClient) { this.dbClient = dbClient; - this.commons = commons; } async retrieveOrderDetails(orderUid: string): Promise { @@ -30,18 +26,28 @@ export class OrderService { r.correlation_id, os.status_code AS order_status_code FROM test_order o - LEFT JOIN result_status r ON o.order_uid = r.order_uid - LEFT JOIN order_status os ON o.order_uid = os.order_uid - WHERE o.order_uid = $1::uuid - ORDER BY os.created_at DESC - LIMIT 1; + LEFT JOIN Lateral ( + SELECT + r.status, + r.correlation_id + FROM result_status r + WHERE o.order_uid = r.order_uid + ORDER BY r.created_at DESC LIMIT 1 + ) r ON true + LEFT JOIN Lateral ( + SELECT os.status_code + FROM order_status os + WHERE o.order_uid = os.order_uid + ORDER BY os.created_at DESC LIMIT 1 + ) os ON true + WHERE o.order_uid = $1::uuid; `; try { const result = await this.dbClient.query(query, [orderUid]); return result.rows[0] || null; } catch (error) { - this.commons.logError("order-db", "Failed to retrieve order details", { error, orderUid }); + console.error("order-db", "Failed to retrieve order details", { error, orderUid }); throw error; } } @@ -69,15 +75,19 @@ export class OrderService { const resultStatusQuery = ` INSERT INTO result_status (order_uid, status, correlation_id) - VALUES ($1::uuid, $2, $3::uuid);`; + VALUES ($1::uuid, $2, $3::uuid); + `; await dbClient.query(resultStatusQuery, [orderUid, resultStatus, correlationId]); }); } catch (error) { - this.commons.logError("order-db", "Failed to update order and result status", { + console.error("order-db", "Failed to update order and result status", { error, orderUid, + correlationId, + statusCode, + resultStatus, }); - throw error; + throw new Error(`Failed to update order and result status`, { cause: error }); } } } diff --git a/lambdas/src/order-result-lambda/init.ts b/lambdas/src/order-result-lambda/init.ts index 5c8f5b929..53588c095 100644 --- a/lambdas/src/order-result-lambda/init.ts +++ b/lambdas/src/order-result-lambda/init.ts @@ -25,7 +25,7 @@ export function buildEnvironment(): Environment { const homeTestBaseUrl = retrieveMandatoryEnvVariable("HOME_TEST_BASE_URL"); const secretsClient = new AwsSecretsClient(awsRegion); const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); - const orderService = new OrderService(dbClient, commons); + const orderService = new OrderService(dbClient); const orderStatusDb = new OrderStatusService(dbClient); const patientDbClient = new PatientDbClient(dbClient); const orderDbClient = new OrderDbClient(dbClient); diff --git a/lambdas/src/result-status-lambda/cors-configuration.ts b/lambdas/src/result-status-lambda/cors-configuration.ts new file mode 100644 index 000000000..bbdcb07d0 --- /dev/null +++ b/lambdas/src/result-status-lambda/cors-configuration.ts @@ -0,0 +1,12 @@ +import { type Options } from "@middy/http-cors"; + +import { defaultCorsOptions as sharedDefaultCorsOptions } from "../lib/security/cors-configuration"; + +const customCorsOptions: Options = { + methods: "POST, OPTIONS", +}; + +export const corsOptions: Options = { + ...sharedDefaultCorsOptions, + ...customCorsOptions, +}; diff --git a/lambdas/src/result-status-lambda/index.test.ts b/lambdas/src/result-status-lambda/index.test.ts new file mode 100644 index 000000000..56fb781ff --- /dev/null +++ b/lambdas/src/result-status-lambda/index.test.ts @@ -0,0 +1,308 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { OrderStatus, ResultStatus } from "../lib/types/status"; +import { lambdaHandler } from "./index"; + +const mockUpdateOrderStatusAndResultStatus = jest.fn(); +const mockRetrieveOrderDetails = jest.fn(); + +jest.mock("./init", () => ({ + init: jest.fn(() => ({ + orderService: { + updateOrderStatusAndResultStatus: mockUpdateOrderStatusAndResultStatus, + retrieveOrderDetails: mockRetrieveOrderDetails, + }, + })), +})); + +jest.mock("../lib/fhir-response", () => ({ + createFhirErrorResponse: jest.fn((code, type, message, severity) => ({ + statusCode: code, + body: JSON.stringify({ issue: [{ code: type, diagnostics: message, severity }] }), + })), + createFhirResponse: jest.fn((code, resource) => ({ + statusCode: code, + body: JSON.stringify(resource), + })), +})); + +const VALID_ORDER_UUID = "123a1234-a12b-1234-abcd-1234567890ab"; +const VALID_PATIENT_UUID = "123a1234-a12b-1234-abcd-1234567890ab"; +const VALID_CORRELATION_ID = "123a1234-a12b-1234-abcd-1234567890ab"; +const VALID_HEADERS = { "X-Correlation-ID": VALID_CORRELATION_ID }; + +const validTask = { + resourceType: "Task", + status: "completed", + intent: "order", + identifier: [{ value: VALID_ORDER_UUID }], + for: { reference: `Patient/${VALID_PATIENT_UUID}` }, + businessStatus: { coding: [{ code: "result-available" }] }, + basedOn: [{ reference: "ServiceRequest/some-ref" }], +}; + +const makeEvent = ( + body: string | null, + headers: Record = {}, +): APIGatewayProxyEvent => + ({ + body, + headers, + }) as APIGatewayProxyEvent; + +const validEventHeaders = { "X-Correlation-ID": VALID_CORRELATION_ID }; + +describe("result-status-lambda handler", () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockRetrieveOrderDetails.mockReset(); + mockUpdateOrderStatusAndResultStatus.mockReset(); + + mockUpdateOrderStatusAndResultStatus.mockResolvedValue({ + order_uid: VALID_ORDER_UUID, + patient_uid: VALID_PATIENT_UUID, + }); + mockRetrieveOrderDetails.mockResolvedValue(null); + }); + + describe("request body parsing", () => { + it("returns 400 when body is null", async () => { + const res = await lambdaHandler(makeEvent(null, VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Request body is required", + "error", + ); + }); + + it("returns 400 when body is invalid JSON", async () => { + const res = await lambdaHandler(makeEvent("not-valid-json", VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Invalid JSON in request body", + "error", + ); + }); + + it("returns 400 when correlation ID header is missing", async () => { + const res = await lambdaHandler(makeEvent(JSON.stringify(validTask), {})); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Invalid correlation ID in headers", + "error", + ); + }); + + it("returns 400 when task fails schema validation", async () => { + const res = await lambdaHandler( + makeEvent(JSON.stringify({ resourceType: "Task" }), VALID_HEADERS), + ); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + expect.stringContaining("Task validation failed"), + "error", + ); + }); + + it("returns 400 when resourceType is not Task", async () => { + const invalidTask = { ...validTask, resourceType: "Observation" }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + }); + + it("returns 400 when businessStatus coding code is not result-available", async () => { + const invalidTask = { + ...validTask, + businessStatus: { coding: [{ code: ResultStatus.Result_Withheld }] }, + }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + }); + + it("returns 400 when identifier array is empty", async () => { + const invalidTask = { ...validTask, identifier: [] }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + }); + }); + + describe("FHIR Task identifier extraction", () => { + it("returns 400 when for.reference has no slash (single segment)", async () => { + const invalidTask = { ...validTask, for: { reference: VALID_PATIENT_UUID } }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Invalid for.reference format", + "error", + ); + }); + + it("returns 400 when for.reference has more than two segments", async () => { + const invalidTask = { + ...validTask, + for: { reference: `Patient/${VALID_PATIENT_UUID}/extra` }, + }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Invalid for.reference format", + "error", + ); + }); + + it("returns 400 when patient ID in for.reference is not a valid UUID", async () => { + const invalidTask = { ...validTask, for: { reference: "Patient/not-a-uuid" } }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Invalid patient ID format", + "error", + ); + }); + + it("returns 400 when order identifier value is not a valid UUID", async () => { + const invalidTask = { ...validTask, identifier: [{ value: "not-a-uuid" }] }; + + const res = await lambdaHandler(makeEvent(JSON.stringify(invalidTask), VALID_HEADERS)); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 400, + "invalid", + "Invalid identifier.value format", + "error", + ); + }); + }); + + describe("order lookup", () => { + it("returns 500 when orderService.retrieveOrderDetails throws", async () => { + mockRetrieveOrderDetails.mockRejectedValueOnce(new Error("DB connection failed")); + + const res = await lambdaHandler( + makeEvent(JSON.stringify(validTask), { "X-Correlation-ID": VALID_CORRELATION_ID }), + ); + + expect(res.statusCode).toBe(500); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 500, + "exception", + "An internal error occurred", + "fatal", + ); + }); + + it("returns 404 when order is not found", async () => { + mockRetrieveOrderDetails.mockResolvedValueOnce(null); + + const res = await lambdaHandler( + makeEvent(JSON.stringify(validTask), { "X-Correlation-ID": VALID_CORRELATION_ID }), + ); + + expect(res.statusCode).toBe(404); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 404, + "not-found", + "Order not found", + "error", + ); + }); + }); + + describe("patient authorisation", () => { + it("returns 403 when patient UID in task does not match order record", async () => { + mockRetrieveOrderDetails.mockResolvedValueOnce({ + patient_uid: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", + }); + + const res = await lambdaHandler( + makeEvent(JSON.stringify(validTask), { "X-Correlation-ID": VALID_CORRELATION_ID }), + ); + + expect(res.statusCode).toBe(403); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 403, + "forbidden", + "Patient UID does not match order record", + "error", + ); + }); + }); + + describe("Order status update", () => { + beforeEach(() => { + mockRetrieveOrderDetails.mockResolvedValue({ patient_uid: VALID_PATIENT_UUID }); + }); + + it("returns 500 when OrderService.updateOrderStatusAndResultStatus throws", async () => { + mockUpdateOrderStatusAndResultStatus.mockRejectedValueOnce(new Error("DB write failed")); + + const res = await lambdaHandler(makeEvent(JSON.stringify(validTask), validEventHeaders)); + + expect(res.statusCode).toBe(500); + expect(createFhirErrorResponse).toHaveBeenCalledWith( + 500, + "exception", + "An internal error occurred", + "fatal", + ); + }); + + it("calls updateOrderStatusAndResultStatus with correct arguments", async () => { + await lambdaHandler(makeEvent(JSON.stringify(validTask), validEventHeaders)); + + expect(mockUpdateOrderStatusAndResultStatus).toHaveBeenCalledWith( + VALID_ORDER_UUID, + OrderStatus.Complete, + ResultStatus.Result_Available, + VALID_CORRELATION_ID, + ); + }); + }); + + describe("success", () => { + it("returns 201 with the original task on success", async () => { + mockRetrieveOrderDetails.mockResolvedValue({ patient_uid: VALID_PATIENT_UUID }); + + const res = await lambdaHandler(makeEvent(JSON.stringify(validTask), validEventHeaders)); + + expect(res.statusCode).toBe(201); + expect(createFhirResponse).toHaveBeenCalledWith( + 201, + expect.objectContaining({ resourceType: "Task" }), + ); + }); + }); +}); diff --git a/lambdas/src/result-status-lambda/index.ts b/lambdas/src/result-status-lambda/index.ts new file mode 100644 index 000000000..e76f4fedf --- /dev/null +++ b/lambdas/src/result-status-lambda/index.ts @@ -0,0 +1,188 @@ +import middy from "@middy/core"; +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 { OrderResultSummary } from "src/lib/db/order-db"; + +import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { securityHeaders } from "../lib/http/security-headers"; +import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; +import { OrderStatus, ResultStatus } from "../lib/types/status"; +import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; +import { generateReadableError } from "../lib/utils/validation-utils"; +import { corsOptions } from "./cors-configuration"; +import { init } from "./init"; +import { resultStatusFHIRTaskSchema } from "./schemas"; + +const name = "result-status-lambda"; + +function parseAndValidateTask(body: string | null, correlationId: string): FHIRTask { + let parsedTask: unknown; + + if (!body) { + console.error(name, "Missing request body"); + throw new Error("Request body is required"); + } + + try { + parsedTask = JSON.parse(body); + } catch (error) { + console.error(name, "Invalid JSON in request body", { error, correlationId }); + throw new Error("Invalid JSON in request body", { cause: error }); + } + + const validationResult = resultStatusFHIRTaskSchema.safeParse(parsedTask); + + if (!validationResult.success) { + const errorDetails = generateReadableError(validationResult.error); + console.error(name, "Task validation failed", { error: errorDetails, correlationId }); + throw new Error(`Task validation failed: ${errorDetails}`); + } + + return validationResult.data; +} + +function extractPatientIdFromFHIRTask(task: FHIRTask): string { + const parts = task.for.reference.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid for.reference format"); + } + + const patientId = parts[1]; + + if (!isUUID(patientId)) { + throw new Error("Invalid patient ID format"); + } + + return patientId; +} + +function extractOrderUidFromFHIRTask(task: FHIRTask): string { + const orderUid = task.identifier?.[0]?.value; + + if (!orderUid) { + throw new Error("Missing identifier.value"); + } + if (!isUUID(orderUid)) { + throw new Error("Invalid identifier.value format"); + } + + return orderUid; +} + +/** + * Lambda handler for POST /result/status endpoint + * Accepts FHIR Task resources and updates result status on database after validation and business logic checks. + * Returns appropriate FHIR responses for success and error cases. + */ +export const lambdaHandler = async ( + event: APIGatewayProxyEvent, +): Promise => { + const { orderService } = init(); + let correlationId: string; + + try { + correlationId = getCorrelationIdFromEventHeaders(event); + } catch (error) { + console.error(name, "Failed to extract correlation ID from request headers", { + error, + }); + return createFhirErrorResponse(400, "invalid", "Invalid correlation ID in headers", "error"); + } + + console.info(name, "Received result status request", { + path: event.path, + method: event.httpMethod, + correlationId: correlationId, + }); + + let task: FHIRTask; + + try { + task = parseAndValidateTask(event.body, correlationId); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid request body"; + return createFhirErrorResponse(400, "invalid", message, "error"); + } + + let patientUid: string, orderUid: string; + + try { + patientUid = extractPatientIdFromFHIRTask(task); + orderUid = extractOrderUidFromFHIRTask(task); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid identifiers"; + console.error(name, "Failed to extract identifiers from FHIR Task", { + error, + correlationId, + }); + return createFhirErrorResponse(400, "invalid", message, "error"); + } + + let orderFromOrderUid: OrderResultSummary | null; + + try { + orderFromOrderUid = await orderService.retrieveOrderDetails(orderUid); + } catch (error) { + console.error(name, "Failed to retrieve order details from database", { + error, + orderUid, + correlationId, + }); + return createFhirErrorResponse(500, "exception", "An internal error occurred", "fatal"); + } + + if (!orderFromOrderUid) { + console.error(name, "Order not found for given order UID", { + orderUid, + correlationId, + }); + return createFhirErrorResponse(404, "not-found", "Order not found", "error"); + } + + if (orderFromOrderUid.patient_uid !== patientUid) { + console.error(name, "Patient UID in Task does not match order record", { + orderUid, + correlationId, + }); + return createFhirErrorResponse( + 403, + "forbidden", + "Patient UID does not match order record", + "error", + ); + } + + if (orderFromOrderUid.correlation_id === correlationId) { + console.info(name, "Duplicate request detected based on correlation ID", { + orderUid, + correlationId, + }); + return createFhirResponse(200, task); + } + + try { + await orderService.updateOrderStatusAndResultStatus( + orderUid, + OrderStatus.Complete, + ResultStatus.Result_Available, + correlationId, + ); + } catch (error) { + console.error(name, "Failed to update result status in database", { + error, + orderUid, + correlationId, + }); + return createFhirErrorResponse(500, "exception", "An internal error occurred", "fatal"); + } + + //TODO: send notification in HOTE-982 + return createFhirResponse(201, task); +}; + +export const handler = middy(lambdaHandler) + .use(httpSecurityHeaders(securityHeaders)) + .use(cors(corsOptions)) + .use(httpErrorHandler()); diff --git a/lambdas/src/result-status-lambda/init.test.ts b/lambdas/src/result-status-lambda/init.test.ts new file mode 100644 index 000000000..bc8f6df6f --- /dev/null +++ b/lambdas/src/result-status-lambda/init.test.ts @@ -0,0 +1,124 @@ +import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { OrderService } from "../lib/db/order-db"; +import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { init } from "./init"; + +// Mock all external dependencies +jest.mock("../lib/db/db-client"); +jest.mock("../lib/db/db-config"); +jest.mock("../lib/secrets/secrets-manager-client"); +jest.mock("../lib/db/order-db"); + +describe("init", () => { + const originalEnv = process.env; + + const mockEnvVariables = { + DB_USERNAME: "test-username", + DB_ADDRESS: "test-address", + DB_PORT: "5432", + DB_NAME: "test-database", + DB_SCHEMA: "test-schema", + DB_SECRET_NAME: "test-secret-name", + }; + + // This represents the return value of postgresConfigFromEnv(secretsClient) + const mockPostgresConfig = { + user: "test-user", + host: "test-host", + port: 5432, + database: "test-db", + password: jest.fn().mockResolvedValue("test-password"), + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset process.env to a clean state + process.env = { ...originalEnv }; + // Set default mock environment variables + Object.assign(process.env, mockEnvVariables); + + (postgresConfigFromEnv as jest.Mock).mockReturnValue(mockPostgresConfig); + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + }); + + describe("successful initialization", () => { + beforeEach(async () => { + jest.clearAllMocks(); + (postgresConfigFromEnv as jest.Mock).mockReturnValue(mockPostgresConfig); + }); + + it("should initialize all components with correct configuration", async () => { + process.env.AWS_REGION = "eu-west-2"; + + const result = init(); + + expect(result).toHaveProperty("orderService"); + expect(result.orderService).toBeInstanceOf(OrderService); + }); + + it("should create AwsSecretsClient with AWS_REGION when set", () => { + process.env.AWS_REGION = "us-east-1"; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { init: initModule } = require("./init"); + initModule(); + expect(AwsSecretsClient).toHaveBeenCalledWith("us-east-1"); + }); + }); + + it("should throw when AWS_REGION is not set", () => { + delete process.env.AWS_REGION; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { init: initModule } = require("./init"); + expect(() => initModule()).toThrow("Missing value for an environment variable AWS_REGION"); + }); + }); + + it("should return an Environment object with all required properties", () => { + const result = init(); + + expect(result).toEqual({ + orderService: expect.any(OrderService), + }); + }); + }); + + describe("singleton behavior", () => { + it("should return the same Environment instance on multiple calls to init", () => { + const env1 = init(); + const env2 = init(); + + expect(env1).toBe(env2); + }); + }); + + describe("rejection retry", () => { + beforeEach(() => { + process.env.AWS_REGION = "us-east-1"; + }); + + it("should allow retry after buildEnvironment throws", () => { + jest.isolateModules(() => { + jest.clearAllMocks(); + (OrderService as jest.Mock).mockImplementationOnce(() => { + throw new Error("DB connection failed"); + }); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { init: singletonInit } = require("./init"); + + expect(() => singletonInit()).toThrow("DB connection failed"); + + // _env was never assigned (??= only assigns if the expression completes) + const result = singletonInit(); + expect(result).toBeTruthy(); + }); + }); + }); +}); diff --git a/lambdas/src/result-status-lambda/init.ts b/lambdas/src/result-status-lambda/init.ts new file mode 100644 index 000000000..da8c97354 --- /dev/null +++ b/lambdas/src/result-status-lambda/init.ts @@ -0,0 +1,27 @@ +import { PostgresDbClient } from "../lib/db/db-client"; +import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { OrderService } from "../lib/db/order-db"; +import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; + +export interface Environment { + orderService: OrderService; +} + +export function buildEnvironment(): Environment { + const awsRegion = retrieveMandatoryEnvVariable("AWS_REGION"); + const secretsClient = new AwsSecretsClient(awsRegion); + const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); + const orderService = new OrderService(dbClient); + + return { + orderService, + }; +} + +let _env: Environment | undefined; + +export function init(): Environment { + _env ??= buildEnvironment(); + return _env; +} diff --git a/lambdas/src/result-status-lambda/schemas.ts b/lambdas/src/result-status-lambda/schemas.ts new file mode 100644 index 000000000..21040b000 --- /dev/null +++ b/lambdas/src/result-status-lambda/schemas.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { + FHIRCodeableConceptSchema, + FHIRIdentifierSchema, + FHIRReferenceSchema, + FHIRTaskSchema, +} from "../lib/models/fhir/fhir-schemas"; + +const resultStatusFHIRCodeableConceptSchema = FHIRCodeableConceptSchema.extend({ + coding: z + .array( + z.object({ + system: z.string().optional(), + code: z.literal("result-available"), + display: z.string().optional(), + }), + ) + .min(1) + .max(1), +}); + +export const resultStatusFHIRTaskSchema = FHIRTaskSchema.extend({ + identifier: z.array(FHIRIdentifierSchema).min(1).max(1), + for: FHIRReferenceSchema, + businessStatus: resultStatusFHIRCodeableConceptSchema, + basedOn: z.array(FHIRReferenceSchema), +}); diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 6dff99cf4..22ca67ebe 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -534,6 +534,34 @@ module "order_status_lambda" { } } +module "result_status_lambda" { + source = "./modules/lambda" + + project_name = var.project_name + function_name = "result-status" + zip_path = "${path.module}/../../lambdas/dist/result-status-lambda.zip" + lambda_role_arn = aws_iam_role.lambda_role.arn + environment = var.environment + api_gateway_id = aws_api_gateway_rest_api.api.id + api_gateway_root_resource_id = aws_api_gateway_rest_api.api.root_resource_id + api_gateway_execution_arn = aws_api_gateway_rest_api.api.execution_arn + api_path = "result/status" + http_method = "POST" + 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" + } +} + # API Gateway deployment resource "aws_api_gateway_deployment" "api_deployment" { rest_api_id = aws_api_gateway_rest_api.api.id @@ -547,7 +575,8 @@ resource "aws_api_gateway_deployment" "api_deployment" { module.order_service_lambda, module.session_lambda, module.order_status_lambda, - module.postcode_lookup_lambda + module.postcode_lookup_lambda, + module.result_status_lambda ] triggers = { @@ -560,7 +589,8 @@ resource "aws_api_gateway_deployment" "api_deployment" { module.order_service_lambda, module.session_lambda, module.order_status_lambda, - module.postcode_lookup_lambda + module.postcode_lookup_lambda, + module.result_status_lambda ])) } diff --git a/tests/tests/api/FullOrderEndpointTest.spec.ts b/tests/tests/api/FullOrderEndpointTest.spec.ts index 910d5e347..eb38cd86d 100644 --- a/tests/tests/api/FullOrderEndpointTest.spec.ts +++ b/tests/tests/api/FullOrderEndpointTest.spec.ts @@ -24,7 +24,6 @@ import { let orderId: string; let patientId: string; -let correlationId: string; const supplierId = OrderTestData.PREVENTX_SUPPLIER_ID; const POLL_TIMEOUT_MS = 15000; @@ -100,7 +99,6 @@ test.describe("Full Order E2E API", { tag: ["@API", "@db"] }, () => { const orderBody = CreateOrderResponseModel.fromJson(await orderResponse.json()); orderId = orderBody.orderUid; - correlationId = randomUUID(); patientId = (await testOrderDb.getPatientUidByNhsNumber(testedUser.nhsNumber!))!; @@ -170,7 +168,7 @@ test.describe("Full Order E2E API", { tag: ["@API", "@db"] }, () => { const testData = ResultsObservationData.buildNormalObservation(orderId, patientId, supplierId); const response = await hivResultsApi.submitTestResults( testData, - headersTestResults(correlationId), + headersTestResults(randomUUID()), ); expect(response.status()).toBe(201); @@ -181,13 +179,13 @@ test.describe("Full Order E2E API", { tag: ["@API", "@db"] }, () => { testedUser.nhsNumber!, testedUser.dob!, orderId, - correlationId, + randomUUID(), 200, ); const resultResponse = await hivResultsApi.getResult( createGetResultParams(testedUser.nhsNumber!, testedUser.dob!, orderId), - createGetResultHeaders(correlationId), + createGetResultHeaders(randomUUID()), ); hivResultsApi.validateStatus(resultResponse, 200); const resultBody = (await resultResponse.json()) as { @@ -263,7 +261,7 @@ test.describe("Full Order E2E API", { tag: ["@API", "@db"] }, () => { ); const response = await hivResultsApi.submitTestResults( testData, - headersTestResults(correlationId), + headersTestResults(randomUUID()), ); expect(response.status()).toBe(201); @@ -276,7 +274,7 @@ test.describe("Full Order E2E API", { tag: ["@API", "@db"] }, () => { testedUser.nhsNumber!, testedUser.dob!, orderId, - correlationId, + randomUUID(), 404, ); }); diff --git a/tests/tests/integration/UpdateResultStatus.spec.ts b/tests/tests/integration/UpdateResultStatus.spec.ts index 364728535..dd4b38b5b 100644 --- a/tests/tests/integration/UpdateResultStatus.spec.ts +++ b/tests/tests/integration/UpdateResultStatus.spec.ts @@ -9,7 +9,6 @@ import { headersTestResults } from "../../utils"; let orderId: string; let patientId: string; -let correlationId: string; const supplierName = "Preventx"; let supplierId: string; @@ -25,7 +24,6 @@ test.describe("Results Flow - Update Order Results Logic", { tag: "@db" }, () => orderId = result.order_uid; patientId = result.patient_uid; - correlationId = randomUUID(); console.log(`Created test order with ID: ${orderId}`); supplierId = await testOrderDb.getSupplierIdByName(supplierName); @@ -39,7 +37,7 @@ test.describe("Results Flow - Update Order Results Logic", { tag: "@db" }, () => const testData = ResultsObservationData.buildNormalObservation(orderId, patientId, supplierId); const response = await hivResultsApi.submitTestResults( testData, - headersTestResults(correlationId), + headersTestResults(randomUUID()), ); expect(response.status()).toBe(201); @@ -62,7 +60,7 @@ test.describe("Results Flow - Update Order Results Logic", { tag: "@db" }, () => const response = await hivResultsApi.submitTestResults( testData, - headersTestResults(correlationId), + headersTestResults(randomUUID()), ); expect(response.status()).toBe(201);