// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { createReference } from '@medplum/core';
import type {
Bundle,
BundleEntry,
ContactPoint,
Expression,
OperationOutcome,
OperationOutcomeIssue,
Organization,
Parameters,
Patient,
Questionnaire,
QuestionnaireResponse,
} from '@medplum/fhirtypes';
import express from 'express';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { initApp, shutdownApp } from '../../app';
import { loadTestConfig } from '../../config/loader';
import { createTestProject } from '../../test.setup';
import type { Repository } from '../repo';
const extractExtension = 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtract';
const allocIdExtension = 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-extractAllocateId';
const contextExtension = 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtractContext';
const valueExtension = 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtractValue';
describe('QuestionnaireResponse/$extract', () => {
const app = express();
let accessToken: string;
let repo: Repository;
let questionnaire: Questionnaire;
let response: QuestionnaireResponse;
beforeAll(async () => {
const config = await loadTestConfig();
await initApp(app, config);
({ accessToken, repo } = await createTestProject({ withAccessToken: true, withRepo: true }));
questionnaire = await repo.createResource({
resourceType: 'Questionnaire',
contained: [
{
resourceType: 'Patient',
id: 'patTemplate',
identifier: [
{
extension: [
{ url: contextExtension, valueString: "item.where(linkId = 'ihi').answer.value" },
{ url: 'http://example.com/other/identifier', valueString: 'foo/bar/baz/quux' },
],
type: { text: 'National Identifier (IHI)' },
system: 'http://example.org/nhio',
_value: {
extension: [{ url: valueExtension, valueString: 'first()' }],
},
},
],
name: [
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'name')" }],
_text: {
extension: [
{
url: valueExtension,
valueString: "item.where(linkId='given' or linkId='family').answer.value.join(' ')",
},
],
},
_family: {
extension: [{ url: valueExtension, valueString: "item.where(linkId = 'family').answer.value.first()" }],
},
_given: [
{
extension: [{ url: valueExtension, valueString: "item.where(linkId = 'given').answer.value" }],
},
],
},
],
telecom: [
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'mobile-phone').answer.value" }],
system: 'phone',
_value: { extension: [{ url: valueExtension, valueString: 'first()' }] },
use: 'mobile',
},
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'email').answer.value" }],
system: 'email',
_value: { extension: [{ url: valueExtension, valueString: 'first()' }] },
use: 'home',
},
],
gender: 'unknown',
_gender: {
extension: [
{ url: valueExtension, valueString: "item.where(linkId = 'gender').answer.value.first().code" },
],
},
},
{
resourceType: 'RelatedPerson',
id: 'rpTemplate',
patient: {
_reference: { extension: [{ url: valueExtension, valueString: '%NewPatientId' }] },
},
relationship: [
{
coding: [
{
extension: [
{ url: valueExtension, valueString: "item.where(linkId = 'relationship').answer.value.first()" },
],
},
],
},
],
name: [
{
_text: {
extension: [
{ url: valueExtension, valueString: "item.where(linkId = 'contact-name').answer.value.first()" },
],
},
},
],
telecom: [
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'phone').answer.value" }],
system: 'phone',
_value: { extension: [{ url: valueExtension, valueString: 'first()' }] },
use: 'mobile',
},
],
},
{
resourceType: 'Observation',
id: 'obsTemplateHeight',
status: 'final',
category: [
{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/observation-category', code: 'vital-signs' }] },
],
code: { coding: [{ system: 'http://loinc.org', code: '8302-2', display: 'Body height' }] },
subject: {
_reference: { extension: [{ url: valueExtension, valueString: `%NewPatientId` }] },
},
effectiveDateTime: '1900-01-01',
_effectiveDateTime: { extension: [{ url: valueExtension, valueString: '%resource.authored' }] },
_issued: { extension: [{ url: valueExtension, valueString: '%resource.authored' }] },
performer: [{ extension: [{ url: valueExtension, valueString: '%resource.author' }] }],
valueQuantity: {
_value: { extension: [{ url: valueExtension, valueString: '(answer.value * 2.54).round()' }] },
unit: 'cm',
system: 'http://unitsofmeasure.org',
code: 'cm',
},
derivedFrom: [
{
extension: [{ url: contextExtension, valueString: '%resource.id' }],
_reference: {
extension: [{ url: valueExtension, valueString: "'QuestionnaireResponse/' + %resource.id" }],
},
},
],
},
{
resourceType: 'Observation',
id: 'obsTemplateWeight',
status: 'final',
category: [
{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/observation-category', code: 'vital-signs' }] },
],
code: { coding: [{ system: 'http://loinc.org', code: '29463-7', display: 'Weight' }] },
subject: { _reference: { extension: [{ url: valueExtension, valueString: `%NewPatientId` }] } },
effectiveDateTime: '1900-01-01',
_effectiveDateTime: { extension: [{ url: valueExtension, valueString: '%resource.authored' }] },
_issued: { extension: [{ url: valueExtension, valueString: '%resource.authored' }] },
performer: [{ extension: [{ url: valueExtension, valueString: '%resource.author' }] }],
valueQuantity: {
_value: { extension: [{ url: valueExtension, valueString: '(answer.value * 0.454).round(1)' }] },
unit: 'kg',
system: 'http://unitsofmeasure.org',
code: 'kg',
},
derivedFrom: [
{
extension: [{ url: contextExtension, valueString: '%resource.id' }],
_reference: {
extension: [{ url: valueExtension, valueString: "'QuestionnaireResponse/' + %resource.id" }],
},
},
],
},
],
extension: [{ url: allocIdExtension, valueString: 'NewPatientId' }],
url: 'http://hl7.org/fhir/uv/sdc/Questionnaire/extract-complex-template',
version: '4.0.0',
name: 'ExtractComplexTemplate',
title: 'Complex Extract Demonstration - Template',
status: 'draft',
experimental: true,
date: '2025-09-04T17:57:37+00:00',
publisher: 'HL7 International / FHIR Infrastructure',
description: 'Complex template-based extraction example',
item: [
{
extension: [
{
url: extractExtension,
extension: [
{ url: 'template', valueReference: { reference: '#patTemplate' } },
{ url: 'fullUrl', valueString: `%NewPatientId` },
],
},
],
linkId: 'patient',
text: 'Patient Information',
type: 'group',
item: [
{
linkId: 'name',
text: 'Name',
type: 'group',
repeats: true,
item: [
{ linkId: 'given', text: 'Given Name(s)', type: 'string', repeats: true },
{ linkId: 'family', text: 'Family/Surname', type: 'string' },
],
},
{
linkId: 'gender',
text: 'Gender',
type: 'choice',
answerValueSet: 'http://hl7.org/fhir/ValueSet/administrative-gender',
},
{ linkId: 'dob', text: 'Date of Birth', type: 'date' },
{ linkId: 'ihi', text: 'National Identifier (IHI)', type: 'string' },
{ linkId: 'mobile-phone', text: 'Mobile Phone number', type: 'string' },
{ linkId: 'email', text: 'Email address', type: 'string' },
],
},
{
extension: [
{ url: extractExtension, extension: [{ url: 'template', valueReference: { reference: '#rpTemplate' } }] },
],
linkId: 'contacts',
text: 'Contacts',
type: 'group',
repeats: true,
item: [
{ linkId: 'contact-name', text: 'Name', type: 'string' },
{
linkId: 'relationship',
text: 'Relationship',
type: 'choice',
answerValueSet: 'http://hl7.org/fhir/ValueSet/patient-contactrelationship',
},
{ linkId: 'phone', text: 'Phone', type: 'string' },
],
},
{
linkId: 'obs',
text: 'Observations',
type: 'group',
item: [
{
extension: [
{
url: extractExtension,
extension: [{ url: 'template', valueReference: { reference: '#obsTemplateHeight' } }],
},
],
linkId: 'height',
text: 'What is your current height (in)',
type: 'decimal',
},
{
extension: [
{
url: extractExtension,
extension: [{ url: 'template', valueReference: { reference: '#obsTemplateWeight' } }],
},
],
linkId: 'weight',
text: 'What is your current weight (lb)',
type: 'decimal',
},
],
},
],
} as unknown as Questionnaire);
response = await repo.createResource<QuestionnaireResponse>({
resourceType: 'QuestionnaireResponse',
status: 'completed',
questionnaire: questionnaire.url as string,
author: { reference: 'Practitioner/author' },
authored: '2025-09-16T12:34:56.000-07:00',
item: [
{
linkId: 'patient',
item: [
{
linkId: 'name',
item: [
{ linkId: 'given', answer: [{ valueString: 'John' }, { valueString: 'Jacob' }] },
{ linkId: 'family', answer: [{ valueString: 'Jingleheimer-Schmidt' }] },
],
},
{
linkId: 'name',
item: [
{ linkId: 'given', answer: [{ valueString: 'Johnny' }] },
{ linkId: 'family', answer: [{ valueString: 'Appleseed' }] },
],
},
{
linkId: 'gender',
answer: [{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'male' } }],
},
{ linkId: 'dob', answer: [{ valueDate: '1832-01-23' }] },
{ linkId: 'ihi', answer: [{ valueString: '012345' }] },
{ linkId: 'mobile-phone', answer: [{ valueString: '555-555-5555' }] },
],
},
{
linkId: 'contacts',
item: [
{ linkId: 'contact-name', answer: [{ valueString: 'Bureau of Land Management' }] },
{
linkId: 'relationship',
answer: [{ valueCoding: { system: 'http://terminology.hl7.org/CodeSystem/v2-0131', code: 'F' } }],
},
],
},
{
linkId: 'contacts',
item: [
{ linkId: 'contact-name', answer: [{ valueString: 'Nathaniel Cooley Chapman' }] },
{
linkId: 'relationship',
answer: [{ valueCoding: { system: 'http://terminology.hl7.org/CodeSystem/v2-0131', code: 'N' } }],
},
{ linkId: 'phone', answer: [{ valueString: '123-456-7890' }] },
],
},
{
linkId: 'obs',
item: [
{ linkId: 'height', answer: [{ valueDecimal: 66 }] },
{ linkId: 'weight', answer: [{ valueDecimal: 134 }] },
],
},
],
});
});
afterAll(async () => {
await shutdownApp();
});
test('Success', async () => {
const res = await request(app)
.get(`/fhir/R4/QuestionnaireResponse/${response.id}/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send();
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Bundle');
const batch = res.body as Bundle;
expect(batch.type).toBe('transaction');
expect(batch.entry).toHaveLength(5);
const [patientEntry, agencyEntry, contactEntry, heightEntry, weightEntry] = batch.entry as BundleEntry[];
expect(patientEntry).toMatchObject<BundleEntry>({
fullUrl: expect.stringMatching(/urn:uuid:\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/),
request: { method: 'POST', url: 'Patient' },
resource: {
resourceType: 'Patient',
identifier: [
{
extension: [{ url: 'http://example.com/other/identifier', valueString: 'foo/bar/baz/quux' }],
type: { text: 'National Identifier (IHI)' },
system: 'http://example.org/nhio',
value: '012345',
},
],
name: [
{ given: ['John', 'Jacob'], family: 'Jingleheimer-Schmidt', text: 'John Jacob Jingleheimer-Schmidt' },
{ given: ['Johnny'], family: 'Appleseed', text: 'Johnny Appleseed' },
],
telecom: [{ system: 'phone', use: 'mobile', value: '555-555-5555' }],
gender: 'male',
},
});
const patientRef = patientEntry.fullUrl;
expect(contactEntry).toMatchObject<BundleEntry>({
fullUrl: expect.stringMatching(/urn:uuid:\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/),
request: { method: 'POST', url: 'RelatedPerson' },
resource: {
resourceType: 'RelatedPerson',
patient: { reference: patientRef },
relationship: [{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/v2-0131', code: 'N' }] }],
name: [{ text: 'Nathaniel Cooley Chapman' }],
telecom: [{ system: 'phone', use: 'mobile', value: '123-456-7890' }],
},
});
expect(agencyEntry).toMatchObject<BundleEntry>({
fullUrl: expect.stringMatching(/urn:uuid:\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/),
request: { method: 'POST', url: 'RelatedPerson' },
resource: {
resourceType: 'RelatedPerson',
patient: { reference: patientRef },
relationship: [{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/v2-0131', code: 'F' }] }],
name: [{ text: 'Bureau of Land Management' }],
},
});
expect(heightEntry).toMatchObject<BundleEntry>({
fullUrl: expect.stringMatching(/urn:uuid:\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/),
request: { method: 'POST', url: 'Observation' },
resource: {
resourceType: 'Observation',
status: 'final',
category: [
{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/observation-category', code: 'vital-signs' }] },
],
code: { coding: [{ system: 'http://loinc.org', code: '8302-2', display: 'Body height' }] },
subject: { reference: patientRef },
effectiveDateTime: '2025-09-16T12:34:56.000-07:00',
performer: [{ reference: 'Practitioner/author' }],
valueQuantity: { value: 168, unit: 'cm', system: 'http://unitsofmeasure.org', code: 'cm' },
derivedFrom: [createReference(response)],
issued: '2025-09-16T12:34:56.000-07:00',
},
});
expect(weightEntry).toMatchObject<BundleEntry>({
fullUrl: expect.stringMatching(/urn:uuid:\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/),
request: { method: 'POST', url: 'Observation' },
resource: {
resourceType: 'Observation',
status: 'final',
category: [
{ coding: [{ system: 'http://terminology.hl7.org/CodeSystem/observation-category', code: 'vital-signs' }] },
],
code: { coding: [{ system: 'http://loinc.org', code: '29463-7', display: 'Weight' }] },
subject: { reference: patientRef },
effectiveDateTime: '2025-09-16T12:34:56.000-07:00',
performer: [{ reference: 'Practitioner/author' }],
valueQuantity: { value: 60.8, unit: 'kg', system: 'http://unitsofmeasure.org', code: 'kg' },
derivedFrom: [createReference(response)],
issued: '2025-09-16T12:34:56.000-07:00',
},
});
});
test('Success at type level with passed in resources', async () => {
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: questionnaire },
{ name: 'questionnaire-response', resource: { ...response, questionnaire: undefined } },
],
} satisfies Parameters);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Bundle');
const batch = res.body as Bundle;
expect(batch.type).toBe('transaction');
expect(batch.entry).toHaveLength(5);
expect(batch.entry?.map((e) => e.request)).toStrictEqual([
{ method: 'POST', url: 'Patient' },
{ method: 'POST', url: 'RelatedPerson' },
{ method: 'POST', url: 'RelatedPerson' },
{ method: 'POST', url: 'Observation' },
{ method: 'POST', url: 'Observation' },
]);
});
test('Handles multiple template elements in same array', async () => {
const staticUrl = 'http://example.com/' + randomUUID();
const questionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'draft',
extension: [{ url: allocIdExtension, valueString: 'NewPatientId' }],
contained: [
{
resourceType: 'Patient',
id: 'patTemplate',
telecom: [
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'email')" }],
system: 'email',
use: 'home',
_value: { extension: [{ url: valueExtension, valueString: 'answer.value.first()' }] },
},
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'pager')" }],
system: 'pager',
_value: { extension: [{ url: valueExtension, valueString: 'answer.value.first()' }] },
},
{ system: 'url', value: staticUrl },
{
extension: [{ url: contextExtension, valueString: "item.where(linkId = 'mobile-phone')" }],
system: 'phone',
use: 'mobile',
_value: { extension: [{ url: valueExtension, valueString: 'answer.value.first()' }] },
},
],
},
],
item: [
{
linkId: 'patient',
text: 'Patient',
type: 'group',
extension: [
{
url: extractExtension,
extension: [
{ url: 'template', valueReference: { reference: '#patTemplate' } },
{ url: 'fullUrl', valueString: '%NewPatientId' },
],
},
],
item: [
{ linkId: 'email', text: 'Email', type: 'string', repeats: true },
{ linkId: 'mobile-phone', text: 'Mobile Phone', type: 'string' },
{ linkId: 'pager', text: 'Pager', type: 'string' },
],
},
],
} as unknown as Questionnaire;
const response: QuestionnaireResponse = {
resourceType: 'QuestionnaireResponse',
status: 'completed',
questionnaire: 'https://medplum.com/Questionnaire/extract-patient-minimal-telecom',
author: { reference: 'Practitioner/87a978a6-4844-4b21-aaa3-1abd91f6e8f1' },
authored: '2025-10-10T20:45:06.072Z',
item: [
{
linkId: 'patient',
item: [
{ linkId: 'email', answer: [{ valueString: 'fake.email+1@example.com' }] },
{ linkId: 'email', answer: [{ valueString: 'fake.email+2@example.com' }] },
{ linkId: 'email', answer: [{ valueString: 'fake.email+3@example.com' }] },
{ linkId: 'mobile-phone', answer: [{ valueString: '555-988-1598' }] },
],
},
],
};
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: questionnaire },
{ name: 'questionnaire-response', resource: { ...response, questionnaire: undefined } },
],
} satisfies Parameters);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Bundle');
const batch = res.body as Bundle;
expect(batch.type).toBe('transaction');
expect(batch.entry).toHaveLength(1);
const patient = batch.entry?.[0].resource as Patient;
expect(patient.telecom).toStrictEqual([
{ system: 'email', use: 'home', value: 'fake.email+1@example.com' },
{ system: 'email', use: 'home', value: 'fake.email+2@example.com' },
{ system: 'email', use: 'home', value: 'fake.email+3@example.com' },
{ system: 'url', value: staticUrl },
{ system: 'phone', use: 'mobile', value: '555-988-1598' },
]);
});
test('QuestionnaireResponse must be specified', async () => {
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [{ name: 'questionnaire', resource: questionnaire }],
} satisfies Parameters);
expect(res.status).toBe(400);
expect(res.body).toMatchObject<OperationOutcome>({
resourceType: 'OperationOutcome',
issue: [
expect.objectContaining<OperationOutcomeIssue>({
severity: 'error',
code: 'invalid',
details: { text: expect.stringContaining('QuestionnaireResponse') },
}),
],
});
});
test('Questionnaire must be specified', async () => {
// Questionnaire must be present
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [{ name: 'questionnaire-response', resource: { ...response, questionnaire: undefined } }],
} satisfies Parameters);
expect(res.status).toBe(400);
expect(res.body).toMatchObject<OperationOutcome>({
resourceType: 'OperationOutcome',
issue: [
expect.objectContaining<OperationOutcomeIssue>({
severity: 'error',
code: 'invalid',
expression: ['QuestionnaireResponse.questionnaire'],
}),
],
});
// Specified Questionnaire must be valid
const res2 = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{
name: 'questionnaire-response',
resource: { ...response, questionnaire: 'http://example.com/survey/fake' },
},
],
} satisfies Parameters);
expect(res2.status).toBe(400);
expect(res2.body).toMatchObject<OperationOutcome>({
resourceType: 'OperationOutcome',
issue: [
expect.objectContaining<OperationOutcomeIssue>({
severity: 'error',
code: 'invalid',
expression: ['QuestionnaireResponse.questionnaire'],
}),
],
});
});
test.each<[Expression | undefined, string]>([
// Non-FHIRPath expression is rejected
[{ expression: 'Patient?status=active', language: 'application/x-fhir-query' }, 'requires FHIRPath'],
// Expression must be present
[{ language: 'text/fhirpath' }, 'requires FHIRPath'],
// Invalid type should be rejected
[undefined, 'Invalid extraction context'],
])('Context extensions must contain valid FHIRPath expressions', async (valueExpression, errorMsg) => {
const invalidQuestionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'unknown',
item: [
{
linkId: 'invalid',
type: 'string',
extension: [{ url: contextExtension, valueExpression, valueBoolean: true }],
},
],
};
const response: QuestionnaireResponse = {
resourceType: 'QuestionnaireResponse',
status: 'completed',
item: [{ linkId: 'invalid', answer: [{ valueString: 'hi' }] }],
};
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: invalidQuestionnaire },
{ name: 'questionnaire-response', resource: response },
],
} satisfies Parameters);
expect(res.status).toBe(400);
expect(res.body).toMatchObject<OperationOutcome>({
resourceType: 'OperationOutcome',
issue: [
expect.objectContaining<OperationOutcomeIssue>({
severity: 'error',
code: 'invalid',
expression: ['Questionnaire.item[0].extension[0]'],
details: { text: expect.stringContaining(errorMsg) },
}),
],
});
});
test('Named expression is stored as context variable', async () => {
const questionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'unknown',
extension: [
{ url: contextExtension, valueExpression: { language: 'text/fhirpath', expression: `'bar'`, name: 'foo' } },
{ url: extractExtension, extension: [{ url: 'template', valueReference: { reference: '#org' } }] },
],
contained: [
{
resourceType: 'Organization',
id: 'org',
_name: { extension: [{ url: valueExtension, valueString: '%foo' }] },
} as unknown as Organization,
],
};
const response: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', status: 'completed' };
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: questionnaire },
{ name: 'questionnaire-response', resource: response },
],
} satisfies Parameters);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Bundle');
const batch = res.body as Bundle;
expect(batch.type).toBe('transaction');
expect(batch.entry).toHaveLength(1);
const entry = batch.entry?.[0] as BundleEntry;
expect(entry.resource).toMatchObject<Organization>({ resourceType: 'Organization', name: 'bar' });
});
test('Extraction cannot begin on incorrect element', async () => {
const questionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'unknown',
identifier: [
{
system: 'http://example.com/id',
value: 'extractable',
extension: [
{ url: extractExtension, extension: [{ url: 'template', valueReference: { reference: '#org' } }] },
],
},
],
contained: [{ resourceType: 'Organization', id: 'org' }],
};
const response: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', status: 'completed' };
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: questionnaire },
{ name: 'questionnaire-response', resource: response },
],
} satisfies Parameters);
expect(res.status).toBe(400);
expect(res.body).toMatchObject<OperationOutcome>({
resourceType: 'OperationOutcome',
issue: [
expect.objectContaining<OperationOutcomeIssue>({
severity: 'error',
code: 'invalid',
expression: ['Questionnaire.identifier[0]'],
details: { text: 'Extraction cannot begin on element of type Identifier' },
}),
],
});
});
test('Requires template resource', async () => {
const questionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'unknown',
extension: [
{ url: extractExtension, extension: [{ url: 'template', valueReference: { reference: '#wrongId' } }] },
],
contained: [{ resourceType: 'Organization', id: 'org' }],
};
const response: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', status: 'completed' };
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: questionnaire },
{ name: 'questionnaire-response', resource: response },
],
} satisfies Parameters);
expect(res.status).toBe(400);
expect(res.body).toMatchObject<OperationOutcome>({
resourceType: 'OperationOutcome',
issue: [
expect.objectContaining<OperationOutcomeIssue>({
severity: 'error',
code: 'invalid',
expression: ['Questionnaire.extension[0]'],
details: { text: expect.stringContaining('Missing template resource') },
}),
],
});
});
test('Honors resourceId field', async () => {
const { id } = await repo.createResource<Patient>({ resourceType: 'Patient' });
const questionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'unknown',
extension: [
{
url: extractExtension,
extension: [
{ url: 'template', valueReference: { reference: '#patient' } },
{ url: 'resourceId', valueString: `'${id}'` },
],
},
],
contained: [{ resourceType: 'Patient', id: 'patient', gender: 'unknown' }],
};
const response: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', status: 'completed' };
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: questionnaire },
{ name: 'questionnaire-response', resource: response },
],
} satisfies Parameters);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Bundle');
const batch = res.body as Bundle;
expect(batch.type).toBe('transaction');
expect(batch.entry).toHaveLength(1);
// Actually send the batch to ensure it correctly updates the intended resource
const res2 = await request(app)
.post(`/fhir/R4/`)
.set('Authorization', 'Bearer ' + accessToken)
.send(batch);
expect(res2.status).toBe(200);
const patient = await repo.readResource<Patient>('Patient', id);
expect(patient.gender).toBe('unknown');
});
test('Keeps default value for primitive field', async () => {
const q: Questionnaire = {
resourceType: 'Questionnaire',
status: 'draft',
contained: [
{
resourceType: 'Patient',
id: 'patientTemplate',
telecom: [
{
extension: [
{
url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtractContext',
valueString: "item.where(linkId = 'phone')",
},
],
system: 'phone',
_value: {
extension: [
{
url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtractValue',
valueString: "item.where(linkId = 'number').answer.value.first()",
},
],
},
use: 'home',
_use: {
extension: [
{
url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtractValue',
valueString: "item.where(linkId = 'use').answer.value.first().code",
},
],
},
} as unknown as ContactPoint,
],
},
],
extension: [
{
url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-templateExtract',
extension: [{ url: 'template', valueReference: { reference: '#patientTemplate' } }],
},
],
item: [
{
linkId: 'phone',
type: 'group',
repeats: true,
required: true,
item: [
{ linkId: 'number', type: 'string', required: true },
{ linkId: 'use', type: 'choice', answerValueSet: 'http://hl7.org/fhir/ValueSet/contact-point-use' },
],
},
],
//...
};
const r: QuestionnaireResponse = {
resourceType: 'QuestionnaireResponse',
status: 'completed',
item: [
{
linkId: 'phone',
item: [{ linkId: 'number', answer: [{ valueString: '+1 555 555 5555' }] }],
},
{
linkId: 'phone',
item: [
{ linkId: 'number', answer: [{ valueString: '+1 800 555 5555' }] },
{ linkId: 'use', answer: [{ valueCoding: { code: 'work' } }] },
],
},
],
};
const res = await request(app)
.post(`/fhir/R4/QuestionnaireResponse/$extract`)
.set('Authorization', 'Bearer ' + accessToken)
.send({
resourceType: 'Parameters',
parameter: [
{ name: 'questionnaire', resource: q },
{ name: 'questionnaire-response', resource: r },
],
} satisfies Parameters);
expect(res.status).toBe(200);
expect(res.body.resourceType).toBe('Bundle');
const batch = res.body as Bundle;
expect(batch.type).toBe('transaction');
expect(batch.entry).toHaveLength(1);
const patient = batch.entry?.[0]?.resource as Patient;
expect(patient.telecom?.map((t) => t.use)).toStrictEqual(['home', 'work']);
// Actually send the batch to ensure it correctly updates the intended resource
const res2 = await request(app)
.post(`/fhir/R4/`)
.set('Authorization', 'Bearer ' + accessToken)
.send(batch);
expect(res2.status).toBe(200);
const result = res2.body as Bundle;
expect(result.entry?.map((e) => e.response?.status)).toStrictEqual(['201']);
});
});