Skip to main content
Glama
client.test.ts144 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Bot, Bundle, Identifier, OperationOutcome, Patient, Practitioner, Reference, SearchParameter, StructureDefinition, } from '@medplum/fhirtypes'; import { randomUUID, webcrypto } from 'crypto'; import PdfPrinter from 'pdfmake'; import type { CustomTableLayout, TDocumentDefinitions, TFontDictionary } from 'pdfmake/interfaces'; import { TextEncoder } from 'util'; import { encodeBase64 } from './base64'; import type { CdsRequest } from './cds'; import type { FetchLike, InviteRequest, LoginState, MedplumClientEventMap, NewPatientRequest, NewProjectRequest, NewUserRequest, } from './client'; import { DEFAULT_ACCEPT, MedplumClient } from './client'; import { createFakeJwt, mockFetch, mockFetchResponse } from './client-test-utils'; import { ContentType } from './contenttype'; import * as environment from './environment'; import { OperationOutcomeError, accepted, allOk, badRequest, forbidden, notFound, serverError, tooManyRequests, unauthorized, unauthorizedTokenAudience, unauthorizedTokenExpired, } from './outcomes'; import { MockAsyncClientStorage } from './storage'; import { getDataType, isDataTypeLoaded, isProfileLoaded } from './typeschema/types'; import type { ProfileResource, WithId } from './utils'; import { createReference, sleep } from './utils'; // Mock the environment module jest.mock('./environment'); const mockEnvironment = environment as jest.Mocked<typeof environment> & { locationUtils: { assign: jest.MockedFunction<(url: string) => void>; reload: jest.MockedFunction<() => void>; getSearch: jest.MockedFunction<() => string>; getOrigin: jest.MockedFunction<() => string>; }; }; const EXAMPLE_XML = ` <note> <to>Frodo</to> <from>Gandalf</from> <heading>Reminder</heading> <body>Don't forget the ring in Gondor!</body> </note>`; const EXAMPLE_CCDA = ` <ClinicalDocument xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:hl7-org:v3" xmlns:voc="urn:hl7-org:v3/voc" xmlns:sdtc="urn:hl7-org:sdtc"> <!-- ** CDA Header ** --> <realmCode code="US"/> <typeId extension="POCD_HD000040" root="2.16.840.1.113883.1.3"/> <!-- CCD document template within C-CDA 2.0--> <templateId root="2.16.840.1.113883.10.20.22.1.2" extension="2014-06-09"/> <!-- Globally unique identifier for the document. Can only be [1..1] --> <id extension="EHRVersion2.0" root="be84a8e4-a22e-4210-a4a6-b3c48273e84c"/> <code code="34133-9" displayName="Summary of episode note" codeSystem="2.16.840.1.113883.6.1" codeSystemName="LOINC"/> <!-- Title of this document --> <title>Summary of Patient Chart</title> <!-- This is the time of document generation --> <effectiveTime value="20141015103026-0500"/> <confidentialityCode code="N" displayName="normal" codeSystem="2.16.840.1.113883.5.25" codeSystemName="Confidentiality"/> <!-- This is the document language code which uses internet standard RFC 4646. This often differs from patient language within recordTarget --> <languageCode code="en-US"/> <setId extension="sTT988" root="2.16.840.1.113883.19.5.99999.19"/> <!-- Version of this document --> <versionNumber value="1"/> </ClinicalDocument> `; const patientStructureDefinition: StructureDefinition = { resourceType: 'StructureDefinition', url: 'http://hl7.org/fhir/StructureDefinition/Patient', status: 'active', kind: 'resource', abstract: false, type: 'Patient', name: 'Patient', snapshot: { element: [ { path: 'Patient', }, { path: 'Patient.id', type: [ { code: 'code', }, ], }, ], }, }; const patientSearchParameter: SearchParameter = { resourceType: 'SearchParameter', id: 'Patient-name', url: 'http://example.com/Patient-name', status: 'active', description: 'Search by name', type: 'string', base: ['Patient'], code: 'name', name: 'name', expression: 'Patient.name', }; const schemaResponse = { data: { StructureDefinitionList: [patientStructureDefinition], SearchParameterList: [patientSearchParameter], }, }; const patientProfileUrl = 'http://example.com/patient-profile'; const patientProfileExtensionUrl = 'http://example.com/patient-profile-extension'; const profileSD = { resourceType: 'StructureDefinition', name: 'PatientProfile', type: 'Patient', url: patientProfileUrl, snapshot: { element: [ { path: 'Patient', }, { path: 'Patient.id', type: [ { code: 'code', }, ], }, { path: 'Patient.extension', slicing: { discriminator: [ { type: 'value', path: 'url', }, ], ordered: false, rules: 'open', }, type: [ { code: 'Extension', }, ], }, { path: 'Patient.extension', sliceName: 'fancy', type: [ { code: 'Extension', profile: [patientProfileExtensionUrl], }, ], }, ], }, }; const profileExtensionSD = { resourceType: 'StructureDefinition', type: 'Extension', derivation: 'constraint', name: 'PatientProfile', url: patientProfileExtensionUrl, snapshot: { element: [ { path: 'Extension', }, ], }, }; // Default environment mocks - will be overridden in specific tests describe('Client', () => { beforeAll(() => { Object.defineProperty(globalThis, 'TextEncoder', { value: TextEncoder }); Object.defineProperty(globalThis, 'crypto', { value: webcrypto }); }); beforeEach(() => { localStorage.clear(); jest.resetAllMocks(); // Default to browser environment for most tests mockEnvironment.isBrowserEnvironment.mockReturnValue(true); mockEnvironment.getWindow.mockReturnValue(window as any); mockEnvironment.isNodeEnvironment.mockReturnValue(false); mockEnvironment.getBuffer.mockReturnValue(undefined); }); afterAll(() => { Object.defineProperty(globalThis.window, 'sessionStorage', { value: undefined }); }); test('Constructor', () => { expect( () => new MedplumClient({ clientId: 'xyz', baseUrl: 'x', }) ).toThrow('Base URL must start with http or https'); expect( () => new MedplumClient({ clientId: 'xyz', baseUrl: 'https://x/', }) ).toThrow(); expect( () => new MedplumClient({ clientId: 'xyz', baseUrl: 'https://x/', fetch: mockFetch(200, {}), }) ).not.toThrow(); expect( () => new MedplumClient({ fetch: mockFetch(200, {}), }) ).not.toThrow(); window.fetch = jest.fn(); expect(() => new MedplumClient()).not.toThrow(); }); test('Missing trailing slash', () => { const client = new MedplumClient({ clientId: 'xyz', baseUrl: 'https://x' }); expect(client.getBaseUrl()).toBe('https://x/'); }); test('Relative URLs', () => { const client = new MedplumClient({ baseUrl: 'https://example.com', fhirUrlPath: 'my-fhir-url-path', authorizeUrl: 'my-authorize-url', tokenUrl: 'my-token-url', logoutUrl: 'my-logout-url', }); expect(client.getBaseUrl()).toBe('https://example.com/'); expect(client.getAuthorizeUrl()).toBe('https://example.com/my-authorize-url'); expect(client.getTokenUrl()).toBe('https://example.com/my-token-url'); expect(client.getLogoutUrl()).toBe('https://example.com/my-logout-url'); }); test('Absolute URLs', () => { const client = new MedplumClient({ baseUrl: 'https://example.com', fhirUrlPath: 'https://fhir.example.com', authorizeUrl: 'https://authorize.example.com', tokenUrl: 'https://token.example.com', logoutUrl: 'https://logout.example.com', fhircastHubUrl: 'https://hub.example.com', }); expect(client.getBaseUrl()).toBe('https://example.com/'); expect(client.getAuthorizeUrl()).toBe('https://authorize.example.com/'); expect(client.getTokenUrl()).toBe('https://token.example.com/'); expect(client.getLogoutUrl()).toBe('https://logout.example.com/'); expect(client.getFhircastHubUrl()).toBe('https://hub.example.com/'); }); test('getAuthorizeUrl', () => { const baseUrl = 'https://x'; const authorizeUrl = 'https://example.com/custom/authorize'; const client = new MedplumClient({ baseUrl, authorizeUrl }); expect(client.getAuthorizeUrl()).toBe(authorizeUrl); }); test('getDefaultHeaders', () => { const client = new MedplumClient({ defaultHeaders: { 'X-Test': '123', Cookie: 'abc' } }); const headers = client.getDefaultHeaders(); expect(headers).toBeDefined(); expect(headers['X-Test']).toBe('123'); expect(headers.Cookie).toBe('abc'); const clientWithoutDefaultHeaders = new MedplumClient(); expect(clientWithoutDefaultHeaders.getDefaultHeaders()).toStrictEqual({}); }); test('storagePrefix option', () => { localStorage.clear(); // Create client with custom storage prefix const client = new MedplumClient({ baseUrl: 'https://example.com/', storagePrefix: '@myapp:', fetch: mockFetch(200, {}), }); // Set some data through the client's storage const storage = (client as any).storage; storage.setString('testKey', 'testValue'); // Verify it's stored with the correct prefix in localStorage expect(localStorage.getItem('@myapp:testKey')).toBe('testValue'); expect(localStorage.getItem('testKey')).toBeNull(); // Verify we can retrieve it through the storage expect(storage.getString('testKey')).toBe('testValue'); // Test with objects const testObject = { foo: 'bar', nested: { value: 123 } }; storage.setObject('config', testObject); expect(localStorage.getItem('@myapp:config')).toBeTruthy(); expect(storage.getObject('config')).toMatchObject(testObject); // Create another client without prefix - should not see prefixed data const clientWithoutPrefix = new MedplumClient({ baseUrl: 'https://example.com/', fetch: mockFetch(200, {}), }); const unprefixedStorage = (clientWithoutPrefix as any).storage; expect(unprefixedStorage.getString('testKey')).toBeUndefined(); expect(unprefixedStorage.getObject('config')).toBeUndefined(); localStorage.clear(); }); test('Restore from localStorage', async () => { window.localStorage.setItem( 'activeLogin', JSON.stringify({ accessToken: createFakeJwt({ client_id: '123', login_id: '123' }), refreshToken: '456', project: { reference: 'Project/123', }, profile: { reference: 'Practitioner/123', }, }) ); const fetch = mockFetch(200, (url) => { if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: '123', login_id: '123' }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('auth/me')) { return { project: { resourceType: 'Project', id: '123' }, membership: { resourceType: 'ProjectMembership', id: '123' }, profile: { resourceType: 'Practitioner', id: '123' }, config: { resourceType: 'UserConfiguration', id: '123' }, accessPolicy: { resourceType: 'AccessPolicy', id: '123' }, }; } return {}; }); const client = new MedplumClient({ baseUrl: 'https://x/', fetch }); expect(client.getBaseUrl()).toStrictEqual('https://x/'); expect(client.isLoading()).toBe(true); expect(client.getProject()).toBeUndefined(); expect(client.getProjectMembership()).toBeUndefined(); expect(client.getProfile()).toBeUndefined(); expect(client.getProfileAsync()).toBeDefined(); expect(client.getUserConfiguration()).toBeUndefined(); expect(client.getAccessPolicy()).toBeUndefined(); const profile = (await client.getProfileAsync()) as ProfileResource; expect(client.isLoading()).toBe(false); expect(profile.id).toBe('123'); expect(client.getProject()).toBeDefined(); expect(client.getProjectMembership()).toBeDefined(); expect(client.getProfile()).toBeDefined(); expect(client.getUserConfiguration()).toBeDefined(); expect(client.getAccessPolicy()).toBeDefined(); expect(client.isSuperAdmin()).toBe(false); expect(client.isProjectAdmin()).toBe(false); }); test('Admin check', async () => { window.localStorage.setItem( 'activeLogin', JSON.stringify({ accessToken: createFakeJwt({ client_id: '123', login_id: '123' }), refreshToken: '456', project: { reference: 'Project/123', }, profile: { reference: 'Practitioner/123', }, }) ); const fetch = mockFetch(200, (url) => { if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: '123', login_id: '123' }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('auth/me')) { return { project: { resourceType: 'Project', id: '123', superAdmin: true }, membership: { resourceType: 'ProjectMembership', id: '123', admin: true }, profile: { resourceType: 'Practitioner', id: '123' }, config: { resourceType: 'UserConfiguration', id: '123' }, accessPolicy: { resourceType: 'AccessPolicy', id: '123' }, }; } return {}; }); const client = new MedplumClient({ baseUrl: 'https://x/', fetch }); const profile = (await client.getProfileAsync()) as ProfileResource; expect(profile.id).toBe('123'); expect(client.isSuperAdmin()).toBe(true); expect(client.isProjectAdmin()).toBe(true); }); test('Clear', () => { const client = new MedplumClient({ fetch: mockFetch(200, {}) }); expect(() => client.clear()).not.toThrow(); expect(sessionStorage.length).toStrictEqual(0); }); test('SignOut', async () => { const client = new MedplumClient({ fetch: mockFetch(200, {}) }); await client.signOut(); expect(client.getActiveLogin()).toBeUndefined(); expect(client.getProfile()).toBeUndefined(); }); test('Sign in direct', async () => { const fetch = mockFetch(200, { login: '123', code: 'abc' }); const client = new MedplumClient({ fetch }); const result1 = await client.startLogin({ email: 'admin@example.com', password: 'admin' }); expect(result1).toBeDefined(); expect(result1.login).toBeDefined(); expect(result1.code).toBeDefined(); }); test('Sign in with Google', async () => { const fetch = mockFetch(200, { login: '123', code: '123' }); const client = new MedplumClient({ fetch }); const result1 = await client.startGoogleLogin({ googleClientId: 'google-client-id', googleCredential: 'google-credential', }); expect(result1).toBeDefined(); expect(result1.login).toBeDefined(); }); test('SignInWithRedirect', async () => { // Mock locationUtils.assign and locationUtils.getSearch const assign = jest.fn(); mockEnvironment.locationUtils.assign.mockImplementation(assign); mockEnvironment.locationUtils.getSearch.mockReturnValue(''); const fetch = mockFetch(200, (url) => { if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: '123', login_id: '123' }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'Patient' } }; } return {}; }); const client = new MedplumClient({ fetch }); // First, test the initial reidrect const result1 = await client.signInWithRedirect(); expect(result1).toBeUndefined(); expect(assign).toHaveBeenCalledWith(expect.stringMatching(/authorize\?.+scope=/)); // Mock response code mockEnvironment.locationUtils.getSearch.mockReturnValue('?code=test-code'); // Next, test processing the response code const result2 = await client.signInWithRedirect(); expect(result2).toBeDefined(); }); test('SignOutWithRedirect', async () => { // Mock locationUtils.assign const assign = jest.fn(); mockEnvironment.locationUtils.assign.mockImplementation(assign); const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); client.signOutWithRedirect(); expect(assign).toHaveBeenCalled(); }); test('Sign in with external auth', async () => { const assign = jest.fn(); mockEnvironment.locationUtils.assign.mockImplementation(assign); const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.signInWithExternalAuth( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', } ); expect(result).toBeUndefined(); expect(assign).toHaveBeenCalledWith(expect.stringMatching(/authorize\?.+scope=/)); expect(assign).toHaveBeenCalledWith(expect.stringContaining('code_challenge')); expect(assign).toHaveBeenCalledWith(expect.stringContaining('code_challenge_method')); }); test('Sign in with external auth -- disabled PKCE', async () => { const assign = jest.fn(); mockEnvironment.locationUtils.assign.mockImplementation(assign); const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.signInWithExternalAuth( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', }, false ); expect(result).toBeUndefined(); expect(assign).not.toHaveBeenCalledWith(expect.stringContaining('code_challenge')); expect(assign).not.toHaveBeenCalledWith(expect.stringContaining('code_challenge_method')); }); test('Sign in with external auth -- no crypto.subtle', async () => { const assign = jest.fn(); mockEnvironment.locationUtils.assign.mockImplementation(assign); const originalSubtle = crypto.subtle; Object.defineProperty(crypto, 'subtle', { value: undefined, writable: true, }); const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.signInWithExternalAuth( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', }, false ); expect(result).toBeUndefined(); expect(assign).not.toHaveBeenCalledWith(expect.stringContaining('code_challenge')); expect(assign).not.toHaveBeenCalledWith(expect.stringContaining('code_challenge_method')); Object.defineProperty(crypto, 'subtle', { value: originalSubtle, writable: true, }); }); test('External auth token exchange', async () => { const clientId = 'medplum-client-123'; const fetch = mockFetch(200, (url) => { if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: clientId, login_id: '123' }), refresh_token: createFakeJwt({ client_id: clientId }), profile: { reference: 'Patient/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'Patient', id: '123' } }; } return {}; }); const client = new MedplumClient({ fetch, clientId }); expect(client.getAccessToken()).toBeUndefined(); const result1 = await client.exchangeExternalAccessToken('we12e121'); expect(result1).toBeDefined(); expect(result1.resourceType).toBeDefined(); expect(client.getAccessToken()).toBeDefined(); }); test('External auth token exchange with clientId param', async () => { const clientId1 = 'medplum-client-123'; const clientId2 = 'medplum-client-456'; const fetch = mockFetch(200, (url) => { if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: clientId2, login_id: '123' }), refresh_token: createFakeJwt({ client_id: clientId2 }), profile: { reference: 'Patient/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'Patient', id: '123' } }; } return {}; }); let client = new MedplumClient({ fetch, clientId: clientId1 }); expect(client.getAccessToken()).toBeUndefined(); await expect(client.exchangeExternalAccessToken('we12e121', clientId2)).rejects.toBeDefined(); client = new MedplumClient({ fetch }); const result1 = await client.exchangeExternalAccessToken('we12e121', clientId2); expect(result1).toBeDefined(); expect(result1.resourceType).toBeDefined(); expect(client.getAccessToken()).toBeDefined(); }); test('External auth token exchange w/o client ID', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); await expect(client.exchangeExternalAccessToken('we12e121')).rejects.toStrictEqual( new Error('MedplumClient is missing clientId') ); }); describe('Get external auth redirect URI', () => { let client: MedplumClient; beforeAll(() => { const fetch = mockFetch(200, {}); client = new MedplumClient({ fetch }); }); test('should give a valid url with all fields for PKCE exchange', async () => { const result = client.getExternalAuthRedirectUri( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', ...(await client.startPkce()), } ); expect(result).toMatch(/https:\/\/auth\.example\.com\/authorize\?.+scope=/); const codeVerifier = sessionStorage.getItem('codeVerifier'); expect(codeVerifier).toHaveLength(128); const { searchParams } = new URL(result); expect(searchParams.get('response_type')).toBe('code'); expect(searchParams.get('code_challenge')).not.toBeNull(); expect(typeof searchParams.get('code_challenge')).toBe('string'); expect(searchParams.get('code_challenge_method')).toBe('S256'); expect(searchParams.get('client_id')).toBe('external-client-123'); expect(searchParams.get('redirect_uri')).toBe('https://me.example.com'); expect(searchParams.get('scope')).not.toBeNull(); expect(typeof searchParams.get('scope')).toBe('string'); expect(searchParams.get('state')).not.toBeNull(); expect(typeof searchParams.get('state')).toBe('string'); expect(() => JSON.parse(searchParams.get('state') as string)).not.toThrow(); expect(JSON.parse(searchParams.get('state') as string)?.codeChallengeMethod).toBe('S256'); expect(typeof JSON.parse(searchParams.get('state') as string)?.codeChallenge).toBe('string'); }); test('PKCE disabled - should give a valid url without fields for PKCE', async () => { const result = client.getExternalAuthRedirectUri( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', }, false ); expect(result).toMatch(/https:\/\/auth\.example\.com\/authorize\?.+scope=/); const { searchParams } = new URL(result); expect(searchParams.get('response_type')).toBe('code'); expect(searchParams.get('client_id')).toBe('external-client-123'); expect(searchParams.get('redirect_uri')).toBe('https://me.example.com'); expect(searchParams.get('scope')).not.toBeNull(); expect(typeof searchParams.get('scope')).toBe('string'); expect(searchParams.get('state')).not.toBeNull(); expect(typeof searchParams.get('state')).toBe('string'); expect(() => JSON.parse(searchParams.get('state') as string)).not.toThrow(); expect(searchParams.get('code_challenge')).toBeNull(); expect(searchParams.get('code_challenge_method')).toBeNull(); expect(JSON.parse(searchParams.get('state') as string)?.codeChallenge).toBeUndefined(); expect(JSON.parse(searchParams.get('state') as string)?.codeChallengeMethod).toBeUndefined(); }); test('should throw if no `codeChallenge` is given', async () => { expect(() => client.getExternalAuthRedirectUri( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', codeChallengeMethod: 'S256', } ) ).toThrow(); }); test('should throw if no `codeChallengeMethod` is given', async () => { expect(() => client.getExternalAuthRedirectUri( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', codeChallenge: 'xyz-123', } ) ).toThrow(); }); test('should respect scope parameter', async () => { const result = client.getExternalAuthRedirectUri( 'https://auth.example.com/authorize', 'external-client-123', 'https://me.example.com', { clientId: 'medplum-client-123', scope: 'profile email foo', }, false ); const { searchParams } = new URL(result); expect(searchParams.get('scope')).toBe('profile email foo'); }); }); test('New project success', async () => { const fetch = mockFetch(200, (url) => { if (url.includes('/auth/newuser')) { return { login: '123' }; } if (url.includes('/auth/newproject')) { return { login: '123', code: 'xyz' }; } if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: '123', login_id: '123' }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'Patient' } }; } return {}; }); const client = new MedplumClient({ fetch }); const newUserRequest: NewUserRequest = { firstName: 'Sally', lastName: 'Foo', email: `george@example.com`, password: 'password', recaptchaToken: 'xyz', }; const response1 = await client.startNewUser(newUserRequest); expect(response1).toBeDefined(); const newProjectRequest: NewProjectRequest = { login: response1.login, projectName: 'Sally World', }; const response2 = await client.startNewProject(newProjectRequest); expect(response2).toBeDefined(); const response3 = await client.processCode(response2.code as string); expect(response3).toBeDefined(); }); test('New patient success', async () => { const fetch = mockFetch(200, (url) => { if (url.includes('/auth/newuser')) { return { login: '123' }; } if (url.includes('/auth/newpatient')) { return { login: '123', code: 'xyz' }; } if (url.includes('/oauth2/token')) { return { access_token: createFakeJwt({ client_id: '123', login_id: '123' }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'Patient' } }; } return {}; }); const client = new MedplumClient({ fetch }); const newUserRequest: NewUserRequest = { firstName: 'Sally', lastName: 'Foo', email: `george@example.com`, password: 'password', recaptchaToken: 'xyz', }; const response1 = await client.startNewUser(newUserRequest); expect(response1).toBeDefined(); const newPatientRequest: NewPatientRequest = { login: response1.login, projectId: '123', }; const response2 = await client.startNewPatient(newPatientRequest); expect(response2).toBeDefined(); const response3 = await client.processCode(response2.code as string); expect(response3).toBeDefined(); }); test('Client credentials flow', async () => { let tokenExpired = true; const fetch = mockFetch(200, (url) => { if (url.includes('Patient/123')) { if (tokenExpired) { return unauthorized; } else { return { resourceType: 'Patient', id: '123' }; } } if (url.includes('oauth2/token')) { tokenExpired = false; return { access_token: createFakeJwt({ client_id: 'test-client-id', login_id: '123' }), refresh_token: createFakeJwt({ client_id: 'test-client-id' }), profile: { reference: 'ClientApplication/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'ClientApplication' } }; } return {}; }); const client = new MedplumClient({ fetch }); const result1 = await client.startClientLogin('test-client-id', 'test-client-secret'); expect(result1).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(2); tokenExpired = true; fetch.mockClear(); const result2 = await client.readResource('Patient', '123'); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(4); }); test('JWT bearer token flow', async () => { const fetch = mockFetch(200, (url) => { if (url.includes('oauth2/token')) { return { access_token: createFakeJwt({ client_id: 'test-client-id', login_id: '123' }), refresh_token: createFakeJwt({ client_id: 'test-client-id' }), profile: { reference: 'ClientApplication/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'ClientApplication' } }; } return {}; }); const client = new MedplumClient({ fetch }); const result1 = await client.startJwtBearerLogin('test-client-id', 'test-client-secret', 'openid profile'); expect(result1).toBeDefined(); expect(result1).toMatchObject({ resourceType: 'ClientApplication' }); expect(fetch).toHaveBeenCalledTimes(2); }); test('JWT assertion flow', async () => { const fetch = mockFetch(200, (url) => { if (url.includes('oauth2/token')) { return { access_token: createFakeJwt({ client_id: 'test-client-id', login_id: '123' }), refresh_token: createFakeJwt({ client_id: 'test-client-id' }), profile: { reference: 'ClientApplication/123' }, }; } if (url.includes('/auth/me')) { return { profile: { resourceType: 'ClientApplication' } }; } return {}; }); const client = new MedplumClient({ fetch }); const result1 = await client.startJwtAssertionLogin('my-jwt'); expect(result1).toBeDefined(); expect(result1).toMatchObject({ resourceType: 'ClientApplication' }); expect(fetch).toHaveBeenCalledTimes(2); }); test('Basic auth in browser', async () => { // Mock browser environment (already set in beforeEach, but explicit for clarity) mockEnvironment.isBrowserEnvironment.mockReturnValue(true); mockEnvironment.getWindow.mockReturnValue(window as any); mockEnvironment.isNodeEnvironment.mockReturnValue(false); mockEnvironment.getBuffer.mockReturnValue(undefined); const fetch = mockFetch(200, () => { return { resourceType: 'Patient', id: '123' }; }); const client = new MedplumClient({ fetch }); client.setBasicAuth('test-client-id', 'test-client-secret'); const result2 = await client.readResource('Patient', '123'); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET', headers: { Accept: DEFAULT_ACCEPT, Authorization: 'Basic dGVzdC1jbGllbnQtaWQ6dGVzdC1jbGllbnQtc2VjcmV0', 'X-Medplum': 'extended', }, }) ); }); test('Basic auth in Node.js', async () => { // Mock Node.js environment mockEnvironment.isBrowserEnvironment.mockReturnValue(false); mockEnvironment.getWindow.mockReturnValue(undefined); mockEnvironment.isNodeEnvironment.mockReturnValue(true); mockEnvironment.getBuffer.mockReturnValue(Buffer as any); const fetch = mockFetch(200, () => { return { resourceType: 'Patient', id: '123' }; }); const client = new MedplumClient({ fetch }); client.setBasicAuth('test-client-id', 'test-client-secret'); const result2 = await client.readResource('Patient', '123'); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET', headers: { Accept: DEFAULT_ACCEPT, Authorization: 'Basic dGVzdC1jbGllbnQtaWQ6dGVzdC1jbGllbnQtc2VjcmV0', 'X-Medplum': 'extended', }, }) ); }); test('Basic auth and startClientLogin with valid token.cid', async () => { const patientId = randomUUID(); const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const accessToken = createFakeJwt({ cid: clientId }); const fetch = mockFetch(200, (url) => { if (url.includes(`Patient/${patientId}`)) { return { resourceType: 'Patient', id: patientId }; } if (url.includes('oauth2/token')) { return { access_token: accessToken, }; } return {}; }); const client = new MedplumClient({ fetch }); client.setBasicAuth(clientId, clientSecret); await client.startClientLogin(clientId, clientSecret); const result2 = await client.readResource('Patient', patientId); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenCalledWith( `https://api.medplum.com/fhir/R4/Patient/${patientId}`, expect.objectContaining({ method: 'GET', headers: { Accept: DEFAULT_ACCEPT, Authorization: `Bearer ${accessToken}`, 'X-Medplum': 'extended', }, }) ); expect(fetch).toHaveBeenCalledWith( `https://api.medplum.com/oauth2/token`, expect.objectContaining({ method: 'POST', headers: { Authorization: 'Basic ' + encodeBase64(clientId + ':' + clientSecret), 'Content-Type': ContentType.FORM_URL_ENCODED, }, }) ); }); test('Basic auth and startClientLogin with valid token.client_id', async () => { const patientId = randomUUID(); const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const accessToken = createFakeJwt({ cid: clientId }); const fetch = mockFetch(200, (url) => { if (url.includes(`Patient/${patientId}`)) { return { resourceType: 'Patient', id: patientId }; } if (url.includes('oauth2/token')) { return { access_token: accessToken, }; } return {}; }); const client = new MedplumClient({ fetch }); client.setBasicAuth(clientId, clientSecret); await client.startClientLogin(clientId, clientSecret); const result2 = await client.readResource('Patient', patientId); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenCalledWith( `https://api.medplum.com/fhir/R4/Patient/${patientId}`, expect.objectContaining({ method: 'GET', headers: { Accept: DEFAULT_ACCEPT, Authorization: `Bearer ${accessToken}`, 'X-Medplum': 'extended', }, }) ); expect(fetch).toHaveBeenCalledWith( `https://api.medplum.com/oauth2/token`, expect.objectContaining({ method: 'POST', headers: { Authorization: 'Basic ' + encodeBase64(clientId + ':' + clientSecret), 'Content-Type': ContentType.FORM_URL_ENCODED, }, }) ); }); test('startClientLogin send credentials in header with valid token.client_id', async () => { const patientId = randomUUID(); const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const accessToken = createFakeJwt({ cid: clientId }); const fetch = mockFetch(200, (url) => { if (url.includes(`Patient/${patientId}`)) { return { resourceType: 'Patient', id: patientId }; } if (url.includes('oauth2/token')) { return { access_token: accessToken, }; } return {}; }); const client = new MedplumClient({ fetch, authCredentialsMethod: 'header' }); await client.startClientLogin(clientId, clientSecret); const result2 = await client.readResource('Patient', patientId); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenCalledWith( `https://api.medplum.com/fhir/R4/Patient/${patientId}`, expect.objectContaining({ method: 'GET', headers: { Accept: DEFAULT_ACCEPT, Authorization: `Bearer ${accessToken}`, 'X-Medplum': 'extended', }, }) ); expect(fetch).toHaveBeenCalledWith( `https://api.medplum.com/oauth2/token`, expect.objectContaining({ method: 'POST', headers: { Authorization: 'Basic ' + encodeBase64(clientId + ':' + clientSecret), 'Content-Type': ContentType.FORM_URL_ENCODED, }, body: 'grant_type=client_credentials', }) ); }); test('Basic auth and startClientLogin with fetched token mismatched client id ', async () => { const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const fetch = mockFetch(200, (url) => { if (url.includes('oauth2/token')) { return { access_token: 'header.' + Buffer.from(JSON.stringify({ client_id: 'different-client-id' })).toString('base64') + '.signature', }; } return {}; }); const client = new MedplumClient({ fetch }); client.setBasicAuth(clientId, clientSecret); const result = client.startClientLogin(clientId, clientSecret); await expect(result).rejects.toThrow(new OperationOutcomeError(unauthorizedTokenAudience)); }); test('Basic auth and startClientLogin with fetched token contains mismatched cid', async () => { const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const fetch = mockFetch(200, (url) => { if (url.includes('oauth2/token')) { return { access_token: createFakeJwt({ cid: 'different-client-id' }), }; } return {}; }); const client = new MedplumClient({ fetch }); client.setBasicAuth(clientId, clientSecret); const result = client.startClientLogin(clientId, clientSecret); await expect(result).rejects.toThrow(new OperationOutcomeError(unauthorizedTokenAudience)); }); test('Basic auth and startClientLogin Failed to fetch tokens', async () => { const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const fetch = mockFetch(500, () => ({})); const client = new MedplumClient({ fetch }); try { client.setBasicAuth(clientId, clientSecret); await client.startClientLogin(clientId, clientSecret); throw new Error('test'); } catch (err) { expect((err as Error).message).toBe('Failed to fetch tokens'); } }); test('Basic auth and startClientLogin Token expired', async () => { const clientId = 'test-client-id'; const clientSecret = 'test-client-secret'; const oneMinuteAgo = Date.now() / 1000 - 60; const fetch = mockFetch(200, (url) => { if (url.includes('oauth2/token')) { return { access_token: createFakeJwt({ exp: oneMinuteAgo }), }; } return {}; }); const client = new MedplumClient({ fetch }); client.setBasicAuth(clientId, clientSecret); const result = client.startClientLogin(clientId, clientSecret); await expect(result).rejects.toThrow(new OperationOutcomeError(unauthorizedTokenExpired)); }); test('Invite user', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const body: InviteRequest = { resourceType: 'Patient', firstName: 'Sally', lastName: 'Foo', email: 'sally@foomedical.com', sendEmail: true, }; const result = await client.invite('123', body); expect(result).toBeDefined(); }); test('HTTP GET', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const request1 = client.get('Practitioner/123'); const request2 = client.get('Practitioner/123'); expect(request2).toBe(request1); const request3 = client.get('Practitioner/123', { cache: 'reload' }); expect(request3).not.toBe(request1); }); test('HTTP POST without a body', async () => { const fetch = mockFetch(201, {}); const client = new MedplumClient({ fetch }); const result = await client.post(client.fhirUrl('Bot', randomUUID(), '$deploy')); expect(result).toStrictEqual({}); }); test('HTTP CREATE with Content-Length 0', async () => { const consoleErrorSpy = jest.spyOn(console, 'error'); const fetch: FetchLike = async (_url: string, _options: RequestInit): Promise<any> => { let streamRead = false; const streamReader = async (type: 'json' | 'blob' | 'text'): Promise<any> => { if (streamRead) { throw new Error('Stream already read'); } streamRead = true; switch (type) { case 'json': { // Simulate parsing non-JSON // This will always throw JSON.parse('EMPTY'); break; } default: return ''; } throw new Error('UNREACHABLE'); }; return Promise.resolve({ ok: true, status: 201, headers: new Map<string, string>([ ['content-type', 'application/json'], ['content-length', '0'], ]), blob: () => streamReader('blob'), json: () => streamReader('json'), text: () => streamReader('text'), }); }; const client = new MedplumClient({ fetch }); await expect(client.post('Practitioner/123', { resourceType: 'Practitioner' })).resolves.toStrictEqual(undefined); expect(consoleErrorSpy).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); test('Read expired and refresh', async () => { let tokenExpired = true; const fetch = mockFetch(200, (url) => { if (url.includes('Patient/123')) { if (tokenExpired) { return unauthorized; } else { return { resourceType: 'Patient', id: '123' }; } } if (url.includes('oauth2/token')) { tokenExpired = false; return { access_token: createFakeJwt({ client_id: '123', login_id: '123' }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('auth/me')) { return { profile: { resourceType: 'Patient', id: '123' }, }; } return {}; }); const client = new MedplumClient({ fetch }); const loginResponse = await client.startLogin({ email: 'admin@example.com', password: 'admin' }); expect(fetch).toHaveBeenCalledTimes(1); fetch.mockClear(); await client.processCode(loginResponse.code as string); expect(fetch).toHaveBeenCalledTimes(2); fetch.mockClear(); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(1); fetch.mockClear(); // Set an expired token tokenExpired = true; client.setAccessToken(createFakeJwt({ exp: 0 }), createFakeJwt({ client_id: '123' })); client.invalidateAll(); const result2 = await client.readResource('Patient', '123'); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(3); }); test('Read expired and refresh with unAuthenticated callback', async () => { const fetch = mockFetch(401, unauthorized); const onUnauthenticated = jest.fn(); const client = new MedplumClient({ fetch, onUnauthenticated }); const result = client.get('expired'); await expect(result).rejects.toThrow(new OperationOutcomeError(unauthorized)); expect(onUnauthenticated).toHaveBeenCalled(); }); test('fhirUrl', () => { const client = new MedplumClient({ fetch: jest.fn() }); expect(client.fhirUrl().toString()).toBe('https://api.medplum.com/fhir/R4/'); expect(client.fhirUrl('Patient').toString()).toBe('https://api.medplum.com/fhir/R4/Patient'); expect(client.fhirUrl('Patient', '123').toString()).toBe('https://api.medplum.com/fhir/R4/Patient/123'); expect(client.fhirUrl('Patient/123').toString()).toBe('https://api.medplum.com/fhir/R4/Patient/123'); }); test('Read resource', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch }); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET' }) ); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('123'); }); test('Read resource with invalid id', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); expect(() => client.readResource('Patient', '')).toThrow( 'The "id" parameter cannot be null, undefined, or an empty string.' ); expect(() => client.readResource('Patient', undefined as unknown as string)).toThrow( 'The "id" parameter cannot be null, undefined, or an empty string.' ); }); test('Read reference', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch }); const result = await client.readReference({ reference: 'Patient/123' }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET' }) ); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('123'); try { await client.readReference({}); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Missing reference'); } try { await client.readReference({ reference: '' }); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Missing reference'); } try { await client.readReference({ reference: 'xyz' }); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Invalid reference'); } try { await client.readReference({ reference: 'xyz?abc' }); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Invalid reference'); } }); test('Read canonical', async () => { const url = 'http://example.com/CodeSystem/' + randomUUID(); const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'CodeSystem', id: '123', url } }], }); const client = new MedplumClient({ fetch }); const result = await client.readCanonical('CodeSystem', url); expect(result?.resourceType).toBe('CodeSystem'); expect(result?.url).toBe(url); expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/fhir/R4/CodeSystem?_count=1&url=${encodeURIComponent(url)}`), expect.anything() ); }); test('Read canonical over multiple types', async () => { const url = 'http://example.com/CodeSystem/' + randomUUID(); const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'CodeSystem', id: '123', url } }], }); const client = new MedplumClient({ fetch }); const result = await client.readCanonical(['CodeSystem', 'ValueSet', 'ConceptMap'], url); expect(result?.resourceType).toBe('CodeSystem'); expect(result?.url).toBe(url); expect(fetch).toHaveBeenCalledWith( expect.stringContaining( `/fhir/R4/?_count=1&_type=CodeSystem%2CValueSet%2CConceptMap&url=${encodeURIComponent(url)}` ), expect.anything() ); }); test('Read cached resource', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch }); expect(client.getCached('Patient', '123')).toBeUndefined(); // Nothing in the cache const readPromise = client.readResource('Patient', '123'); expect(client.getCached('Patient', '123')).toBeUndefined(); // Promise in the cache const result = await readPromise; expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET' }) ); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('123'); expect(client.getCached('Patient', '123')).toBe(result); // Value in the cache }); test('Read cached resource not found', async () => { expect.assertions(7); const fetch = mockFetch(404, notFound); const client = new MedplumClient({ fetch }); const reference = { reference: 'Patient/not-found' }; expect(client.getCached('Patient', 'not-found')).toBeUndefined(); // Nothing in the cache expect(client.getCachedReference(reference)).toBeUndefined(); const readPromise = client.readResource('Patient', 'not-found'); expect(client.getCached('Patient', 'not-found')).toBeUndefined(); // Promise in the cache expect(client.getCachedReference(reference)).toBeUndefined(); try { await readPromise; } catch (err) { expect(err).toBeDefined(); } expect(client.getCached('Patient', 'not-found')).toBeUndefined(); // Should not throw expect(client.getCachedReference(reference)).toBeUndefined(); }); test('Read cached reference', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch }); const reference = { reference: 'Patient/123' }; expect(client.getCachedReference(reference)).toBeUndefined(); const readPromise = client.readReference(reference); expect(client.getCachedReference(reference)).toBeUndefined(); // Promise in the cache const result = await readPromise; expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET' }) ); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('123'); expect(client.getCachedReference(reference)).toBe(result); expect(client.getCachedReference({})).toBeUndefined(); expect(client.getCachedReference({ reference: '' })).toBeUndefined(); expect(client.getCachedReference({ reference: 'xyz' })).toBeUndefined(); expect(client.getCachedReference({ reference: 'xyz?abc' })).toBeUndefined(); expect(client.getCachedReference({ reference: 'system' })).toMatchObject({ resourceType: 'Device', id: 'system', deviceName: [{ name: 'System' }], }); }); test('Read system reference', async () => { const client = new MedplumClient({ fetch }); // Get expect(client.getCachedReference({ reference: 'system' })).toMatchObject({ resourceType: 'Device', id: 'system', deviceName: [{ name: 'System' }], }); // Read async const result = await client.readReference({ reference: 'system' }); expect(result).toMatchObject({ resourceType: 'Device', id: 'system', deviceName: [{ name: 'System' }], }); }); test('Disabled cache read cached resource', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch, cacheTime: 0 }); expect((client as any).requestCache).toBeUndefined(); expect((client as any).autoBatchQueue).toBeUndefined(); expect(client.getCached('Patient', '123')).toBeUndefined(); // Nothing in the cache const readPromise = client.readResource('Patient', '123'); expect(client.getCached('Patient', '123')).toBeUndefined(); // Cache is disabled const result = await readPromise; expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET' }) ); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('123'); expect(client.getCached('Patient', '123')).toBeUndefined(); // Cache is disabled }); test('Read history', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.readHistory('Patient', '123'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123/_history', expect.objectContaining({ method: 'GET' }) ); fetch.mockClear(); const r2 = await client.readHistory('Patient', '123', { count: 5, offset: 100 }); expect(r2).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123/_history?_count=5&_offset=100', expect.objectContaining({ method: 'GET' }) ); }); test('Read patient everything', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.readPatientEverything('123'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123/$everything', expect.objectContaining({ method: 'GET' }) ); }); test('Read patient summary', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.readPatientSummary('123'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123/$summary', expect.objectContaining({ method: 'GET' }) ); }); test('Create resource', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createResource({ resourceType: 'Patient' }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', }, }) ); }); test('Create resource missing resourceType', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); try { await client.createResource({} as Patient); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Missing resourceType'); } }); test('Create resource if none exist returns existing', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch }); const result = await client.createResourceIfNoneExist<Patient>( { resourceType: 'Patient', name: [{ given: ['Alice'], family: 'Smith' }], }, 'name:contains=alice' ); expect(result).toBeDefined(); expect(result.id).toBe('123'); // Expect existing patient }); test.each([undefined, {}])('Create resource if none exist creates new', async (emptyOptions) => { const fetch = mockFetch(200, (_url, options) => { if (options.method === 'GET') { return { resourceType: 'Bundle', total: 0, entry: [] }; } else { return { resourceType: 'Patient', id: '123' }; } }); const client = new MedplumClient({ fetch }); const result = await client.createResourceIfNoneExist<Patient>( { resourceType: 'Patient', name: [{ given: ['Bob'], family: 'Smith' }], }, 'name:contains=bob', emptyOptions ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', 'If-None-Exist': 'name:contains=bob', }, }) ); }); test.each<HeadersInit>([[['foo', 'bar']], { foo: 'bar' }, new Headers({ foo: 'bar' })])( 'Conditional create merges passed-in headers', async (headers) => { const fetch = mockFetch(200, (_url, _options) => { return { resourceType: 'Patient', id: '123' }; }); const client = new MedplumClient({ fetch }); const result = await client.createResourceIfNoneExist<Patient>( { resourceType: 'Patient', name: [{ given: ['Bob'], family: 'Smith' }], }, 'name:contains=bob', { headers } ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient', expect.objectContaining({ method: 'POST', }) ); const passedHeaders = new Headers(fetch.mock.calls[0][1].headers); expect(passedHeaders.get('foo')).toStrictEqual('bar'); expect(passedHeaders.get('If-None-Exist')).toStrictEqual('name:contains=bob'); } ); test('Update resource', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.updateResource({ resourceType: 'Patient', id: '123' }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'PUT', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', }, }) ); }); test('Upsert resource', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.upsertResource( { resourceType: 'Patient', identifier: [{ system: 'http://example.com/mrn', value: '24601' }], }, 'identifier=http://example.com/mrn|24601' ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient?identifier=http%3A%2F%2Fexample.com%2Fmrn%7C24601', expect.objectContaining({ method: 'PUT', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', }, }) ); }); test('Update resource validation', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); try { await client.updateResource({} as Patient); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Missing resourceType'); } try { await client.updateResource({ resourceType: 'Patient' }); fail('Expected error'); } catch (err) { expect((err as Error).message).toStrictEqual('Missing id'); } }); test('Not modified', async () => { const fetch = mockFetch(304, { resourceType: 'Patient', id: '777' }); const client = new MedplumClient({ fetch }); const result = await client.updateResource({ resourceType: 'Patient', id: '777' }); expect(result).not.toBeUndefined(); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('777'); }); test('Bad Request', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); try { await client.updateResource({ resourceType: 'Patient', id: '888' }); fail('Expected error'); } catch (err) { expect(err).toBeDefined(); } }); test('Create attachment', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createAttachment({ data: 'Hello world', contentType: ContentType.TEXT, }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.TEXT, 'X-Medplum': 'extended', }, }) ); }); describe('Create attachment -- XML', () => { test('Non-CDA XML', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createAttachment({ data: EXAMPLE_XML, contentType: ContentType.XML, }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.XML, 'X-Medplum': 'extended', }, }) ); }); test('C-CDA -- String', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createAttachment({ data: EXAMPLE_CCDA, contentType: ContentType.XML, }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, // Make sure the content type is updated 'Content-Type': ContentType.CDA_XML, 'X-Medplum': 'extended', }, }) ); }); test('C-CDA -- Encoded bytes', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createAttachment({ data: new TextEncoder().encode(EXAMPLE_CCDA), contentType: ContentType.XML, }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, // Make sure the content type is updated 'Content-Type': ContentType.CDA_XML, 'X-Medplum': 'extended', }, }) ); }); test('C-CDA -- File', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createAttachment({ data: new File([EXAMPLE_CCDA], 'cda.xml'), contentType: ContentType.XML, }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, // Make sure the content type is updated 'Content-Type': ContentType.CDA_XML, 'X-Medplum': 'extended', }, }) ); }); }); test('Create attachment (deprecated legacy version)', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createAttachment('Hello world', undefined, ContentType.TEXT); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.TEXT, 'X-Medplum': 'extended', }, }) ); }); test('Create binary', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createBinary({ data: 'Hello world', contentType: ContentType.TEXT, }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.TEXT, 'X-Medplum': 'extended', }, }) ); }); test('Create binary with filename', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createBinary({ data: 'Hello world', contentType: ContentType.TEXT, filename: 'hello.txt', }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary?_filename=hello.txt', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.TEXT, 'X-Medplum': 'extended', }, }) ); }); test('Create binary (deprecated legacy version)', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createBinary('Hello world', undefined, ContentType.TEXT); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.TEXT, 'X-Medplum': 'extended', }, }) ); }); test('Create binary with filename (deprecated legacy version)', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.createBinary('Hello world', 'hello.txt', ContentType.TEXT); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary?_filename=hello.txt', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.TEXT, 'X-Medplum': 'extended', }, }) ); }); test('Create binary with progress event listener', async () => { const xhrMock: Partial<XMLHttpRequest> = { open: jest.fn(), send: jest.fn(), setRequestHeader: jest.fn(), upload: {} as XMLHttpRequestUpload, readyState: 4, status: 200, response: { resourceType: 'Binary', }, }; jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest); const onProgress = jest.fn(); const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const promise = client.createBinary('Hello world', undefined, ContentType.TEXT, onProgress); expect(xhrMock.open).toHaveBeenCalled(); expect(xhrMock.setRequestHeader).toHaveBeenCalled(); // Emulate xhr progress events (xhrMock.upload?.onprogress as EventListener)(new Event('')); (xhrMock.upload?.onload as EventListener)(new Event('')); (xhrMock.onload as EventListener)(new Event('')); const result = await promise; expect(result).toBeDefined(); expect(onProgress).toHaveBeenCalledTimes(2); }); test('Create pdf not enabled', async () => { expect.assertions(1); const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); try { await client.createPdf({ docDefinition: { content: ['Hello world'] } }); } catch (err) { expect((err as Error).message).toStrictEqual('PDF creation not enabled'); } }); test('Create pdf success', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch, createPdf }); const footer = jest.fn(() => 'footer'); const result = await client.createPdf( { content: ['Hello World'], defaultStyle: { font: 'Helvetica', }, footer, }, undefined, undefined, fonts ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': 'application/pdf', 'X-Medplum': 'extended', }, }) ); expect(footer).toHaveBeenCalled(); }); test('Create pdf with filename', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch, createPdf }); const result = await client.createPdf( { content: ['Hello world'], defaultStyle: { font: 'Helvetica' } }, 'report.pdf', undefined, fonts ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Binary?_filename=report.pdf', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': 'application/pdf', 'X-Medplum': 'extended', }, }) ); }); test('Create comment on Encounter', async () => { const fetch = mockFetch(200, (_url, options) => JSON.parse(options.body)); const client = new MedplumClient({ fetch }); const result = await client.createComment( { resourceType: 'Encounter', id: '999', status: 'arrived', class: { code: 'test' } }, 'Hello world' ); expect(result).toBeDefined(); expect(result.basedOn).toBeDefined(); expect(result.encounter).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Communication', expect.objectContaining({ method: 'POST', }) ); }); test('Create comment on ServiceRequest', async () => { const fetch = mockFetch(200, (_url, options) => JSON.parse(options.body)); const client = new MedplumClient({ fetch }); const result = await client.createComment( { resourceType: 'ServiceRequest', id: '999', status: 'active', intent: 'order', subject: { reference: 'Patient/123' }, }, 'Hello world' ); expect(result).toBeDefined(); expect(result.basedOn).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Communication', expect.objectContaining({ method: 'POST', }) ); }); test('Create comment on Patient', async () => { const fetch = mockFetch(200, (_url, options) => JSON.parse(options.body)); const client = new MedplumClient({ fetch }); const result = await client.createComment({ resourceType: 'Patient', id: '999' }, 'Hello world'); expect(result).toBeDefined(); expect(result.basedOn).toBeDefined(); expect(result.subject).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Communication', expect.objectContaining({ method: 'POST', }) ); }); test('Patch resource', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.patchResource('Patient', '123', [ { op: 'replace', path: '/name/0/family', value: 'Doe' }, ]); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'PATCH', }) ); }); test('Delete resource', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.deleteResource('Patient', 'xyz'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/xyz', expect.objectContaining({ method: 'DELETE', }) ); }); test('Validate resource', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.validateResource({ resourceType: 'Patient' }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/$validate', expect.objectContaining({ method: 'POST', }) ); }); test('Execute bot by ID', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const bot: WithId<Bot> = { resourceType: 'Bot', id: '123', name: 'Test Bot', identifier: [{ system: 'https://example.com', value: '123' }], code: 'export async function handler() {}', }; const result1 = await client.executeBot(bot.id, {}); expect(result1).toBeDefined(); expect(fetch).toHaveBeenCalledWith('https://api.medplum.com/fhir/R4/Bot/123/$execute', expect.objectContaining({})); }); test('Execute bot by Identifier', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const bot: Bot = { resourceType: 'Bot', id: '123', name: 'Test Bot', identifier: [{ system: 'https://example.com', value: '123' }], code: 'export async function handler() {}', }; const result2 = await client.executeBot(bot.identifier?.[0] as Identifier, {}); expect(result2).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Bot/$execute?identifier=https%3A%2F%2Fexample.com%7C123', expect.objectContaining({}) ); }); test('Request schema', async () => { const fetch = mockFetch(200, schemaResponse); const client = new MedplumClient({ fetch }); // Issue two requests simultaneously const request1 = client.requestSchema('Patient'); const request2 = client.requestSchema('Patient'); expect(request2).toBe(request1); await request1; expect(isDataTypeLoaded('Patient')).toBe(true); }); test('requestProfileSchema', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: profileSD }], }); const client = new MedplumClient({ fetch }); // Issue two requests simultaneously const request1 = client.requestProfileSchema(patientProfileUrl); const request2 = client.requestProfileSchema(patientProfileUrl); expect(request2).toBe(request1); await request1; expect(isProfileLoaded(patientProfileUrl)).toBe(true); expect(getDataType(profileSD.type, patientProfileUrl)).toBeDefined(); }); test('requestProfileSchema expandProfile', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: profileSD }, { resource: profileExtensionSD }], }); const client = new MedplumClient({ fetch }); // Issue two requests simultaneously const request1 = client.requestProfileSchema(patientProfileUrl, { expandProfile: true }); const request2 = client.requestProfileSchema(patientProfileUrl, { expandProfile: true }); expect(request2).toBe(request1); await request1; await request2; expect(isProfileLoaded(patientProfileUrl)).toBe(true); expect(isProfileLoaded(patientProfileExtensionUrl)).toBe(true); expect(getDataType(profileSD.type, patientProfileUrl)).toBeDefined(); expect(getDataType(profileExtensionSD.type, patientProfileExtensionUrl)).toBeDefined(); }); test('Search', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const result = await client.search('Patient', 'name:contains=alice'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient?name%3Acontains=alice', expect.objectContaining({ method: 'GET' }) ); }); test('Search no filters', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const result = await client.search('Patient'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient', expect.objectContaining({ method: 'GET' }) ); }); test('Search one', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const result = await client.searchOne('Patient', 'name:contains=alice'); expect(result).toBeDefined(); expect(result?.resourceType).toBe('Patient'); }); test('Search one ReadablePromise', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const promise1 = client.searchOne('Patient', 'name:contains=alice'); expect(() => promise1.read()).toThrow(); const promise2 = client.searchOne('Patient', 'name:contains=alice'); expect(promise2).toBe(promise1); await promise1; const result = promise1.read(); expect(result).toBeDefined(); expect(result?.resourceType).toBe('Patient'); }); test('Search resources', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const result = await client.searchResources('Patient', '_count=1&name:contains=alice'); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); expect(result[0].resourceType).toBe('Patient'); expect(result.bundle).toBeDefined(); expect(result.bundle.resourceType).toBe('Bundle'); }); test('Search resources with record of params', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const result = await client.searchResources('Patient', { _count: 1, 'name:contains': 'alice' }); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); expect(result[0].resourceType).toBe('Patient'); }); test('Search resources ReadablePromise', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const promise1 = client.searchResources('Patient', '_count=1&name:contains=alice'); expect(() => promise1.read()).toThrow(); const promise2 = client.searchResources('Patient', '_count=1&name:contains=alice'); expect(promise2).toBe(promise1); await promise1; const result = promise1.read(); expect(result).toBeDefined(); expect(result.length).toBe(1); expect(result[0].resourceType).toBe('Patient'); }); test('Search and cache', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', entry: [{ resource: { resourceType: 'Patient', id: '123' } }], }); const client = new MedplumClient({ fetch }); const result = await client.search('Patient'); expect(result).toBeDefined(); expect(client.getCachedReference(createReference(result.entry?.[0]?.resource as Patient))).toBeDefined(); }); test('Search and return 404', async () => { const fetch = mockFetch(404, () => 'string_representation'); (fetch as unknown as jest.Mock).mockImplementation(() => ({ status: 404, headers: { get(name: string): string | undefined { return { 'content-type': 'string_representation', }[name]; }, }, })); const client = new MedplumClient({ fetch }); try { await client.search('Patient'); } catch (err) { expect((err as OperationOutcomeError).outcome).toMatchObject(notFound); } }); describe('maxRetries', () => { test('should try 3 times by default', async () => { const fetch = mockFetch(500, serverError(new Error('Something is broken'))); const client = new MedplumClient({ fetch }); await expect(client.get(client.fhirUrl('Patient', '123'))).rejects.toThrow( 'Internal server error (Error: Something is broken)' ); expect(fetch).toHaveBeenCalledTimes(3); }); test('should only try once when maxRetries = 0', async () => { const fetch = mockFetch(500, serverError(new Error('Something is broken'))); const client = new MedplumClient({ fetch }); await expect(client.get(client.fhirUrl('Patient', '123'), { maxRetries: 0 })).rejects.toThrow( 'Internal server error (Error: Something is broken)' ); expect(fetch).toHaveBeenCalledTimes(1); }); test.each([400, 401, 403, 404])('%d status code is not retried', async (statusCode) => { const fetch = mockFetch(statusCode, (): OperationOutcome => { switch (statusCode) { case 400: return badRequest('The request is not good'); case 401: return unauthorized; case 403: return forbidden; case 404: return notFound; default: throw new Error('Invalid status code'); } }); const client = new MedplumClient({ fetch }); switch (statusCode) { case 400: await expect(client.get(client.fhirUrl('Patient', '123'))).rejects.toThrow('The request is not good'); break; case 401: await expect(client.get(client.fhirUrl('Patient', '123'))).rejects.toThrow( new OperationOutcomeError(unauthorized) ); break; case 403: await expect(client.get(client.fhirUrl('Patient', '123'))).rejects.toThrow( new OperationOutcomeError(forbidden) ); break; case 404: await expect(client.get(client.fhirUrl('Patient', '123'))).rejects.toThrow('Not found'); break; default: throw new Error('Invalid status code'); } expect(fetch).toHaveBeenCalledTimes(1); }); test('should not retry after request is aborted', async () => { const fetch = jest.fn().mockImplementation((async (_url: string, options?: RequestInit) => { return new Promise((_resolve, reject) => { if (!options?.signal) { throw new Error('options.signal required for this test'); } const timeout = setTimeout(() => { reject(new Error('Timeout')); }, 3000); options.signal.addEventListener('abort', () => { clearTimeout(timeout); const abortError = new Error('Request aborted'); abortError.name = 'AbortError'; reject(abortError); }); }); }) satisfies FetchLike); const client = new MedplumClient({ fetch }); const controller = new AbortController(); const getPromise = client.get(client.fhirUrl('Patient', '123'), { signal: controller.signal }); await sleep(0); controller.abort(); await expect(getPromise).rejects.toThrow('Request aborted'); expect(fetch).toHaveBeenCalledTimes(1); }); test('should retry on fetch errors', async () => { const fetch = jest.fn().mockImplementation(async (_url: string, _options?: RequestInit) => { throw new Error('Some kind of fetch error occurred'); }); const client = new MedplumClient({ fetch }); await expect(client.get(client.fhirUrl('Patient', '123'))).rejects.toThrow('Some kind of fetch error occurred'); expect(fetch).toHaveBeenCalledTimes(3); }); }); describe('Paginated Search ', () => { let fetch: FetchLike; beforeEach(() => { const resources = [ { resource: { resourceType: 'Patient', id: '123' } }, { resource: { resourceType: 'Patient', id: '456' } }, { resource: { resourceType: 'Patient', id: '789' } }, ]; fetch = mockFetch(200, (url) => { const parsedUrl = new URL(url); const offset = Number.parseInt(parsedUrl.searchParams.get('_offset') ?? '0', 10); const count = Number.parseInt(parsedUrl.searchParams.get('_count') ?? '1', 10); if (offset >= resources.length) { return { resourceType: 'Bundle', entry: [], link: [], }; } parsedUrl.searchParams.set('_offset', (offset + count).toString()); const nextLink = { relation: 'next', url: parsedUrl.toString() }; return { resourceType: 'Bundle', entry: resources.slice(offset, offset + count), link: [nextLink], } as Bundle; }); }); test('Search resources pages', async () => { const client = new MedplumClient({ fetch }); let numPages = 0; for await (const page of client.searchResourcePages('Patient', '_count=1')) { expect(page).toHaveLength(1); expect(page[0].resourceType).toBe('Patient'); numPages += 1; } expect(numPages).toBe(3); }); test('Search resources pages uneven', async () => { const client = new MedplumClient({ fetch }); let numPages = 0; for await (const page of client.searchResourcePages('Patient', '_count=2')) { expect(page).toHaveLength(numPages === 0 ? 2 : 1); expect(page[0].resourceType).toBe('Patient'); numPages += 1; } expect(numPages).toBe(2); }); test('Search resources pages with offset', async () => { const client = new MedplumClient({ fetch }); let numPages = 0; for await (const page of client.searchResourcePages('Patient', { _count: '2', _offset: '1' })) { expect(page).toHaveLength(2); expect(page[0].resourceType).toBe('Patient'); numPages += 1; } expect(numPages).toBe(1); }); test('Search resources pages with cache', async () => { const client = new MedplumClient({ fetch }); let numPages = 0; // Populate the cache await client.search('Patient', '_count=1'); // Iterate through pages for await (const page of client.searchResourcePages('Patient', '_count=1')) { expect(page).toHaveLength(1); expect(page[0].resourceType).toBe('Patient'); numPages += 1; } expect(numPages).toBe(3); }); }); test('ValueSet $expand', async () => { const fetch = mockFetch(200, { resourceType: 'ValueSet' }); const client = new MedplumClient({ fetch }); const result = await client.valueSetExpand({ url: 'system', filter: 'filter', count: 20 }); expect(result).toBeDefined(); expect(result.resourceType).toBe('ValueSet'); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('https://api.medplum.com/fhir/R4/ValueSet/$expand'), expect.objectContaining({ method: 'GET' }) ); const url = new URL(fetch.mock.calls[0][0] as string); expect(url.searchParams.get('url')).toBe('system'); expect(url.searchParams.get('filter')).toBe('filter'); expect(url.searchParams.get('count')).toBe('20'); }); describe('Batch', () => { const bundle: Bundle = { resourceType: 'Bundle', type: 'transaction', entry: [ { fullUrl: 'urn:uuid:61ebe359-bfdc-4613-8bf2-c5e300945f0a', resource: { resourceType: 'Patient', name: [{ use: 'official', given: ['Alice'], family: 'Smith' }], gender: 'female', birthDate: '1974-12-25', }, request: { method: 'POST', url: 'Patient', }, }, { fullUrl: 'urn:uuid:88f151c0-a954-468a-88bd-5ae15c08e059', resource: { resourceType: 'Patient', identifier: [{ system: 'http:/example.org/fhir/ids', value: '234234' }], name: [{ use: 'official', given: ['Bob'], family: 'Jones' }], gender: 'male', birthDate: '1974-12-25', }, request: { method: 'POST', url: 'Patient', ifNoneExist: 'identifier=http:/example.org/fhir/ids|234234', }, }, ], }; test('Execute batch', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', type: 'transaction-response', }); const client = new MedplumClient({ fetch }); const result = await client.executeBatch(bundle); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', }, body: expect.stringContaining('Bundle'), }) ); }); test('Execute batch with options', async () => { const fetch = mockFetch(200, { resourceType: 'Bundle', type: 'transaction-response', }); const signal = new AbortController().signal; const options: RequestInit = { headers: { 'X-Test': '123' }, signal }; const client = new MedplumClient({ fetch }); const result = await client.executeBatch(bundle, options); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', ...options.headers, }, body: expect.stringContaining('Bundle'), }) ); }); }); test('Send email', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.sendEmail({ to: 'alice@example.com', subject: 'Test', text: 'Hello', }); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/email/v1/send', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.JSON, 'X-Medplum': 'extended', }, body: expect.stringContaining('alice@example.com'), }) ); }); test('Push to agent', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.pushToAgent( { resourceType: 'Agent', id: '123' }, { resourceType: 'Device', id: '456' }, 'XYZ', ContentType.HL7_V2 ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Agent/123/$push', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', }, body: expect.stringMatching(/.+"destination":".+"body":"XYZ","contentType":"x-application\/hl7-v2\+er7".+/), }) ); }); test('Push to agent -- waitForResponse, waitTimeout configured', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.pushToAgent( { resourceType: 'Agent', id: '123' }, { resourceType: 'Device', id: '456' }, 'XYZ', ContentType.HL7_V2, true, { waitTimeout: 20000 } ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Agent/123/$push', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.FHIR_JSON, 'X-Medplum': 'extended', }, body: expect.stringMatching( /.+"destination":".+"body":"XYZ".+"contentType":"x-application\/hl7-v2\+er7".+"waitForResponse":true.+"waitTimeout":20000.+/ ), }) ); }); test('Get CDS services', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.getCdsServices(); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/cds-services', expect.objectContaining({ method: 'GET' }) ); }); test('Call CDS service', async () => { const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); const result = await client.callCdsService('service-id', {} as CdsRequest); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/cds-services/service-id', expect.objectContaining({ method: 'POST' }) ); }); test('Storage events', async () => { // Mock locationUtils.reload const mockReload = jest.fn(); mockEnvironment.locationUtils.reload.mockImplementation(mockReload); const mockAddEventListener = jest.fn(); window.addEventListener = mockAddEventListener; const fetch = mockFetch(200, {}); const client = new MedplumClient({ fetch }); expect(client).toBeDefined(); expect(mockAddEventListener).toHaveBeenCalled(); expect(mockAddEventListener.mock.calls[0][0]).toBe('storage'); const callback = mockAddEventListener.mock.calls[0][1]; mockReload.mockReset(); callback({ key: 'randomKey' }); expect(mockReload).not.toHaveBeenCalled(); const practitioner1 = randomUUID(); const practitioner2 = randomUUID(); // Should NOT refresh when we have the same profile mockReload.mockReset(); callback({ key: 'activeLogin', oldValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` }, project: { reference: 'Project/123' }, accessToken: '12345', refreshToken: 'abcde', } satisfies LoginState), newValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` }, project: { reference: 'Project/123' }, accessToken: '6789', refreshToken: 'fghi', } satisfies LoginState), } as StorageEvent); expect(mockReload).not.toHaveBeenCalled(); expect(client.getAccessToken()).toBe('6789'); // Should refresh when we change to a new profile mockReload.mockReset(); callback({ key: 'activeLogin', oldValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` } satisfies Reference<Practitioner>, }), newValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner2}` } satisfies Reference<Practitioner>, }), } as StorageEvent); expect(mockReload).toHaveBeenCalled(); // Should refresh when going from no profile to a new profile mockReload.mockReset(); callback({ key: 'activeLogin', oldValue: null, newValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` } satisfies Reference<Practitioner>, }), } as StorageEvent); expect(mockReload).toHaveBeenCalled(); // Should refresh when going from a profile to no profile (logged out) mockReload.mockReset(); callback({ key: 'activeLogin', oldValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` } satisfies Reference<Practitioner>, }), newValue: null, } as StorageEvent); expect(mockReload).toHaveBeenCalled(); // Should refresh when storage is cleared mockReload.mockReset(); callback({ key: null }); expect(mockReload).toHaveBeenCalled(); // Should refresh if sessionDetails.profile.id is not the same as the ID of the profile in the newEvent mockReload.mockReset(); // @ts-expect-error This is a no-no, overriding private field client.sessionDetails = { profile: { id: randomUUID() } as Practitioner }; callback({ key: 'activeLogin', oldValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` }, project: { reference: 'Project/123' }, accessToken: '12345', refreshToken: 'abcde', } satisfies LoginState), newValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` }, project: { reference: 'Project/123' }, accessToken: '6789', refreshToken: 'fghi', } satisfies LoginState), } as StorageEvent); expect(mockReload).toHaveBeenCalled(); // Should NOT refresh if sessionDetails.profile.id IS the same as the ID of the profile in the newEvent mockReload.mockReset(); // @ts-expect-error This is a no-no, overriding private field client.sessionDetails = { profile: { id: practitioner1 } as Practitioner }; callback({ key: 'activeLogin', oldValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` }, project: { reference: 'Project/123' }, accessToken: '12345', refreshToken: 'abcde', } satisfies LoginState), newValue: JSON.stringify({ profile: { reference: `Practitioner/${practitioner1}` }, project: { reference: 'Project/123' }, accessToken: '6789', refreshToken: 'fghi', } satisfies LoginState), } as StorageEvent); expect(mockReload).not.toHaveBeenCalled(); }); test('setAccessToken', async () => { const patient: Patient = { resourceType: 'Patient', id: '123' }; const fetch = jest.fn(async (url: string) => ({ status: 200, headers: { get: () => ContentType.FHIR_JSON }, json: async () => (url.endsWith('/auth/me') ? { profile: patient } : patient), })); const client = new MedplumClient({ fetch }); const accessToken = createFakeJwt({ login_id: '123' }); client.setAccessToken(accessToken); expect(client.getAccessToken()).toStrictEqual(accessToken); await expect(client.readResource('Patient', '123')).resolves.toMatchObject(patient); expect(fetch).toHaveBeenCalledTimes(1); expect((fetch.mock.calls[0] as any[])[1].headers.Authorization).toBe(`Bearer ${accessToken}`); expect(client.getProfile()).toBeUndefined(); await expect(client.getProfileAsync()).resolves.toMatchObject(patient); const expectedCalls = fetch.mock.calls.length; await expect(client.getProfileAsync()).resolves.toMatchObject(patient); expect(fetch.mock.calls).toHaveLength(expectedCalls); }); test('Client created with accessToken option set', async () => { const patient: Patient = { resourceType: 'Patient', id: '123' }; const fetch = jest.fn(async (url: string) => ({ status: 200, headers: { get: () => ContentType.FHIR_JSON }, json: async () => (url.endsWith('/auth/me') ? { profile: patient } : patient), })); const accessToken = createFakeJwt({ login_id: '123' }); const client = new MedplumClient({ fetch, accessToken }); expect(client.getAccessToken()).toStrictEqual(accessToken); await expect(client.readResource('Patient', '123')).resolves.toMatchObject(patient); expect(fetch).toHaveBeenCalledTimes(1); expect((fetch.mock.calls[0] as any[])[1].headers.Authorization).toBe(`Bearer ${accessToken}`); expect(client.getProfile()).toBeUndefined(); await expect(client.getProfileAsync()).resolves.toMatchObject(patient); const expectedCalls = fetch.mock.calls.length; await expect(client.getProfileAsync()).resolves.toMatchObject(patient); expect(fetch.mock.calls).toHaveLength(expectedCalls); }); test('graphql', async () => { const fetch = mockFetch(200, {}); const medplum = new MedplumClient({ fetch }); const result = await medplum.graphql(`{ Patient(id: "123") { resourceType id name { given family } } }`); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/$graphql', expect.objectContaining({ method: 'POST', headers: { Accept: DEFAULT_ACCEPT, 'Content-Type': ContentType.JSON, 'X-Medplum': 'extended', }, body: expect.stringContaining('Patient'), }) ); }); test('graphql variables', async () => { const fetch = mockFetch(200, {}); const medplum = new MedplumClient({ fetch }); const result = await medplum.graphql( `query GetPatientById($patientId: ID!) { Patient(id: $patientId) { resourceType id name { given family } } }`, 'GetPatientById', { patientId: '123' } ); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/$graphql', expect.objectContaining({ body: expect.stringContaining('GetPatientById'), }) ); }); test('Auto batch single request', async () => { const medplum = new MedplumClient({ fetch: mockFetch(200, { resourceType: 'Patient' }), autoBatchTime: 100 }); const patient = await medplum.readResource('Patient', '123'); expect(patient).toBeDefined(); }); test('Auto batch single request error', async () => { const fetch = mockFetch(404, notFound); const medplum = new MedplumClient({ fetch, autoBatchTime: 100 }); try { await medplum.readResource('Patient', 'xyz-not-found'); fail('Expected error'); } catch (err) { const outcome = (err as OperationOutcomeError).outcome; expect(outcome).toMatchObject(notFound); } }); test('Auto batch multiple requests', async () => { const medplum = new MedplumClient({ fetch: mockFetch(200, { resourceType: 'Bundle', entry: [ { response: { status: '200' }, resource: { resourceType: 'Patient' }, }, { response: { status: '200' }, resource: { resourceType: 'Practitioner' }, }, ], }), autoBatchTime: 100, }); // Start two requests at the same time const patientPromise = medplum.readResource('Patient', '123'); const practitionerPromise = medplum.readResource('Practitioner', '123'); // Wait for the batch to be sent const patient = await patientPromise; const practitioner = await practitionerPromise; expect(patient).toBeDefined(); expect(practitioner).toBeDefined(); }); test('Auto batch error', async () => { const medplum = new MedplumClient({ fetch: mockFetch(200, { resourceType: 'Bundle', entry: [ { response: { status: '200' }, resource: { resourceType: 'Patient' }, }, { response: { status: '404', outcome: notFound }, }, ], }), autoBatchTime: 100, }); await expect(async () => { // Start multiple requests to force a batch const patientPromise = medplum.readResource('Patient', '123'); await medplum.readResource('Patient', '9999999-does-not-exist'); await patientPromise; }).rejects.toThrow('Not found'); }); test('Retry on 500', async () => { let count = 0; const fetch = jest.fn(async () => { if (count === 0) { count++; return { status: 500 }; } return { status: 200, headers: { get: () => ContentType.FHIR_JSON }, json: async () => ({ resourceType: 'Patient' }), }; }); const client = new MedplumClient({ fetch }); const patient = await client.readResource('Patient', '123'); expect(patient).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(2); }); test('Retry on 429', async () => { jest.useFakeTimers(); let count = 0; const fetch = jest.fn(async (): Promise<Partial<Response>> => { if (count === 0) { count++; return { status: 429, headers: new Headers({ ratelimit: '"requests";r=0;t=1, "fhirInteractions";r=12;t=60' }) }; } return { status: 200, headers: new Headers({ 'content-type': ContentType.FHIR_JSON }), json: async () => ({ resourceType: 'Patient' }), }; }); const client = new MedplumClient({ fetch }); const patientPromise = client.readResource('Patient', '123'); // Promise should resolve after one second retry delay await jest.advanceTimersByTimeAsync(800); expect(patientPromise.isPending()).toBe(true); await jest.advanceTimersByTimeAsync(250); jest.useRealTimers(); expect(patientPromise.isOk()).toBe(true); await patientPromise; expect(fetch).toHaveBeenCalledTimes(2); }); test('Skip long retry delay', async () => { jest.useFakeTimers(); let count = 0; const fetch = jest.fn(async (): Promise<Partial<Response>> => { if (count === 0) { count++; return { status: 429, headers: new Headers({ ratelimit: '"requests";r=0;t=30, "fhirInteractions";r=12;t=30' }), text: jest.fn().mockReturnValue(tooManyRequests), }; } return { status: 200, headers: new Headers({ 'content-type': ContentType.FHIR_JSON }), json: async () => ({ resourceType: 'Patient' }), }; }); const client = new MedplumClient({ fetch }); let err: Error | undefined; const patientPromise = client.readResource('Patient', '123').catch((e) => { err = e; }); // Promise should resolve immediately without delay await jest.advanceTimersByTimeAsync(0); await expect(err).toStrictEqual(new OperationOutcomeError(tooManyRequests)); jest.useRealTimers(); await patientPromise; expect(fetch).toHaveBeenCalledTimes(1); }); test('Dispatch on bad connection', async () => { const fetch = jest.fn(async () => { throw new Error('Failed to fetch'); }); const mockDispatchEvent = jest.fn(); const client = new MedplumClient({ fetch }); client.dispatchEvent = mockDispatchEvent; try { await client.readResource('Patient', '123'); fail('Expected error'); } catch (err) { expect(mockDispatchEvent).toHaveBeenCalled(); expect(err).toBeDefined(); } }); test('Handle HL7 response', async () => { const fetch = jest.fn(async () => ({ status: 200, headers: { get: () => ContentType.HL7_V2 }, text: async () => 'MSH|^~\\&|1|\r\n', })); const client = new MedplumClient({ fetch }); const response = await client.post('/$process-message', 'MSH|^~\\&|1|\r\n', ContentType.HL7_V2); expect(response).toBeDefined(); expect(response).toStrictEqual('MSH|^~\\&|1|\r\n'); }); test('Log non-JSON response', async () => { // Handle the ugly case where server returns JSON header but non-JSON body const fetch = jest.fn(async () => ({ status: 200, headers: { get: () => ContentType.JSON }, json: () => Promise.reject(new Error('Not JSON')), })); console.error = jest.fn(); const client = new MedplumClient({ fetch }); try { await client.readResource('Patient', '123'); fail('Expected error'); } catch (err) { expect(err).toBeDefined(); } expect(console.error).toHaveBeenCalledTimes(1); }); describe('Bulk Data Export', () => { let fetch: any; beforeEach(() => { let count = 0; fetch = jest.fn(async (url) => { if (url.includes('/$export?_since=200')) { return mockFetchResponse(200, accepted('bulkdata/id/status'), { 'content-location': 'bulkdata/id/status' }); } if (url.includes('/$export')) { return mockFetchResponse(202, accepted('bulkdata/id/status'), { 'content-location': 'bulkdata/id/status' }); } if (url.includes('bulkdata/id/status')) { if (count < 1) { count++; return mockFetchResponse(202, {}); } } return mockFetchResponse(200, { transactionTime: '2023-05-18T22:55:31.280Z', request: 'https://api.medplum.com/fhir/R4/$export?_type=Observation', requiresAccessToken: false, output: [ { type: 'ProjectMembership', url: 'https://api.medplum.com/storage/TEST', }, ], error: [], }); }); }); test('System Level', async () => { const medplum = new MedplumClient({ fetch }); const response = await medplum.bulkExport(undefined, undefined, undefined, { pollStatusOnAccepted: true }); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/$export'), expect.objectContaining({ headers: { Accept: DEFAULT_ACCEPT, Prefer: 'respond-async', 'X-Medplum': 'extended', }, }) ); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('bulkdata/id/status'), expect.any(Object)); expect(fetch).toHaveBeenCalledTimes(3); expect(response.output?.length).toBe(1); }); test('with optional params type, since, options', async () => { const medplum = new MedplumClient({ fetch }); const response = await medplum.bulkExport(undefined, 'Observation', 'testdate', { headers: { test: 'test' }, pollStatusOnAccepted: true, }); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/$export?_type=Observation&_since=testdate'), expect.any(Object) ); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('bulkdata/id/status'), expect.any(Object)); expect(fetch).toHaveBeenCalledTimes(3); expect(response.output?.length).toBe(1); }); test('Group of Patients', async () => { const medplum = new MedplumClient({ fetch }); const groupId = randomUUID(); const response = await medplum.bulkExport(`Group/${groupId}`, undefined, undefined, { pollStatusOnAccepted: true, }); expect(fetch).toHaveBeenCalledWith(expect.stringContaining(`/Group/${groupId}/$export`), expect.any(Object)); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('bulkdata/id/status'), expect.any(Object)); expect(fetch).toHaveBeenCalledTimes(3); expect(response.output?.length).toBe(1); }); test('All Patient', async () => { const medplum = new MedplumClient({ fetch }); const response = await medplum.bulkExport('Patient', undefined, undefined, { pollStatusOnAccepted: true }); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/Patient/$export'), expect.any(Object)); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('bulkdata/id/status'), expect.any(Object)); expect(fetch).toHaveBeenCalledTimes(3); expect(response.output?.length).toBe(1); }); test('Kick off missing content-location', async () => { const fetch = mockFetch(202, allOk); const medplum = new MedplumClient({ fetch }); const response = await medplum.bulkExport(); expect(response.output).not.toBeDefined(); expect(fetch).toHaveBeenCalledTimes(1); }); test('Failed Kickoff', async () => { const failFetch = jest.fn(async () => { return { status: 404, json: jest.fn(async () => { return notFound; }), headers: { get: jest.fn(), }, }; }); const medplum = new MedplumClient({ fetch: failFetch }); try { await medplum.bulkExport(`Patient`); } catch (err) { expect((err as Error).message).toBe('Not found'); } }); test('Poll after token refresh', async () => { const clientId = randomUUID(); const clientSecret = randomUUID(); const statusUrl = 'status-' + randomUUID(); const locationUrl = 'location-' + randomUUID(); const mockTokens = { access_token: createFakeJwt({ client_id: clientId, login_id: '123' }), refresh_token: createFakeJwt({ client_id: clientId }), profile: { reference: 'Patient/123' }, }; const mockMe = { project: { resourceType: 'Project', id: '123' }, membership: { resourceType: 'ProjectMembership', id: '123' }, profile: { resourceType: 'Practitioner', id: '123' }, config: { resourceType: 'UserConfiguration', id: '123' }, accessPolicy: { resourceType: 'AccessPolicy', id: '123' }, }; let count = 0; const mockFetch = async (url: string, options: any): Promise<any> => { count++; switch (count) { case 1: // First, handle the initial startClientLogin client credentials flow expect(options.method).toBe('POST'); expect(url).toBe('https://api.medplum.com/oauth2/token'); return mockFetchResponse(200, mockTokens); case 2: // MedplumClient will automatically fetch the user profile after token refresh expect(options.method).toBe('GET'); expect(url).toBe('https://api.medplum.com/auth/me'); return mockFetchResponse(200, mockMe); case 3: // Next, handle the initial bulk export - mock an expired token response expect(options.method).toBe('POST'); expect(url).toBe('https://api.medplum.com/fhir/R4/$export'); return mockFetchResponse(401, forbidden); case 4: // Now MedplumClient will try to automatically refresh the token expect(options.method).toBe('POST'); expect(url).toBe('https://api.medplum.com/oauth2/token'); return mockFetchResponse(200, mockTokens); case 5: // And then MedplumClient will automatically fetch the user profile again expect(options.method).toBe('GET'); expect(url).toBe('https://api.medplum.com/auth/me'); return mockFetchResponse(200, mockMe); case 6: // Ok, whew, we are refreshed, so we can finally get the bulk export // However, the bulk export isn't "done", so return "Accepted" expect(options.method).toBe('POST'); expect(url).toBe('https://api.medplum.com/fhir/R4/$export'); return mockFetchResponse(202, accepted(statusUrl)); case 7: // Report status complete, and send the location of the bulk export expect(options.method).toBe('GET'); expect(url).toBe('https://api.medplum.com/' + statusUrl); return mockFetchResponse(201, {}, { location: locationUrl }); case 8: // What a journey! Finally, we can get the contents of the bulk export expect(options.method).toBe('GET'); expect(url).toBe('https://api.medplum.com/' + locationUrl); return mockFetchResponse(200, { resourceType: 'Bundle' }); } throw new Error('Unexpected fetch call: ' + url); }; const medplum = new MedplumClient({ fetch: mockFetch }); await medplum.startClientLogin(clientId, clientSecret); const result = await medplum.bulkExport(undefined, undefined, undefined, { pollStatusOnAccepted: true, followRedirectOnCreated: true, }); expect(result).toMatchObject({ resourceType: 'Bundle' }); }); }); describe('Downloading resources', () => { const baseUrl = 'https://api.medplum.com/'; const fhirUrlPath = 'fhir/R4/'; const accessToken = 'fake'; test('Downloading resources via URL', async () => { const fetch = mockFetch(200, (url: string) => url); const client = new MedplumClient({ fetch, baseUrl, fhirUrlPath }); client.setAccessToken(accessToken); const blob = await client.download(baseUrl); expect(fetch).toHaveBeenCalledWith( baseUrl, expect.objectContaining({ headers: { Accept: '*/*', Authorization: `Bearer ${accessToken}`, 'X-Medplum': 'extended', }, }) ); expect(await blob.text()).toStrictEqual(baseUrl); }); test('Downloading resources via `Binary/{id}` URL', async () => { const fetch = mockFetch(200, (url: string) => url); const client = new MedplumClient({ fetch, baseUrl, fhirUrlPath }); client.setAccessToken(accessToken); const blob = await client.download('Binary/fake-id'); expect(fetch).toHaveBeenCalledWith( `${baseUrl}${fhirUrlPath}Binary/fake-id`, expect.objectContaining({ headers: { Accept: '*/*', Authorization: `Bearer ${accessToken}`, 'X-Medplum': 'extended', }, }) ); expect(await blob.text()).toStrictEqual(`${baseUrl}${fhirUrlPath}Binary/fake-id`); }); }); describe('Media', () => { test('Create Media', async () => { const fetch = mockFetch(200, {}); fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'Media', id: '123' })); fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'Binary', id: '456', url: 'Binary/456' }) ); fetch.mockImplementationOnce(async () => mockFetchResponse(200, { resourceType: 'Media', id: '123' })); const client = new MedplumClient({ fetch }); const media = await client.createMedia({ data: 'Hello world', contentType: 'text/plain', filename: 'hello.txt', }); expect(media).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(3); const calls = fetch.mock.calls; expect(calls).toHaveLength(3); expect(calls[0][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Media'); expect(calls[1][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Binary?_filename=hello.txt'); expect(calls[2][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Media/123'); expect(JSON.parse(calls[2][1].body)).toMatchObject({ resourceType: 'Media', status: 'completed', content: { contentType: 'text/plain', url: 'Binary/456', title: 'hello.txt', }, }); }); test('Upload Media', async () => { const fetch = mockFetch(200, {}); fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'Media', id: '123' })); fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'Binary', id: '456', url: 'Binary/456' }) ); fetch.mockImplementationOnce(async () => mockFetchResponse(200, { resourceType: 'Media', id: '123' })); const client = new MedplumClient({ fetch }); const media = await client.uploadMedia('Hello world', 'text/plain', 'hello.txt'); expect(media).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(3); const calls = fetch.mock.calls; expect(calls).toHaveLength(3); expect(calls[0][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Media'); expect(calls[1][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Binary?_filename=hello.txt'); expect(calls[2][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Media/123'); expect(JSON.parse(calls[2][1].body)).toMatchObject({ resourceType: 'Media', status: 'completed', content: { contentType: 'text/plain', url: 'Binary/456', title: 'hello.txt', }, }); }); }); describe('DocumentReference', () => { test('Create DocumentReference', async () => { const fetch = mockFetch(200, {}); fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'DocumentReference', id: '123' }) ); fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'Binary', id: '456', url: 'Binary/456' }) ); fetch.mockImplementationOnce(async () => mockFetchResponse(200, { resourceType: 'DocumentReference', id: '123' }) ); const client = new MedplumClient({ fetch }); const documentReference = await client.createDocumentReference({ data: 'Hello world', contentType: 'text/plain', filename: 'hello.txt', }); expect(documentReference).toBeDefined(); expect(fetch).toHaveBeenCalledTimes(3); const calls = fetch.mock.calls; expect(calls).toHaveLength(3); expect(calls[0][0]).toStrictEqual('https://api.medplum.com/fhir/R4/DocumentReference'); expect(calls[1][0]).toStrictEqual('https://api.medplum.com/fhir/R4/Binary?_filename=hello.txt'); expect(calls[2][0]).toStrictEqual('https://api.medplum.com/fhir/R4/DocumentReference/123'); expect(JSON.parse(calls[2][1].body)).toMatchObject({ resourceType: 'DocumentReference', content: [ { attachment: { contentType: 'text/plain', url: 'Binary/456', title: 'hello.txt', }, }, ], }); }); }); describe('Prefer async', () => { test('Follow Content-Location', async () => { const fetch = jest.fn(); // First time, return 202 Accepted with Content-Location fetch.mockImplementationOnce(async () => mockFetchResponse( 202, {}, { 'content-location': 'https://example.com/content-location/1', } ) ); // Second time, return 202 Accepted with Content-Location fetch.mockImplementationOnce(async () => mockFetchResponse( 202, {}, { 'content-location': 'https://example.com/content-location/1', } ) ); // Third time, return 201 Created with Location fetch.mockImplementationOnce(async () => mockFetchResponse(201, {}, { location: 'https://example.com/location/1' }) ); // Fourth time, return 201 with JSON fetch.mockImplementationOnce(async () => mockFetchResponse(201, { resourceType: 'Patient' })); const client = new MedplumClient({ fetch }); const response = await client.startAsyncRequest('/test', { method: 'POST', body: '{}', pollStatusOnAccepted: true, followRedirectOnCreated: true, }); expect(fetch).toHaveBeenCalledTimes(4); expect((response as any).resourceType).toStrictEqual('Patient'); }); }); describe('Token refresh', () => { test('should not clear sessionDetails when profile is refreshing', async () => { const fetch = mockFetch(200, (url) => { if (url.includes('Patient/123')) { return { resourceType: 'Patient', id: '123' }; } if (url.includes('oauth2/token')) { return { access_token: createFakeJwt({ client_id: '123', login_id: '123', exp: Math.floor(Date.now() / 1000) + 1 }), refresh_token: createFakeJwt({ client_id: '123' }), profile: { reference: 'Patient/123' }, }; } if (url.includes('auth/me')) { return { profile: { resourceType: 'Patient', id: '123' }, }; } return {}; }); const client = new MedplumClient({ fetch, refreshGracePeriod: 0 }); const loginResponse = await client.startLogin({ email: 'admin@example.com', password: 'admin' }); expect(fetch).toHaveBeenCalledTimes(1); fetch.mockClear(); await client.processCode(loginResponse.code as string); expect(fetch).toHaveBeenCalledTimes(2); fetch.mockClear(); expect(client.getProfile()).toBeDefined(); const refreshingPromise = new Promise<void>((resolve) => { client.addEventListener('profileRefreshing', () => { resolve(); }); }); const now = Date.now(); jest.useFakeTimers().setSystemTime(now + 2000); // Call refreshIfExpired const refreshedPromise = client.refreshIfExpired(); // Wait to receive event await refreshingPromise; // Check that profile is still defined // This is where the test failed before this PR expect(client.getProfile()).toBeDefined(); await refreshedPromise; expect(client.getProfile()).toBeDefined(); jest.useRealTimers(); }); }); test('Verbose mode', async () => { const fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: { get: () => ContentType.FHIR_JSON, forEach: (cb: (value: string, key: string) => void) => cb('bar', 'foo'), }, json: () => Promise.resolve({ resourceType: 'Patient', id: '123' }), }); }); console.log = jest.fn(); const client = new MedplumClient({ fetch, verbose: true }); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET' }) ); expect(result.resourceType).toBe('Patient'); expect(result.id).toBe('123'); expect(console.log).toHaveBeenCalledWith('> GET https://api.medplum.com/fhir/R4/Patient/123'); expect(console.log).toHaveBeenCalledWith('> Accept: application/fhir+json, */*; q=0.1'); expect(console.log).toHaveBeenCalledWith('> X-Medplum: extended'); expect(console.log).toHaveBeenCalledWith('< 200 OK'); expect(console.log).toHaveBeenCalledWith('< foo: bar'); }); test('setVerbose', async () => { const fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: { get: () => ContentType.FHIR_JSON, forEach: (cb: (value: string, key: string) => void) => cb('bar', 'foo'), }, json: () => Promise.resolve({ resourceType: 'Patient', id: '123' }), }); }); console.log = jest.fn(); const client = new MedplumClient({ fetch }); // First request without verbose mode - should not log let result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(console.log).not.toHaveBeenCalled(); // Enable verbose mode using setVerbose client.setVerbose(true); // Second request with verbose mode enabled - should log result = await client.readResource('Patient', '456'); expect(result).toBeDefined(); expect(console.log).toHaveBeenCalledWith('> GET https://api.medplum.com/fhir/R4/Patient/456'); expect(console.log).toHaveBeenCalledWith('> Accept: application/fhir+json, */*; q=0.1'); expect(console.log).toHaveBeenCalledWith('> X-Medplum: extended'); expect(console.log).toHaveBeenCalledWith('< 200 OK'); expect(console.log).toHaveBeenCalledWith('< foo: bar'); // Disable verbose mode using setVerbose (console.log as jest.Mock).mockClear(); client.setVerbose(false); // Third request with verbose mode disabled - should not log result = await client.readResource('Patient', '789'); expect(result).toBeDefined(); expect(console.log).not.toHaveBeenCalled(); }); describe('Log Levels', () => { test('Default log level is none', () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); const client = new MedplumClient({ fetch }); expect(client.getLogLevel()).toBe('none'); }); test('Constructor with logLevel option', async () => { const fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: { get: () => ContentType.FHIR_JSON, forEach: jest.fn(), }, json: () => Promise.resolve({ resourceType: 'Patient', id: '123' }), }); }); console.log = jest.fn(); const client = new MedplumClient({ fetch, logLevel: 'basic' }); expect(client.getLogLevel()).toBe('basic'); await client.readResource('Patient', '123'); // Should log method, URL, and status expect(console.log).toHaveBeenCalledWith('> GET https://api.medplum.com/fhir/R4/Patient/123'); expect(console.log).toHaveBeenCalledWith('< 200 OK'); // Should NOT log headers in basic mode expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Authorization')); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Accept')); }); test('Backward compatibility: verbose true maps to verbose level', () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); const client = new MedplumClient({ fetch, verbose: true }); expect(client.getLogLevel()).toBe('verbose'); }); test('Backward compatibility: verbose false maps to none level', () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); const client = new MedplumClient({ fetch, verbose: false }); expect(client.getLogLevel()).toBe('none'); }); test('logLevel takes precedence over verbose', () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); const client = new MedplumClient({ fetch, verbose: true, logLevel: 'basic' }); expect(client.getLogLevel()).toBe('basic'); }); test('setLogLevel changes log level at runtime', async () => { const fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: { get: () => ContentType.FHIR_JSON, forEach: jest.fn(), }, json: () => Promise.resolve({ resourceType: 'Patient', id: '123' }), }); }); console.log = jest.fn(); const client = new MedplumClient({ fetch }); expect(client.getLogLevel()).toBe('none'); // No logging initially await client.readResource('Patient', '123'); expect(console.log).not.toHaveBeenCalled(); // Enable basic logging client.setLogLevel('basic'); await client.readResource('Patient', '456'); expect(console.log).toHaveBeenCalledWith('> GET https://api.medplum.com/fhir/R4/Patient/456'); expect(console.log).toHaveBeenCalledWith('< 200 OK'); // Should not log headers expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Accept')); (console.log as jest.Mock).mockClear(); // Enable verbose logging client.setLogLevel('verbose'); await client.readResource('Patient', '789'); expect(console.log).toHaveBeenCalledWith('> GET https://api.medplum.com/fhir/R4/Patient/789'); expect(console.log).toHaveBeenCalledWith('< 200 OK'); // Should log headers in verbose mode expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Accept')); (console.log as jest.Mock).mockClear(); // Disable logging client.setLogLevel('none'); await client.readResource('Patient', '999'); expect(console.log).not.toHaveBeenCalled(); }); test('setVerbose updates logLevel for backward compatibility', () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); const client = new MedplumClient({ fetch }); client.setVerbose(true); expect(client.getLogLevel()).toBe('verbose'); client.setVerbose(false); expect(client.getLogLevel()).toBe('none'); }); test('Basic mode logs request and response without headers', async () => { const fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 201, statusText: 'Created', headers: { get: () => ContentType.FHIR_JSON, forEach: (cb: (value: string, key: string) => void) => { cb('application/fhir+json', 'content-type'); cb('Bearer secret-token', 'authorization'); cb('session-cookie', 'cookie'); }, }, json: () => Promise.resolve({ resourceType: 'Patient', id: '123' }), }); }); console.log = jest.fn(); const client = new MedplumClient({ fetch, logLevel: 'basic' }); await client.createResource({ resourceType: 'Patient', name: [{ given: ['Test'] }] }); // Should log method, URL, and status expect(console.log).toHaveBeenCalledWith(expect.stringContaining('POST')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Patient')); expect(console.log).toHaveBeenCalledWith('< 201 Created'); // Should NOT log any headers expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('authorization')); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Bearer')); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('cookie')); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('content-type')); }); test('Verbose mode logs all headers including sensitive ones', async () => { const fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 200, statusText: 'OK', headers: { get: () => ContentType.FHIR_JSON, forEach: (cb: (value: string, key: string) => void) => { cb('application/fhir+json', 'content-type'); cb('Bearer secret-token', 'authorization'); }, }, json: () => Promise.resolve({ resourceType: 'Patient', id: '123' }), }); }); console.log = jest.fn(); const client = new MedplumClient({ fetch, logLevel: 'verbose' }); await client.readResource('Patient', '123'); // Should log everything including sensitive headers expect(console.log).toHaveBeenCalledWith(expect.stringContaining('authorization')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Bearer secret-token')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('content-type')); }); test('None mode logs nothing', async () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); console.log = jest.fn(); const client = new MedplumClient({ fetch, logLevel: 'none' }); await client.readResource('Patient', '123'); expect(console.log).not.toHaveBeenCalled(); }); }); test('Disable extended mode', async () => { const fetch = mockFetch(200, () => ({ resourceType: 'Patient', id: '123' })); const client = new MedplumClient({ fetch, extendedMode: false }); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); const fetchArgs = fetch.mock.calls[0]; const fetchOptions = fetchArgs[1]; expect(fetchOptions.headers).not.toHaveProperty('X-Medplum'); expect(fetchOptions.headers).not.toHaveProperty('x-medplum'); }); test('Track rate limit status', async () => { const fetch = jest.fn((_url: string, _options?: any) => { return Promise.resolve( mockFetchResponse( 200, { resourceType: 'Patient' }, { 'content-type': 'application/fhir+json', ratelimit: `"requests";t=59;r=15, "fhirInteractions";r=255;t=59`, } ) ); }); const client = new MedplumClient({ fetch }); expect(client.rateLimitStatus()).toStrictEqual([]); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(client.rateLimitStatus()).toStrictEqual( expect.arrayContaining([ { name: 'requests', remainingUnits: 15, secondsUntilReset: 59, resetsAfter: expect.any(Number) }, { name: 'fhirInteractions', remainingUnits: 255, secondsUntilReset: 59, resetsAfter: expect.any(Number) }, ]) ); }); test('Track rate limit status with zero', async () => { const fetch = jest.fn((_url: string, _options?: any) => { return Promise.resolve( mockFetchResponse( 200, { resourceType: 'Patient' }, { 'content-type': 'application/fhir+json', ratelimit: `"requests";r=59539;t=4, "fhirInteractions";r=0;t=3`, } ) ); }); const client = new MedplumClient({ fetch }); expect(client.rateLimitStatus()).toStrictEqual([]); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(client.rateLimitStatus()).toStrictEqual( expect.arrayContaining([ { name: 'requests', remainingUnits: 59539, secondsUntilReset: 4, resetsAfter: expect.any(Number) }, { name: 'fhirInteractions', remainingUnits: 0, secondsUntilReset: 3, resetsAfter: expect.any(Number) }, ]) ); }); test('Invalid rate limit header', async () => { let fetch = jest.fn((_url: string, _options?: any) => { return Promise.resolve( mockFetchResponse( 200, { resourceType: 'Patient' }, { 'content-type': 'application/fhir+json', ratelimit: `"requests";r=15`, } ) ); }); const client = new MedplumClient({ fetch }); const result = await client.readResource('Patient', '123'); expect(result).toBeDefined(); expect(() => client.rateLimitStatus()).toThrow(/parse RateLimit/); fetch = jest.fn((_url: string, _options?: any) => { return Promise.resolve( mockFetchResponse( 200, { resourceType: 'Patient' }, { 'content-type': 'application/fhir+json', ratelimit: `"";r=15;t=60`, } ) ); }); const client2 = new MedplumClient({ fetch }); const result2 = await client2.readResource('Patient', '123'); expect(result2).toBeDefined(); expect(() => client.rateLimitStatus()).toThrow(/parse RateLimit/); }); test('Call-time auto-batch opt-out', async () => { const fetch = mockFetch(200, { resourceType: 'Patient', id: '123' }); const client = new MedplumClient({ fetch, autoBatchTime: 50 }); // Make a request with disableAutoBatch=true const promise1 = client.readResource('Patient', '123', { disableAutoBatch: true }); // Make normal requests that should be batched const promise2 = client.readResource('Patient', '456'); expect(promise2).toBeDefined(); const promise3 = client.readResource('Patient', '789'); expect(promise3).toBeDefined(); // The first request should complete immediately await promise1; expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith( 'https://api.medplum.com/fhir/R4/Patient/123', expect.objectContaining({ method: 'GET', }) ); // The second request should be batched and not executed yet expect(fetch).toHaveBeenCalledTimes(1); // Wait for the batch timeout await new Promise((resolve) => { setTimeout(resolve, 100); }); // Now the second request should have been executed expect(fetch).toHaveBeenCalledTimes(2); }); }); describe('Passed in async-backed `ClientStorage`', () => { test('MedplumClient resolves initialized after storage is initialized', async () => { const fetch = mockFetch(200, { success: true }); const storage = new MockAsyncClientStorage(); const medplum = new MedplumClient({ fetch, storage }); expect(storage.isInitialized).toStrictEqual(false); expect(medplum.isInitialized).toStrictEqual(false); storage.setInitialized(); await medplum.getInitPromise(); expect(storage.isInitialized).toStrictEqual(true); expect(medplum.isInitialized).toStrictEqual(true); }); test('MedplumClient should resolve initialized when sync storage used', async () => { const fetch = mockFetch(200, { success: true }); const medplum = new MedplumClient({ fetch }); await expect(medplum.getInitPromise()).resolves.toBeUndefined(); }); test('MedplumClient emits `storageInitFailed` when storage.getInitPromise throws', async () => { const fetch = mockFetch(200, { success: true }); class TestStorage extends MockAsyncClientStorage { reject!: (err: Error) => void; promise: Promise<void>; constructor() { super(); this.promise = new Promise((_resolve, reject) => { this.reject = reject; }); } getInitPromise(): Promise<void> { return this.promise; } rejectInitPromise(): void { this.reject(new Error('Storage init failed!')); } } const storage = new TestStorage(); const medplum = new MedplumClient({ fetch, storage }); const dispatchEventSpy = jest.spyOn(medplum, 'dispatchEvent'); storage.rejectInitPromise(); await expect(medplum.getInitPromise()).rejects.toThrow('Storage init failed!'); expect(dispatchEventSpy).toHaveBeenCalledWith<[MedplumClientEventMap['storageInitFailed']]>({ type: 'storageInitFailed', payload: { error: new Error('Storage init failed!') }, }); }); }); function createPdf( docDefinition: TDocumentDefinitions, tableLayouts?: Record<string, CustomTableLayout>, fonts?: TFontDictionary ): Promise<Buffer> { return new Promise((resolve, reject) => { const printer = new PdfPrinter(fonts || {}); const pdfDoc = printer.createPdfKitDocument(docDefinition, { tableLayouts }); const chunks: Uint8Array[] = []; pdfDoc.on('data', (chunk: Uint8Array) => chunks.push(chunk)); pdfDoc.on('end', () => resolve(Buffer.concat(chunks))); pdfDoc.on('error', reject); pdfDoc.end(); }); } function fail(message: string): never { throw new Error(message); } const fonts: TFontDictionary = { Helvetica: { normal: 'Helvetica', bold: 'Helvetica-Bold', italics: 'Helvetica-Oblique', bolditalics: 'Helvetica-BoldOblique', }, };

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/medplum/medplum'

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