// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import type { WithId } from '@medplum/core';
import { ContentType, createReference } from '@medplum/core';
import type {
AsyncJob,
Bundle,
Communication,
DiagnosticReport,
Login,
Observation,
Organization,
Patient,
Project,
ProjectMembership,
UserConfiguration,
} from '@medplum/fhirtypes';
import type { Job } from 'bullmq';
import express from 'express';
import type { RateLimiterRes } from 'rate-limiter-flexible';
import { RateLimiterRedis } from 'rate-limiter-flexible';
import request from 'supertest';
import { initApp, shutdownApp } from '../../app';
import { loadTestConfig } from '../../config/loader';
import { runInAsyncContext } from '../../context';
import { createTestProject, initTestAuth, waitForAsyncJob } from '../../test.setup';
import type { SetAccountsJobData } from '../../workers/set-accounts';
import { execSetAccountsJob, getSetAccountsQueue } from '../../workers/set-accounts';
import { setAccountsHandler } from './set-accounts';
const app = express();
let accessToken: string;
let login: WithId<Login>;
let membership: WithId<ProjectMembership>;
let project: WithId<Project>;
let observation: Observation;
let diagnosticReport: DiagnosticReport;
let patient: Patient;
let organization1: Organization;
let organization2: Organization;
describe('Patient Set Accounts Operation', () => {
beforeEach(async () => {
const config = await loadTestConfig();
await initApp(app, config);
({ accessToken, login, membership, project } = await createTestProject({
withAccessToken: true,
withClient: true,
membership: { admin: true },
}));
// Create organization
const orgRes = await request(app)
.post('/fhir/R4/Organization')
.set('Authorization', 'Bearer ' + accessToken)
.send({ resourceType: 'Organization' });
expect(orgRes.status).toBe(201);
organization1 = orgRes.body as Organization;
const orgRes2 = await request(app)
.post('/fhir/R4/Organization')
.set('Authorization', 'Bearer ' + accessToken)
.send({ resourceType: 'Organization' });
expect(orgRes2.status).toBe(201);
organization2 = orgRes2.body as Organization;
// Create patient
const res1 = await request(app)
.post('/fhir/R4/Patient')
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Patient',
name: [{ given: ['Alice'], family: 'Smith' }],
} satisfies Patient);
expect(res1.status).toBe(201);
patient = res1.body as Patient;
// Create observation
const res2 = await request(app)
.post('/fhir/R4/Observation')
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Observation',
status: 'final',
code: {
coding: [
{
system: 'http://loinc.org',
code: 'test-code',
},
],
},
subject: createReference(patient),
} satisfies Observation);
expect(res2.status).toBe(201);
observation = res2.body as Observation;
//Create a diagnostic report
const res3 = await request(app)
.post('/fhir/R4/DiagnosticReport')
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'DiagnosticReport',
subject: createReference(patient),
status: 'final',
code: {
coding: [
{
system: 'http://loinc.org',
code: 'test-code',
},
],
},
} satisfies DiagnosticReport);
expect(res3.status).toBe(201);
diagnosticReport = res3.body as DiagnosticReport;
});
afterEach(async () => {
await shutdownApp();
});
test('Updates target patient and compartment resources', async () => {
// Execute the operation adding the organization to the patient's compartment
const res3 = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
{
name: 'accounts',
valueReference: createReference(organization2),
},
{
name: 'propagate',
valueBoolean: true,
},
],
});
expect(res3.status).toBe(200);
const result = res3.body;
expect(result.parameter?.[0].name).toBe('resourcesUpdated');
expect(result.parameter?.[0].valueInteger).toBe(3); // Observation and DiagnosticReport
//check if the accounts are updated on the patient
const res4 = await request(app)
.get(`/fhir/R4/Patient/${patient.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res4.status).toBe(200);
const updatedPatient = res4.body as Patient;
expect(updatedPatient.meta?.accounts).toBeDefined();
expect(updatedPatient.meta?.accounts?.[0].reference).toBe(`Organization/${organization1.id}`);
expect(updatedPatient.meta?.accounts?.[1].reference).toBe(`Organization/${organization2.id}`);
// Check if accounts are updated on the observation
const res5 = await request(app)
.get(`/fhir/R4/Observation/${observation.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res5.status).toBe(200);
const updatedObservation = res5.body as Observation;
expect(updatedObservation.meta?.accounts).toBeDefined();
expect(updatedObservation.meta?.accounts?.[0].reference).toBe(`Organization/${organization1.id}`);
expect(updatedObservation.meta?.accounts?.[1].reference).toBe(`Organization/${organization2.id}`);
// Check if accounts are updated on the diagnostic report
const res6 = await request(app)
.get(`/fhir/R4/DiagnosticReport/${diagnosticReport.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res6.status).toBe(200);
const updatedDiagnosticReport = res6.body as DiagnosticReport;
expect(updatedDiagnosticReport.meta?.accounts).toBeDefined();
expect(updatedDiagnosticReport.meta?.accounts?.[0].reference).toBe(`Organization/${organization1.id}`);
expect(updatedDiagnosticReport.meta?.accounts?.[1].reference).toBe(`Organization/${organization2.id}`);
});
test('Resources returned in $patient-everything but NOT in the patient compartment are not updated', async () => {
const res7 = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
{
name: 'accounts',
valueReference: createReference(organization2),
},
{
name: 'propagate',
valueBoolean: true,
},
],
});
expect(res7.status).toBe(200);
const numberResourcesUpdated = res7.body.parameter?.[0].valueInteger;
const res = await request(app)
.get(`/fhir/R4/Patient/${patient.id}/$everything`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res.status).toBe(200);
const everything = res.body as Bundle;
const allResources = everything.entry?.length ?? 0;
const resourcesNotInCompartment = everything.entry?.filter((entry) => entry?.search?.mode !== 'match').length ?? 0;
//Number of resources updated only includes the ones in the compartment, not other resources returned in $patient-everything
expect(numberResourcesUpdated).toBe(allResources - resourcesNotInCompartment);
});
test('Patient not found', async () => {
const res = await request(app)
.post(`/fhir/R4/Patient/not-found/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
],
});
expect(res.status).toBe(404);
});
test('setAccountsHandler() called without an id', async () => {
const res = await setAccountsHandler({
params: { id: '' },
method: 'POST',
url: '/fhir/R4/Patient/$set-accounts',
pathname: '/fhir/R4/Patient/$set-accounts',
body: {},
query: {},
});
expect(res[0].issue?.[0]?.details?.text).toBe('Must specify resource type and ID');
});
test('Preserves other meta fields on compartment resources', async () => {
//Create a Communication in Patient's compartment with a security tag
const res1 = await request(app)
.post(`/fhir/R4/Communication`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Communication',
subject: createReference(patient),
status: 'completed',
meta: {
security: [
{
system: 'http://terminology.hl7.org/CodeSystem/v3-Confidentiality',
code: 'N',
},
],
},
} satisfies Communication);
expect(res1.status).toBe(201);
const communication = res1.body as Communication;
expect(communication.meta?.security).toBeDefined();
const res2 = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.set('x-medplum', 'extended')
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
{
name: 'propagate',
valueBoolean: true,
},
],
});
expect(res2.status).toBe(200);
const res4 = await request(app)
.get(`/fhir/R4/Communication/${communication.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res4.status).toBe(200);
const updatedCommunication = res4.body as Communication;
expect(updatedCommunication.meta?.accounts).toHaveLength(1);
expect(updatedCommunication.meta?.security).toBeDefined();
});
test('Preserves changes to accounts of compartment resources', async () => {
const res1 = await request(app)
.post(`/fhir/R4/Observation/${observation.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization2),
},
],
});
expect(res1.status).toBe(200);
const result = res1.body;
expect(result.parameter?.[0].name).toBe('resourcesUpdated');
expect(result.parameter?.[0].valueInteger).toBe(1); // Observation only
// Check if accounts are updated on the observation
const res2 = await request(app)
.get(`/fhir/R4/Observation/${observation.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res2.status).toBe(200);
const updatedObservation = res2.body as Observation;
expect(updatedObservation.meta?.accounts).toBeDefined();
expect(updatedObservation.meta?.accounts?.[0].reference).toBe(`Organization/${organization2.id}`);
// Execute the operation adding the organization to the patient's compartment
const res3 = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
{
name: 'propagate',
valueBoolean: true,
},
],
});
expect(res3.status).toBe(200);
const result2 = res3.body;
expect(result2.parameter?.[0].name).toBe('resourcesUpdated');
expect(result2.parameter?.[0].valueInteger).toBe(3); // Observation and DiagnosticReport included
//check if the accounts are updated on the patient
const res4 = await request(app)
.get(`/fhir/R4/Patient/${patient.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res4.status).toBe(200);
const updatedPatient = res4.body as Patient;
expect(updatedPatient.meta?.accounts).toBeDefined();
expect(updatedPatient.meta?.accounts?.[0].reference).toBe(`Organization/${organization1.id}`);
// Check if accounts are updated on the observation
const res5 = await request(app)
.get(`/fhir/R4/Observation/${observation.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res5.status).toBe(200);
const finalObservation = res5.body as Observation;
expect(finalObservation.meta?.accounts).toHaveLength(2);
expect(finalObservation.meta?.accounts?.[0].reference).toBe(`Organization/${organization2.id}`);
expect(finalObservation.meta?.accounts?.[1].reference).toBe(`Organization/${organization1.id}`);
// Check if accounts are updated on the diagnostic report
const res6 = await request(app)
.get(`/fhir/R4/DiagnosticReport/${diagnosticReport.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(res6.status).toBe(200);
const updatedDiagnosticReport = res6.body as DiagnosticReport;
expect(updatedDiagnosticReport.meta?.accounts).toBeDefined();
expect(updatedDiagnosticReport.meta?.accounts?.[0].reference).toBe(`Organization/${organization1.id}`);
});
test('Non-admin user cannot set accounts', async () => {
accessToken = await initTestAuth();
const res = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
],
});
expect(res.status).toBe(403);
});
test('Supports async response', async () => {
const queue = getSetAccountsQueue() as any;
queue.add.mockClear();
// Start the operation
const initRes = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.set('Content-Type', ContentType.FHIR_JSON)
.set('Prefer', 'respond-async')
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'accounts',
valueReference: createReference(organization1),
},
{
name: 'propagate',
valueBoolean: true,
},
],
});
expect(initRes.status).toBe(202);
expect(initRes.headers['content-location']).toBeDefined();
// Manually push through BullMQ job
expect(queue.add).toHaveBeenCalledWith(
'SetAccountsJobData',
expect.objectContaining<Partial<SetAccountsJobData>>({ resourceType: 'Patient', id: patient.id })
);
const job = { id: 1, data: queue.add.mock.calls[0][1] } as unknown as Job;
queue.add.mockClear();
await runInAsyncContext(
{ login, membership, project, userConfig: {} as unknown as UserConfiguration },
undefined,
undefined,
() => execSetAccountsJob(job)
);
// Check the export status
const contentLocation = new URL(initRes.headers['content-location']);
await waitForAsyncJob(initRes.headers['content-location'], app, accessToken);
const statusRes = await request(app)
.get(contentLocation.pathname)
.set('Authorization', 'Bearer ' + accessToken);
expect(statusRes.status).toBe(200);
const resBody = statusRes.body as AsyncJob;
expect(resBody.output?.parameter).toStrictEqual(
expect.arrayContaining([{ name: 'resourcesUpdated', valueInteger: 3 }])
);
});
test('Enforces rate limits in async mode', async () => {
const queue = getSetAccountsQueue() as any;
queue.add.mockClear();
const { accessToken, repo } = await createTestProject({
withAccessToken: true,
withRepo: true,
membership: { admin: true },
project: { systemSetting: [{ name: 'userFhirQuota', valueInteger: 400 }] },
});
const patient = await repo.createResource({ resourceType: 'Patient' });
await repo.createResource({
resourceType: 'Observation',
status: 'final',
subject: createReference(patient),
code: { text: 'Eye color' },
});
await repo.createResource({
resourceType: 'Observation',
status: 'final',
subject: createReference(patient),
code: { text: 'Hair color' },
});
// Start the operation
const initRes = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.set('Content-Type', ContentType.FHIR_JSON)
.set('Prefer', 'respond-async')
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'accounts', valueReference: createReference(organization1) },
{ name: 'propagate', valueBoolean: true },
],
});
expect(initRes.status).toBe(202);
expect(initRes.headers['content-location']).toBeDefined();
// Manually push through BullMQ job
expect(queue.add).toHaveBeenCalledWith(
'SetAccountsJobData',
expect.objectContaining<Partial<SetAccountsJobData>>({ resourceType: 'Patient', id: patient.id })
);
const job = { id: 1, data: queue.add.mock.calls[0][1] } as unknown as Job;
queue.add.mockClear();
// Mock out rate limiter to periodically block the user
let count = 0;
const consumeMock = jest.spyOn(RateLimiterRedis.prototype, 'consume').mockImplementation(async (key, _points) => {
count = (count + 1) % 3;
if (!key.toString().includes(membership.id)) {
// allowed
return {
remainingPoints: 100,
msBeforeNext: 100,
consumedPoints: 100,
isFirstInDuration: false,
} as RateLimiterRes;
}
return {
remainingPoints: 200 - count * 100, // Allow every third call
msBeforeNext: 20, // Wait for one fake timers tick before next retry
consumedPoints: 100,
isFirstInDuration: false,
} as RateLimiterRes;
});
// Manually execute worker
await runInAsyncContext(
{ login, membership, project, userConfig: {} as unknown as UserConfiguration },
undefined,
undefined,
() => execSetAccountsJob(job)
);
expect(consumeMock).toHaveBeenCalledTimes(10); // Rate limits applied
// Check the export status
const contentLocation = new URL(initRes.headers['content-location']);
await waitForAsyncJob(initRes.headers['content-location'], app, accessToken);
const statusRes = await request(app)
.get(contentLocation.pathname)
.set('Authorization', 'Bearer ' + accessToken);
expect(statusRes.status).toBe(200); // Job waits for rate limits and ultimately succeeds
});
test('Removes account without extended header', async () => {
const setTwo = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'accounts', valueReference: createReference(organization1) },
{ name: 'accounts', valueReference: createReference(organization2) },
{ name: 'propagate', valueBoolean: false },
],
});
expect(setTwo.status).toBe(200);
const get1 = await request(app)
.get(`/fhir/R4/Patient/${patient.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(get1.status).toBe(200);
expect(get1.body.meta?.accounts?.map((r: any) => r.reference)).toEqual(
expect.arrayContaining([`Organization/${organization1.id}`, `Organization/${organization2.id}`])
);
const setOne = await request(app)
.post(`/fhir/R4/Patient/${patient.id}/$set-accounts`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'accounts', valueReference: createReference(organization2) },
{ name: 'propagate', valueBoolean: false },
],
});
expect(setOne.status).toBe(200);
expect(setOne.body.parameter?.[0]).toMatchObject({ name: 'resourcesUpdated', valueInteger: 1 });
const get2 = await request(app)
.get(`/fhir/R4/Patient/${patient.id}`)
.set('Authorization', 'Bearer ' + accessToken);
expect(get2.status).toBe(200);
const acctRefs = (get2.body.meta?.accounts ?? []).map((r: any) => r.reference);
expect(acctRefs).toEqual([`Organization/${organization2.id}`]);
});
test('Accounts applied to resource with no default profile', async () => {
const { accessToken, repo } = await createTestProject({
withAccessToken: true,
withRepo: true,
project: {
defaultProfile: [
{ resourceType: 'Patient', profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'] },
],
},
membership: { admin: true },
});
const organization = await repo.createResource<Organization>({ resourceType: 'Organization' });
const orgRef = createReference(organization);
const patientRes = await request(app)
.post(`/fhir/R4/Patient`)
.set('Authorization', 'Bearer ' + accessToken)
.set('Content-Type', ContentType.FHIR_JSON)
.set('X-Medplum', 'extended')
.send({
resourceType: 'Patient',
meta: { accounts: [orgRef] },
} satisfies Patient);
expect(patientRes.status).toBe(201);
const patient = patientRes.body as Patient;
const patientRef = createReference(patient);
expect(patient.meta?.accounts).toStrictEqual([orgRef]);
expect(patient.meta?.compartment).toContainEqual(orgRef);
const reportRes = await request(app)
.post(`/fhir/R4/DiagnosticReport`)
.set('Authorization', 'Bearer ' + accessToken)
.set('Content-Type', ContentType.FHIR_JSON)
.set('X-Medplum', 'extended')
.send({
resourceType: 'DiagnosticReport',
status: 'final',
code: { text: 'Lab report' },
subject: patientRef,
} satisfies DiagnosticReport);
expect(reportRes.status).toBe(201);
const diagnosticReport = reportRes.body as DiagnosticReport;
expect(diagnosticReport.subject).toStrictEqual(patientRef);
expect(diagnosticReport.meta?.compartment).toStrictEqual(expect.arrayContaining([orgRef, patientRef]));
});
});