diff --git a/src/retrieve-data-helpers/service-exchange.js b/src/retrieve-data-helpers/service-exchange.js index 75e4de1..f217aaf 100644 --- a/src/retrieve-data-helpers/service-exchange.js +++ b/src/retrieve-data-helpers/service-exchange.js @@ -2,8 +2,8 @@ import axios from 'axios'; import queryString from 'query-string'; import retrieveLaunchContext from './launch-context-retrieval'; import { - storeExchange, - storeLaunchContext, + storeExchange, + storeLaunchContext, } from '../actions/service-exchange-actions'; import { productionClientId, allScopes } from '../config/fhir-config'; import generateJWT from './jwt-generator'; @@ -11,21 +11,21 @@ import generateJWT from './jwt-generator'; const uuidv4 = require('uuid/v4'); const remapSmartLinks = ({ - dispatch, - cardResponse, - fhirAccessToken, - patientId, - fhirServerUrl, -}) => { - ((cardResponse && cardResponse.cards) || []) - .flatMap((card) => card.links || []) - .filter(({ type }) => type === 'smart') - .forEach((link) => retrieveLaunchContext( - link, - fhirAccessToken, - patientId, - fhirServerUrl, - ).catch((e) => e).then((newLink) => dispatch(storeLaunchContext(newLink)))); + dispatch, + cardResponse, + fhirAccessToken, + patientId, + fhirServerUrl, + }) => { + ((cardResponse && cardResponse.cards) || []) + .flatMap((card) => card.links || []) + .filter(({ type }) => type === 'smart') + .forEach((link) => retrieveLaunchContext( + link, + fhirAccessToken, + patientId, + fhirServerUrl, + ).catch((e) => e).then((newLink) => dispatch(storeLaunchContext(newLink)))); }; /** @@ -34,107 +34,234 @@ const remapSmartLinks = ({ * @returns {*} - String URL with its query parameters URI encoded if necessary */ function encodeUriParameters(template) { - if (template && template.split('?').length > 1) { - const splitUrl = template.split('?'); - const queryParams = queryString.parse(splitUrl[1]); - Object.keys(queryParams).forEach((param) => { - const val = queryParams[param]; - queryParams[param] = encodeURIComponent(val); + if (template && template.split('?').length > 1) { + const splitUrl = template.split('?'); + const queryParams = queryString.parse(splitUrl[1]); + Object.keys(queryParams).forEach((param) => { + const val = queryParams[param]; + queryParams[param] = encodeURIComponent(val); + }); + splitUrl[1] = queryString.stringify(queryParams, { encode: false }); + return splitUrl.join('?'); + } + return template; +} + +/** + * ---- Prefetch utilities aligned with CDS Hooks spec (v3 ballot) ---- + * - Prefetch tokens: {{context.foo}}, {{userPractitionerId}}, etc. + * - Simpler FHIRPath in tokens: {{today()}}, {{today() - 90 days}} + * - Basic advisory validation for prefetch query restrictions + */ +function formatDateYYYYMMDD(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function resolveSimpleFhirPathTokens(str) { + if (!str) return str; + const today = new Date(); + today.setHours(0, 0, 0, 0); + // {{ today() }} -> YYYY-MM-DD + let out = str.replace(/\{\{\s*today\(\)\s*}}/gi, formatDateYYYYMMDD(today)); + // {{ today() +/- N days }} -> YYYY-MM-DD + out = out.replace(/\{\{\s*today\(\)\s*([+-])\s*(\d+)\s*day[s]?\s*}}/gi, (m, sign, num) => { + const n = parseInt(num, 10) || 0; + const d = new Date(today); + d.setDate(d.getDate() + (sign === '-' ? -n : n)); + return formatDateYYYYMMDD(d); + }); + return out; +} + +function deriveUserIdentifiers(userId) { + const ids = {}; + if (typeof userId !== 'string') return ids; + const parts = userId.split('/'); + if (parts.length === 2) { + const [type, id] = parts; + if (type === 'Practitioner') ids.userPractitionerId = id; + if (type === 'PractitionerRole') ids.userPractitionerRoleId = id; + if (type === 'Patient') ids.userPatientId = id; + if (type === 'RelatedPerson') ids.userRelatedPersonId = id; + } + return ids; +} + +function replaceContextTokens(template, activityContext = {}) { + // Only replace first-level primitives from context per spec + return template.replace(/\{\{\s*context\.([A-Za-z0-9_]+)\s*}}/g, (match, key) => { + const val = activityContext[key]; + const isPrimitive = ['string', 'number', 'boolean'].includes(typeof val) || val === 0; + return isPrimitive ? String(val) : match; // leave unresolved if not available }); - splitUrl[1] = queryString.stringify(queryParams, { encode: false }); - return splitUrl.join('?'); - } - return template; +} + +function replaceUserIdentifierTokens(template, ids = {}) { + return template + .replace(/\{\{\s*userPractitionerId\s*}}/g, ids.userPractitionerId ?? '{{userPractitionerId}}') + .replace(/\{\{\s*userPractitionerRoleId\s*}}/g, ids.userPractitionerRoleId ?? '{{userPractitionerRoleId}}') + .replace(/\{\{\s*userPatientId\s*}}/g, ids.userPatientId ?? '{{userPatientId}}') + .replace(/\{\{\s*userRelatedPersonId\s*}}/g, ids.userRelatedPersonId ?? '{{userRelatedPersonId}}'); +} + +function hasUnresolvedPrefetchTokens(template) { + return /\{\{[^}]+}}/.test(template); +} + +function validatePrefetchQuery(prefetchValue) { + // Advisory checks per "Prefetch query restrictions" (non-blocking) + try { + const parts = prefetchValue.split('?'); + if (parts.length < 2) return true; // instance read or no query + const usp = new URLSearchParams(parts[1]); + for (const [k, v] of usp.entries()) { + // Allow token modifier :in, allow sort:asc/desc in key; warn on others + if (k.includes(':') && !k.endsWith(':in') && !k.startsWith('sort:')) { + console.warn(`Prefetch param "${k}" includes a modifier that may be unsupported by CDS Hooks query restrictions.`); + } + // Date prefixes eq, lt, gt, ge, le are typical; best-effort advisory only + if ((k === 'date' || k === 'dateTime' || k === 'instant') && v && /^(ne|sa|eb)/.test(v)) { + console.warn(`Date prefix in "${k}=${v}" may be outside recommended set (eq|lt|gt|ge|le).`); + } + } + } catch (e) { + console.warn('Could not validate prefetch query', e); + } + return true; } /** - * Replace prefetch templates in the query parameters with the Patient ID and/or User ID in context - * @param prefetch - Prefetch key/value pair from a CDS Service definition - * @returns {*} - New prefetch key/value pair Object with prefetch template filled out + * Replace prefetch tokens using current context (patientId, userId, and any extra hook context) + * and resolve simple FHIRPath date tokens. */ -function completePrefetchTemplate(state, prefetch) { - const patient = state.patientState.currentPatient.id; - const user = state.patientState.currentUser || state.patientState.defaultUser; - const prefetchRequests = { ...prefetch }; - Object.keys(prefetchRequests).forEach((prefetchKey) => { - let prefetchTemplate = prefetchRequests[prefetchKey]; - prefetchTemplate = prefetchTemplate.replace( - /{{\s*context\.patientId\s*}}/g, - patient, - ); - prefetchTemplate = prefetchTemplate.replace(/{{\s*user\s*}}/g, user); - prefetchTemplate = prefetchTemplate.replace(/{{\s*context\.userId\s*}}/g, user); - prefetchRequests[prefetchKey] = encodeUriParameters(prefetchTemplate); - }); - return prefetchRequests; +function completePrefetchTemplate(state, prefetch, activityContext = {}) { + const patient = state.patientState?.currentPatient?.id; + const user = state.patientState?.currentUser || state.patientState?.defaultUser; + + // Build a working context that includes required fields + const ctx = { ...activityContext }; + if (patient && !ctx.patientId) ctx.patientId = patient; + if (user && !ctx.userId) ctx.userId = user; + + const userIds = deriveUserIdentifiers(ctx.userId); + + const prefetchRequests = { ...prefetch }; + Object.keys(prefetchRequests).forEach((prefetchKey) => { + let prefetchTemplate = String(prefetchRequests[prefetchKey] ?? ''); + + // Backwards compatible replacements + if (patient) { + prefetchTemplate = prefetchTemplate.replace(/\{\{\s*context\.patientId\s*}}/g, patient); + } + if (user) { + prefetchTemplate = prefetchTemplate.replace(/\{\{\s*user\s*}}/g, user); + prefetchTemplate = prefetchTemplate.replace(/\{\{\s*context\.userId\s*}}/g, user); + } + + // Spec-compliant replacements + prefetchTemplate = replaceContextTokens(prefetchTemplate, ctx); + prefetchTemplate = replaceUserIdentifierTokens(prefetchTemplate, userIds); + + // Resolve simple FHIRPath date tokens like {{today()}}, {{today() - 90 days}} + prefetchTemplate = resolveSimpleFhirPathTokens(prefetchTemplate); + + // Encode values for querystring while preserving keys + prefetchRequests[prefetchKey] = encodeUriParameters(prefetchTemplate); + }); + + return prefetchRequests; } /** * Fetch data from FHIR server for each prefetch request and return a Promise with the data resolved eventually - * @param baseUrl - FHIR server base URL to prefetch data from - * @param prefetch - Prefetch templates from a CDS Service definition filled out - * @returns {Promise} - Promise object to eventually fetch data + * - Supports instance-level reads (e.g., Patient/123) + * - Supports type-level searches via POST to _search first, then GET fallback + * - Skips unresolved templates that still contain tokens */ -async function prefetchDataPromises(state, baseUrl, prefetch) { - const resultingPrefetch = {}; - const prefetchRequests = { - ...completePrefetchTemplate(state, prefetch), - }; - - const prefetchKeys = Object.keys(prefetchRequests); - const headers = { Accept: 'application/json+fhir' }; - const { accessToken } = state.fhirServerState; - if (accessToken && accessToken.access_token) { - headers.Authorization = `Bearer ${accessToken.access_token}`; - } - - // Create an array of promises for each request - const promises = prefetchKeys.map(async (key) => { - const prefetchValue = prefetchRequests[key]; - const resource = prefetchValue.split('?')[0]; - const params = new URLSearchParams(prefetchValue.split('?')[1]); - let usePost = true; +async function prefetchDataPromises(state, baseUrl, prefetch, activityContext = {}) { + const resultingPrefetch = {}; + const prefetchRequests = { ...completePrefetchTemplate(state, prefetch, activityContext) }; - try { - if (usePost) { - const result = await axios({ - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - data: params.toString(), - url: `${baseUrl}/${resource}/_search`, - }); - if (result.data && Object.keys(result.data).length) { - resultingPrefetch[key] = result.data; - } - } - } catch (err) { - usePost = false; - console.log( - `Unable to prefetch data using POST for ${baseUrl}/${prefetchValue}`, - err, - ); + const headers = { Accept: 'application/json+fhir' }; + const { accessToken } = state.fhirServerState; + if (accessToken && accessToken.access_token) { + headers.Authorization = `Bearer ${accessToken.access_token}`; } - if (!usePost) { - try { - const result = await axios({ - method: 'GET', - url: `${baseUrl}/${prefetchValue}`, - headers, - }); - if (result.data && Object.keys(result.data).length) { - resultingPrefetch[key] = result.data; + const prefetchKeys = Object.keys(prefetchRequests); + + const promises = prefetchKeys.map(async (key) => { + const prefetchValue = prefetchRequests[key]; + + if (!prefetchValue || hasUnresolvedPrefetchTokens(prefetchValue)) { + console.warn(`Skipping prefetch "${key}" due to unresolved tokens: ${prefetchValue}`); + return; } - } catch (err) { - console.log(`Unable to prefetch data for ${baseUrl}/${prefetchValue}`, err); - } - } - }); - // Wait for all promises to complete - await Promise.all(promises); + validatePrefetchQuery(prefetchValue); + + const [path, query] = prefetchValue.split('?'); + const isSearch = Boolean(query); + + if (!isSearch) { + // Instance level read (e.g., Patient/123) + try { + const result = await axios({ + method: 'GET', + url: `${baseUrl}/${path}`, + headers, + }); + if (result.data && Object.keys(result.data).length) { + resultingPrefetch[key] = result.data; + } + } catch (err) { + console.log(`Unable to prefetch instance ${baseUrl}/${path}`, err); + } + return; + } + + // Type level search; prefer POST to _search then fallback to GET + const resourceType = path; // e.g., Observation + const params = new URLSearchParams(query); + let usePost = true; - return resultingPrefetch; + try { + const result = await axios({ + method: 'POST', + url: `${baseUrl}/${resourceType}/_search`, + headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, + data: params.toString(), + }); + if (result.data && Object.keys(result.data).length) { + resultingPrefetch[key] = result.data; + return; // success via POST + } + } catch (err) { + usePost = false; + console.log(`Unable to prefetch data using POST for ${baseUrl}/${resourceType}/_search`, err); + } + + if (!usePost) { + try { + const result = await axios({ + method: 'GET', + url: `${baseUrl}/${path}?${params.toString()}`, + headers, + }); + if (result.data && Object.keys(result.data).length) { + resultingPrefetch[key] = result.data; + } + } catch (err) { + console.log(`Unable to prefetch data for ${baseUrl}/${prefetchValue}`, err); + } + } + }); + + await Promise.all(promises); + return resultingPrefetch; } /** @@ -146,99 +273,99 @@ async function prefetchDataPromises(state, baseUrl, prefetch) { * @returns {Promise} - Promise object to eventually return service response data */ function callServices(dispatch, state, url, context, exchangeRound = 0) { - const hook = state.hookState.currentHook; - const fhirServer = state.fhirServerState.currentFhirServer; + const hook = state.hookState.currentHook; + const fhirServer = state.fhirServerState.currentFhirServer; - const activityContext = {}; - activityContext.patientId = state.patientState.currentPatient.id; - activityContext.userId = state.patientState.currentUser || state.patientState.defaultUser; + const activityContext = {}; + activityContext.patientId = state.patientState.currentPatient.id; + activityContext.userId = state.patientState.currentUser || state.patientState.defaultUser; - if (context && context.length) { - context.forEach((contextKey) => { - activityContext[contextKey.key] = contextKey.value; - }); - } - - const hookInstance = uuidv4(); - const accessTokenProperty = state.fhirServerState.accessToken; - let fhirAuthorization; - if (accessTokenProperty) { - fhirAuthorization = { - access_token: accessTokenProperty.access_token, - token_type: 'Bearer', - expires_in: accessTokenProperty.expires_in, - scope: allScopes, - subject: productionClientId, + if (context && context.length) { + context.forEach((contextKey) => { + activityContext[contextKey.key] = contextKey.value; + }); + } + + const hookInstance = uuidv4(); + const accessTokenProperty = state.fhirServerState.accessToken; + let fhirAuthorization; + if (accessTokenProperty) { + fhirAuthorization = { + access_token: accessTokenProperty.access_token, + token_type: 'Bearer', + expires_in: accessTokenProperty.expires_in, + scope: allScopes, + subject: productionClientId, + }; + } + const request = { + hookInstance, + hook, + fhirServer, + context: activityContext, }; - } - const request = { - hookInstance, - hook, - fhirServer, - context: activityContext, - }; - - if (fhirAuthorization) { - request.fhirAuthorization = fhirAuthorization; - } - - const serviceDefinition = state.cdsServicesState.configuredServices[url]; - - const sendRequest = () => axios({ - method: 'post', - url, - data: request, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${generateJWT(url)}`, - }, - }); - - const dispatchResult = (result) => { - if (result.data && Object.keys(result.data).length) { - dispatch(storeExchange(url, request, result.data, result.status, exchangeRound)); - remapSmartLinks({ - dispatch, - cardResponse: result.data, - fhirAccessToken: state.fhirServerState.accessToken, - patientId: state.patientState.currentPatient.id, - fhirServerUrl: state.fhirServerState.currentFhirServer, - }); - } else { - dispatch(storeExchange( - url, - request, - 'No response returned. Check developer tools for more details.', - )); + + if (fhirAuthorization) { + request.fhirAuthorization = fhirAuthorization; } - }; - - const dispatchErrors = (err) => { - console.error(`Could not POST data to CDS Service ${url}`, err); - dispatch(storeExchange( - url, - request, - 'Could not get a response from the CDS Service. ' + + const serviceDefinition = state.cdsServicesState.configuredServices[url]; + + const sendRequest = () => axios({ + method: 'post', + url, + data: request, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${generateJWT(url)}`, + }, + }); + + const dispatchResult = (result) => { + if (result.data && Object.keys(result.data).length) { + dispatch(storeExchange(url, request, result.data, result.status, exchangeRound)); + remapSmartLinks({ + dispatch, + cardResponse: result.data, + fhirAccessToken: state.fhirServerState.accessToken, + patientId: state.patientState.currentPatient.id, + fhirServerUrl: state.fhirServerState.currentFhirServer, + }); + } else { + dispatch(storeExchange( + url, + request, + 'No response returned. Check developer tools for more details.', + )); + } + }; + + const dispatchErrors = (err) => { + console.error(`Could not POST data to CDS Service ${url}`, err); + dispatch(storeExchange( + url, + request, + 'Could not get a response from the CDS Service. ' + 'See developer tools for more details', - )); - }; + )); + }; - // Wait for prefetch to be fulfilled before making a request to the CDS service, if the service has prefetch expectations - const needPrefetch = serviceDefinition.prefetch + // Wait for prefetch to be fulfilled before making a request to the CDS service, if the service has prefetch expectations + const needPrefetch = serviceDefinition.prefetch && Object.keys(serviceDefinition.prefetch).length > 0; - const prefetchPromise = needPrefetch - ? prefetchDataPromises(state, fhirServer, serviceDefinition.prefetch) - : Promise.resolve({}); + const prefetchPromise = needPrefetch + ? prefetchDataPromises(state, fhirServer, serviceDefinition.prefetch, activityContext) + : Promise.resolve({}); - return prefetchPromise.then((prefetchResults) => { - if (prefetchResults && Object.keys(prefetchResults).length > 0) { - request.prefetch = prefetchResults; - } - return sendRequest() - .then(dispatchResult) - .catch(dispatchErrors); - }); + return prefetchPromise.then((prefetchResults) => { + if (prefetchResults && Object.keys(prefetchResults).length > 0) { + request.prefetch = prefetchResults; + } + return sendRequest() + .then(dispatchResult) + .catch(dispatchErrors); + }); } export default callServices; diff --git a/tests/retrieve-data-helpers/service-exchange.test.js b/tests/retrieve-data-helpers/service-exchange.test.js index 8937b66..ec83626 100644 --- a/tests/retrieve-data-helpers/service-exchange.test.js +++ b/tests/retrieve-data-helpers/service-exchange.test.js @@ -4,258 +4,387 @@ import 'core-js/es/array/flat-map'; describe('Service Exchange', () => { - console.error = jest.fn(); - console.log = jest.fn(); - - let mockAxios; - let axios; - let actions; - let fhirConfig; - - let mockStore = {}; - let defaultStore = {}; - let callServices; - - let mockPatient; - let mockFhirServer; - let mockServiceWithPrefetch; - let mockServiceWithPrefetchEncoded; - let mockServiceNoEncoding; - let mockServiceWithoutPrefetch; - let mockServiceWithEmptyPrefetch; - let mockHookInstance; - let mockRequest; - let mockRequestWithContext; - let mockRequestWithFhirAuthorization; - let mockAccessToken; - - let noDataMessage = 'No response returned. Check developer tools for more details.'; - let failedServiceCallMessage = 'Could not get a response from the CDS Service. See developer tools for more details'; - - let prefetchedData = 'prefetch'; - const mockServiceResult = { test: 'result' }; - const jwtMock = 'jwt-mock'; - - function setMocksAndTestFunction(testStore) { - const mockStoreWrapper = configureStore([]); - mockStore = mockStoreWrapper(testStore); - jest.setMock('../../src/store/store', mockStore); - jest.dontMock('query-string'); - axios = require('axios').default; - mockAxios = new MockAdapter(axios); - jest.mock('uuid/v4', () => { return jest.fn(() => { return mockHookInstance })}); - actions = require('../../src/actions/service-exchange-actions'); - jest.setMock('../../src/retrieve-data-helpers/jwt-generator', () => jwtMock); - callServices = require('../../src/retrieve-data-helpers/service-exchange').default; - } - - beforeEach(() => { - fhirConfig = require('../../src/config/fhir-config'); - mockPatient = 'patient-1'; - mockFhirServer = 'http://fhir-server-example.com'; - mockServiceWithPrefetch = 'http://example.com/cds-services/id-1'; - mockServiceWithoutPrefetch = 'http://example.com/cds-services/id-2'; - mockServiceNoEncoding = 'http://example.com/cds-services/id-3'; - mockServiceWithPrefetchEncoded = 'http://example.com/cds-services/id-4'; - mockHookInstance = '123'; - mockAccessToken = { - access_token: 'access-token', - expires_in: '600', + console.error = jest.fn(); + console.log = jest.fn(); + + let mockAxios; + let axios; + let actions; + let fhirConfig; + + let mockStore = {}; + let defaultStore = {}; + let callServices; + + let mockPatient; + let mockFhirServer; + let mockServiceWithPrefetch; + let mockServiceWithPrefetchEncoded; + let mockServiceNoEncoding; + let mockServiceWithoutPrefetch; + let mockServiceWithEmptyPrefetch; + let mockServiceWithSimpleFhirPath; + let mockServiceWithUserTokens; + let mockServiceWithUnresolved; + let mockServiceWithQueryRestriction; + let mockHookInstance; + let mockRequest; + let mockRequestWithContext; + let mockRequestWithFhirAuthorization; + let mockAccessToken; + + let noDataMessage = 'No response returned. Check developer tools for more details.'; + let failedServiceCallMessage = 'Could not get a response from the CDS Service. See developer tools for more details'; + + let prefetchedData = 'prefetch'; + const mockServiceResult = { test: 'result' }; + const jwtMock = 'jwt-mock'; + + function setMocksAndTestFunction(testStore) { + const mockStoreWrapper = configureStore([]); + mockStore = mockStoreWrapper(testStore); + jest.setMock('../../src/store/store', mockStore); + jest.dontMock('query-string'); + axios = require('axios').default; + mockAxios = new MockAdapter(axios); + jest.mock('uuid/v4', () => { return jest.fn(() => { return mockHookInstance })}); + actions = require('../../src/actions/service-exchange-actions'); + jest.setMock('../../src/retrieve-data-helpers/jwt-generator', () => jwtMock); + callServices = require('../../src/retrieve-data-helpers/service-exchange').default; } - mockRequest = { - hookInstance: mockHookInstance, - hook: 'patient-view', - fhirServer: mockFhirServer, - context: { patientId: mockPatient, userId: 'Practitioner/specified-1' } - }; - mockRequestWithContext = Object.assign({}, mockRequest, { - context: { - ...mockRequest.context, - selections: ['selection/id'], - draftOrders: [{ - foo: 'foo', - }], - }, - }); - mockRequestWithFhirAuthorization = Object.assign({}, mockRequest, { - fhirAuthorization: { - access_token: mockAccessToken.access_token, - token_type: 'Bearer', - expires_in: mockAccessToken.expires_in, - scope: fhirConfig.allScopes, - subject: fhirConfig.productionClientId, - }, - }); - defaultStore = { - hookState: { currentHook: 'patient-view', currentScreen: 'patient-view'}, - patientState: { - defaultUser: 'Practitioner/default', - currentUser: 'Practitioner/specified-1', - currentPatient: { - id: mockPatient + beforeEach(() => { + fhirConfig = require('../../src/config/fhir-config'); + mockPatient = 'patient-1'; + mockFhirServer = 'http://fhir-server-example.com'; + mockServiceWithPrefetch = 'http://example.com/cds-services/id-1'; + mockServiceWithoutPrefetch = 'http://example.com/cds-services/id-2'; + mockServiceNoEncoding = 'http://example.com/cds-services/id-3'; + mockServiceWithPrefetchEncoded = 'http://example.com/cds-services/id-4'; + mockServiceWithSimpleFhirPath = 'http://example.com/cds-services/id-5'; + mockServiceWithUserTokens = 'http://example.com/cds-services/id-6'; + mockServiceWithUnresolved = 'http://example.com/cds-services/id-7'; + mockServiceWithQueryRestriction = 'http://example.com/cds-services/id-8'; + mockHookInstance = '123'; + mockAccessToken = { + access_token: 'access-token', + expires_in: '600', } - }, - fhirServerState: { - currentFhirServer: mockFhirServer - }, - cdsServicesState: { - configuredServices: { - [`${mockServiceWithPrefetch}`]: { - prefetch: { - test: 'Observation?patient={{context.patientId}}&code=http://loinc.org|2857-1' - } - }, - [`${mockServiceWithPrefetchEncoded}`]: { - prefetch: { - first: 'Conditions?patient={{context.patientId}}', - test: `Observation?patient={{context.patientId}}&code=${encodeURIComponent('http://loinc.org|2857-1')}`, - second: 'Patient/{{context.patientId}}' - } - }, - [`${mockServiceNoEncoding}`]: { - prefetch: { - test: 'Patient/{{context.patientId}}' + mockRequest = { + hookInstance: mockHookInstance, + hook: 'patient-view', + fhirServer: mockFhirServer, + context: { patientId: mockPatient, userId: 'Practitioner/specified-1' } + }; + mockRequestWithContext = Object.assign({}, mockRequest, { + context: { + ...mockRequest.context, + selections: ['selection/id'], + draftOrders: [{ + foo: 'foo', + }], + }, + }); + mockRequestWithFhirAuthorization = Object.assign({}, mockRequest, { + fhirAuthorization: { + access_token: mockAccessToken.access_token, + token_type: 'Bearer', + expires_in: mockAccessToken.expires_in, + scope: fhirConfig.allScopes, + subject: fhirConfig.productionClientId, + }, + }); + + defaultStore = { + hookState: { currentHook: 'patient-view', currentScreen: 'patient-view'}, + patientState: { + defaultUser: 'Practitioner/default', + currentUser: 'Practitioner/specified-1', + currentPatient: { + id: mockPatient + } + }, + fhirServerState: { + currentFhirServer: mockFhirServer + }, + cdsServicesState: { + configuredServices: { + [`${mockServiceWithPrefetch}`]: { + prefetch: { + test: 'Observation?patient={{context.patientId}}&code=http://loinc.org|2857-1' + } + }, + [`${mockServiceWithPrefetchEncoded}`]: { + prefetch: { + first: 'Conditions?patient={{context.patientId}}', + test: `Observation?patient={{context.patientId}}&code=${encodeURIComponent('http://loinc.org|2857-1')}`, + second: 'Patient/{{context.patientId}}' + } + }, + [`${mockServiceNoEncoding}`]: { + prefetch: { + test: 'Patient/{{context.patientId}}' + } + }, + [`${mockServiceWithEmptyPrefetch}`]: { + prefetch: {}, + }, + [`${mockServiceWithoutPrefetch}`]: {}, + [`${mockServiceWithSimpleFhirPath}`]: { + prefetch: { + test: 'Observation?patient={{context.patientId}}&date=ge{{today() - 90 days}}' + } + }, + [`${mockServiceWithUserTokens}`]: { + prefetch: { + test: 'Encounter?participant={{userPractitionerId}}' + } + }, + [`${mockServiceWithUnresolved}`]: { + prefetch: { + test: 'Observation?patient={{context.nonexistent}}' + } + }, + [`${mockServiceWithQueryRestriction}`]: { + prefetch: { + test: 'Patient?identifier:contains=foo' + } + } + } } - }, - [`${mockServiceWithEmptyPrefetch}`]: { - prefetch: {}, - }, - [`${mockServiceWithoutPrefetch}`]: {} } - } - } - }); - - afterEach(() => { - mockAxios.reset(); - mockStore.clearActions(); - jest.resetModules(); - }); + }); - describe('When prefetch is needed by a service', () => { - let spy; - beforeEach(() => { - setMocksAndTestFunction(defaultStore); - spy = jest.spyOn(actions, 'storeExchange'); + afterEach(() => { + mockAxios.reset(); + mockStore.clearActions(); + jest.resetModules(); }); - describe('and the prefetch call is successful with data', () => { - beforeEach(() => { - mockRequest.prefetch = { test: prefetchedData }; - }); - - it('resolves and dispatches a successful CDS Service call when prefetch is retrieved', () => { - defaultStore.fhirServerState.accessToken = mockAccessToken; - mockRequestWithFhirAuthorization.prefetch = { test: prefetchedData }; - const serviceResultStatus = 200; - mockAxios.onGet(`${mockFhirServer}/Observation?code=${encodeURIComponent('http://loinc.org|2857-1')}&patient=${mockPatient}`) - .reply((config) => { - expect(config.headers['Authorization']).toEqual(`Bearer ${mockAccessToken.access_token}`); - return [200, prefetchedData]; - }) - .onPost(mockServiceWithPrefetch).reply(serviceResultStatus, mockServiceResult); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithPrefetch, mockRequestWithFhirAuthorization, mockServiceResult, serviceResultStatus, 0); + + describe('When prefetch is needed by a service', () => { + let spy; + beforeEach(() => { + setMocksAndTestFunction(defaultStore); + spy = jest.spyOn(actions, 'storeExchange'); }); - }); - - it('resolves and dispatches an appropriate message when no data comes back from services', () => { - mockAxios.onGet(`${mockFhirServer}/Patient/${mockPatient}`) - .reply(200, prefetchedData) - .onPost(mockServiceNoEncoding).reply(200, {}); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceNoEncoding).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceNoEncoding, mockRequest, noDataMessage); + describe('and the prefetch call is successful with data', () => { + beforeEach(() => { + mockRequest.prefetch = { test: prefetchedData }; + }); + + it('resolves and dispatches a successful CDS Service call when prefetch is retrieved', () => { + defaultStore.fhirServerState.accessToken = mockAccessToken; + mockRequestWithFhirAuthorization.prefetch = { test: prefetchedData }; + const serviceResultStatus = 200; + mockAxios + .onPost(`${mockFhirServer}/Observation/_search`) + .reply((config) => { + expect(config.headers['Authorization']).toEqual(`Bearer ${mockAccessToken.access_token}`); + expect(config.data).toContain(`patient=${mockPatient}`); + expect(config.data).toContain(`code=${encodeURIComponent('http://loinc.org|2857-1')}`); + return [200, prefetchedData]; + }) + .onPost(mockServiceWithPrefetch).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithPrefetch, mockRequestWithFhirAuthorization, mockServiceResult, serviceResultStatus, 0); + }); + }); + + it('resolves and dispatches an appropriate message when no data comes back from services', () => { + mockAxios.onGet(`${mockFhirServer}/Patient/${mockPatient}`) + .reply(200, prefetchedData) + .onPost(mockServiceNoEncoding).reply(200, {}); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceNoEncoding).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceNoEncoding, mockRequest, noDataMessage); + }); + }); + + it('resolves and dispatches an appropriate error message when service call fails', () => { + mockAxios + .onPost(`${mockFhirServer}/Observation/_search`).reply(200, prefetchedData) + .onPost(`${mockFhirServer}/Conditions/_search`).reply(500) + .onGet(`${mockFhirServer}/Patient/${mockPatient}`).reply(200, {}) + .onPost(mockServiceWithPrefetchEncoded).reply(500); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetchEncoded).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithPrefetchEncoded, mockRequest, failedServiceCallMessage); + }); + }); }); - }); - - it('resolves and dispatches an appropriate error message when service call fails', () => { - mockAxios.onGet(`${mockFhirServer}/Observation?code=${encodeURIComponent('http://loinc.org|2857-1')}&patient=${mockPatient}`) - .reply(200, prefetchedData) - .onGet(`${mockFhirServer}/Conditions?patient=${mockPatient}`).reply(500) - .onGet(`${mockFhirServer}/Patient/${mockPatient}`).reply(200, {}) - .onPost(mockServiceWithPrefetchEncoded).reply(500); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetchEncoded).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithPrefetchEncoded, mockRequest, failedServiceCallMessage); + + describe('and the prefetch call is unsuccessful', () => { + it('continues to POST to CDS service without the prefetch property that failed', ()=> { + const serviceResultStatus = 200; + mockAxios + .onPost(`${mockFhirServer}/Observation/_search`).reply(404) + .onPost(mockServiceWithPrefetch).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); }); - }); - }); - describe('and the prefetch call is unsuccessful', () => { - it('continues to POST to CDS service without the prefetch property that failed', ()=> { - const serviceResultStatus = 200; - mockAxios.onGet(`${mockFhirServer}/Observation?code=${encodeURIComponent('http://loinc.org|2857-1')}&patient=${mockPatient}`) - .reply(404) - .onPost(mockServiceWithPrefetch).reply(serviceResultStatus, mockServiceResult); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); + // New tests for enhanced prefetch logic + describe('enhanced prefetch tokens and behaviors', () => { + beforeEach(() => { + // ensure mocks/spies are in place + }); + + it('falls back to GET when POST to _search fails', () => { + const serviceResultStatus = 200; + mockAxios + .onPost(`${mockFhirServer}/Observation/_search`).reply(500) + .onGet(`${mockFhirServer}/Observation?patient=${mockPatient}&code=${encodeURIComponent('http://loinc.org|2857-1')}`) + .reply(200, prefetchedData) + .onPost(mockServiceWithPrefetch).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithPrefetch).then(() => { + const expectedReq = expect.objectContaining({ + hookInstance: mockHookInstance, + hook: 'patient-view', + fhirServer: mockFhirServer, + context: { patientId: mockPatient, userId: 'Practitioner/specified-1' }, + }); + expect(spy).toHaveBeenCalledWith( + mockServiceWithPrefetch, + expectedReq, + mockServiceResult, + serviceResultStatus, + 0 + ); + }); + }); + + it('resolves simple FHIRPath tokens like {{today() - 90 days}}', () => { + mockRequest.prefetch = { test: prefetchedData }; + const serviceResultStatus = 200; + const today = new Date(); + today.setHours(0,0,0,0); + const d = new Date(today); + d.setDate(d.getDate() - 90); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth()+1).padStart(2,'0'); + const dd = String(d.getDate()).padStart(2,'0'); + const expected = `${yyyy}-${mm}-${dd}`; + + mockAxios + .onPost(`${mockFhirServer}/Observation/_search`) + .reply((config) => { + expect(config.data).toContain(`patient=${mockPatient}`); + expect(config.data).toContain(`date=ge${expected}`); + return [200, prefetchedData]; + }) + .onPost(mockServiceWithSimpleFhirPath).reply(serviceResultStatus, mockServiceResult); + + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithSimpleFhirPath).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithSimpleFhirPath, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); + + it('replaces user identifier tokens like {{userPractitionerId}}', () => { + mockRequest.prefetch = { test: prefetchedData }; + const serviceResultStatus = 200; + mockAxios + .onPost(`${mockFhirServer}/Encounter/_search`) + .reply((config) => { + expect(config.data).toContain('participant=specified-1'); + return [200, prefetchedData]; + }) + .onPost(mockServiceWithUserTokens).reply(serviceResultStatus, mockServiceResult); + + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithUserTokens).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithUserTokens, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); + + it('skips unresolved tokens rather than calling the FHIR server', () => { + const serviceResultStatus = 200; + // No FHIR mocks set up — unresolved token should cause skip + mockAxios.onPost(mockServiceWithUnresolved).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithUnresolved).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithUnresolved, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); + + it('warns (advisory) on query modifiers outside recommended set', () => { + console.warn = jest.fn(); + mockRequest.prefetch = { test: prefetchedData }; + const serviceResultStatus = 200; + mockAxios + .onPost(`${mockFhirServer}/Patient/_search`).reply(200, prefetchedData) + .onPost(mockServiceWithQueryRestriction).reply(serviceResultStatus, mockServiceResult); + + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithQueryRestriction).then(() => { + expect(console.warn).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(mockServiceWithQueryRestriction, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); }); - }); }); - }); - describe('When prefetch is not needed by a service', () => { - let spy; - beforeEach(() => { - setMocksAndTestFunction(defaultStore); - spy = jest.spyOn(actions, 'storeExchange'); - }); + describe('When prefetch is not needed by a service', () => { + let spy; + beforeEach(() => { + setMocksAndTestFunction(defaultStore); + spy = jest.spyOn(actions, 'storeExchange'); + }); - it('resolves and dispatches data from a successful CDS service call', () => { - const serviceResultStatus = 200; - mockAxios.onPost(mockServiceWithoutPrefetch).reply(serviceResultStatus, mockServiceResult); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); - }); - }); + it('resolves and dispatches data from a successful CDS service call', () => { + const serviceResultStatus = 200; + mockAxios.onPost(mockServiceWithoutPrefetch).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); - it('resolves and dispatches data from a successful CDS Service call with default user', () => { - defaultStore.patientState.currentUser = ''; - mockRequest.context.userId = 'Practitioner/default'; - const serviceResultStatus = 200; - mockAxios.onPost(mockServiceWithoutPrefetch).reply(serviceResultStatus, mockServiceResult); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); - }); - }); + it('resolves and dispatches data from a successful CDS Service call with default user', () => { + defaultStore.patientState.currentUser = ''; + mockRequest.context.userId = 'Practitioner/default'; + const serviceResultStatus = 200; + mockAxios.onPost(mockServiceWithoutPrefetch).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); - it('resolves and dispatches data from a successful CDS Service call with empty an prefetch object', () => { - const serviceResultStatus = 200; - mockAxios.onPost(mockServiceWithEmptyPrefetch).reply(serviceResultStatus, mockServiceResult); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithEmptyPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithEmptyPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); - }); - }); + it('resolves and dispatches data from a successful CDS Service call with empty an prefetch object', () => { + const serviceResultStatus = 200; + mockAxios.onPost(mockServiceWithEmptyPrefetch).reply(serviceResultStatus, mockServiceResult); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithEmptyPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithEmptyPrefetch, mockRequest, mockServiceResult, serviceResultStatus, 0); + }); + }); - it('resolves and dispatches an appropriate message if no data is returned from service', () => { - mockAxios.onPost(mockServiceWithoutPrefetch).reply(200, {}); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, noDataMessage); - }); - }); - it('resolves and dispatches an appropriate message when service call fails', () => { - mockAxios.onPost(mockServiceWithoutPrefetch).reply(500); - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, failedServiceCallMessage); - }); - }); - it('resolves with context passed in for the context parameter', () => { - const serviceResultStatus = 200; - mockAxios.onPost(mockServiceWithoutPrefetch).reply(serviceResultStatus, mockServiceResult); - const context = [ - { - key: 'selections', - value: ['selection/id'], - }, - { - key: 'draftOrders', - value: [{ foo: 'foo' }], - }, - ]; - return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch, context).then(() => { - expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequestWithContext, mockServiceResult, serviceResultStatus, 0) - }); + it('resolves and dispatches an appropriate message if no data is returned from service', () => { + mockAxios.onPost(mockServiceWithoutPrefetch).reply(200, {}); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, noDataMessage); + }); + }); + + it('resolves and dispatches an appropriate message when service call fails', () => { + mockAxios.onPost(mockServiceWithoutPrefetch).reply(500); + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequest, failedServiceCallMessage); + }); + }); + + it('resolves with context passed in for the context parameter', () => { + const serviceResultStatus = 200; + mockAxios.onPost(mockServiceWithoutPrefetch).reply(serviceResultStatus, mockServiceResult); + const context = [ + { + key: 'selections', + value: ['selection/id'], + }, + { + key: 'draftOrders', + value: [{ foo: 'foo' }], + }, + ]; + return callServices(mockStore.dispatch, mockStore.getState(), mockServiceWithoutPrefetch, context).then(() => { + expect(spy).toHaveBeenCalledWith(mockServiceWithoutPrefetch, mockRequestWithContext, mockServiceResult, serviceResultStatus, 0) + }); + }); }); - }); });