From 86bdc31d7a8453aed3eb5ca9b22d9a2dc538478f Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Fri, 18 Oct 2024 15:08:02 +0300 Subject: [PATCH 1/3] chore(#142): adding tests to increase coverage --- mediator/src/controllers/cht.ts | 6 +- mediator/src/controllers/tests/cht.spec.ts | 178 +++++++++ mediator/src/controllers/tests/utils.ts | 4 + mediator/src/mappers/openmrs.ts | 4 +- mediator/src/middlewares/schemas/encounter.ts | 4 + .../schemas/tests/cht-request-factories.ts | 18 + .../schemas/tests/fhir-resource-factories.ts | 24 +- .../tests/openmrs-resource-factories.ts | 32 ++ mediator/src/routes/cht.ts | 2 +- mediator/src/routes/tests/cht.spec.ts | 58 --- mediator/src/utils/cht.ts | 12 +- mediator/src/utils/fhir.ts | 34 +- mediator/src/utils/openmrs.ts | 40 +- mediator/src/utils/openmrs_sync.ts | 13 +- mediator/src/utils/tests/cht.spec.ts | 92 ++++- mediator/src/utils/tests/fhir.spec.ts | 134 ++++++- mediator/src/utils/tests/openmrs.spec.ts | 82 +++++ mediator/src/utils/tests/openmrs_sync.spec.ts | 342 ++++++++++++------ 18 files changed, 820 insertions(+), 259 deletions(-) create mode 100644 mediator/src/controllers/tests/cht.spec.ts create mode 100644 mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts delete mode 100644 mediator/src/routes/tests/cht.spec.ts create mode 100644 mediator/src/utils/tests/openmrs.spec.ts diff --git a/mediator/src/controllers/cht.ts b/mediator/src/controllers/cht.ts index 960ab638..2cb4d4db 100644 --- a/mediator/src/controllers/cht.ts +++ b/mediator/src/controllers/cht.ts @@ -27,7 +27,7 @@ export async function createPatient(chtPatientDoc: any) { export async function updatePatientIds(chtFormDoc: any) { // first, get the existing patient from fhir server - const response = await getFHIRPatientResource(chtFormDoc.external_id); + const response = await getFHIRPatientResource(chtFormDoc.doc.external_id); if (response.status != 200) { return { status: 500, data: { message: `FHIR responded with ${response.status}`} }; @@ -37,10 +37,10 @@ export async function updatePatientIds(chtFormDoc: any) { } const fhirPatient = response.data.entry[0].resource; - addId(fhirPatient, chtPatientIdentifierType, chtFormDoc.patient_id); + addId(fhirPatient, chtPatientIdentifierType, chtFormDoc.doc.patient_id); // now, we need to get the actual patient doc from cht... - const patient_uuid = await getPatientUUIDFromSourceId(chtFormDoc._id); + const patient_uuid = await getPatientUUIDFromSourceId(chtFormDoc.doc._id); if (patient_uuid){ addId(fhirPatient, chtDocumentIdentifierType, patient_uuid); return updateFhirResource({ ...fhirPatient, resourceType: 'Patient' }); diff --git a/mediator/src/controllers/tests/cht.spec.ts b/mediator/src/controllers/tests/cht.spec.ts new file mode 100644 index 00000000..446fcdd6 --- /dev/null +++ b/mediator/src/controllers/tests/cht.spec.ts @@ -0,0 +1,178 @@ +import { + createPatient, + updatePatientIds, + createEncounter +} from '../cht' +import { + ChtPatientFactory, + ChtSMSPatientFactory, + ChtPatientIdsFactory, + ChtPregnancyForm +} from '../../middlewares/schemas/tests/cht-request-factories'; +import { + PatientFactory, + EncounterFactory, + ObservationFactory +} from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { + chtDocumentIdentifierType, + chtPatientIdentifierType +} from '../../mappers/cht'; + +import * as fhir from '../../utils/fhir'; +import * as cht from '../../utils/cht'; + +import axios from 'axios'; +import { randomUUID } from 'crypto'; + +jest.mock('axios'); + +describe('CHT outgoing document controllers', () => { + describe('createPatient', () => { + it('creates a FHIR Patient from CHT patient doc', async () => { + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + + const data = ChtPatientFactory.build(); + + const res = await createPatient(data); + + expect(res.status).toBe(200); + + // assert that the create resource has the right identifier and type + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Patient', + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: data.doc._id + }) + ]), + }) + ); + }); + + it('creates a FHIR Patient from an SMS form using source id', async () => { + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + + let sourceId = randomUUID(); + jest.spyOn(cht, 'getPatientUUIDFromSourceId').mockResolvedValueOnce(sourceId); + + const data = ChtSMSPatientFactory.build(); + + const res = await createPatient(data); + + expect(res.status).toBe(200); + + // assert that the createid resource has the right identifier and type + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Patient', + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: sourceId + }) + ]), + }) + ); + }); + }); + + describe('updatePatientIds', () => { + it('updates patient ids', async () => { + const existingPatient = PatientFactory.build(); + jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValue({ + data: { total: 1, entry: [ { resource: existingPatient } ] }, + status: 200, + }); + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + + let sourceId = randomUUID(); + jest.spyOn(cht, 'getPatientUUIDFromSourceId').mockResolvedValueOnce(sourceId); + + const data = ChtPatientIdsFactory.build(); + + const res = await updatePatientIds(data); + + expect(res.status).toBe(200); + + // assert that the created resource has the right identifier and type + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + id: existingPatient.id, + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: sourceId + }) + ]), + }) + ); + }); + }); + + describe('createEncounter', () => { + it('creates FHIR Encounter from CHT form', async () => { + jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValueOnce({ + data: { total: 1, entry: [ { resource: PatientFactory.build() } ] }, + status: 200, + }); + // observations use createFhirResource + jest.spyOn(fhir, 'createFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + // encounter uses updatedFhirResource + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + + const data = ChtPregnancyForm.build(); + + const res = await createEncounter(data); + + expect(res.status).toBe(200); + + // assert that the encounter was created + expect(fhir.updateFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Encounter', + identifier: expect.arrayContaining([ + expect.objectContaining({ + type: chtDocumentIdentifierType, + value: data.id + }) + ]), + }) + ); + + // assert that at least one observation was created with the right codes + expect(fhir.createFhirResource).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'Observation', + code: { + coding: expect.arrayContaining([{ + code: data.observations[0].code + }]) + }, + valueCodeableConcept: { + coding: expect.arrayContaining([{ + code: data.observations[0].valueCode + }]) + } + }) + ); + }); + }); +}); diff --git a/mediator/src/controllers/tests/utils.ts b/mediator/src/controllers/tests/utils.ts index 5fe3e797..b582c66f 100644 --- a/mediator/src/controllers/tests/utils.ts +++ b/mediator/src/controllers/tests/utils.ts @@ -5,6 +5,7 @@ import { deleteFhirSubscription, createFHIRSubscriptionResource, } from '../../utils/fhir'; +import { queryCht } from '../../utils/cht'; jest.mock('../../utils/fhir'); jest.mock('../../utils/cht'); @@ -24,3 +25,6 @@ export const mockCreateFHIRSubscriptionResource = export const mockCreateChtRecord = createChtFollowUpRecord as jest.MockedFn< typeof createChtFollowUpRecord >; +export const mockQueryCht = queryCht as jest.MockedFn< + typeof queryCht +>; diff --git a/mediator/src/mappers/openmrs.ts b/mediator/src/mappers/openmrs.ts index 99f9a5fe..c1cf0424 100644 --- a/mediator/src/mappers/openmrs.ts +++ b/mediator/src/mappers/openmrs.ts @@ -14,7 +14,7 @@ export const openMRSIdentifierType: fhir4.CodeableConcept = { export const openMRSSource = 'openmrs'; -const visitNoteType: fhir4.CodeableConcept = { +export const visitNoteType: fhir4.CodeableConcept = { text: "Visit Note", coding: [{ system: "http://fhir.openmrs.org/code-system/encounter-type", @@ -23,7 +23,7 @@ const visitNoteType: fhir4.CodeableConcept = { }] } -const visitType: fhir4.CodeableConcept = { +export const visitType: fhir4.CodeableConcept = { text: "Home Visit", coding: [{ system: "http://fhir.openmrs.org/code-system/visit-type", diff --git a/mediator/src/middlewares/schemas/encounter.ts b/mediator/src/middlewares/schemas/encounter.ts index b6d3d50c..0665720a 100644 --- a/mediator/src/middlewares/schemas/encounter.ts +++ b/mediator/src/middlewares/schemas/encounter.ts @@ -21,4 +21,8 @@ export const EncounterSchema = joi.object({ type: joi.array().length(1).required(), subject: joi.required(), participant: joi.array().length(1).required(), + period: joi.object({ + start: joi.string(), + end: joi.string() + }) }); diff --git a/mediator/src/middlewares/schemas/tests/cht-request-factories.ts b/mediator/src/middlewares/schemas/tests/cht-request-factories.ts index c45dd7f1..337c1504 100644 --- a/mediator/src/middlewares/schemas/tests/cht-request-factories.ts +++ b/mediator/src/middlewares/schemas/tests/cht-request-factories.ts @@ -12,6 +12,24 @@ export const ChtPatientDoc = Factory.define('chtPatientDoc') .attr('sex', 'female') .attr('patient_id', randomUUID()); +export const ChtSMSPatientFactory = Factory.define('chtPatient') + .attr('doc', () => ChtSMSPatientDoc.build()) + +export const ChtSMSPatientDoc = Factory.define('chtPatientDoc') + .attr('_id', randomUUID()) + .attr('name', 'John Doe') + .attr('phone', '+9770000000') + .attr('date_of_birth', '2000-01-01') + .attr('sex', 'female') + .attr('source_id', randomUUID()); + +export const ChtPatientIdsFactory = Factory.define('chtPatientIds') + .attr('doc', () => ChtPatientIdsDoc.build()) + +export const ChtPatientIdsDoc = Factory.define('chtPatientIds') + .attr('external_id', randomUUID()) + .attr('patient_uuid', randomUUID()); + export const ChtPregnancyForm = Factory.define('chtPregnancyDoc') .attr('patient_uuid', randomUUID()) .attr('reported_date', Date.now()) diff --git a/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts b/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts index a489694d..34a6befa 100644 --- a/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts +++ b/mediator/src/middlewares/schemas/tests/fhir-resource-factories.ts @@ -1,12 +1,11 @@ import { randomUUID } from 'crypto'; import { Factory } from 'rosie'; import { VALID_CODE, VALID_SYSTEM } from '../endpoint'; +import { chtDocumentIdentifierType } from '../../../mappers/cht'; const identifier = [ { - type: { - text: 'CHT Document Identifier' - }, + type: chtDocumentIdentifierType, system: 'cht', value: randomUUID(), }, @@ -28,11 +27,15 @@ export const EncounterFactory = Factory.define('encounter') .attr('resourceType', 'Encounter') .attr('id', randomUUID()) .attr('identifier', identifier) - .attr('status', 'planned') + .attr('status', 'finished') .attr('class', 'outpatient') .attr('type', [{ text: 'Community health worker visit' }]) .attr('subject', { reference: 'Patient/3' }) - .attr('participant', [{ type: [{ text: 'Community health worker' }] }]); + .attr('participant', [{ type: [{ text: 'Community health worker' }] }]) + .attr('period', { + start: new Date(new Date().getTime() - 60 * 60 * 1000).toISOString(), + end: new Date(new Date().getTime() - 50 * 60 * 1000).toISOString() + }) export const EndpointFactory = Factory.define('endpoint') .attr('connectionType', { system: VALID_SYSTEM, code: VALID_CODE }) @@ -56,3 +59,14 @@ export const ServiceRequestFactory = Factory.define('serviceRequest') .attr('intent', 'order') .attr('subject', SubjectFactory.build()) .attr('requester', RequesterFactory.build()); + +export const ObservationFactory = Factory.define('Observation') + .attr('resourceType', 'Observation') + .attr('id', () => randomUUID()) + .attr('encounter', () => { reference: 'Encounter/' + randomUUID() }) + .attr('code', { + coding: [{ code: 'DANGER_SIGNS' }], + }) + .attr('valueCodeableConcept', { + coding: [{ code: 'HIGH_BLOOD_PRESSURE' }] + }); diff --git a/mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts b/mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts new file mode 100644 index 00000000..48907540 --- /dev/null +++ b/mediator/src/middlewares/schemas/tests/openmrs-resource-factories.ts @@ -0,0 +1,32 @@ +import { randomUUID } from 'crypto'; +import { Factory } from 'rosie'; +import { visitNoteType, visitType } from '../../../mappers/openmrs'; + +// creates an openmrs patient with the special address extension +export const OpenMRSPatientFactory = Factory.define('openMRSFhirPatient') + .attr('resourceType', 'Patient') + .attr('id', () => randomUUID()) // Assign a random UUID for the patient + .attr('address', ['addressKey', 'addressValue'], (addressKey, addressValue) => [ + { + extension: [{ + extension: [ + { + url: `http://fhir.openmrs.org/ext/address#${addressKey}`, + valueString: addressValue + } + ] + }] + } + ]); + +// creates an openmrs encounter with visit type +export const OpenMRSVisitFactory = Factory.define('openMRSVisit') + .attr('resourceType', 'Encounter') + .attr('id', () => randomUUID()) // Assign a random UUID for the patient + .attr('type', visitType); + +// creates an openmrs encounter with visit note type +export const OpenMRSVisitNoteFactory = Factory.define('openMRSVisit') + .attr('resourceType', 'Encounter') + .attr('id', () => randomUUID()) // Assign a random UUID for the patient + .attr('type', visitNoteType); diff --git a/mediator/src/routes/cht.ts b/mediator/src/routes/cht.ts index 4444d10b..1bebab29 100644 --- a/mediator/src/routes/cht.ts +++ b/mediator/src/routes/cht.ts @@ -13,7 +13,7 @@ router.post( router.post( '/patient_ids', - requestHandler((req) => updatePatientIds(req.body.doc)) + requestHandler((req) => updatePatientIds(req.body)) ); router.post( diff --git a/mediator/src/routes/tests/cht.spec.ts b/mediator/src/routes/tests/cht.spec.ts deleted file mode 100644 index c45fd724..00000000 --- a/mediator/src/routes/tests/cht.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import request from 'supertest'; -import app from '../../..'; -import { ChtPatientFactory, ChtPregnancyForm } from '../../middlewares/schemas/tests/cht-request-factories'; -import { PatientFactory, EncounterFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; -import * as fhir from '../../utils/fhir'; -import axios from 'axios'; - -jest.mock('axios'); - -describe('POST /cht/patient', () => { - it('accepts incoming request with valid patient resource', async () => { - jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ - data: {}, - status: 200, - }); - - const data = ChtPatientFactory.build(); - - const res = await request(app).post('/cht/patient').send(data); - - expect(res.status).toBe(200); - expect(res.body).toEqual({}); - - /* - expect(fhir.createFhirResource).toHaveBeenCalledWith({ - ...data, - resourceType: 'Patient', - }); - */ - expect(fhir.updateFhirResource).toHaveBeenCalled(); - }); - - it('accepts incoming request with valid form', async () => { - jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValueOnce({ - data: { total: 1, entry: [ { resource: PatientFactory.build() } ] }, - status: 200, - }); - jest.spyOn(fhir, 'createFhirResource').mockResolvedValueOnce({ - data: {}, - status: 200, - }); - - const data = ChtPregnancyForm.build(); - - const res = await request(app).post('/cht/encounter').send(data); - - expect(res.status).toBe(200); - expect(res.body).toEqual({}); - /* - expect(fhir.createFhirResource).toHaveBeenCalledWith({ - ...data, - resourceType: 'Patient', - }); - */ - expect(fhir.updateFhirResource).toHaveBeenCalled(); - }); - -}); diff --git a/mediator/src/utils/cht.ts b/mediator/src/utils/cht.ts index 32be635b..e59545cf 100644 --- a/mediator/src/utils/cht.ts +++ b/mediator/src/utils/cht.ts @@ -36,11 +36,11 @@ export async function createChtFollowUpRecord(patientId: string) { return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } -async function getLocation(fhirPatient: fhir4.Patient) { +export async function getLocationFromOpenMRSPatient(fhirPatient: fhir4.Patient) { // first, extract address value; is fchv area available? const addresses = fhirPatient.address?.[0]?.extension?.[0]?.extension; let addressKey = "http://fhir.openmrs.org/ext/address#address4" @@ -107,7 +107,7 @@ export async function createChtPatient(fhirPatient: fhir4.Patient) { cht_patient._meta = { form: "openmrs_patient" } - const location_id = await getLocation(fhirPatient); + const location_id = await getLocationFromOpenMRSPatient(fhirPatient); cht_patient.location_id = location_id; return chtRecordsApi(cht_patient); @@ -125,7 +125,7 @@ export async function chtRecordsApi(doc: any) { return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } @@ -136,7 +136,7 @@ export async function getChtDocumentById(doc_id: string) { return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } @@ -147,7 +147,7 @@ export async function queryCht(query: any) { return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } diff --git a/mediator/src/utils/fhir.ts b/mediator/src/utils/fhir.ts index 8a7547f3..33ce7a88 100644 --- a/mediator/src/utils/fhir.ts +++ b/mediator/src/utils/fhir.ts @@ -110,7 +110,7 @@ export async function getFHIRPatientResource(patientId: string) { return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } @@ -161,27 +161,30 @@ export async function deleteFhirSubscription(id?: string) { export async function createFhirResource(doc: fhir4.Resource) { try { const res = await axios.post(`${FHIR.url}/${doc.resourceType}`, doc, axiosOptions); - return { status: res.status, data: res.data }; + return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } export async function updateFhirResource(doc: fhir4.Resource) { try { const res = await axios.put(`${FHIR.url}/${doc.resourceType}/${doc.id}`, doc, axiosOptions); - return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } export async function getFhirResourcesSince(lastUpdated: Date, resourceType: string) { + return getResourcesSince(FHIR.url, lastUpdated, resourceType); +} + +export async function getResourcesSince(url: string, lastUpdated: Date, resourceType: string) { try { - let nextUrl = `${FHIR.url}/${resourceType}/?_lastUpdated=gt${lastUpdated.toISOString()}`; + let nextUrl = `${url}/${resourceType}/?_lastUpdated=gt${lastUpdated.toISOString()}`; let results: fhir4.Resource[] = []; // for encounters, include related resources if (resourceType === 'Encounter') { @@ -199,13 +202,13 @@ export async function getFhirResourcesSince(lastUpdated: Date, resourceType: str nextUrl = nextLink ? nextLink.url : null; if (nextUrl) { const qs = nextUrl.split('?')[1]; - nextUrl = `${FHIR.url}/?${qs}`; + nextUrl = `${url}/?${qs}`; } } return { status: 200, data: results }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } @@ -218,19 +221,6 @@ export async function getFhirResource(id: string, resourceType: string) { return { status: res?.status, data: res?.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; - } -} - -export async function getQuestionnaire(name: string){ - try { - const res = await axios.get( - `${FHIR.url}/Questionnaire`, - axiosOptions - ); - return { status: res?.status, data: res?.data }; - } catch (error: any) { - logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } diff --git a/mediator/src/utils/openmrs.ts b/mediator/src/utils/openmrs.ts index 6b404901..5bc87bc3 100644 --- a/mediator/src/utils/openmrs.ts +++ b/mediator/src/utils/openmrs.ts @@ -2,6 +2,7 @@ import { OPENMRS } from '../../config'; import axios from 'axios'; import { logger } from '../../logger'; import https from 'https'; +import { getResourcesSince } from './fhir'; const axiosOptions = { auth: { @@ -14,41 +15,8 @@ const axiosOptions = { timeout: OPENMRS.timeout }; -export async function getOpenMRSPatientResource(patientId: string) { - return await axios.get( - `${OPENMRS.url}/Patient/?identifier=${patientId}`, - axiosOptions - ); -} - export async function getOpenMRSResourcesSince(lastUpdated: Date, resourceType: string) { - try { - let nextUrl = `${OPENMRS.url}/${resourceType}/?_lastUpdated=gt${lastUpdated.toISOString()}`; - let results: fhir4.Resource[] = []; - // for encounters, include related resources - if (resourceType === 'Encounter') { - nextUrl = nextUrl + '&_revinclude=Observation:encounter&_include=Encounter:patient'; - } - - while (nextUrl) { - const res = await axios.get(nextUrl, axiosOptions); - - if (res.data.entry){ - results = results.concat(res.data.entry.map((entry: any) => entry.resource)); - } - - const nextLink = res.data.link && res.data.link.find((link: any) => link.relation === 'next'); - nextUrl = nextLink ? nextLink.url : null; - if (nextUrl) { - const qs = nextUrl.split('?')[1]; - nextUrl = `${OPENMRS.url}/?${qs}`; - } - } - return { status: 200, data: results }; - } catch (error: any) { - logger.error(error); - return { status: error.status, data: error.data }; - } + return getResourcesSince(OPENMRS.url, lastUpdated, resourceType); } export async function createOpenMRSResource(doc: fhir4.Resource) { @@ -57,7 +25,7 @@ export async function createOpenMRSResource(doc: fhir4.Resource) { return { status: res.status, data: res.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } @@ -67,6 +35,6 @@ export async function updateOpenMRSResource(doc: fhir4.Resource) { return { status: res.status, data: res.data }; } catch (error: any) { logger.error(error); - return { status: error.status, data: error.data }; + return { status: error.response?.status, data: error.response?.data }; } } diff --git a/mediator/src/utils/openmrs_sync.ts b/mediator/src/utils/openmrs_sync.ts index 81c2d715..509ed183 100644 --- a/mediator/src/utils/openmrs_sync.ts +++ b/mediator/src/utils/openmrs_sync.ts @@ -174,7 +174,7 @@ export async function syncPatients(startTime: Date){ /* Get a patient from a list of resources, by an encounters subject reference */ -function getPatient(encounter: fhir4.Encounter, references: fhir4.Resource[]): fhir4.Patient { +export function getPatient(encounter: fhir4.Encounter, references: fhir4.Resource[]): fhir4.Patient { return references.filter((resource) => { return resource.resourceType === 'Patient' && `Patient/${resource.id}` === encounter.subject?.reference })[0] as fhir4.Patient; @@ -184,7 +184,7 @@ function getPatient(encounter: fhir4.Encounter, references: fhir4.Resource[]): f Get a list of observations from a list of resources where the observations encounter reference is the encounter */ -function getObservations(encounter: fhir4.Encounter, references: fhir4.Resource[]): fhir4.Observation[] { +export function getObservations(encounter: fhir4.Encounter, references: fhir4.Resource[]): fhir4.Observation[] { return references.filter((resource) => { if (resource.resourceType === 'Observation') { const observation = resource as fhir4.Observation; @@ -201,7 +201,7 @@ function getObservations(encounter: fhir4.Encounter, references: fhir4.Resource[ Updates the OpenMRS Id on the CHT encounter to the VisitNote Sends Observations for the visitNote Encounter */ -async function sendEncounterToOpenMRS( +export async function sendEncounterToOpenMRS( encounter: fhir4.Encounter, references: fhir4.Resource[] ) { @@ -221,7 +221,7 @@ async function sendEncounterToOpenMRS( if (visitNoteResponse.status == 200 || visitNoteResponse.status == 201) { const visitNote = visitNoteResponse.data as fhir4.Encounter; // save openmrs id on orignal encounter - logger.info(`Updating Encounter ${patient.id} with openMRSId ${visitNote.id}`); + logger.info(`Updating Encounter ${encounter.id} with openMRSId ${visitNote.id}`); copyIdToNamedIdentifier(visitNote, encounter, openMRSIdentifierType); addSourceMeta(visitNote, chtSource); await updateFhirResource(encounter); @@ -238,7 +238,7 @@ async function sendEncounterToOpenMRS( Send Observation from OpenMRS to FHIR Replacing the subject reference */ -async function sendObservationToFhir(observation: fhir4.Observation, patient: fhir4.Patient) { +export async function sendObservationToFhir(observation: fhir4.Observation, patient: fhir4.Patient) { logger.info(`Sending Observation ${observation.code!.coding![0]!.code} to FHIR`); replaceReference(observation, 'subject', patient); createFhirResource(observation); @@ -251,7 +251,7 @@ async function sendObservationToFhir(observation: fhir4.Observation, patient: fh If this encounter matches a CHT form, gathers observations and sends them to CHT */ -async function sendEncounterToFhir( +export async function sendEncounterToFhir( encounter: fhir4.Encounter, references: fhir4.Resource[] ) { @@ -263,7 +263,6 @@ async function sendEncounterToFhir( logger.error(`Not sending encounter which is incomplete ${encounter.id}`); return } - logger.info(`Sending Encounter ${encounter.id} to FHIR`); const patient = getPatient(encounter, references); const observations = getObservations(encounter, references); diff --git a/mediator/src/utils/tests/cht.spec.ts b/mediator/src/utils/tests/cht.spec.ts index 7f99b3a5..205fbdbe 100644 --- a/mediator/src/utils/tests/cht.spec.ts +++ b/mediator/src/utils/tests/cht.spec.ts @@ -1,7 +1,15 @@ -import { createChtFollowUpRecord, generateChtRecordsApiUrl } from '../cht'; +import { + createChtFollowUpRecord, + generateChtRecordsApiUrl, + getLocationFromOpenMRSPatient, + queryCht } from '../cht'; import axios from 'axios'; +import { logger } from '../../../logger'; +import { OpenMRSPatientFactory } from '../../middlewares/schemas/tests/openmrs-resource-factories'; +import { mockQueryCht } from '../../controllers/tests/utils'; jest.mock('axios'); +jest.mock('../../../logger'); const mockAxios = axios as jest.Mocked; @@ -36,4 +44,86 @@ describe('CHT Utils', () => { expect(res).toContain(`${username}:${password}`); }); }); + + describe('getLocationFromOpenMRSPatient', () => { + it('should return place ID if address contains place ID', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'FCHV Area [12345]' + }); + + const result = await getLocationFromOpenMRSPatient(fhirPatient as any); + + expect(result).toBe('12345'); + }); + + it('should return an empty string if no address or place ID is found', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'Unknown Area' + }); + + mockQueryCht.mockResolvedValue({ status: 200, data: { docs: [] } }); // Simulating no result from the query + + const result = await getLocationFromOpenMRSPatient(fhirPatient as any); + + expect(result).toBe(''); + }); + + it('should return address5 if address4 is not available', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address5', + addressValue: 'Health Center [54321]' + }); + + const result = await getLocationFromOpenMRSPatient(fhirPatient as any); + + expect(result).toBe('54321'); + }); + + it('should handle error cases by returning an empty string when query fails', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'Unknown Location' + }); + + mockQueryCht.mockRejectedValue(new Error('Database query failed')); + + const result = await getLocationFromOpenMRSPatient(fhirPatient as any); + + expect(result).toBe(''); + }); + }); + + describe('queryCHT', () => { + it('should return data when the query is successful', async () => { + const mockQuery = { selector: { type: 'contact' } }; + const mockResponse = { status: 200, data: { docs: [{ place_id: '12345' }] } }; + + mockAxios.post.mockResolvedValue(mockResponse); // Simulate a successful response + + const result = await queryCht(mockQuery); + + expect(mockAxios.post).toHaveBeenCalledWith(expect.stringContaining('_find'), mockQuery, expect.anything()); + expect(result).toEqual(mockResponse); + }); + + it('should log an error and return error.response.data when the query fails', async () => { + const mockQuery = { selector: { type: 'contact' } }; + const mockError = { + response: { status: 500, data: 'Internal Server Error' } + } + + mockAxios.post.mockRejectedValue(mockError); // Simulate an error response + const loggerErrorSpy = jest.spyOn(logger, 'error'); // Spy on the logger's error method + + const result = await queryCht(mockQuery); + + expect(loggerErrorSpy).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + status: mockError.response.status, + data: mockError.response.data + }); + }); + }); }); diff --git a/mediator/src/utils/tests/fhir.spec.ts b/mediator/src/utils/tests/fhir.spec.ts index 63854743..a619327b 100644 --- a/mediator/src/utils/tests/fhir.spec.ts +++ b/mediator/src/utils/tests/fhir.spec.ts @@ -1,5 +1,8 @@ import { logger } from '../../../logger'; -import { EncounterFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { + EncounterFactory, + PatientFactory +} from '../../middlewares/schemas/tests/fhir-resource-factories'; import { createFHIRSubscriptionResource, createFhirResource, @@ -7,8 +10,11 @@ import { generateFHIRSubscriptionResource, getFHIROrgEndpointResource, getFHIRPatientResource, + getFhirResourcesSince, + addId } from '../fhir'; import axios from 'axios'; +import { FHIR } from '../../../config'; jest.mock('axios'); jest.mock('../../../logger'); @@ -201,7 +207,9 @@ describe('FHIR Utils', () => { }); it('should return an error if the FHIR server returns an error', async () => { - const data = { status: 400, data: { message: 'Bad request' } }; + const data = { + response: { status: 400, data: { message: 'Bad request' } } + }; mockAxios.post = jest.fn().mockRejectedValue(data); @@ -211,8 +219,128 @@ describe('FHIR Utils', () => { expect(mockAxios.post.mock.calls[0][0]).toContain(resourceType); expect(mockAxios.post.mock.calls[0][1]).toEqual({...encounter, resourceType}); expect(res.status).toEqual(400); - expect(res.data).toEqual(data.data); + expect(res.data).toEqual(data.response.data); expect(logger.error).toBeCalledTimes(1); }); }); + + describe('addIds', () => { + it('should add ids to a fhir patient', () => { + const patient = PatientFactory.build(); + const idType = { coding: [{ code: 'OpenMRS ID' }] }; + const value = '12345'; + + const result = addId(patient, idType, value); + + expect(result.identifier).toBeDefined(); + // patient has one idenditifer already, so afterwards, should be 2 + expect(result.identifier?.length).toBe(2); + // and the one we are checking is the second one + expect(result.identifier?.[1]).toEqual({ + type: idType, + value: value + }); + }); + }); + + describe('getFhirResourcesSince', () => { + it('should fetch FHIR resources successfully', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Patient'; + const mockResponse = { + data: { + entry: [ + { resource: { id: '123', resourceType: 'Patient' } } + ], + link: [] + } + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(mockAxios.get).toHaveBeenCalledWith( + `${FHIR.url}/Patient/?_lastUpdated=gt2023-01-01T00:00:00.000Z`, + expect.anything() // axiosOptions + ); + expect(result.status).toBe(200); + expect(result.data).toEqual([{ id: '123', resourceType: 'Patient' }]); + }); + + it('should include related resources for encounters', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Encounter'; + const mockResponse = { + data: { + entry: [ + { resource: { id: 'enc-123', resourceType: 'Encounter' } } + ], + link: [] + } + }; + mockAxios.get.mockResolvedValue(mockResponse); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(mockAxios.get).toHaveBeenCalledWith( + `${FHIR.url}/Encounter/?_lastUpdated=gt2023-01-01T00:00:00.000Z&_revinclude=Observation:encounter&_include=Encounter:patient`, + expect.anything() // axiosOptions + ); + expect(result.status).toBe(200); + expect(result.data).toEqual([{ id: 'enc-123', resourceType: 'Encounter' }]); + }); + + it('should handle pagination', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Patient'; + const mockFirstPageResponse = { + data: { + entry: [ + { resource: { id: '123', resourceType: 'Patient' } } + ], + link: [ + { relation: 'next', url: `${FHIR.url}/Patient/?page=2` } + ] + } + }; + const mockSecondPageResponse = { + data: { + entry: [ + { resource: { id: '124', resourceType: 'Patient' } } + ], + link: [] + } + }; + mockAxios.get + .mockResolvedValueOnce(mockFirstPageResponse) + .mockResolvedValueOnce(mockSecondPageResponse); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(mockAxios.get).toHaveBeenCalledTimes(2); + expect(result.status).toBe(200); + expect(result.data).toEqual([ + { id: '123', resourceType: 'Patient' }, + { id: '124', resourceType: 'Patient' } + ]); + }); + + it('should return an error if the request fails', async () => { + const lastUpdated = new Date('2023-01-01T00:00:00Z'); + const resourceType = 'Patient'; + const mockError = { + response: { + status: 500, + data: 'Internal Server Error' + } + }; + mockAxios.get.mockRejectedValue(mockError); + + const result = await getFhirResourcesSince(lastUpdated, resourceType); + + expect(logger.error).toHaveBeenCalledWith(mockError); + expect(result.status).toBe(500); + expect(result.data).toBe('Internal Server Error'); + }); + }); }); diff --git a/mediator/src/utils/tests/openmrs.spec.ts b/mediator/src/utils/tests/openmrs.spec.ts new file mode 100644 index 00000000..1a40447c --- /dev/null +++ b/mediator/src/utils/tests/openmrs.spec.ts @@ -0,0 +1,82 @@ +import axios from 'axios'; +import { createOpenMRSResource, updateOpenMRSResource, getOpenMRSResourcesSince } from '../openmrs'; +import { logger } from '../../../logger'; +import { OPENMRS } from '../../../config'; + +jest.mock('axios'); +jest.mock('../../../logger'); + +describe('OpenMRS utility functions', () => { + const mockAxiosGet = axios.get as jest.Mock; + const mockAxiosPost = axios.post as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createOpenMRSResource', () => { + it('should create a new OpenMRS resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockResponse = { status: 201, data: mockResource }; + mockAxiosPost.mockResolvedValue(mockResponse); + + const result = await createOpenMRSResource(mockResource); + + expect(mockAxiosPost).toHaveBeenCalledWith( + `${OPENMRS.url}/Patient`, + mockResource, + expect.anything() // axiosOptions + ); + expect(result).toEqual({ status: 201, data: mockResource }); + }); + + it('should handle errors when creating a resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockError = { + response: { status: 500, data: 'Internal Server Error' } + }; + mockAxiosPost.mockRejectedValue(mockError); + + const result = await createOpenMRSResource(mockResource); + + expect(logger.error).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + status: mockError.response.status, + data: mockError.response.data + }); + }); + }); + + describe('updateOpenMRSResource', () => { + it('should update an existing OpenMRS resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockResponse = { status: 200, data: mockResource }; + mockAxiosPost.mockResolvedValue(mockResponse); + + const result = await updateOpenMRSResource(mockResource); + + expect(mockAxiosPost).toHaveBeenCalledWith( + `${OPENMRS.url}/Patient/456`, + mockResource, + expect.anything() // axiosOptions + ); + expect(result).toEqual({ status: 200, data: mockResource }); + }); + + it('should handle errors when updating a resource', async () => { + const mockResource = { id: '456', resourceType: 'Patient' }; + const mockError = { + response: { status: 500, data: 'Internal Server Error' } + }; + mockAxiosPost.mockRejectedValue(mockError); + + const result = await updateOpenMRSResource(mockResource); + + expect(logger.error).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + status: mockError.response.status, + data: mockError.response.data + }); + }); + }); +}); diff --git a/mediator/src/utils/tests/openmrs_sync.spec.ts b/mediator/src/utils/tests/openmrs_sync.spec.ts index 765c68b4..6a0c0933 100644 --- a/mediator/src/utils/tests/openmrs_sync.spec.ts +++ b/mediator/src/utils/tests/openmrs_sync.spec.ts @@ -1,149 +1,261 @@ -import { compare, syncPatients, syncEncounters } from '../openmrs_sync'; +import { + compare, + syncPatients, + syncEncounters, + getPatient +} from '../openmrs_sync'; import * as fhir from '../fhir'; import * as openmrs from '../openmrs'; import * as cht from '../cht'; -import { PatientFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { PatientFactory, EncounterFactory, ObservationFactory } from '../../middlewares/schemas/tests/fhir-resource-factories'; +import { visitType, visitNoteType } from '../../mappers/openmrs'; +import { chtDocumentIdentifierType, chtPatientIdentifierType } from '../../mappers/cht'; +import { getIdType } from '../../utils/fhir'; import axios from 'axios'; +import { logger } from '../../../logger'; jest.mock('axios'); +jest.mock('../../../logger'); describe('OpenMRS Sync', () => { - it('compares resources with the gvien key', async () => { - const lastUpdated = new Date(); - lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); - - const constants = { - resourceType: 'Patient', - meta: { lastUpdated: lastUpdated } - } - - jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ - data: [ - { id: 'outgoing', ...constants }, - { id: 'toupdate', ...constants } - ], - status: 200, - }); - jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ - data: [ - { id: 'incoming', ...constants }, - { id: 'toupdate', ...constants } - ], - status: 200, - }); + describe('compare', () => { + it('compares resources with the gvien key', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); - const getKey = (obj: any) => { return obj.id }; - const startTime = new Date(); - startTime.setHours(startTime.getHours() - 1); - const comparison = await compare(getKey, 'Patient', startTime) + const constants = { + resourceType: 'Patient', + meta: { lastUpdated: lastUpdated } + } - expect(comparison.incoming).toEqual([{id: 'incoming', ...constants }]); - expect(comparison.outgoing).toEqual([{id: 'outgoing', ...constants }]); - expect(comparison.toupdate).toEqual([{id: 'toupdate', ...constants }]); + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [ + { id: 'outgoing', ...constants }, + { id: 'toupdate', ...constants } + ], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [ + { id: 'incoming', ...constants }, + { id: 'toupdate', ...constants } + ], + status: 200, + }); - expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); - expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); - }); + const getKey = (obj: any) => { return obj.id }; + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await compare(getKey, 'Patient', startTime) - it('loads references for related resources', async () => { - const lastUpdated = new Date(); - lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); - const reference = { - id: 'reference0', - resourceType: 'Patient', - meta: { lastUpdated: lastUpdated } - }; - const resource = { - id: 'resource0', - resourceType: 'Encounter', - meta: { lastUpdated: lastUpdated } - }; - - jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ - data: [ resource, reference ], - status: 200, - }); - jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ - data: [ resource ], - status: 200, + expect(comparison.incoming).toEqual([{id: 'incoming', ...constants }]); + expect(comparison.outgoing).toEqual([{id: 'outgoing', ...constants }]); + expect(comparison.toupdate).toEqual([{id: 'toupdate', ...constants }]); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); }); - const getKey = (obj: any) => { return obj.id }; - const startTime = new Date(); - startTime.setHours(startTime.getHours() - 1); - const comparison = await compare(getKey, 'Encounter', startTime) + it('loads references for related resources', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + const reference = { + id: 'reference0', + resourceType: 'Patient', + meta: { lastUpdated: lastUpdated } + }; + const resource = { + id: 'resource0', + resourceType: 'Encounter', + meta: { lastUpdated: lastUpdated } + }; - expect(comparison.references).toContainEqual(reference); - expect(comparison.toupdate).toEqual([resource]); + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [ resource, reference ], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [ resource ], + status: 200, + }); - expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); - expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); - }); + const getKey = (obj: any) => { return obj.id }; + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await compare(getKey, 'Encounter', startTime) - it('sends incoming Patients to FHIR and CHT', async () => { - const lastUpdated = new Date(); - lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + expect(comparison.references).toContainEqual(reference); + expect(comparison.toupdate).toEqual([resource]); - const openMRSPatient = PatientFactory.build(); - openMRSPatient.meta = { lastUpdated: lastUpdated }; - jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ - data: [], - status: 200, - }); - jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ - data: [openMRSPatient], - status: 200, + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); }); - jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ - data: openMRSPatient, - status: 200 + }); + + describe('syncPatients', () => { + it('sends incoming Patients to FHIR and CHT', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + + const openMRSPatient = PatientFactory.build(); + openMRSPatient.meta = { lastUpdated: lastUpdated }; + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [openMRSPatient], + status: 200, + }); + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: openMRSPatient, + status: 200 + }); + jest.spyOn(cht, 'createChtPatient') + + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncPatients(startTime); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + + expect(fhir.updateFhirResource).toHaveBeenCalledWith(openMRSPatient); + expect(cht.createChtPatient).toHaveBeenCalledWith(openMRSPatient); }); - jest.spyOn(cht, 'createChtPatient') - const getKey = (obj: any) => { return obj.id }; - const startTime = new Date(); - startTime.setHours(startTime.getHours() - 1); - const comparison = await syncPatients(startTime); + it('sends outgoing Patients to OpenMRS', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); - expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); - expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + const fhirPatient = PatientFactory.build(); + fhirPatient.meta = { lastUpdated: lastUpdated }; - expect(fhir.updateFhirResource).toHaveBeenCalledWith(openMRSPatient); - expect(cht.createChtPatient).toHaveBeenCalledWith(openMRSPatient); - }); + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [fhirPatient], + status: 200, + }); + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValueOnce({ + data: fhirPatient, + status: 200 + }); + jest.spyOn(fhir, 'updateFhirResource') - it('sends outgoing Patients to OpenMRS', async () => { - const lastUpdated = new Date(); - lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncPatients(startTime); - const fhirPatient = PatientFactory.build(); - fhirPatient.meta = { lastUpdated: lastUpdated }; + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); - jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ - data: [fhirPatient], - status: 200, - }); - jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ - data: [], - status: 200, + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith(fhirPatient); + // updating with openmrs id + expect(fhir.updateFhirResource).toHaveBeenCalledWith(fhirPatient); }); - jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValueOnce({ - data: fhirPatient, - status: 200 + }); + describe('syncEncounters', () => { + it('sends incoming Encounters to FHIR and CHT', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); + + const openMRSPatient = PatientFactory.build(); + openMRSPatient.meta = { lastUpdated: lastUpdated }; + const openMRSEncounter = EncounterFactory.build(); + openMRSEncounter.meta = { lastUpdated: lastUpdated }; + openMRSEncounter.subject = { + reference: `Patient/${openMRSPatient.id}` + }; + const openMRSObservation = ObservationFactory.build(); + openMRSObservation.encounter = { reference: 'Encounter/' + openMRSEncounter.id } + + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [openMRSEncounter, openMRSPatient, openMRSObservation], + status: 200, + }); + + jest.spyOn(fhir, 'getFHIRPatientResource').mockResolvedValueOnce({ + data: { entry: [{ resource: openMRSPatient }] }, + status: 200, + }); + + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValue({ + data: [], + status: 200, + }); + + jest.spyOn(fhir, 'createFhirResource') + + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncEncounters(startTime); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + + expect(fhir.updateFhirResource).toHaveBeenCalledWith(openMRSEncounter); + expect(fhir.createFhirResource).toHaveBeenCalledWith(openMRSObservation); }); - //jest.spyOn(fhir, 'updateFhirResource') - const getKey = (obj: any) => { return obj.id }; - const startTime = new Date(); - startTime.setHours(startTime.getHours() - 1); - const comparison = await syncPatients(startTime); + it('sends outgoing Encounters to OpenMRS', async () => { + const lastUpdated = new Date(); + lastUpdated.setMinutes(lastUpdated.getMinutes() - 30); - expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); - expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + const fhirEncounter = EncounterFactory.build(); + fhirEncounter.meta = { lastUpdated: lastUpdated }; + const fhirObservation = ObservationFactory.build(); + fhirObservation.encounter = { reference: 'Encounter/' + fhirEncounter.id } + const chtDocId = { + system: "cht", + type: chtDocumentIdentifierType, + value: getIdType(fhirEncounter, chtDocumentIdentifierType) + } - expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith(fhirPatient); - // updating with openmrs id - //expect(fhir.updateFhirResource).toHaveBeenCalledWith(fhirPatient); + jest.spyOn(fhir, 'getFhirResourcesSince').mockResolvedValueOnce({ + data: [fhirEncounter, fhirObservation], + status: 200, + }); + + jest.spyOn(openmrs, 'getOpenMRSResourcesSince').mockResolvedValueOnce({ + data: [], + status: 200, + }); + + jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValue({ + data: [], + status: 200, + }); + + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const comparison = await syncEncounters(startTime); + + expect(fhir.getFhirResourcesSince).toHaveBeenCalled(); + expect(openmrs.getOpenMRSResourcesSince).toHaveBeenCalled(); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith( + expect.objectContaining({ + "type": expect.arrayContaining([visitType]), + "identifier": expect.arrayContaining([chtDocId]) + }) + ); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith( + expect.objectContaining({ + "type": expect.arrayContaining([visitNoteType]), + }) + ); + + expect(openmrs.createOpenMRSResource).toHaveBeenCalledWith(fhirObservation); + }); }); }); From 1a5be52f81ec78567f61ad53c661a3b74334102d Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Tue, 22 Oct 2024 12:22:17 +0300 Subject: [PATCH 2/3] chore(#142): sonar fixes --- mediator/src/utils/cht.ts | 68 ++++++---- mediator/src/utils/fhir.ts | 46 +++++-- mediator/src/utils/openmrs_sync.ts | 117 +++++++++++------- mediator/src/utils/tests/openmrs_sync.spec.ts | 8 +- 4 files changed, 158 insertions(+), 81 deletions(-) diff --git a/mediator/src/utils/cht.ts b/mediator/src/utils/cht.ts index e59545cf..22fdc1d8 100644 --- a/mediator/src/utils/cht.ts +++ b/mediator/src/utils/cht.ts @@ -40,7 +40,11 @@ export async function createChtFollowUpRecord(patientId: string) { } } -export async function getLocationFromOpenMRSPatient(fhirPatient: fhir4.Patient) { +/* + Get the address field from an OpenMRS Patient + Assuming it is stored at a specific path in the fhir Patient +*/ +function getAddressFromOpenMRSPatient(fhirPatient: fhir4.Patient) { // first, extract address value; is fchv area available? const addresses = fhirPatient.address?.[0]?.extension?.[0]?.extension; let addressKey = "http://fhir.openmrs.org/ext/address#address4" @@ -51,10 +55,47 @@ export async function getLocationFromOpenMRSPatient(fhirPatient: fhir4.Patient) addressKey = "http://fhir.openmrs.org/ext/address#address5" addressValue = addresses?.find((ext: any) => ext.url === addressKey)?.valueString; - // still no... return nothing - if (!addressValue) { - return ''; - } + } + return addressValue; +} + +/* + * Query CouchDB to get a place_id from a name + * This is a workaround for patients not having an place_id + * in the address field (as described above) + * Because it relies on names matching excatly, and qurying a + * CHT couchdb directly, it is not intended for general use +*/ +async function getPlaceIdFromCouch(addressValue: string) { + const query: CouchDBQuery = { + selector: { + type: "contact", + name: addressValue + }, + fields: ['place_id'] + } + const location = await queryCht(query); + + // edge cases can result in more than one location, get first matching + // if not found by name, no more we can do, give up + if (!location.data?.docs || location.data.docs.length == 0){ + return ''; + } else { + return location.data.docs[0].place_id; + } +} + +/* + * get a CHT place_id from an OpenMRS patient + * assumes that either the patient has an address containing the palce id + * (see above), or the name matches the contact name in CHT + * It is to support a specific workflow and is not intended for general use. +*/ +export async function getLocationFromOpenMRSPatient(fhirPatient: fhir4.Patient) { + // if no address found, return empty string + const addressValue = getAddressFromOpenMRSPatient(fhirPatient); + if (!addressValue) { + return ''; } // does the name have a place id included? @@ -66,22 +107,7 @@ export async function getLocationFromOpenMRSPatient(fhirPatient: fhir4.Patient) return match[1]; } else { // if not, query by name - const query: CouchDBQuery = { - selector: { - type: "contact", - name: addressValue - }, - fields: ['place_id'] - } - const location = await queryCht(query); - - // edge cases can result in more than one location, get first matching - // if not found by name, no more we can do, give up - if (!location.data?.docs || location.data.docs.length == 0){ - return ''; - } else { - return location.data.docs[0].place_id; - } + return getPlaceIdFromCouch(addressValue); } } diff --git a/mediator/src/utils/fhir.ts b/mediator/src/utils/fhir.ts index 33ce7a88..f56260f2 100644 --- a/mediator/src/utils/fhir.ts +++ b/mediator/src/utils/fhir.ts @@ -182,14 +182,43 @@ export async function getFhirResourcesSince(lastUpdated: Date, resourceType: str return getResourcesSince(FHIR.url, lastUpdated, resourceType); } +/* + * get the "next" url from a fhir paginated response and a base url +*/ +function getNextUrl(url: string, pagination: any) { + let nextUrl = ''; + const nextLink = pagination.link && pagination.link.find((link: any) => link.relation === 'next'); + if (nextLink?.url) { + const qs = nextLink.url.split('?')[1]; + nextUrl = `${url}/?${qs}`; + } + return nextUrl; +} + +/* + * Gets the full url for a resource type, given base url + * For some resource types, it is usefult o get related resources + * This function returns the full url including include clauses + * currently it is only for encounters, to include observations + * and the subject patient +*/ +function getResourceUrl(baseUrl: string, lastUpdated: Date, resourceType: string) { + let url = `${baseUrl}/${resourceType}/?_lastUpdated=gt${lastUpdated.toISOString()}`; + // for encounters, include related resources + if (resourceType === 'Encounter') { + url = url + '&_revinclude=Observation:encounter&_include=Encounter:patient'; + } + return url +} + +/* + * get resources of a given type from url, where lastUpdated is > the given data + * if results are paginated, goes through all pages +*/ export async function getResourcesSince(url: string, lastUpdated: Date, resourceType: string) { try { - let nextUrl = `${url}/${resourceType}/?_lastUpdated=gt${lastUpdated.toISOString()}`; let results: fhir4.Resource[] = []; - // for encounters, include related resources - if (resourceType === 'Encounter') { - nextUrl = nextUrl + '&_revinclude=Observation:encounter&_include=Encounter:patient'; - } + let nextUrl = getResourceUrl(url, lastUpdated, resourceType); while (nextUrl) { const res = await axios.get(nextUrl, axiosOptions); @@ -198,12 +227,7 @@ export async function getResourcesSince(url: string, lastUpdated: Date, resource results = results.concat(res.data.entry.map((entry: any) => entry.resource)); } - const nextLink = res.data.link && res.data.link.find((link: any) => link.relation === 'next'); - nextUrl = nextLink ? nextLink.url : null; - if (nextUrl) { - const qs = nextUrl.split('?')[1]; - nextUrl = `${url}/?${qs}`; - } + nextUrl = getNextUrl(url, res.data); } return { status: 200, data: results }; } catch (error: any) { diff --git a/mediator/src/utils/openmrs_sync.ts b/mediator/src/utils/openmrs_sync.ts index 509ed183..0836adfe 100644 --- a/mediator/src/utils/openmrs_sync.ts +++ b/mediator/src/utils/openmrs_sync.ts @@ -211,27 +211,39 @@ export async function sendEncounterToOpenMRS( } logger.info(`Sending Encounter ${encounter.id} to OpenMRS`); + const patient = getPatient(encounter, references); const observations = getObservations(encounter, references); const patientId = getIdType(patient, openMRSIdentifierType); const openMRSVisit = buildOpenMRSVisit(patientId, encounter); + const visitResponse = await createOpenMRSResource(openMRSVisit[0]); - if (visitResponse.status == 200 || visitResponse.status == 201) { - const visitNoteResponse = await createOpenMRSResource(openMRSVisit[1]); - if (visitNoteResponse.status == 200 || visitNoteResponse.status == 201) { - const visitNote = visitNoteResponse.data as fhir4.Encounter; - // save openmrs id on orignal encounter - logger.info(`Updating Encounter ${encounter.id} with openMRSId ${visitNote.id}`); - copyIdToNamedIdentifier(visitNote, encounter, openMRSIdentifierType); - addSourceMeta(visitNote, chtSource); - await updateFhirResource(encounter); - observations.forEach((observation) => { - logger.info(`Sending Observation ${observation.code!.coding![0]!.code} to OpenMRS`); - const openMRSObservation = buildOpenMRSObservation(observation, patientId, visitNote.id || ''); - createOpenMRSResource(openMRSObservation); - }); - } + if (visitResponse.status != 201) { + logger.error(`Error saving visit to OpenMRS ${encounter.id}: ${visitResponse.status}`); + return + } + + const visitNoteResponse = await createOpenMRSResource(openMRSVisit[1]); + if (visitNoteResponse.status != 201) { + logger.error(`Error saving visit note to OpenMRS ${encounter.id}: ${visitNoteResponse.status}`); + return } + + const visitNote = visitNoteResponse.data as fhir4.Encounter; + + logger.info(`Updating Encounter ${encounter.id} with openMRSId ${visitNote.id}`); + + // save openmrs id on orignal encounter + copyIdToNamedIdentifier(visitNote, encounter, openMRSIdentifierType); + addSourceMeta(visitNote, chtSource); + + await updateFhirResource(encounter); + + observations.forEach((observation) => { + logger.info(`Sending Observation ${observation.code!.coding![0]!.code} to OpenMRS`); + const openMRSObservation = buildOpenMRSObservation(observation, patientId, visitNote.id || ''); + createOpenMRSResource(openMRSObservation); + }); } /* @@ -259,44 +271,59 @@ export async function sendEncounterToFhir( logger.error(`Not re-sending encounter from cht ${encounter.id}`); return } + if (!encounter.period?.end) { logger.error(`Not sending encounter which is incomplete ${encounter.id}`); return } + logger.info(`Sending Encounter ${encounter.id} to FHIR`); - const patient = getPatient(encounter, references); + const observations = getObservations(encounter, references); - if (patient && patient.id) { - // get patient from FHIR to resolve reference - const patientResponse = await getFHIRPatientResource(patient.id); - if (patientResponse.status == 200 || patientResponse.status == 201) { - const existingPatient = patientResponse.data?.entry[0].resource; - copyIdToNamedIdentifier(encounter, encounter, openMRSIdentifierType); - addSourceMeta(encounter, openMRSSource); - - logger.info(`Replacing ${encounter.subject!.reference} with ${patient.id} for ${encounter.id}`); - replaceReference(encounter, 'subject', existingPatient); - - // remove unused references - delete encounter.participant; - delete encounter.location; - - const response = await updateFhirResource(encounter); - if (response.status == 200 || response.status == 201) { - observations.forEach(o => sendObservationToFhir(o, existingPatient)); - - logger.info(`Sending Encounter ${encounter.id} to CHT`); - const chtResponse = await chtRecordFromObservations(existingPatient.id, observations); - if (chtResponse.status == 200) { - const chtId = chtResponse.data.id; - addId(encounter, chtDocumentIdentifierType, chtId) - await updateFhirResource(encounter); - } - } - } - } else { + + const patient = getPatient(encounter, references); + if (!patient?.id) { logger.error(`Patient ${encounter.subject!.reference} not found for ${encounter.id}`); + return + } + + // get patient from FHIR to resolve reference + const patientResponse = await getFHIRPatientResource(patient.id); + if (patientResponse.status != 200) { + logger.error(`Error getting Patient ${patient.id}: ${patientResponse.status}`); + return + } + + const existingPatient = patientResponse.data?.entry[0].resource; + copyIdToNamedIdentifier(encounter, encounter, openMRSIdentifierType); + addSourceMeta(encounter, openMRSSource); + + logger.info(`Replacing ${encounter.subject!.reference} with ${patient.id} for ${encounter.id}`); + replaceReference(encounter, 'subject', existingPatient); + + // remove unused references + delete encounter.participant; + delete encounter.location; + + const response = await updateFhirResource(encounter); + if (response.status != 201) { + logger.error(`Error saving encounter to fhir ${encounter.id}: ${response.status}`); + return } + + observations.forEach(o => sendObservationToFhir(o, existingPatient)); + + logger.info(`Sending Encounter ${encounter.id} to CHT`); + const chtResponse = await chtRecordFromObservations(existingPatient.id, observations); + if (chtResponse.status != 200) { + logger.error(`Error saving encounter to cht ${encounter.id}: ${chtResponse.status}`); + return + } + + const chtId = chtResponse.data.id; + addId(encounter, chtDocumentIdentifierType, chtId) + + await updateFhirResource(encounter); } /* diff --git a/mediator/src/utils/tests/openmrs_sync.spec.ts b/mediator/src/utils/tests/openmrs_sync.spec.ts index 6a0c0933..8f76eeae 100644 --- a/mediator/src/utils/tests/openmrs_sync.spec.ts +++ b/mediator/src/utils/tests/openmrs_sync.spec.ts @@ -110,7 +110,7 @@ describe('OpenMRS Sync', () => { }); jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ data: openMRSPatient, - status: 200 + status: 201 }); jest.spyOn(cht, 'createChtPatient') @@ -142,7 +142,7 @@ describe('OpenMRS Sync', () => { }); jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValueOnce({ data: fhirPatient, - status: 200 + status: 201 }); jest.spyOn(fhir, 'updateFhirResource') @@ -190,7 +190,7 @@ describe('OpenMRS Sync', () => { jest.spyOn(fhir, 'updateFhirResource').mockResolvedValue({ data: [], - status: 200, + status: 201, }); jest.spyOn(fhir, 'createFhirResource') @@ -232,7 +232,7 @@ describe('OpenMRS Sync', () => { jest.spyOn(openmrs, 'createOpenMRSResource').mockResolvedValue({ data: [], - status: 200, + status: 201, }); const startTime = new Date(); From d6db1d6dcd7490a88e302fb14b7b274536e561e6 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Tue, 22 Oct 2024 12:42:21 +0300 Subject: [PATCH 3/3] chore(#142): sonar fixes --- mediator/src/utils/fhir.ts | 2 +- mediator/src/utils/openmrs_sync.ts | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/mediator/src/utils/fhir.ts b/mediator/src/utils/fhir.ts index f56260f2..28fad9b6 100644 --- a/mediator/src/utils/fhir.ts +++ b/mediator/src/utils/fhir.ts @@ -187,7 +187,7 @@ export async function getFhirResourcesSince(lastUpdated: Date, resourceType: str */ function getNextUrl(url: string, pagination: any) { let nextUrl = ''; - const nextLink = pagination.link && pagination.link.find((link: any) => link.relation === 'next'); + const nextLink = pagination.link?.find((link: any) => link.relation === 'next'); if (nextLink?.url) { const qs = nextLink.url.split('?')[1]; nextUrl = `${url}/?${qs}`; diff --git a/mediator/src/utils/openmrs_sync.ts b/mediator/src/utils/openmrs_sync.ts index 0836adfe..585b3e03 100644 --- a/mediator/src/utils/openmrs_sync.ts +++ b/mediator/src/utils/openmrs_sync.ts @@ -313,16 +313,26 @@ export async function sendEncounterToFhir( observations.forEach(o => sendObservationToFhir(o, existingPatient)); + sendEncounterToCht(encounter, existingPatient, observations); +} + +/* + Send an Encounter from OpenMRS to CHT +*/ +export async function sendEncounterToCht( + encounter: fhir4.Encounter, + patient: fhir4.Patient, + observations: fhir4.Observation[] +) { logger.info(`Sending Encounter ${encounter.id} to CHT`); - const chtResponse = await chtRecordFromObservations(existingPatient.id, observations); + const chtResponse = await chtRecordFromObservations(patient, observations); if (chtResponse.status != 200) { logger.error(`Error saving encounter to cht ${encounter.id}: ${chtResponse.status}`); return } const chtId = chtResponse.data.id; - addId(encounter, chtDocumentIdentifierType, chtId) - + addId(encounter, chtDocumentIdentifierType, chtId); await updateFhirResource(encounter); }