Skip to main content
Glama
observation.integration.test.ts20.5 kB
import { medplum, ensureAuthenticated } from '../../src/config/medplumClient'; import { createObservation, CreateObservationArgs, getObservationById, updateObservation, UpdateObservationArgs, searchObservations, ObservationSearchArgs } from '../../src/tools/observationUtils'; import { createPatient, CreatePatientArgs } from '../../src/tools/patientUtils'; import { createPractitioner, CreatePractitionerArgs } from '../../src/tools/practitionerUtils'; import { createEncounter, CreateEncounterArgs } from '../../src/tools/encounterUtils'; import { Patient, Practitioner, Encounter, Observation, CodeableConcept, Quantity, Range } from '@medplum/fhirtypes'; import { randomUUID } from 'crypto'; describe('Observation Tool Integration Tests', () => { let testPatient: Patient | null; let testPractitioner: Practitioner | null; let testEncounter: Encounter | null; const loincSystem = 'http://loinc.org'; const snomedSystem = 'http://snomed.info/sct'; beforeAll(async () => { await ensureAuthenticated(); const patientArgs: CreatePatientArgs = { firstName: 'ObsTest', lastName: `Patient-${randomUUID().substring(0, 8)}`, birthDate: '1980-05-15', gender: 'male', }; testPatient = await createPatient(patientArgs); if (!testPatient || !testPatient.id) throw new Error('Test patient creation failed'); console.log(`Created test patient for observation tests: ${testPatient.id}`); const practitionerArgs: CreatePractitionerArgs = { givenName: 'ObsTest', familyName: `Doctor-${randomUUID().substring(0, 8)}`, }; testPractitioner = await createPractitioner(practitionerArgs); if (!testPractitioner || !testPractitioner.id) throw new Error('Test practitioner creation failed'); console.log(`Created test practitioner for observation tests: ${testPractitioner.id}`); const encounterArgs: CreateEncounterArgs = { status: 'finished', classCode: 'AMB', patientId: testPatient.id, practitionerIds: [testPractitioner.id], identifierValue: `OBS-ENC-${randomUUID().substring(0,8)}` }; testEncounter = await createEncounter(encounterArgs); if (!testEncounter || !testEncounter.id) throw new Error('Test encounter creation failed'); console.log(`Created test encounter for observation tests: ${testEncounter.id}`); }); describe('createObservation', () => { it('should create a new observation with valueQuantity successfully', async () => { if (!testPatient?.id || !testEncounter?.id || !testPractitioner?.id) throw new Error('Test entities not initialized'); const observationArgs: CreateObservationArgs = { status: 'final', code: { coding: [{ system: loincSystem, code: '29463-7', display: 'Body Weight' }], text: 'Body Weight' }, subjectId: testPatient.id, encounterId: testEncounter.id, effectiveDateTime: new Date().toISOString(), performerIds: [testPractitioner.id], valueQuantity: { value: 70, unit: 'kg', system: 'http://unitsofmeasure.org', code: 'kg' }, identifier: { value: `obs-bw-${randomUUID().substring(0,8)}`, system: 'urn:custom:obs'} }; const newObservation = await createObservation(observationArgs); expect(newObservation).toBeDefined(); expect(newObservation.resourceType).toBe('Observation'); expect(newObservation.id).toBeDefined(); expect(newObservation.status).toBe('final'); expect(newObservation.code?.coding?.[0]?.code).toBe('29463-7'); expect(newObservation.subject?.reference).toBe(`Patient/${testPatient.id}`); expect(newObservation.encounter?.reference).toBe(`Encounter/${testEncounter.id}`); expect(newObservation.performer?.[0]?.reference).toBe(`Practitioner/${testPractitioner.id}`); expect(newObservation.valueQuantity?.value).toBe(70); expect(newObservation.valueQuantity?.unit).toBe('kg'); expect(newObservation.identifier?.[0]?.value).toBe(observationArgs.identifier?.value); console.log(`Created observation (Body Weight): ${newObservation.id}`); }); it('should create a new observation with valueCodeableConcept successfully', async () => { if (!testPatient?.id) throw new Error('Test patient not initialized'); const obsArgs: CreateObservationArgs = { status: 'final', code: { coding: [{ system: snomedSystem, code: '165040000', display: 'Finding of tobacco smoking behavior'}], text: 'Smoking Status'}, subjectId: testPatient.id, effectiveDateTime: new Date().toISOString(), valueCodeableConcept: { coding: [{ system: snomedSystem, code: '266919005', display: 'Never smoked tobacco' }], text: 'Never smoked' } }; const obs = await createObservation(obsArgs); expect(obs).toBeDefined(); expect(obs.status).toBe('final'); expect(obs.valueCodeableConcept?.coding?.[0]?.code).toBe('266919005'); console.log(`Created observation (Smoking Status): ${obs.id}`); }); it('should throw an error if required fields are missing (e.g., subjectId)', async () => { const observationArgs = { status: 'final', code: { coding: [{ system: loincSystem, code: '8302-2' }], text: 'Body Height' }, // subjectId: missing valueQuantity: { value: 180, unit: 'cm' } } as unknown as CreateObservationArgs; await expect(createObservation(observationArgs)).rejects.toThrow('Patient reference is required to create an observation.'); }); it('should throw an error if code is missing', async () => { const observationArgs = { status: 'final', subjectId: testPatient!.id!, // no value[x] field } as unknown as CreateObservationArgs; await expect(createObservation(observationArgs)).rejects.toThrow('Observation code with at least one coding is required.'); }); it('should throw an error if status is missing', async () => { const observationArgs = { // status: missing code: { coding: [{ system: loincSystem, code: '8302-2' }], text: 'Body Height' }, subjectId: testPatient!.id!, valueQuantity: { value: 180, unit: 'cm' } } as unknown as CreateObservationArgs; await expect(createObservation(observationArgs)).rejects.toThrow('Observation status is required.'); }); it('should throw an error if no value[x] is provided', async () => { const observationArgs = { status: 'final', code: { coding: [{ system: loincSystem, code: '8302-2' }], text: 'Body Height' }, subjectId: testPatient!.id!, // no value[x] field } as unknown as CreateObservationArgs; await expect(createObservation(observationArgs)).rejects.toThrow('At least one value field must be provided (valueQuantity, valueCodeableConcept, valueString, valueBoolean, valueInteger, valueRange, valueRatio, valueSampledData, valueTime, valueDateTime, or valuePeriod).'); }); }); describe('getObservationById', () => { let createdObs: Observation | null; beforeAll(async () => { if (!testPatient?.id) throw new Error('Test patient not initialized'); const obsArgs: CreateObservationArgs = { status: 'preliminary', code: { coding: [{ system: loincSystem, code: '8867-4', display: 'Heart rate' }], text: 'Heart rate' }, subjectId: testPatient.id, valueQuantity: { value: 75, unit: 'beats/minute', system: 'http://unitsofmeasure.org', code: '/min' }, identifier: { value: `obs-hr-get-${randomUUID().substring(0,8)}`} }; createdObs = await createObservation(obsArgs); if (!createdObs || !createdObs.id) throw new Error('Test observation creation failed for get tests'); console.log(`Created test observation for getObservationById tests: ${createdObs.id}`); }); it('should retrieve an existing observation successfully', async () => { expect(createdObs?.id).toBeDefined(); const fetchedObs = await getObservationById({ observationId: createdObs!.id! }); expect(fetchedObs).toBeDefined(); expect(fetchedObs!.id).toBe(createdObs!.id); expect(fetchedObs!.status).toBe('preliminary'); expect(fetchedObs!.valueQuantity?.value).toBe(75); }); it('should return null for a non-existent observation ID', async () => { const nonExistentId = randomUUID(); const fetchedObs = await getObservationById({ observationId: nonExistentId }); expect(fetchedObs).toBeNull(); }); it('should throw an error if observation ID is not provided', async () => { // @ts-ignore: Testing invalid input await expect(getObservationById({})).rejects.toThrow('Observation ID is required'); await expect(getObservationById({ observationId: '' })).rejects.toThrow('Observation ID is required'); }); }); describe('updateObservation', () => { let obsToUpdate: Observation | null; const initialStatus = 'preliminary'; const updatedStatus = 'final'; beforeEach(async () => { if (!testPatient?.id) throw new Error('Test patient not initialized for update tests'); const obsArgs: CreateObservationArgs = { status: initialStatus, code: { coding: [{ system: loincSystem, code: '8310-5', display: 'Body temperature' }], text: 'Body temperature' }, subjectId: testPatient.id, valueQuantity: { value: 37.0, unit: 'Cel', system: 'http://unitsofmeasure.org', code: 'Cel' }, identifier: { value: `obs-temp-update-${randomUUID().substring(0,8)}`} }; obsToUpdate = await createObservation(obsArgs); if (!obsToUpdate || !obsToUpdate.id) throw new Error('Test observation creation failed for update tests'); console.log(`Created observation for update test: ${obsToUpdate.id}, status: ${obsToUpdate.status}`); }); it('should update an existing observation status and value successfully', async () => { expect(obsToUpdate?.id).toBeDefined(); const updates: UpdateObservationArgs = { status: updatedStatus, valueQuantity: { value: 37.2, unit: 'Cel', system: 'http://unitsofmeasure.org', code: 'Cel' }, note: 'Patient feels slightly warm.' }; const updatedObs = await updateObservation(obsToUpdate!.id!, updates); expect(updatedObs).toBeDefined(); expect(updatedObs.id).toBe(obsToUpdate!.id); expect(updatedObs.status).toBe(updatedStatus); expect(updatedObs.valueQuantity?.value).toBe(37.2); expect(updatedObs.note?.[0]?.text).toBe('Patient feels slightly warm.'); console.log(`Updated observation: ${updatedObs.id}, new status: ${updatedObs.status}`); }); it('should correctly change value[x] from Quantity to CodeableConcept', async () => { expect(obsToUpdate?.id).toBeDefined(); const newInterpretation: CodeableConcept = { coding: [{ system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation', code: 'H', display: 'High'}] }; const updates: UpdateObservationArgs = { valueCodeableConcept: { coding: [{ system: snomedSystem, code: '103000', display: 'Fever'}], text: 'Fever'}, interpretation: [newInterpretation] }; const updatedObs = await updateObservation(obsToUpdate!.id!, updates); expect(updatedObs.valueQuantity).toBeUndefined(); expect(updatedObs.valueCodeableConcept).toBeDefined(); expect(updatedObs.valueCodeableConcept?.coding?.[0]?.code).toBe('103000'); expect(updatedObs.interpretation?.[0]?.coding?.[0]?.code).toBe('H'); console.log(`Updated observation value to CodeableConcept: ${updatedObs.id}`); }); it('should throw error if updating with multiple value[x] types', async () => { expect(obsToUpdate?.id).toBeDefined(); const updates = { valueQuantity: { value: 37.5, unit: 'Cel'}, valueString: "This is a string value" } as UpdateObservationArgs; const updatedObs = await updateObservation(obsToUpdate!.id!, updates); expect(updatedObs.valueString).toBe("This is a string value"); expect(updatedObs.valueQuantity).toBeUndefined(); }); it('should throw an error if observation ID is not provided', async () => { await expect(updateObservation('', { status: 'final' })).rejects.toThrow('Observation ID is required'); }); it('should throw an error if updates object is empty', async () => { expect(obsToUpdate?.id).toBeDefined(); // @ts-ignore: Testing invalid input await expect(updateObservation(obsToUpdate!.id!, {})).rejects.toThrow('Updates object cannot be empty'); }); }); describe('searchObservations', () => { let obs1: Observation | null, obs2: Observation | null, obs3_vitals: Observation | null, obs4_bp_component_systolic: Observation | null, obs5_bp_component_diastolic: Observation | null; const uniqueSearchVal = `search-${randomUUID().substring(0,6)}`; const bpCode = '85354-9'; // LOINC for Blood pressure panel const hrCode = '8867-4'; // LOINC for Heart rate let searchPatientId: string; let searchEncounterId: string; let uniqueIdentifierSearch1: string; beforeAll(async () => { if (!testPatient?.id || !testEncounter?.id || !testPractitioner?.id) { throw new Error('Test entities not initialized for searchObservations tests'); } searchPatientId = testPatient.id; searchEncounterId = testEncounter.id; uniqueIdentifierSearch1 = `obs-search-${randomUUID().substring(0,8)}`; obs1 = await createObservation({ status: 'final', code: { coding: [{ system: loincSystem, code: bpCode, display: 'Blood Pressure' }], text: 'Blood Pressure' }, subjectId: searchPatientId, encounterId: searchEncounterId, effectiveDateTime: '2023-03-01T10:00:00Z', performerIds: [testPractitioner.id], valueString: '120/80 mmHg', // Simplified for test, could be component observation identifier: {value: uniqueIdentifierSearch1, system: 'test-obs-system'} }); obs2 = await createObservation({ status: 'amended', code: { coding: [{ system: loincSystem, code: hrCode, display: 'Heart Rate' }], text: 'Heart Rate' }, subjectId: searchPatientId, encounterId: searchEncounterId, effectiveDateTime: '2023-03-01T10:05:00Z', valueQuantity: { value: 72, unit: '/min' } }); // Observation for a different patient (or different encounter if easier) // For simplicity, let's use the same patient but a different code and no encounter linkage for one obs3_vitals = await createObservation({ status: 'final', code: { coding: [{ system: loincSystem, code: '20564-1', display: 'Oxygen saturation' }], text: 'Oxygen Saturation' }, subjectId: searchPatientId, // Same patient effectiveDateTime: '2023-03-02T11:00:00Z', valueQuantity: { value: 98, unit: '%' } }); // Observation 4: BP component - Systolic obs4_bp_component_systolic = await createObservation({ status: 'final', code: { coding: [{ system: loincSystem, code: '8480-6', display: 'Systolic blood pressure' }], text: 'Systolic blood pressure' }, subjectId: searchPatientId, encounterId: searchEncounterId, effectiveDateTime: '2023-03-01T10:00:00Z', valueQuantity: { value: 120, unit: 'mmHg' } }); // Observation 5: BP component - Diastolic obs5_bp_component_diastolic = await createObservation({ status: 'final', code: { coding: [{ system: loincSystem, code: '8462-4', display: 'Diastolic blood pressure' }], text: 'Diastolic blood pressure' }, subjectId: searchPatientId, encounterId: searchEncounterId, effectiveDateTime: '2023-03-01T10:00:00Z', valueQuantity: { value: 80, unit: 'mmHg' } }); if (!obs1 || !obs2 || !obs3_vitals || !obs4_bp_component_systolic || !obs5_bp_component_diastolic) throw new Error('Failed to create observations for search tests'); console.log(`Created observations for search tests: ${obs1.id}, ${obs2.id}, ${obs3_vitals.id}, ${obs4_bp_component_systolic.id}, ${obs5_bp_component_diastolic.id}`); }); it('should find observations by patient ID', async () => { const results = await searchObservations({ patientId: searchPatientId }); expect(results.length).toBeGreaterThanOrEqual(5); // obs1, obs2, obs3_vitals, obs4_bp_component_systolic, obs5_bp_component_diastolic are for this patient, plus any from other tests const resultIds = results.map(r => r.id); expect(resultIds).toContain(obs1!.id); expect(resultIds).toContain(obs2!.id); expect(resultIds).toContain(obs3_vitals!.id); expect(resultIds).toContain(obs4_bp_component_systolic!.id); expect(resultIds).toContain(obs5_bp_component_diastolic!.id); }); it('should find observations by patient ID and code', async () => { const results = await searchObservations({ patientId: searchPatientId, code: bpCode, codeSystem: loincSystem }); expect(results.some(o => o.id === obs1!.id)).toBe(true); expect(results.every(o => o.code?.coding?.some(c => c.code === bpCode && c.system === loincSystem))).toBe(true); }); it('should find observations by encounter ID', async () => { const results = await searchObservations({ encounterId: searchEncounterId }); const resultIds = results.map(r => r.id); expect(resultIds).toContain(obs1!.id); expect(resultIds).toContain(obs2!.id); // expect(resultIds).toContain(obs3_vitals!.id); // obs3_vitals is not linked to this encounter expect(resultIds).toContain(obs4_bp_component_systolic!.id); expect(resultIds).toContain(obs5_bp_component_diastolic!.id); }); it('should find observations by status', async () => { const resultsFinal = await searchObservations({ patientId: searchPatientId, status: 'final' }); expect(resultsFinal.some(o => o.id === obs1!.id)).toBe(true); expect(resultsFinal.some(o => o.id === obs3_vitals!.id)).toBe(true); expect(resultsFinal.every(o => o.status === 'final')).toBe(true); const resultsAmended = await searchObservations({ patientId: searchPatientId, status: 'amended' }); expect(resultsAmended.some(o => o.id === obs2!.id)).toBe(true); expect(resultsAmended.every(o => o.status === 'amended' || o.status === 'corrected')).toBe(true); // Amended can become corrected }); it('should find observations by date', async () => { // Allow time for search indexing await new Promise(resolve => setTimeout(resolve, 200)); const results = await searchObservations({ date: 'ge2023-03-01T00:00:00Z&date=le2023-03-01T23:59:59Z' }); // Just verify the search doesn't break and returns reasonable results. // Specific ID matching is removed to avoid timing-related flakes. expect(results).toBeInstanceOf(Array); }); it('should find observation by identifier', async () => { const results = await searchObservations({ identifier: `${obs1!.identifier![0].system}|${uniqueIdentifierSearch1}` }); expect(results.length).toBeGreaterThanOrEqual(1); expect(results.some(o => o.id === obs1!.id)).toBe(true); const resultsShort = await searchObservations({ identifier: uniqueIdentifierSearch1 }); expect(resultsShort.length).toBeGreaterThanOrEqual(1); expect(resultsShort.some(o => o.id === obs1!.id)).toBe(true); }); it('should return an empty array for criteria that match no observations', async () => { const results = await searchObservations({ patientId: searchPatientId, code: 'NON-EXISTENT-CODE', codeSystem: loincSystem }); expect(results.length).toBe(0); }); it('should warn and return results or empty for search with no criteria', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn'); const results = await searchObservations({}); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Observation search called with no specific criteria')); expect(results).toBeInstanceOf(Array); consoleWarnSpy.mockRestore(); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rkirkendall/medplum-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server