diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b73aa2..f8684f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: test_fsh: @@ -16,7 +16,7 @@ jobs: uses: actions/setup-node@v3 with: node-version: 17 - cache: 'npm' + cache: "npm" - name: Install sushi run: npm install -g fsh-sushi - name: Run sushi @@ -27,11 +27,13 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@v3 + - name: Run fake FHIR in docker + run: docker-compose -f docker-compose.dev.yml up -d - name: Use Node and enable cache uses: actions/setup-node@v3 with: node-version: 17 - cache: 'npm' + cache: "npm" - name: Install dependencies run: | npm ci @@ -39,8 +41,6 @@ jobs: run: npm run lint - name: Setup .env file run: cp env/dev.env .env - - name: Run fake FHIR in docker - run: docker-compose -f docker-compose.dev.yml up -d - name: npm build run: npm run build --if-present - name: npm test diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index afd3c2d..78f037f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,29 +1,12 @@ version: "3.7" services: fhir: - image: "hapiproject/hapi:v5.6.0" + image: "hapiproject/hapi:v6.1.0" ports: - "8090:8080" environment: - spring.datasource.url: "jdbc:postgresql://fhir-db:5432/hapi_r4" - spring.datasource.username: admin - spring.datasource.password: admin - spring.datasource.driverClassName: org.postgresql.Driver - hapi.fhir.subscription.resthook_enabled: "true" - hapi.fhir.subscription.websocket_enabled: "true" hapi.fhir.client_id_strategy: ANY hapi.fhir.cors.allowed_origin: "*" hapi.fhir.fhir_version: R4 hapi.fhir.allow_cascading_deletes: "true" hapi.fhir.reuse_cached_search_results_millis: -1 - fhir-db: - image: postgres:latest - volumes: - - fhir-db-data:/var/lib/postgresql/data - environment: - POSTGRES_PASSWORD: admin - POSTGRES_USER: admin - POSTGRES_DB: hapi_r4 - -volumes: - fhir-db-data: diff --git a/package.json b/package.json index 9091016..2b7c53c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build": "react-scripts build", "predeploy": "react-scripts build && cp build/index.html build/404.html", "deploy": "COMMIT_MSG=$(git log -1 --pretty=%B | sed 's/ /_/g'); gh-pages -d build --message ${COMMIT_MSG}", - "test": "react-scripts test", + "test": "react-scripts test --verbose --runInBand", "eject": "react-scripts eject", "lint": "eslint . --ext .js,.jsx,.ts,.tsx" }, diff --git a/src/code_systems/types.ts b/src/code_systems/types.ts index 18f6209..d7863f2 100644 --- a/src/code_systems/types.ts +++ b/src/code_systems/types.ts @@ -18,10 +18,18 @@ export type BundleResponse = { ]; }; }; + count?: number; }, ]; }; +export type ErrorDetails = { + errorCode: string; + resourceType: string; + diagnostics: string; +}; +export type RetrievableResource = "Practitioner" | "Patient" | "Observation"; + /** * FHIR reference with reference and type required. */ diff --git a/src/components/UI/ModalWrapper.tsx b/src/components/UI/ModalWrapper.tsx index 131139c..f881a70 100644 --- a/src/components/UI/ModalWrapper.tsx +++ b/src/components/UI/ModalWrapper.tsx @@ -4,14 +4,14 @@ import Modal from "./Modal"; import classes from "./Modal.module.css"; export interface ModalState { - message: string | null | undefined; + message: string | JSX.Element | null | undefined; isError: boolean | null | undefined; } export interface Props { onClear: () => void; isError: boolean | null | undefined; - modalMessage: string | null | undefined; + modalMessage: string | JSX.Element | null | undefined; } const ModalWrapper: FC = (props: Props) => { @@ -31,7 +31,7 @@ const ModalWrapper: FC = (props: Props) => { } > -

{modalMessage}

