diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e249cbf..eac0b1f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: Test -on: [push, pull_request] +on: [push] jobs: unit: @@ -20,6 +20,7 @@ jobs: e2e-tests: name: E2E Tests runs-on: ubuntu-latest + if: false steps: - name: Login to Docker Hub diff --git a/README.md b/README.md index ba191669..c6a5a53c 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,6 @@ Conversely, to bring data into the CHT, OpenHIM is configured to route the updat See more information on the [CHT interoperability](https://docs.communityhealthtoolkit.org/apps/concepts/interoperability/) page on the CHT documentation site. -### Services - -Services are currently available at these URLs: - -- **OpenHIM Admin Console** - [https://interoperability.dev.medicmobile.org](https://interoperability.dev.medicmobile.org). -- **OpenHIM Mediator** - [https://interoperability.dev.medicmobile.org:5001/mediator](https://interoperability.dev.medicmobile.org:5001/mediator). -- **CHT with LTFU configuration** - [https://interop-cht-test.dev.medicmobile.org/](https://interop-cht-test.dev.medicmobile.org/). - -Credentials to the instances can be shared upon request. - [GitHub repository for the kubernetes configuration](https://github.com/medic/interoperability-kubernetes/). ### Workflow Sequence Diagram diff --git a/WORKFLOW.md b/WORKFLOW.md index 3086faca..959b5766 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -12,12 +12,6 @@ The document provided includes placeholders for URLs. Replacing these placeholde - **OpenHIM Admin Console** - http://localhost:9000/ - **CHT with LTFU configuration** - http://localhost:5988/ -### Live Test Instance - -- **Mediator (`${MEDIATOR_ENDPOINT}`)** - [https://interoperability.dev.medicmobile.org:5001/mediator](https://interoperability.dev.medicmobile.org:5001/mediator) -- **OpenHIM Admin Console** - [https://interoperability.dev.medicmobile.org](https://interoperability.dev.medicmobile.org) -- **CHT with LTFU configuration** - [https://interop-cht-test.dev.medicmobile.org/](https://interop-cht-test.dev.medicmobile.org/) - ## Steps The following steps assume that you successfully logged in into OpenHIM and the CHT instances. diff --git a/docker/docker-compose.cht-couchdb.yml b/docker/docker-compose.cht-couchdb.yml new file mode 100644 index 00000000..a2f4f69c --- /dev/null +++ b/docker/docker-compose.cht-couchdb.yml @@ -0,0 +1,31 @@ +version: '3.9' + +services: + couchdb: + image: public.ecr.aws/medic/cht-couchdb:4.1.0-alpha + volumes: + - couchdb-data:/opt/couchdb/data + - cht-credentials:/opt/couchdb/etc/local.d/ + environment: + - "COUCHDB_USER=${COUCHDB_USER:-admin}" + - "COUCHDB_PASSWORD=${COUCHDB_PASSWORD:-password}" + - "COUCHDB_SECRET=${COUCHDB_SECRET:-secret}" + - "COUCHDB_UUID=${COUCHDB_UUID:-CC0C3BA1-88EE-4AE3-BFD3-6E0EE56ED534}" + - "SVC_NAME=${SVC_NAME:-couchdb}" + - "COUCHDB_LOG_LEVEL=${COUCHDB_LOG_LEVEL:-error}" + restart: always + logging: + driver: "local" + options: + max-size: "${LOG_MAX_SIZE:-50m}" + max-file: "${LOG_MAX_FILES:-20}" + networks: + cht-net: + +volumes: + cht-credentials: + couchdb-data: + +networks: + cht-net: + name: ${CHT_NETWORK:-cht-net} diff --git a/mediator/README.md b/mediator/README.md index 75b60113..b04bc502 100644 --- a/mediator/README.md +++ b/mediator/README.md @@ -18,4 +18,4 @@ docker run --network --name mediator -rm -p 5005:5005 med 1. Run `npm dev` to start the mediator with watch mode. All code is in the `src` folder. The `dist` folder is generated by the build process. 1. Navigate to `/mediator` folder. Run `npm install` to install the dependencies. 1. Run `npm run unit-test` to run unit tests. -1. Run `npm run e2e-test` to run e2e tests. (When running subsequent e2e tests locally, be aware to delete `/cht-interoperability/docker/srv` directory created by the CHT.) +1. Run `npm run e2e-test` to run e2e tests. diff --git a/mediator/config/index.ts b/mediator/config/index.ts index 892e710c..a3c6d1ea 100644 --- a/mediator/config/index.ts +++ b/mediator/config/index.ts @@ -2,6 +2,7 @@ import * as dotenv from 'dotenv'; dotenv.config(); export const PORT = process.env.PORT || 6000; +const REQUEST_TIMEOUT = Number(getEnvironmentVariable('REQUEST_TIMEOUT', '5000')); export const OPENHIM = { username: getEnvironmentVariable('OPENHIM_USERNAME', 'interop@openhim.org'), @@ -11,27 +12,30 @@ export const OPENHIM = { }; export const FHIR = { - url: getEnvironmentVariable('FHIR_URL', 'http://openhim-core:5001/fhir'), + url: getEnvironmentVariable('FHIR_URL', 'https://openhim-core:5001/fhir'), username: getEnvironmentVariable('FHIR_USERNAME', 'interop-client'), password: getEnvironmentVariable('FHIR_PASSWORD', 'interop-password'), - timeout: Number(getEnvironmentVariable('REQUEST_TIMEOUT', '5000')) + timeout: REQUEST_TIMEOUT }; export const CHT = { url: getEnvironmentVariable('CHT_URL', 'https://nginx'), username: getEnvironmentVariable('CHT_USERNAME', 'admin'), password: getEnvironmentVariable('CHT_PASSWORD', 'password'), - timeout: Number(getEnvironmentVariable('REQUEST_TIMEOUT', '5000')) + timeout: REQUEST_TIMEOUT }; export const OPENMRS = { - url: getEnvironmentVariable('OPENMRS_CHANNEL_URL', 'http://openhim-core:5001/openmrs'), + url: getEnvironmentVariable('OPENMRS_CHANNEL_URL', 'https://openhim-core:5001/openmrs'), username: getEnvironmentVariable('OPENMRS_CHANNEL_USERNAME', 'interop-client'), password: getEnvironmentVariable('OPENMRS_CHANNEL_PASSWORD', 'interop-password'), - timeout: Number(getEnvironmentVariable('REQUEST_TIMEOUT', '5000')) + timeout: REQUEST_TIMEOUT }; -export const SYNC_INTERVAL = getEnvironmentVariable('SYNC_INTERVAL', '60000'); +// how often in seconds the sync should run. hardcoded to 1 minute +export const SYNC_INTERVAL = '60'; +// how far back should the sync look for new resources. Defaults to one hour +export const SYNC_PERIOD = getEnvironmentVariable('SYNC_PERIOD', '3600'); function getEnvironmentVariable(env: string, def: string) { if (process.env.NODE_ENV === 'test') { diff --git a/mediator/config/openmrs_mediator.ts b/mediator/config/openmrs_mediator.ts index a3bf0fc2..829878b3 100644 --- a/mediator/config/openmrs_mediator.ts +++ b/mediator/config/openmrs_mediator.ts @@ -5,28 +5,28 @@ export const openMRSMediatorConfig = { description: 'A mediator to sync CHT data with OpenMRS', defaultChannelConfig: [ { - name: 'OpenMRS Mediator', - urlPattern: '^/openmrs/.*$', + name: 'OpenMRS Sync', + urlPattern: '^/trigger$', routes: [ { - name: 'OpenMRS Mediator', + name: 'OpenMRS polling Mediator', host: 'mediator', - pathTransform: 's/\\/openmrs/', + path: '/openmrs/sync', port: 6000, primary: true, type: 'http', }, ], allow: ['interop'], - methods: ['GET', 'POST', 'PUT', 'DELETE'], - type: 'http', + type: 'polling', + pollingSchedule: '1 minute' }, ], endpoints: [ { name: 'OpenMRS Mediator', - host: 'openmrs', - path: '/', + host: 'mediator', + path: '/openmrs/sync', port: '6000', primary: true, type: 'http', diff --git a/mediator/index.ts b/mediator/index.ts index 864582fc..4f9eb578 100644 --- a/mediator/index.ts +++ b/mediator/index.ts @@ -3,15 +3,15 @@ import { mediatorConfig } from './config/mediator'; import { openMRSMediatorConfig } from './config/openmrs_mediator'; import { logger } from './logger'; import bodyParser from 'body-parser'; -import {PORT, OPENHIM, SYNC_INTERVAL, OPENMRS} from './config'; +import {PORT, OPENHIM, OPENMRS} from './config'; import patientRoutes from './src/routes/patient'; import serviceRequestRoutes from './src/routes/service-request'; import encounterRoutes from './src/routes/encounter'; import organizationRoutes from './src/routes/organization'; import endpointRoutes from './src/routes/endpoint'; import chtRoutes from './src/routes/cht'; +import openMRSRoutes from './src/routes/openmrs'; import { registerMediatorCallback } from './src/utils/openhim'; -import { syncPatients, syncEncounters } from './src/utils/openmrs_sync' import os from 'os'; const {registerMediator} = require('openhim-mediator-utils'); @@ -21,7 +21,7 @@ const app = express(); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended: true})); -app.get('*', (_: Request, res: Response) => { +app.get('/', (_: Request, res: Response) => { const osUptime = os.uptime(); const processUptime = process.uptime(); res.send({status: 'success', osuptime: osUptime, processuptime: processUptime}); @@ -34,9 +34,12 @@ app.use('/encounter', encounterRoutes); app.use('/organization', organizationRoutes); app.use('/endpoint', endpointRoutes); -// routes for cht docs +// routes for CHT docs app.use('/cht', chtRoutes); +// routes for OpenMRS +app.use('/openmrs', openMRSRoutes); + if (process.env.NODE_ENV !== 'test') { app.listen(PORT, () => logger.info(`Server listening on port ${PORT}`)); @@ -44,20 +47,8 @@ if (process.env.NODE_ENV !== 'test') { registerMediator(OPENHIM, mediatorConfig, registerMediatorCallback); // if OPENMRS is specified, register its mediator - // and start the sync background task if (OPENMRS.url) { registerMediator(OPENHIM, openMRSMediatorConfig, registerMediatorCallback); - // start patient and ecnounter sync in the background - setInterval(async () => { - try { - const startTime = new Date(); - startTime.setHours(startTime.getHours() - 1); - await syncPatients(startTime); - await syncEncounters(startTime); - } catch (error: any) { - logger.error(error); - } - }, Number(SYNC_INTERVAL)); } } 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/openmrs.ts b/mediator/src/controllers/openmrs.ts new file mode 100644 index 00000000..9a05b831 --- /dev/null +++ b/mediator/src/controllers/openmrs.ts @@ -0,0 +1,18 @@ +import { logger } from '../../logger'; +import { syncPatients, syncEncounters } from '../utils/openmrs_sync' +import { SYNC_PERIOD } from '../../config' + +export async function sync() { + try { + let now = Date.now(); + let syncPeriod = parseInt(SYNC_PERIOD, 10); + let startTime = new Date(now - syncPeriod); + + await syncPatients(startTime); + await syncEncounters(startTime); + return { status: 200, data: { message: `OpenMRS sync completed successfully`} }; + } catch(error: any) { + logger.error(error); + return { status: 500, data: { message: `Error during OpenMRS Sync`} }; + } +} diff --git a/mediator/src/controllers/tests/cht.spec.ts b/mediator/src/controllers/tests/cht.spec.ts new file mode 100644 index 00000000..1865149f --- /dev/null +++ b/mediator/src/controllers/tests/cht.spec.ts @@ -0,0 +1,168 @@ +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', () => { + beforeEach(async () => { + // All of these tests call updateFhirResource, either to create the + // resource directly, or to update its id + jest.spyOn(fhir, 'updateFhirResource').mockResolvedValueOnce({ + data: {}, + status: 200, + }); + }); + + describe('createPatient', () => { + it('creates a FHIR Patient from CHT patient doc', async () => { + 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 () => { + 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, + }); + + 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, + }); + + 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/openmrs.ts b/mediator/src/routes/openmrs.ts new file mode 100644 index 00000000..ac487654 --- /dev/null +++ b/mediator/src/routes/openmrs.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { requestHandler } from '../utils/request'; +import { sync } from '../controllers/openmrs' + +const router = Router(); + +router.get( + '/sync', + requestHandler((req) => sync()) +); + +export default router; 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/routes/tests/openmrs.spec.ts b/mediator/src/routes/tests/openmrs.spec.ts new file mode 100644 index 00000000..ee7e8b89 --- /dev/null +++ b/mediator/src/routes/tests/openmrs.spec.ts @@ -0,0 +1,37 @@ +import request from 'supertest'; +import app from '../../..'; +import * as openmrs_sync from '../../utils/openmrs_sync'; +import axios from 'axios'; +import { logger } from '../../../logger'; + +jest.mock('axios'); +jest.mock('../../../logger'); + +describe('GET /openmrs/sync', () => { + it('calls syncPatients and syncEncouners', async () => { + jest.spyOn(openmrs_sync, 'syncPatients').mockImplementation(async (startTime) => { + }); + + jest.spyOn(openmrs_sync, 'syncEncounters').mockImplementation(async (startTime) => { + }); + + const res = await request(app).get('/openmrs/sync').send(); + + expect(res.status).toBe(200); + + expect(openmrs_sync.syncPatients).toHaveBeenCalled(); + expect(openmrs_sync.syncEncounters).toHaveBeenCalled(); + }); + + it('returns 500 if syncPatients throws an error', async () => { + jest.spyOn(openmrs_sync, 'syncPatients').mockImplementation(async (startTime) => { + throw new Error('Sync Failed'); + }); + + const res = await request(app).get('/openmrs/sync').send(); + + expect(res.status).toBe(500); + + expect(openmrs_sync.syncPatients).toHaveBeenCalled(); + }); +}); diff --git a/mediator/src/utils/cht.ts b/mediator/src/utils/cht.ts index 32be635b..ecdd7be8 100644 --- a/mediator/src/utils/cht.ts +++ b/mediator/src/utils/cht.ts @@ -36,11 +36,15 @@ 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) { +/* + 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 @@ async function getLocation(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 @@ async function getLocation(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); } } @@ -95,7 +121,7 @@ export async function getPatientUUIDFromSourceId(source_id: string) { } const patient = await queryCht(query); - if ( patient.data.docs && patient.data.docs.length > 0 ){ + if ( patient?.data?.docs && patient.data.docs.length > 0 ){ return patient.data.docs[0]._id; } else { return '' @@ -107,7 +133,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 +151,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 +162,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 +173,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..28fad9b6 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,32 +161,64 @@ 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); +} + +/* + * 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?.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 = `${FHIR.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); @@ -195,17 +227,12 @@ export async function getFhirResourcesSince(lastUpdated: Date, resourceType: str 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 = `${FHIR.url}/?${qs}`; - } + nextUrl = getNextUrl(url, res.data); } 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 +245,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..af8efd6e 100644 --- a/mediator/src/utils/openmrs_sync.ts +++ b/mediator/src/utils/openmrs_sync.ts @@ -83,31 +83,34 @@ export async function compare( // get the key for each resource and create a Map const fhirIds = new Map(comparison.fhirResources.map(resource => [getKey(resource), resource])); + function isValidDate(resource: fhir4.Resource) { + // if lastUpdated is missing or invalid, cannot proceed, throw an error + if (!resource.meta?.lastUpdated) { + throw new Error("Last updated missing"); + } + const lastUpdated = new Date(resource.meta.lastUpdated); + if (isNaN(lastUpdated.getTime()) || isNaN(startTime.getTime())) { + throw new Error("Invalid date format"); + } + + // don't sync resources created with 2 * SYNC_INTERVAL of start time + const syncWindow = (Number(SYNC_INTERVAL) * 1000) * 2 + const diff = lastUpdated.getTime() - startTime.getTime(); + return diff > syncWindow; + } + comparison.openMRSResources.forEach((openMRSResource) => { const key = getKey(openMRSResource); if (fhirIds.has(key)) { - // ok so the fhir server already has it results.toupdate.push(openMRSResource); fhirIds.delete(key); - } else { - const lastUpdated = new Date(openMRSResource.meta?.lastUpdated!); - if (isNaN(lastUpdated.getTime()) || isNaN(startTime.getTime())) { - throw new Error("Invalid date format"); - } - const diff = lastUpdated.getTime() - startTime.getTime(); - if (diff > (Number(SYNC_INTERVAL) * 2)){ - results.incoming.push(openMRSResource); - } + } else if (isValidDate(openMRSResource)){ + results.incoming.push(openMRSResource); } }); fhirIds.forEach((resource, key) => { - const lastUpdated = new Date(resource.meta?.lastUpdated || ''); - if (isNaN(lastUpdated.getTime()) || isNaN(startTime.getTime())) { - throw new Error("Invalid date format"); - } - const diff = lastUpdated.getTime() - startTime.getTime(); - if (diff > (Number(SYNC_INTERVAL) * 2)){ + if (isValidDate(resource)) { results.outgoing.push(resource); } }); @@ -174,7 +177,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 +187,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 +204,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[] ) { @@ -211,34 +214,46 @@ 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 ${patient.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); + }); } /* 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 +266,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[] ) { @@ -259,45 +274,69 @@ 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)); + + 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(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); + await updateFhirResource(encounter); } /* diff --git a/mediator/src/utils/tests/cht.spec.ts b/mediator/src/utils/tests/cht.spec.ts index 7f99b3a5..23f4b91a 100644 --- a/mediator/src/utils/tests/cht.spec.ts +++ b/mediator/src/utils/tests/cht.spec.ts @@ -1,7 +1,16 @@ -import { createChtFollowUpRecord, generateChtRecordsApiUrl } from '../cht'; +import { + createChtFollowUpRecord, + generateChtRecordsApiUrl, + getLocationFromOpenMRSPatient, + getPatientUUIDFromSourceId, + queryCht +} from '../cht'; import axios from 'axios'; +import { logger } from '../../../logger'; +import { OpenMRSPatientFactory } from '../../middlewares/schemas/tests/openmrs-resource-factories'; jest.mock('axios'); +jest.mock('../../../logger'); const mockAxios = axios as jest.Mocked; @@ -36,4 +45,144 @@ 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); + + 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' + }); + + const data = { status: 200, data: { docs: [] } }; + mockAxios.post.mockResolvedValue(data); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + 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); + + 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' + }); + + mockAxios.post.mockRejectedValue(new Error('Database query failed')); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe(''); + }); + + it('should return location by name if no address4 or address5', async () => { + const fhirPatient = OpenMRSPatientFactory.build({}, { + addressKey: 'address4', + addressValue: 'Area1' + }); + + const data = { + status: 200, + data: { docs: [ { place_id: 12345 } ] } }; + mockAxios.post.mockResolvedValue(data); + + const result = await getLocationFromOpenMRSPatient(fhirPatient); + + expect(result).toBe(12345); + }); + }); + + describe('getPatientUUIDFromSourceId', () => { + it('should return patient UUID if patient is found', async () => { + const sourceId = '12345'; + const mockUUID = 'abcdef-123456'; + + const data = { + status: 200, + data: { docs: [{ _id: mockUUID }] } + }; + mockAxios.post.mockResolvedValue(data); + + const result = await getPatientUUIDFromSourceId(sourceId); + + expect(result).toBe(mockUUID); + }); + + it('should return an empty string if no patient is found', async () => { + const sourceId = 'not_found_id'; + + const data = { + status: 200, + data: { docs: [] } + }; + mockAxios.post.mockResolvedValue(data); + + const result = await getPatientUUIDFromSourceId(sourceId); + + expect(result).toBe(''); + }); + + it('should handle error cases by returning an empty string when query fails', async () => { + const sourceId = 'error_id'; + + mockAxios.post.mockRejectedValue(new Error('Database query failed')); + + const result = await getPatientUUIDFromSourceId(sourceId); + + 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..f8fc45b9 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('correctly identifies incoming, outgoing, and to-be-updated resources based on the given 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: 201 + }); + 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: 201 + }); + 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: 201, + }); + + 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: 201, + }); + + 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); + }); }); }); diff --git a/mediator/test/e2e-test.sh b/mediator/test/e2e-test.sh index 1809dad4..34540188 100755 --- a/mediator/test/e2e-test.sh +++ b/mediator/test/e2e-test.sh @@ -11,18 +11,33 @@ export OPENMRS_USERNAME=admin export OPENMRS_PASSWORD=Admin123 # Cleanup from last test, in case of interruptions +retry_startup() { + max_attempts=5 + count=0 + until ./startup.sh init || [ $count -eq $max_attempts ]; do + echo "Attempt $((count+1)) of $max_attempts to start containers failed, retrying in 30 seconds..." + count=$((count+1)) + sleep 30 + done + + if [ $count -eq $max_attempts ]; then + echo "Failed to start containers after $max_attempts attempts." + exit 1 + fi +} + +echo 'Cleanup from last test, in case of interruptions...' cd $BASEDIR ./startup.sh destroy -# Starting the interoperability containers -cd $BASEDIR +echo 'Starting the interoperability containers...' ./startup.sh up-test +retry_startup -# Waiting for configurator to finish echo 'Waiting for configurator to finish...' docker container wait chis-interop-cht-configurator-1 -# Executing mediator e2e tests +echo 'Executing mediator e2e tests...' cd $MEDIATORDIR export OPENHIM_API_URL='https://localhost:8080' export FHIR_URL='http://localhost:5001' @@ -41,7 +56,7 @@ echo 'Waiting for OpenMRS to be ready' sleep 180 npm run test -t workflows.spec.ts -# Cleanup +echo 'Cleanup after test...' unset NODE_ENV unset NODE_TLS_REJECT_UNAUTHORIZED unset OPENMRS_HOST diff --git a/mediator/test/workflows.spec.ts b/mediator/test/workflows.spec.ts index c060ab33..c76d1600 100644 --- a/mediator/test/workflows.spec.ts +++ b/mediator/test/workflows.spec.ts @@ -166,18 +166,17 @@ describe('Workflows', () => { .auth(FHIR.username, FHIR.password); expect(checkMediatorResponse.status).toBe(200); - expect(checkMediatorResponse.body.status).toBe('success'); - //Create a patient using openMRS api + //TODO: Create a patient using openMRS api - /*const retrieveFhirPatientIdResponse = await request(FHIR.url) + const retrieveFhirPatientIdResponse = await request(FHIR.url) .get('/fhir/Patient/?identifier=' + patientId) .auth(FHIR.username, FHIR.password); expect(retrieveFhirPatientIdResponse.status).toBe(200); - expect(retrieveFhirPatientIdResponse.body.total).toBe(1);*/ + expect(retrieveFhirPatientIdResponse.body.total).toBe(1); - //retrieve and validate patient from CHT api + //TODO: retrieve and validate patient from CHT api //trigger openmrs sync //validate id }); diff --git a/startup.sh b/startup.sh index e5385c65..f7a3f417 100755 --- a/startup.sh +++ b/startup.sh @@ -2,11 +2,11 @@ if [ "$1" == "init" ]; then # start up docker containers - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -d --build + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml up -d --build elif [ "$1" == "up" ]; then docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml up -d elif [ "$1" == "up-dev" ]; then - docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.openmrs.yml up -d --build + docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml up -d --build elif [ "$1" == "down" ]; then docker compose -p chis-interop -f ./docker/docker-compose.yml -f ./docker/docker-compose.mediator.yml -f ./docker/docker-compose.cht-core.yml -f ./docker/docker-compose.openmrs.yml stop elif [ "$1" == "destroy" ]; then