diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84791b2..920051c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: run: cp env/dev.env .env - name: Run fake FHIR in docker run: docker-compose -f docker-compose.dev.yml up -d - - name: Build + - name: npm build run: npm run build --if-present - name: npm test env: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 416a273..afd3c2d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,6 +14,8 @@ services: 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: diff --git a/src/code_systems/types.ts b/src/code_systems/types.ts index b69d622..57b95dc 100644 --- a/src/code_systems/types.ts +++ b/src/code_systems/types.ts @@ -4,3 +4,20 @@ import { Coding } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/models-r4"; * Subset of FHIR Coding Type, with all fields required. */ export type RequiredCoding = Required>; + +export type BundleResponse = { + entry: [ + { + response: { + status: string | number; + outcome: { + issue?: [ + { + diagnostics: string; + }, + ]; + }; + }; + }, + ]; +}; diff --git a/src/fhir/api.test.ts b/src/fhir/api.test.ts index d73a47e..0bbc57f 100644 --- a/src/fhir/api.test.ts +++ b/src/fhir/api.test.ts @@ -1,14 +1,146 @@ 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"; 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"; 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 === "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 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); + +const getPatientIdentifier = (patientData: Bundle) => { + const patientResource = getPatientResource(patientData); + return patientResource.identifier?.at(0)?.value; +}; + +const getPatientResource = (patientData: Bundle) => { + return patientData.entry + ?.map((entry) => entry.resource) + .filter((resource) => resource?.resourceType === "Patient") + .pop() as Patient; +}; + +const getPatientGivenNames = (patientData: Bundle) => { + const patientResource = getPatientResource(patientData); + return patientResource.name?.at(0)?.given; +}; describe("FHIR resources", () => { + beforeEach(async () => { + fetchMock.dontMock(); + await deletePatients(); + }); + + /** + * Before doing tests on the database, we want to clear all its data + */ + test("database is clear on setup", async () => { + const postDelete = await getPatients(); + expect("entry" in postDelete).toBeFalsy(); + }); + + /** + * Given no patients exist in the FHIR API + * When a report bundle is sent to the FHIR API + * Then there should be one entry in the FHIR API, and the identifier should be the MRN from the bundle + */ + test("bundle creates patient", async () => { + const bundle = createBundle(initialValues, reportedGenes); + + const createPatient = await sendBundle(bundle); + // check it's the right patient + await checkResponseOK(createPatient); + const patientData = await getPatients(); + expect("entry" in patientData).toBeTruthy(); + expect(getPatientIdentifier(patientData)).toEqual(initialValues.patient.mrn); + }); + /** * Given that form data has been correctly populated * When a FHIR bundle is created @@ -32,8 +164,8 @@ describe("FHIR resources", () => { const bundle = createBundle(initialWithNoVariant, []); // null variant entry - const variantNotes = bundle.entry - .filter((entry) => entry.resource.resourceType === "Observation") + const variantNotes = (bundle.entry as Array) + .filter((entry) => entry.resource?.resourceType === "Observation") .map((entry) => entry.resource as Observation) .filter((obs) => obs.meta?.profile?.includes("http://hl7.org/fhir/uv/genomics-reporting/StructureDefinition/Variant"), @@ -49,4 +181,30 @@ describe("FHIR resources", () => { console.info(JSON.stringify(output.messages, null, 2)); expect(output.valid).toBeTruthy(); }); + /** + * Given that a patient has been created + * When their details are updated + * Then the updated values should persist + */ + test("Information can be updated", async () => { + const identifier = initialValues.patient.mrn; + const originalBundle = createBundle(initialValues, reportedGenes); + + await sendBundle(originalBundle); + const originalPatient = await getPatients(identifier); + + const newValues = { ...initialValues }; + newValues.patient.firstName = "Daffy"; + const updatedBundle = createBundle(newValues, reportedGenes); + + await sendBundle(updatedBundle); + const updatedPatient = await getPatients(identifier); + // check it's the right patient by identifier + expect(getPatientIdentifier(originalPatient)).toEqual(getPatientIdentifier(updatedPatient)); + expect(getPatientIdentifier(originalPatient)).toEqual(initialValues.patient.mrn); + // check that the new value is in the updated entry + expect(getPatientGivenNames(updatedPatient)).toEqual(["Daffy"]); + // check the two entries are different + expect(getPatientGivenNames(updatedPatient)).not.toEqual(getPatientGivenNames(originalPatient)); + }); }); diff --git a/src/fhir/api.ts b/src/fhir/api.ts index ad976e9..0251bec 100644 --- a/src/fhir/api.ts +++ b/src/fhir/api.ts @@ -1,4 +1,4 @@ -import { Resource } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/models-r4"; +import { Bundle, Resource } from "@smile-cdr/fhirts/dist/FHIR-R4/classes/models-r4"; import { FormValues } from "../components/reports/ReportForm"; import { createNullVariantAndIdentifier, @@ -17,7 +17,6 @@ import { import { VariantSchema } from "../components/reports/formDataValidation"; import { loincResources } from "../code_systems/loincCodes"; import { RequiredCoding } from "../code_systems/types"; -import { v4 as uuidv4 } from "uuid"; /** * Create a report bundle @@ -33,7 +32,7 @@ export const bundleRequest = (form: FormValues, reportedGenes: RequiredCoding[]) }; }; -export const createBundle = (form: FormValues, reportedGenes: RequiredCoding[]) => { +export const createBundle = (form: FormValues, reportedGenes: RequiredCoding[]): Bundle => { const org = organisationAndIdentifier(form.address); const patient = patientAndIdentifier(form.patient, org.identifier); const specimen = specimenAndIdentifier(form.sample, patient.identifier); @@ -91,12 +90,11 @@ export const createBundle = (form: FormValues, reportedGenes: RequiredCoding[]) variants.map((variant) => variant.identifier), ); return { - id: uuidv4(), resourceType: "Bundle", - type: "batch", + type: Bundle.TypeEnum.Batch, entry: [ - createEntry(patient.resource, patient.identifier), createEntry(org.resource, org.identifier), + createEntry(patient.resource, patient.identifier), createEntry(specimen.resource, specimen.identifier), createEntry(authoriser.resource), createEntry(reporter.resource),