diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4f092d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "jest.jestCommandLine": "/workspaces/nhs-eps-spine-client/node_modules/.bin/jest --no-cache", + "jest.nodeEnv": { + "POWERTOOLS_DEV": true, + "NODE_OPTIONS": "--experimental-vm-modules" + } +} diff --git a/src/live-spine-client.ts b/src/live-spine-client.ts index 62a4d36..43022f6 100644 --- a/src/live-spine-client.ts +++ b/src/live-spine-client.ts @@ -13,6 +13,7 @@ import axiosRetry from "axios-retry" import {handleCallError, handleErrorResponse} from "./utils" import Mustache from "mustache" import CLINICAL_CONTENT_VIEW_TEMPLATE from "./resources/clinical_content_view" +import PRESCRIPTION_SEARCH_TEMPLATE from "./resources/prescription_search" // timeout in ms to wait for response from spine to avoid lambda timeout const SPINE_TIMEOUT = 45000 @@ -20,27 +21,76 @@ const SPINE_TIMEOUT = 45000 // Clinical Content View Globals const CLINICAL_VIEW_REQUEST_PATH = "syncservice-pds/pds" +// Prescription Search Globals +const PRESCRIPTION_SEARCH_REQUEST_PATH = "syncservice-pds/pds" + export interface ClinicalViewParams { - requestId: string, - prescriptionId: string, - organizationId: string, - repeatNumber?: string, - sdsRoleProfileId: string, - sdsId: string, - jobRoleCode: string + requestId: string, + prescriptionId: string, + organizationId: string, + repeatNumber?: string, + sdsRoleProfileId: string, + sdsId: string, + jobRoleCode: string } interface ClinicalContentViewPartials { - messageGUID: string, - toASID: string, - fromASID: string - creationTime: string, - agentPersonSDSRoleProfileId: string, - agentPersonSDSId: string, - agentPersonJobRoleCode: string, - organizationId: string, - prescriptionId: string, - repeatNumber: string + messageGUID: string, + toASID: string, + fromASID: string + creationTime: string, + agentPersonSDSRoleProfileId: string, + agentPersonSDSId: string, + agentPersonJobRoleCode: string, + organizationId: string, + prescriptionId: string, + repeatNumber: string +} + +export interface PrescriptionSearchParams { + requestId: string, + prescriptionId: string, + organizationId: string, + sdsRoleProfileId: string, + sdsId: string, + jobRoleCode: string, + nhsNumber?: string + dispenserOrg?: string + prescriberOrg?: string + releaseVersion?: string + prescriptionStatus?: string + prescriptionStatus1?: string + prescriptionStatus2?: string + prescriptionStatus3?: string + creationDateRange?: { + lowDate?: string + highDate?: string + } + mySiteOrganisation?: string +} + +export interface PrescriptionSearchPartials { + messageGUID: string, + toASID: string, + fromASID: string + creationTime: string, + agentPersonSDSRoleProfileId: string, + agentPersonSDSId: string, + agentPersonJobRoleCode: string, + prescriptionId?: string + nhsNumber?: string + dispenserOrg?: string + prescriberOrg?: string + releaseVersion?: string + prescriptionStatus?: string + prescriptionStatus1?: string + prescriptionStatus2?: string + prescriptionStatus3?: string + creationDateRange?: { + lowDate?: string + highDate?: string + } + mySiteOrganisation?: string } export class LiveSpineClient implements SpineClient { @@ -118,10 +168,9 @@ export class LiveSpineClient implements SpineClient { timeout: SPINE_TIMEOUT }) - // This can be removed when https://nhsd-jira.digital.nhs.uk/browse/AEA-3448 is complete handleErrorResponse(this.logger, response) return response - } catch (error) { + } catch(error) { handleCallError(this.logger, error) } } @@ -131,14 +180,14 @@ export class LiveSpineClient implements SpineClient { } async getStatus(): Promise { - if (!this.isCertificateConfigured()) { + if(!this.isCertificateConfigured()) { return {status: "pass", message: "Spine certificate is not configured"} } const axiosConfig: AxiosRequestConfig = {timeout: 20000} let endpoint: string - if (process.env.healthCheckUrl === undefined) { + if(process.env.healthCheckUrl === undefined) { axiosConfig.httpsAgent = this.httpsAgent endpoint = this.getSpineEndpoint("healthcheck") } else { @@ -202,6 +251,58 @@ export class LiveSpineClient implements SpineClient { } } + async prescriptionSearch( + inboundHeaders: APIGatewayProxyEventHeaders, + params: PrescriptionSearchParams + ): Promise { + try { + const address = this.getSpineEndpoint(PRESCRIPTION_SEARCH_REQUEST_PATH) + + const outboundHeaders = { + "nhsd-correlation-id": inboundHeaders["nhsd-correlation-id"], + "nhsd-request-id": inboundHeaders["nhsd-request-id"], + "x-request-id": inboundHeaders["x-request-id"], + "x-correlation-id": inboundHeaders["x-correlation-id"], + "SOAPAction": "urn:nhs:names:services:mmquery/PRESCRIPTIONSEARCH_SM01" + } + + const partials: PrescriptionSearchPartials = { + messageGUID: params.requestId, + toASID: this.spineASID ?? "", + fromASID: this.spineASID ?? "", + creationTime: new Date().toISOString(), + agentPersonSDSRoleProfileId: params.sdsRoleProfileId, + agentPersonSDSId: params.sdsId, + agentPersonJobRoleCode: params.jobRoleCode, + prescriptionId: params.prescriptionId, + nhsNumber: params.nhsNumber, + dispenserOrg: params.dispenserOrg, + prescriberOrg: params.prescriberOrg, + releaseVersion: params.releaseVersion, + prescriptionStatus: params.prescriptionStatus, + prescriptionStatus1: params.prescriptionStatus1, + prescriptionStatus2: params.prescriptionStatus2, + prescriptionStatus3: params.prescriptionStatus3, + creationDateRange: params.creationDateRange, + mySiteOrganisation: params.mySiteOrganisation + } + + const requestBody = Mustache.render(PRESCRIPTION_SEARCH_TEMPLATE, partials) + + this.logger.info(`Making request to ${address}`) + const response = await this.axiosInstance.post(address, requestBody, { + headers: outboundHeaders, + httpsAgent: this.httpsAgent, + timeout: SPINE_TIMEOUT + }) + + handleErrorResponse(this.logger, response) + return response + } catch(error) { + handleCallError(this.logger, error) + } + } + onAxiosRetry = (retryCount, error) => { this.logger.warn(error) this.logger.warn(`Call to spine failed - retrying. Retry count ${retryCount}`) diff --git a/src/resources/prescription_search.ts b/src/resources/prescription_search.ts new file mode 100644 index 0000000..af9b3fa --- /dev/null +++ b/src/resources/prescription_search.ts @@ -0,0 +1,114 @@ +/* eslint-disable max-len */ +export default ` + + + uuid:{{messageGUID}} + urn:nhs:names:services:mmquery/PRESCRIPTIONSEARCH_SM01 + https://pds-sync.national.ncrs.nhs.uk/syncservice-pds/pds + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#prescriptionId}} + + {{/prescriptionId}} + {{#nhsNumber}} + + {{/nhsNumber}} + {{#dispenserOrg}} + + {{/dispenserOrg}} + {{#prescriberOrg}} + + {{/prescriberOrg}} + {{#releaseVersion}} + + {{/releaseVersion}} + {{#prescriptionStatus}} + + {{/prescriptionStatus}} + {{#prescriptionStatus1}} + + {{/prescriptionStatus1}} + {{#prescriptionStatus2}} + + {{/prescriptionStatus2}} + {{#prescriptionStatus3}} + + {{/prescriptionStatus3}} + {{#creationDateRange}} + + + + {{#lowDate}} + + {{/lowDate}} + {{#highDate}} + + {{/highDate}} + + + + {{/creationDateRange}} + {{#mySiteOrganisation}} + + {{/mySiteOrganisation}} + + + + +` diff --git a/src/resources/prescription_search_sandbox.ts b/src/resources/prescription_search_sandbox.ts new file mode 100644 index 0000000..4f006e1 --- /dev/null +++ b/src/resources/prescription_search_sandbox.ts @@ -0,0 +1,133 @@ +/* eslint-disable max-len */ +export default ` + + uuid:1234567890123456 + urn:nhs:names:services:mmquery/PRESCRIPTIONSEARCHRESPONSE_SM01 + + + https://mmquery.national.ncrs.nhs.uk/syncservice + + + + + + + + + + + + uuid:9C564D38-D4C9-11E2-9720-0800271DF5D7 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` diff --git a/src/sandbox-spine-client.ts b/src/sandbox-spine-client.ts index 0b62448..dc6d0c3 100644 --- a/src/sandbox-spine-client.ts +++ b/src/sandbox-spine-client.ts @@ -2,6 +2,7 @@ import {AxiosResponse} from "axios" import {SpineClient} from "./spine-client" import {StatusCheckResponse} from "./status" import CLINICAL_CONTENT_VIEW_SANDBOX_RESPONSE from "./resources/clinical_content_view_sandbox" +import PRESCRIPTION_SEARCH_SANDBOX_RESPONSE from "./resources/prescription_search_sandbox" export class SandboxSpineClient implements SpineClient { async getStatus(): Promise { @@ -30,4 +31,13 @@ export class SandboxSpineClient implements SpineClient { } as unknown as AxiosResponse return Promise.resolve(response) } + + async prescriptionSearch(): Promise { + const response: AxiosResponse = { + data: PRESCRIPTION_SEARCH_SANDBOX_RESPONSE, + status: 200, + statusText: "OK" + } as unknown as AxiosResponse + return Promise.resolve(response) + } } diff --git a/src/spine-client.ts b/src/spine-client.ts index a4760ad..6dbf57d 100644 --- a/src/spine-client.ts +++ b/src/spine-client.ts @@ -1,5 +1,5 @@ import {Logger} from "@aws-lambda-powertools/logger" -import {ClinicalViewParams, LiveSpineClient} from "./live-spine-client" +import {ClinicalViewParams, PrescriptionSearchParams, LiveSpineClient} from "./live-spine-client" import {SandboxSpineClient} from "./sandbox-spine-client" import {APIGatewayProxyEventHeaders} from "aws-lambda" import {AxiosResponse} from "axios" @@ -19,11 +19,15 @@ export interface SpineClient { inboundHeaders: APIGatewayProxyEventHeaders, params: ClinicalViewParams ): Promise + prescriptionSearch( + inboundHeaders: APIGatewayProxyEventHeaders, + params: PrescriptionSearchParams + ): Promise } export function createSpineClient(logger: Logger): SpineClient { const liveMode = process.env.TargetSpineServer !== "sandbox" - if (liveMode) { + if(liveMode) { return new LiveSpineClient(logger) } else { return new SandboxSpineClient() diff --git a/tests/prescriptonSearch.test.ts b/tests/prescriptonSearch.test.ts new file mode 100644 index 0000000..4e5f19b --- /dev/null +++ b/tests/prescriptonSearch.test.ts @@ -0,0 +1,144 @@ +import {PrescriptionSearchParams, LiveSpineClient} from "../src/live-spine-client" +import {jest, expect, describe} from "@jest/globals" +import MockAdapter from "axios-mock-adapter" +import axios from "axios" +import {Logger} from "@aws-lambda-powertools/logger" + +const mock = new MockAdapter(axios) +process.env.TargetSpineServer = "spine" +type spineFailureTestData = { + httpResponseCode: number + spineStatusCode: string + errorMessage: string + scenarioDescription: string +} + +const mockParams: PrescriptionSearchParams = { + requestId: "request_id", + prescriptionId: "prescription_id", + organizationId: "organization_id", + sdsRoleProfileId: "sds_role_profile_id", + sdsId: "sds_id", + jobRoleCode: "job_role_code" +} + +const mockResponse = "" +const mockAddress = "https://spine/syncservice-pds/pds" +const mockHeaders = {} + +describe("live prescriptionSearch", () => { + const logger = new Logger({serviceName: "spineClient"}) + + afterEach(() => { + mock.reset() + }) + + test("successful response when http response is status 200", async () => { + mock.onPost(mockAddress).reply(200, mockResponse) + const spineClient = new LiveSpineClient(logger) + const spineResponse = await spineClient.prescriptionSearch(mockHeaders, mockParams) + + expect(spineResponse.status).toBe(200) + expect(spineResponse.data).toStrictEqual(mockResponse) + }) + + test("log response time on successful call", async () => { + mock.onPost(mockAddress).reply(200, mockResponse) + const mockLoggerInfo = jest.spyOn(Logger.prototype, "info") + const spineClient = new LiveSpineClient(logger) + + await spineClient.prescriptionSearch(mockHeaders, mockParams) + + expect(mockLoggerInfo).toHaveBeenCalledWith("spine request duration", {"spine_duration": expect.any(Number)}) + }) + + test("log response time on unsuccessful call", async () => { + mock.onPost(mockAddress).reply(401) + const mockLoggerInfo = jest.spyOn(Logger.prototype, "info") + const spineClient = new LiveSpineClient(logger) + + await expect(spineClient.prescriptionSearch(mockHeaders, mockParams)) + .rejects.toThrow("Request failed with status code 401") + + expect(mockLoggerInfo).toHaveBeenCalledWith("spine request duration", {"spine_duration": expect.any(Number)}) + }) + + const testCases: Array = [ + { + httpResponseCode: 200, + spineStatusCode: "99", + errorMessage: "Unsuccessful status code response from spine", + scenarioDescription: "spine returns a non-successful response status" + }, + { + httpResponseCode: 500, + spineStatusCode: "0", + errorMessage: "Request failed with status code 500", + scenarioDescription: "spine returns an unsuccessful HTTP status code" + } + ] + + const runTestCase = async ({ + httpResponseCode, + spineStatusCode, + errorMessage + }: spineFailureTestData) => { + mock.onPost(mockAddress).reply(httpResponseCode, {statusCode: spineStatusCode}) + const spineClient = new LiveSpineClient(logger) + + await expect(spineClient.prescriptionSearch(mockHeaders, mockParams)).rejects.toThrow(errorMessage) + } + + test.each(testCases)( + "should throw an error when $scenarioDescription", + runTestCase + ) + + test.each(testCases)( + "should throw an error when $scenarioDescription", + async (testCase) => { + await runTestCase(testCase) + } + ) + + test("should throw error when unsuccessful http request", async () => { + const spineClient = new LiveSpineClient(logger) + mock.onPost(mockAddress).networkError() + await expect(spineClient.prescriptionSearch(mockHeaders, mockParams)).rejects.toThrow("Network Error") + }) + + test("should throw error when timeout on http request", async () => { + const spineClient = new LiveSpineClient(logger) + mock.onPost(mockAddress).timeout() + await expect(spineClient.prescriptionSearch(mockHeaders, mockParams)).rejects.toThrow("timeout of 45000ms exceeded") + }) + + test("should not throw error when one unsuccessful and one successful http request", async () => { + mock + .onPost(mockAddress).networkErrorOnce() + .onPost(mockAddress).reply(200, mockResponse) + + const mockLoggerWarn = jest.spyOn(Logger.prototype, "warn") + + const spineClient = new LiveSpineClient(logger) + + const spineResponse = await spineClient.prescriptionSearch(mockHeaders, mockParams) + + expect(spineResponse.status).toBe(200) + expect(spineResponse.data).toStrictEqual(mockResponse) + expect(mockLoggerWarn).toHaveBeenCalledWith("Call to spine failed - retrying. Retry count 1") + }) + + test("should retry only 3 times when http request errors", async () => { + mock.onPost(mockAddress).networkError() + const mockLoggerWarn = jest.spyOn(Logger.prototype, "warn") + + const spineClient = new LiveSpineClient(logger) + + await expect(spineClient.prescriptionSearch(mockHeaders, mockParams)).rejects.toThrow("Network Error") + expect(mockLoggerWarn).toHaveBeenCalledWith("Call to spine failed - retrying. Retry count 1") + expect(mockLoggerWarn).toHaveBeenCalledWith("Call to spine failed - retrying. Retry count 2") + expect(mockLoggerWarn).toHaveBeenCalledWith("Call to spine failed - retrying. Retry count 3") + expect(mockLoggerWarn).not.toHaveBeenCalledWith("Call to spine failed - retrying. Retry count 4") + }) +})