+
{modalMessage}
); }; diff --git a/src/components/reports/ReportForm.module.css b/src/components/reports/ReportForm.module.css index e03874e..4290fc9 100644 --- a/src/components/reports/ReportForm.module.css +++ b/src/components/reports/ReportForm.module.css @@ -43,3 +43,8 @@ text-align: center; text-decoration: underline; } + +.errors-table { + font: inherit; + font-size: 86%; +} diff --git a/src/components/reports/ReportForm.test.tsx b/src/components/reports/ReportForm.test.tsx index 96104bd..fb3df01 100644 --- a/src/components/reports/ReportForm.test.tsx +++ b/src/components/reports/ReportForm.test.tsx @@ -1,9 +1,10 @@ -import ReportForm from "./ReportForm"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Patient } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/patient"; -import { noValues } from "./FormDefaults"; import { act } from "react-dom/test-utils"; +import { createPractitioner, deleteFhirData, getResources, TestReportForm } from "../../fhir/testUtilities"; +import { Practitioner } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/practitioner"; +import { createIdentifier } from "../../fhir/resource_helpers"; const clearAndType = (element: Element, value: string) => { userEvent.clear(element); @@ -16,7 +17,7 @@ type DropDown = { }; const setDummyValues = (withDates: boolean, dropDowns?: DropDown[]) => { - const dummyValue = "Always the same"; + const dummyValue = "Always_the_same"; const form = screen.getByRole("form"); const textInputs = within(form).getAllByLabelText( /^((?!resultOutput|date|address|gender|specimen type|search|gene symbol|follow up).)*$/i, @@ -137,6 +138,38 @@ const setReportFields = async () => { jest.setTimeout(20000); describe("Report form", () => { + beforeEach(() => { + return deleteFhirData(); + }); + + test("Error modal exists", async () => { + // Arrange + const practitioner = new Practitioner(); + practitioner.resourceType = "Practitioner"; + const identifier = createIdentifier("always_the_same_report"); + practitioner.identifier = [identifier]; + + await createPractitioner(practitioner); + await createPractitioner(practitioner); + + // Act + render(); + await setLabAndPatient(); + await setSample(); + await setVariantFields(); + await setReportFields(); + + await act(async () => { + userEvent.click(screen.getByText(/submit/i)); + }); + + // Assert + await waitFor(() => { + expect(screen.getByText(/error/i, { selector: "h2" })).toBeInTheDocument(); + }); + // const errorModal = await screen.findByText(/error/i, { selector: "h2" }); + // expect(errorModal).toBeInTheDocument(); + }); /** * Given the report form * When all data filled in @@ -144,7 +177,7 @@ describe("Report form", () => { */ test("Report with variant", async () => { // Arrange - render(); + render(); // Act await setLabAndPatient(); @@ -167,7 +200,7 @@ describe("Report form", () => { */ test("Report without variant", async () => { // Arrange - render(); + render(); // Act await setLabAndPatient(); diff --git a/src/components/reports/ReportForm.tsx b/src/components/reports/ReportForm.tsx index 07fbc3e..50c90c9 100644 --- a/src/components/reports/ReportForm.tsx +++ b/src/components/reports/ReportForm.tsx @@ -6,7 +6,7 @@ import { FhirContext } from "../fhir/FhirContext"; import Card from "../UI/Card"; import classes from "./ReportForm.module.css"; import { addressSchema, patientSchema, reportDetailSchema, sampleSchema, variantsSchema } from "./formDataValidation"; -import { bundleRequest } from "../../fhir/api"; +import { bundleRequest, getErrors } from "../../fhir/api"; import Patient from "./formSteps/Patient"; import Sample from "./formSteps/Sample"; import Variant from "./formSteps/Variant"; @@ -63,10 +63,38 @@ const ReportForm: FC = (props: Props) => { const bundle = bundleRequest(values, reportedGenes); setResult(JSON.stringify(JSON.parse(bundle.body), null, 2)); + const resourceList = JSON.parse(bundle.body).entry.map((entry: any) => entry.resource.resourceType); ctx.client ?.request(bundle) - .then((response) => console.debug("Bundle submitted", bundle, response)) + .then((response) => { + const errors = getErrors(response, resourceList); + if (errors.length > 0) { + const errorsTable = ( + <> + + + + + + + + + + {errors.map((error, i) => ( + + + + + + ))} + +
CodeResourceInformation
{error.errorCode}{error.resourceType}{error.diagnostics}
+ + ); + setModal({ message: errorsTable, isError: true }); + } + }) .catch((error) => { console.error(error); setModal({ diff --git a/src/fhir/api.test.ts b/src/fhir/api.test.ts index 674ed03..574dc46 100644 --- a/src/fhir/api.test.ts +++ b/src/fhir/api.test.ts @@ -1,5 +1,4 @@ import { Fhir } from "fhir/fhir"; -import { BundleResponse } from "../code_systems/types"; import { createBundle } from "./api"; import { initialValues, initialWithNoVariant } from "../components/reports/FormDefaults"; import { Observation } from "fhir/r4"; @@ -7,97 +6,11 @@ import { geneCoding } from "../code_systems/hgnc"; import { Bundle } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/bundle"; import { BundleEntry } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/bundleEntry"; import { Patient } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/models-r4"; +import { sendBundle, deleteFhirData, getResources } from "./testUtilities"; const fhir = new Fhir(); const reportedGenes = [geneCoding("HGNC:4389", "GNA01")]; -const FHIR_URL = process.env.REACT_APP_FHIR_URL || ""; - -const checkResponseOK = async (response: Response) => { - const r = await response.json(); - - if (!response.ok) { - console.error(r.body); - throw new Error(response.statusText); - } - if (r.type !== "bundle-response") { - return r; - } - - const errors = (r as BundleResponse).entry - .filter((entry) => entry.response.status.toString().startsWith("4")) - .map((entry) => entry.response.outcome.issue); - - if (errors.length > 1) { - errors.forEach((issue) => { - let message = "unknown error in bundle"; - if (issue && issue.length > 0) { - message = issue[0].diagnostics; - } - throw new Error(message); - }); - } - - console.debug(`check response: ${JSON.stringify(r, null, 2)}`); - return r; -}; - -const getPatients = async (identifier?: string): Promise => { - let url = `${FHIR_URL}/Patient`; - if (identifier) url = `${FHIR_URL}/Patient?identifier=${identifier}`; - const response = await fetch(url); - return await checkResponseOK(response); -}; - -const getObservations = async (identifier?: string): Promise => { - let url = `${FHIR_URL}/Observation`; - if (identifier) url = `${FHIR_URL}/Observation?identifier=${identifier}`; - const response = await fetch(url); - return await checkResponseOK(response); -}; - -const sendBundle = async (bundle: Bundle) => { - const sentBundle = await fetch(`${FHIR_URL}/`, { - method: "POST", - body: JSON.stringify(bundle), - headers: { - "Content-Type": "application/json", - }, - }); - await new Promise((r) => setTimeout(r, 1500)); - return sentBundle; -}; - -/** - * Given an ID string - * this will delete the chosen patient's records, otherwise it will remove all patients by looping - * through and extracting IDs from the search bundle - */ -const deletePatients = async (patientId?: string) => { - const patientData = await getPatients(); - if (!("entry" in patientData)) { - console.debug("Nothing to delete; no patients in database"); - return; - } - if (patientId) { - await deleteAndCascadeDelete([patientId]); - } else { - const patientIds = patientData.entry?.map((entry) => entry.resource?.id) as string[]; - await deleteAndCascadeDelete(patientIds); - } - - // await new Promise((r) => setTimeout(r, 1500)); -}; - -const deleteAndCascadeDelete = async (patientIds: string[]) => { - console.debug(`deleting ids: ${patientIds}`); - for (const patientId of patientIds) { - const response = await fetch(`${FHIR_URL}/Patient/${patientId}?_cascade=delete`, { - method: "DELETE", - }); - await checkResponseOK(response); - } -}; jest.setTimeout(20000); @@ -119,16 +32,15 @@ const getPatientGivenNames = (patientData: Bundle) => { }; describe("FHIR resources", () => { - beforeEach(async () => { - fetchMock.dontMock(); - await deletePatients(); + beforeEach(() => { + return deleteFhirData(); }); /** * Before doing tests on the database, we want to clear all its data */ test("database is clear on setup", async () => { - const postDelete = await getPatients(); + const postDelete = await getResources("Patient"); expect("entry" in postDelete).toBeFalsy(); }); @@ -140,11 +52,10 @@ describe("FHIR resources", () => { test("bundle creates patient", async () => { const bundle = createBundle(initialValues, reportedGenes); - const createPatient = await sendBundle(bundle); + await sendBundle(bundle); // check it's the right patient - await checkResponseOK(createPatient); - const patientData = await getPatients(); + const patientData = await getResources("Patient"); expect("entry" in patientData).toBeTruthy(); expect(getPatientIdentifier(patientData)).toEqual(initialValues.patient.mrn); }); @@ -165,7 +76,7 @@ describe("FHIR resources", () => { // check it has the expected profile await sendBundle(bundle); const expectedProfile = "http://hl7.org/fhir/uv/genomics-reporting/StructureDefinition/variant"; - const obsResponse = await getObservations(); + const obsResponse = await getResources("Observation"); const varProfile = (obsResponse.entry as Array) .filter((entry) => entry.resource?.resourceType === "Observation") @@ -182,7 +93,7 @@ describe("FHIR resources", () => { test("Bundle without variants", async () => { const bundle = createBundle(initialWithNoVariant, []); await sendBundle(bundle); - const obsResponse = await getObservations(); + const obsResponse = await getResources("Observation"); // null variant entry const variantNotes = (obsResponse.entry as Array) @@ -212,14 +123,14 @@ describe("FHIR resources", () => { const originalBundle = createBundle(initialValues, reportedGenes); await sendBundle(originalBundle); - const originalPatient = await getPatients(identifier); + const originalPatient = await getResources("Patient", identifier); const newValues = { ...initialValues }; newValues.patient.firstName = "Daffy"; const updatedBundle = createBundle(newValues, reportedGenes); await sendBundle(updatedBundle); - const updatedPatient = await getPatients(identifier); + const updatedPatient = await getResources("Patient", identifier); // check it's the right patient by identifier expect(getPatientIdentifier(originalPatient)).toEqual(getPatientIdentifier(updatedPatient)); expect(getPatientIdentifier(originalPatient)).toEqual(initialValues.patient.mrn); diff --git a/src/fhir/api.ts b/src/fhir/api.ts index 4c44d4f..8669249 100644 --- a/src/fhir/api.ts +++ b/src/fhir/api.ts @@ -16,7 +16,7 @@ import { } from "./resources"; import { VariantSchema } from "../components/reports/formDataValidation"; import { loincResources } from "../code_systems/loincCodes"; -import { RequiredCoding } from "../code_systems/types"; +import { BundleResponse, ErrorDetails, RequiredCoding } from "../code_systems/types"; /** * Create a report bundle @@ -128,3 +128,23 @@ const createEntry = (resource: Resource, identifier?: string) => { request: requestInfo, }; }; + +export const getErrors = (errorData: BundleResponse, resourceTypes?: string[]) => { + const errorArray: ErrorDetails[] = []; + let count = 0; + + for (const error of errorData.entry) { + if (!error.response.status.toString().startsWith("2")) { + error.count = count; + const errorDetails: ErrorDetails = { + errorCode: error.response.status.toString(), + resourceType: resourceTypes?.[error.count] as string, + diagnostics: error.response.outcome.issue?.[0].diagnostics as string, + }; + errorArray.push(errorDetails); + } + count++; + } + + return errorArray; +}; diff --git a/src/fhir/testUtilities.tsx b/src/fhir/testUtilities.tsx new file mode 100644 index 0000000..861f740 --- /dev/null +++ b/src/fhir/testUtilities.tsx @@ -0,0 +1,127 @@ +import { Bundle } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/bundle"; +import { getErrors } from "./api"; +import { FhirContext } from "../components/fhir/FhirContext"; +import ReportForm from "../components/reports/ReportForm"; +import { noValues } from "../components/reports/FormDefaults"; +import FHIR from "fhirclient/lib/entry/browser"; +import React from "react"; +import { RetrievableResource } from "../code_systems/types"; +import { Practitioner } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/practitioner"; + +/** + * Utilities to be used only during testing, no actual tests here. + */ + +const FHIR_URL = process.env.REACT_APP_FHIR_URL || ""; + +export const createPractitioner = async (practitioner: Practitioner) => { + const sendPractitioner = await fetch(`${FHIR_URL}/Practitioner`, { + method: "POST", + body: JSON.stringify(practitioner), + headers: { + "Content-Type": "application/json", + }, + }); + // await new Promise((r) => setTimeout(r, 500)); + return checkResponseOK(sendPractitioner); +}; + +export const sendBundle = async (bundle: Bundle) => { + const sentBundle = await fetch(`${FHIR_URL}/`, { + method: "POST", + body: JSON.stringify(bundle), + headers: { + "Content-Type": "application/json", + }, + }); + // await new Promise((r) => setTimeout(r, 500)); + return checkResponseOK(sentBundle); +}; + +const checkResponseOK = async (response: Response) => { + const jsonData = await response.json(); + if (!response.ok) { + console.error(JSON.stringify(jsonData)); + throw new Error(response.statusText); + } + if (!(jsonData.type === "bundle-response")) { + return jsonData; + } + const errors = getErrors(jsonData); + if (errors.length > 1) { + errors.forEach((error) => { + const message = error.diagnostics; + throw new Error(message); + }); + } + console.debug(`check response: ${JSON.stringify(jsonData, null, 2)}`); + return jsonData; +}; + +export const getResources = async ( + resource: "Practitioner" | "Patient" | "Observation", + id?: string, +): Promise => { + let url = `${FHIR_URL}/${resource}`; + if (id) { + if (resource === "Practitioner") { + url = `${FHIR_URL}/Practitioner/${id}`; + } + url = `${FHIR_URL}/${resource}?identifier=${id}`; + } + const response = await fetch(url); + return await checkResponseOK(response); +}; + +export const deleteFhirData = async (resource?: RetrievableResource, id?: string) => { + let resources = [resource]; + + if (!resource) { + resources = ["Patient", "Practitioner"]; + } + for (const resourceToDelete of resources) { + const fhirData = await getResources(resourceToDelete as RetrievableResource); + if (!("entry" in fhirData)) { + console.debug(`No ${resourceToDelete} to delete`); + continue; + } + if (id) { + await deleteAndCascadeDelete([id], resourceToDelete as RetrievableResource); + } else { + const resourceId = fhirData.entry?.map((entry) => entry.resource?.id) as string[]; + await deleteAndCascadeDelete(resourceId, resourceToDelete as RetrievableResource); + } + } +}; + +const deleteAndCascadeDelete = async (identifiers: string[], resource: RetrievableResource) => { + console.debug(`deleting ${identifiers.length}x ids of resource '${resource}'`); + for (const id of identifiers) { + const response = await fetch(`${FHIR_URL}/${resource}/${id}?_cascade=delete`, { + method: "DELETE", + }); + await checkResponseOK(response); + // await new Promise((r) => setTimeout(r, 500)); + } +}; + +/** + * Report Form setup for testing of the modal output. + * + * Contains hooks for displaying the modal and the fhir client context being set. + * @constructor + */ +export const TestReportForm: React.FC = () => { + const client = FHIR.client(FHIR_URL); + + return ( + <> +
+ +
+ "" }}> + + + + ); +}; diff --git a/src/setupTests.ts b/src/setupTests.ts index 10d6db4..265d241 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -2,15 +2,29 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom +import { Practitioner } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/practitioner"; import "@testing-library/jest-dom"; import { enableFetchMocks } from "jest-fetch-mock"; +import { createIdentifier } from "./fhir/resource_helpers"; +import { createPractitioner } from "./fhir/testUtilities"; -enableFetchMocks(); +beforeAll(async () => { + /* + * For some reason on first load of FHIR server, creating a batch of requests doesn't seem to validate + * Sending a resource before any test are run fixed the issue + */ + const practitioner = new Practitioner(); + practitioner.resourceType = "Practitioner"; + practitioner.identifier = [createIdentifier("initial")]; + await createPractitioner(practitioner); + + enableFetchMocks(); +}); global.beforeEach(() => { fetchMock.resetMocks(); - fetchMock.mockResponse((request: Request) => { + fetchMock.mockIf(/clinicaltables\.nlm\.nih\.gov\/api\/genes\/v4\/search/, (request: Request) => { let requestData: (number | string[] | string[][] | null)[] = [0, [], null, []]; if (request.url.includes("TW")) { requestData = [2, ["TWO", "TWENTY"], null, [["TWO_SYMBOL"], ["TWENTY_SYMBOL"]]];