diff --git a/service/.env.test b/service/.env.test index a8b8e9c4..ff938da8 100644 --- a/service/.env.test +++ b/service/.env.test @@ -1,2 +1,3 @@ VSAC_API_KEY="example-api-key" -AUTHORING=true \ No newline at end of file +AUTHORING=true +DATABASE_URL='mongodb://localhost:27017/measure-repository?replicaSet=rs0' \ No newline at end of file diff --git a/service/jest-mongodb-config.js b/service/jest-mongodb-config.js index 88ec6f91..80efdda5 100644 --- a/service/jest-mongodb-config.js +++ b/service/jest-mongodb-config.js @@ -1,12 +1,14 @@ module.exports = { mongodbMemoryServerOptions: { binary: { - verison: '6.0.1', + version: '6.0.1', skipMD5: true }, autoStart: false, - instance: {} - }, - - useSharedDBForAllJestWorkers: false + instance: {}, + replSet: { + count: 3, + storageEngine: 'wiredTiger' + } + } }; diff --git a/service/package.json b/service/package.json index 25c2a83d..1797c030 100644 --- a/service/package.json +++ b/service/package.json @@ -22,7 +22,7 @@ "dev:watch": "nodemon ./src/index.ts", "dev:clean": "npm run db:reset && npm run dev", "start": "node ./dist/index.js", - "test": "jest", + "test": "jest --runInBand --silent", "test:watch": "jest --watch" }, "devDependencies": { @@ -57,4 +57,4 @@ "uuid": "^9.0.0", "zod": "^3.20.2" } -} \ No newline at end of file +} diff --git a/service/src/config/serverConfig.ts b/service/src/config/serverConfig.ts index 217fdd99..5bf86acd 100644 --- a/service/src/config/serverConfig.ts +++ b/service/src/config/serverConfig.ts @@ -85,6 +85,30 @@ export const serverConfig: ServerConfig = { route: '/:id/$data-requirements', method: 'POST', reference: 'http://hl7.org/fhir/us/cqfmeasures/OperationDefinition/Measure-data-requirements' + }, + { + name: 'draft', + route: '/$draft', + method: 'GET', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' + }, + { + name: 'draft', + route: '/$draft', + method: 'POST', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' + }, + { + name: 'draft', + route: '/:id/$draft', + method: 'GET', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' + }, + { + name: 'draft', + route: '/:id/$draft', + method: 'POST', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' } ] }, @@ -139,6 +163,30 @@ export const serverConfig: ServerConfig = { route: '/:id/$data-requirements', method: 'POST', reference: 'http://hl7.org/fhir/us/cqfmeasures/OperationDefinition/Library-data-requirements' + }, + { + name: 'draft', + route: '/$draft', + method: 'GET', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' + }, + { + name: 'draft', + route: '/$draft', + method: 'POST', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' + }, + { + name: 'draft', + route: '/:id/$draft', + method: 'GET', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' + }, + { + name: 'draft', + route: '/:id/$draft', + method: 'POST', + reference: 'https://hl7.org/fhir/uv/crmi/STU1/OperationDefinition-crmi-draft.html' } ] } diff --git a/service/src/db/dbOperations.ts b/service/src/db/dbOperations.ts index af184f06..d9687c53 100644 --- a/service/src/db/dbOperations.ts +++ b/service/src/db/dbOperations.ts @@ -1,6 +1,7 @@ import { FhirResourceType, loggers } from '@projecttacoma/node-fhir-server-core'; import { Filter } from 'mongodb'; import { Connection } from './Connection'; +import { ArtifactResourceType, FhirArtifact } from '../types/service-types'; const logger = loggers.get('default'); @@ -12,6 +13,18 @@ export async function findResourceById(id: string, return collection.findOne({ id: id }, { projection: { _id: 0, _dataRequirements: 0 } }); } +/** + * Retrieves the artifact of the given type with the given url and version + */ +export async function findArtifactByUrlAndVersion( + url: string, + version: string, + resourceType: ArtifactResourceType +) { + const collection = Connection.db.collection(resourceType); + return collection.findOne({ url, version }, { projection: { _id: 0 } }); +} + /** * searches the database and returns an array of all resources of the given type that match the given query */ @@ -112,3 +125,29 @@ export async function deleteResource(id: string, resourceType: string) { } return { id, deleted: false }; } + +/** + * Drafts an active parent artifact and all of its children (if applicable) in a batch + */ +export async function batchDraft(drafts: FhirArtifact[]) { + let error = null; + const results: FhirArtifact[] = []; + const draftSession = Connection.connection?.startSession(); + try { + await draftSession?.withTransaction(async () => { + for (const draft of drafts) { + const collection = await Connection.db.collection(draft.resourceType); + await collection.insertOne(draft as any, { session: draftSession }); + results.push(draft); + } + }); + console.log('Batch draft transaction committed.'); + } catch (err) { + console.log('Batch draft transaction failed: ' + err); + error = err; + } finally { + await draftSession?.endSession(); + } + if (error) throw error; + return results; +} diff --git a/service/src/requestSchemas.ts b/service/src/requestSchemas.ts index aa5d30c0..5d345ece 100644 --- a/service/src/requestSchemas.ts +++ b/service/src/requestSchemas.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { BadRequestError, NotImplementedError } from './util/errorUtils'; const DATE_REGEX = /([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)/; +const VERSION_REGEX = /^\d+\.\d+\.\d+(\.\d+|)$/; // Operation Definition: http://hl7.org/fhir/us/cqfmeasures/STU4/OperationDefinition-cqfm-package.html const UNSUPPORTED_PACKAGE_ARGS = [ @@ -52,6 +53,8 @@ const UNSUPPORTED_LIBRARY_SEARCH_ARGS = [...UNSUPPORTED_CORE_SEARCH_ARGS, 'conte const hasIdentifyingInfo = (args: Record) => args.id || args.url || args.identifier; +const idAndVersion = (args: Record) => args.id && args.version; + /** * Returns a function that checks if any unsupported params are present, then runs the * other passed in functions in sequence. Each catchFunction is expected to check for @@ -90,6 +93,20 @@ export function catchMissingIdentifyingInfo(val: Record, ctx: z.Ref } } +/** + * Checks that id and version are included. Adds an issue to the ctx + * that triggers a BadRequest to be thrown if not. + */ +export function catchMissingIdAndVersion(val: Record, ctx: z.RefinementCtx) { + if (!idAndVersion(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + params: { serverErrorCode: constants.ISSUE.CODE.REQUIRED }, + message: 'Must provide id and version' + }); + } +} + /** * For searches, checks that the version only appears in combination with a url. Adds an * issue to the ctx that triggers a BadRequest to be thrown if url is not specified when version @@ -110,6 +127,12 @@ const stringToBool = z .transform(x => (typeof x === 'boolean' ? x : x === 'true')); const stringToNumber = z.coerce.number(); const checkDate = z.string().regex(DATE_REGEX, 'Invalid FHIR date'); +const checkVersion = z.string().regex(VERSION_REGEX, 'Invalid Semantic Version'); + +export const DraftArgs = z + .object({ id: z.string(), version: checkVersion }) + .strict() + .superRefine(catchInvalidParams([hasIdentifyingInfo, catchMissingIdAndVersion])); export const IdentifyingParameters = z .object({ diff --git a/service/src/services/LibraryService.ts b/service/src/services/LibraryService.ts index d291d3e3..15e11e44 100644 --- a/service/src/services/LibraryService.ts +++ b/service/src/services/LibraryService.ts @@ -1,5 +1,6 @@ import { loggers, RequestArgs, RequestCtx } from '@projecttacoma/node-fhir-server-core'; import { + batchDraft, createResource, deleteResource, findDataRequirementsWithQuery, @@ -9,9 +10,20 @@ import { findResourcesWithQuery, updateResource } from '../db/dbOperations'; -import { LibrarySearchArgs, LibraryDataRequirementsArgs, PackageArgs, parseRequestSchema } from '../requestSchemas'; +import { + LibrarySearchArgs, + LibraryDataRequirementsArgs, + PackageArgs, + parseRequestSchema, + DraftArgs +} from '../requestSchemas'; import { Service } from '../types/service'; -import { createLibraryPackageBundle, createSearchsetBundle, createSummarySearchsetBundle } from '../util/bundleUtils'; +import { + createBatchResponseBundle, + createLibraryPackageBundle, + createSearchsetBundle, + createSummarySearchsetBundle +} from '../util/bundleUtils'; import { BadRequestError, ResourceNotFoundError } from '../util/errorUtils'; import { getMongoQueryFromRequest } from '../util/queryUtils'; import { @@ -22,13 +34,17 @@ import { checkExpectedResourceType, checkFieldsForCreate, checkFieldsForUpdate, - checkFieldsForDelete + checkFieldsForDelete, + checkExistingArtifact, + checkIsOwned, + checkDraft } from '../util/inputUtils'; import { v4 as uuidv4 } from 'uuid'; import { Calculator } from 'fqm-execution'; const logger = loggers.get('default'); import { Filter } from 'mongodb'; -import { FhirLibraryWithDR } from '../types/service-types'; +import { CRMILibrary, FhirLibraryWithDR } from '../types/service-types'; +import { getChildren, modifyResourcesForDraft } from '../util/serviceUtils'; /* * Implementation of a service for the `Library` resource @@ -144,6 +160,54 @@ export class LibraryService implements Service { return deleteResource(args.id, 'Library'); } + /** + * result of sending a POST or GET request to: + * {BASE_URL}/4_0_1/Library/$draft or {BASE_URL}/4_0_1/Library/[id]/$draft + * drafts a new version of an existing Library artifact in active status, + * as well as for all resource it is composed of + * requires id and version parameters + */ + async draft(args: RequestArgs, { req }: RequestCtx) { + logger.info(`${req.method} ${req.path}`); + + // checks that the authoring environment variable is true + checkDraft(); + + if (req.method === 'POST') { + const contentType: string | undefined = req.headers['content-type']; + checkContentTypeHeader(contentType); + } + + const params = gatherParams(req.query, args.resource); + validateParamIdSource(req.params.id, params.id); + + const query = extractIdentificationForQuery(args, params); + + const parsedParams = parseRequestSchema({ ...params, ...query }, DraftArgs); + + const activeLibrary = await findResourceById(parsedParams.id, 'Library'); + if (!activeLibrary) { + throw new ResourceNotFoundError(`No resource found in collection: Library, with id: ${args.id}`); + } + checkIsOwned(activeLibrary, 'Child artifacts cannot be directly drafted'); + + await checkExistingArtifact(activeLibrary.url, parsedParams.version, 'Library'); + + // recursively get any child artifacts from the artifact if they exist + const children = activeLibrary.relatedArtifact ? await getChildren(activeLibrary.relatedArtifact) : []; + + const draftArtifacts = await modifyResourcesForDraft( + [activeLibrary, ...(await Promise.all(children))], + params.version + ); + + // now we want to batch insert the parent Library artifact and any of its children + const newDrafts = await batchDraft(draftArtifacts); + + // we want to return a Bundle containing the created artifacts + return createBatchResponseBundle(newDrafts); + } + /** * result of sending a POST or GET request to: * {BASE_URL}/4_0_1/Library/$cqfm.package or {BASE_URL}/4_0_1/Library/:id/$cqfm.package diff --git a/service/src/services/MeasureService.ts b/service/src/services/MeasureService.ts index 1cfa3466..39a05fbb 100644 --- a/service/src/services/MeasureService.ts +++ b/service/src/services/MeasureService.ts @@ -1,5 +1,6 @@ import { loggers, RequestArgs, RequestCtx } from '@projecttacoma/node-fhir-server-core'; import { + batchDraft, createResource, deleteResource, findDataRequirementsWithQuery, @@ -10,7 +11,12 @@ import { updateResource } from '../db/dbOperations'; import { Service } from '../types/service'; -import { createMeasurePackageBundle, createSearchsetBundle, createSummarySearchsetBundle } from '../util/bundleUtils'; +import { + createBatchResponseBundle, + createMeasurePackageBundle, + createSearchsetBundle, + createSummarySearchsetBundle +} from '../util/bundleUtils'; import { BadRequestError, ResourceNotFoundError } from '../util/errorUtils'; import { getMongoQueryFromRequest } from '../util/queryUtils'; import { @@ -21,13 +27,23 @@ import { checkExpectedResourceType, checkFieldsForCreate, checkFieldsForUpdate, - checkFieldsForDelete + checkFieldsForDelete, + checkDraft, + checkExistingArtifact, + checkIsOwned } from '../util/inputUtils'; import { Calculator } from 'fqm-execution'; -import { MeasureSearchArgs, MeasureDataRequirementsArgs, PackageArgs, parseRequestSchema } from '../requestSchemas'; +import { + MeasureSearchArgs, + MeasureDataRequirementsArgs, + PackageArgs, + parseRequestSchema, + DraftArgs +} from '../requestSchemas'; import { v4 as uuidv4 } from 'uuid'; import { Filter } from 'mongodb'; -import { FhirLibraryWithDR } from '../types/service-types'; +import { CRMIMeasure, FhirLibraryWithDR } from '../types/service-types'; +import { getChildren, modifyResourcesForDraft } from '../util/serviceUtils'; const logger = loggers.get('default'); @@ -146,6 +162,54 @@ export class MeasureService implements Service { return deleteResource(args.id, 'Measure'); } + /** + * result of sending a POST or GET request to: + * {BASE_URL}/4_0_1/Measure/$draft or {BASE_URL}/4_0_1/Measure/[id]/$draft + * drafts a new version of an existing Measure artifact in active status, + * as well as for all resources it is composed of + * requires id and version parameters + */ + async draft(args: RequestArgs, { req }: RequestCtx) { + logger.info(`${req.method} ${req.path}`); + + // checks that the authoring environment variable is true + checkDraft(); + + if (req.method === 'POST') { + const contentType: string | undefined = req.headers['content-type']; + checkContentTypeHeader(contentType); + } + + const params = gatherParams(req.query, args.resource); + validateParamIdSource(req.params.id, params.id); + + const query = extractIdentificationForQuery(args, params); + + const parsedParams = parseRequestSchema({ ...params, ...query }, DraftArgs); + + const activeMeasure = await findResourceById(parsedParams.id, 'Measure'); + if (!activeMeasure) { + throw new ResourceNotFoundError(`No resource found in collection: Measure, with id: ${args.id}`); + } + checkIsOwned(activeMeasure, 'Child artifacts cannot be directly drafted.'); + + await checkExistingArtifact(activeMeasure.url, parsedParams.version, 'Measure'); + + // recursively get any child artifacts from the artifact if they exist + const children = activeMeasure.relatedArtifact ? await getChildren(activeMeasure.relatedArtifact) : []; + + const draftArtifacts = await modifyResourcesForDraft( + [activeMeasure, ...(await Promise.all(children))], + params.version + ); + + // now we want to batch insert the parent Measure artifact and any of its children + const newDrafts = await batchDraft(draftArtifacts); + + // we want to return a Bundle containing the created artifacts + return createBatchResponseBundle(newDrafts); + } + /** * result of sending a POST or GET request to: * {BASE_URL}/4_0_1/Measure/$cqfm.package or {BASE_URL}/4_0_1/Measure/:id/$cqfm.package diff --git a/service/src/types/service-types.ts b/service/src/types/service-types.ts index 56f2f175..e58f3a22 100644 --- a/service/src/types/service-types.ts +++ b/service/src/types/service-types.ts @@ -3,3 +3,23 @@ import { Filter } from 'mongodb'; export type FhirLibraryWithDR = fhir4.Library & { _dataRequirements?: Filter; }; + +export interface CRMIMeasure extends fhir4.Measure { + id: string; + url: string; + version: string; + title: string; + description: string; +} + +export interface CRMILibrary extends fhir4.Library { + id: string; + url: string; + version: string; + title: string; + description: string; +} + +// type representing the resource types that are relevant to the Measure Repository Service +export type FhirArtifact = CRMIMeasure | CRMILibrary; +export type ArtifactResourceType = FhirArtifact['resourceType']; diff --git a/service/src/util/bundleUtils.ts b/service/src/util/bundleUtils.ts index dc36a9c1..5f6aeb7f 100644 --- a/service/src/util/bundleUtils.ts +++ b/service/src/util/bundleUtils.ts @@ -8,6 +8,7 @@ import { PackageArgs } from '../requestSchemas'; import fs from 'fs'; import { getMongoQueryFromRequest } from './queryUtils'; import { z } from 'zod'; +import { FhirArtifact } from '../types/service-types'; const logger = loggers.get('default'); @@ -26,6 +27,24 @@ export function createSearchsetBundle(entries: T[] }; } +/** + * Takes in an array of FHIR resources and creates a FHIR batch-response Bundle with the + * created resources as entries + * TODO: should this response bundle be of type batch-response or something else? + * The CRMI IG only says the following: The Bundle result containing the new resource(s) + * https://build.fhir.org/ig/HL7/crmi-ig/OperationDefinition-crmi-draft.html + */ +export function createBatchResponseBundle(entries: T[]): fhir4.Bundle { + return { + resourceType: 'Bundle', + meta: { lastUpdated: new Date().toISOString() }, + id: v4(), + type: 'batch-response', + total: entries.length, + entry: entries.map(e => ({ resource: e })) + }; +} + /** * * Takes in a number of FHIR resources and creates a FHIR searchset Bundle without entries diff --git a/service/src/util/inputUtils.ts b/service/src/util/inputUtils.ts index 5469982c..7c50c8c0 100644 --- a/service/src/util/inputUtils.ts +++ b/service/src/util/inputUtils.ts @@ -2,6 +2,8 @@ import { RequestArgs, RequestQuery, FhirResourceType } from '@projecttacoma/node import { Filter } from 'mongodb'; import { BadRequestError } from './errorUtils'; import _ from 'lodash'; +import { ArtifactResourceType } from '../types/service-types'; +import { findArtifactByUrlAndVersion } from '../db/dbOperations'; /* * Gathers parameters from both the query and the FHIR parameter request body resource @@ -92,6 +94,31 @@ export function checkFieldsForCreate(resource: fhir4.Measure | fhir4.Library) { } } +export function checkIsOwned(resource: fhir4.Measure | fhir4.Library, message: string) { + if ( + resource.extension?.some( + e => e.url === 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned' && e.valueBoolean === true + ) + ) { + throw new BadRequestError(message); + } +} + +export function checkDraft() { + if (process.env.AUTHORING === 'false') { + throw new BadRequestError('The Publishable repository service does not support the $draft operation.'); + } +} + +export async function checkExistingArtifact(url: string, version: string, resourceType: ArtifactResourceType) { + const existingArtifact = await findArtifactByUrlAndVersion(url, version, resourceType); + if (existingArtifact) { + throw new BadRequestError( + `A ${resourceType} artifact with url ${url} and version ${version} already exists in the database` + ); + } +} + export function checkFieldsForUpdate( resource: fhir4.Measure | fhir4.Library, oldResource: fhir4.Measure | fhir4.Library diff --git a/service/src/util/serviceUtils.ts b/service/src/util/serviceUtils.ts new file mode 100644 index 00000000..fda8b382 --- /dev/null +++ b/service/src/util/serviceUtils.ts @@ -0,0 +1,89 @@ +import { findArtifactByUrlAndVersion } from '../db/dbOperations'; +import { ArtifactResourceType, FhirArtifact } from '../types/service-types'; +import { v4 as uuidv4 } from 'uuid'; +import { BadRequestError } from './errorUtils'; + +export type ChildArtifactInfo = { + resourceType: 'Measure' | 'Library'; + url: string; + version: string; +}; + +/** + * Helper function that takes an array of related artifacts and recursively + * finds all of the child artifacts (related artifacts whose type is composed-of + * and has the isOwned extension) + */ +export async function getChildren(relatedArtifacts: fhir4.RelatedArtifact[]) { + let children: FhirArtifact[] = []; + + for (const ra of relatedArtifacts) { + if ( + ra.type === 'composed-of' && + ra.resource && + ra.extension?.some( + e => e.url === 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned' && e.valueBoolean === true + ) + ) { + let resourceType: ArtifactResourceType; + if (ra.resource.includes('Measure')) { + resourceType = 'Measure'; + } else { + resourceType = 'Library'; + } + + const [url, version] = ra.resource.split('|'); + + // search for the related artifact in the published measure repository + const childArtifact = (await findArtifactByUrlAndVersion(url, version, resourceType)) as FhirArtifact; + if (!childArtifact) { + throw new Error('No artifacts found in search'); + } + + children.push(childArtifact); + + if (childArtifact.relatedArtifact) { + const nested = await getChildren(childArtifact.relatedArtifact); + children = children.concat(nested); + } + } + } + return children; +} + +/** + * Helper function that takes an active artifact and returns it with a new id, + * draft status, and version (from the $draft parameter). It also removes + * effectivePeriod, approvalDate, and any extensions which are only valid for + * active artifacts + */ +export async function modifyResourcesForDraft(artifacts: FhirArtifact[], version: string) { + for (const artifact of artifacts) { + artifact.id = uuidv4(); + artifact.status = 'draft'; + artifact.version = version; + if (artifact.relatedArtifact) { + artifact.relatedArtifact.forEach(ra => { + if ( + ra.type === 'composed-of' && + ra.resource && + ra.extension?.some( + e => e.url === 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned' && e.valueBoolean === true + ) + ) { + const url = ra.resource.split('|')[0]; + ra.resource = url + '|' + version; + } + }); + } + + const checkExisting = await findArtifactByUrlAndVersion(artifact.url, version, artifact.resourceType); + if (checkExisting) { + throw new BadRequestError( + `A ${artifact.resourceType} with url ${artifact.url} and version ${artifact.version} already exists in the database.` + ); + } + } + + return artifacts; +} diff --git a/service/test/services/LibraryService.test.ts b/service/test/services/LibraryService.test.ts index 84514d8c..78a3af87 100644 --- a/service/test/services/LibraryService.test.ts +++ b/service/test/services/LibraryService.test.ts @@ -3,6 +3,7 @@ import { serverConfig } from '../../src/config/serverConfig'; import { cleanUpTestDatabase, setupTestDatabase, createTestResource } from '../utils'; import supertest from 'supertest'; import { Calculator } from 'fqm-execution'; +import { CRMILibrary } from '../../src/types/service-types'; let server: Server; @@ -125,6 +126,45 @@ const LIBRARY_WITH_SAME_SYSTEM2: fhir4.Library = { status: 'active' }; +const PARENT_ACTIVE_LIBRARY: CRMILibrary = { + resourceType: 'Library', + type: { coding: [{ code: 'logic-library' }] }, + status: 'active', + id: 'parentLibrary', + relatedArtifact: [ + { + type: 'composed-of', + resource: 'http://child-library.com|1', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ] + } + ], + url: 'http://parent-library.com', + version: '1', + description: 'Example description', + title: 'Parent Active Library' +}; + +const CHILD_ACTIVE_LIBRARY: CRMILibrary = { + resourceType: 'Library', + id: 'childLibrary', + type: { coding: [{ code: 'logic-library' }] }, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ], + status: 'active', + version: '1', + url: 'http://child-library.com', + description: 'Example description', + title: 'Child Active Library' +}; describe('LibraryService', () => { beforeAll(() => { @@ -139,7 +179,9 @@ describe('LibraryService', () => { LIBRARY_WITH_IDENTIFIER_SYSTEM, LIBRARY_WITH_IDENTIFIER_SYSTEM_AND_VALUE, LIBRARY_WITH_SAME_SYSTEM, - LIBRARY_WITH_SAME_SYSTEM2 + LIBRARY_WITH_SAME_SYSTEM2, + PARENT_ACTIVE_LIBRARY, + CHILD_ACTIVE_LIBRARY ]); }); @@ -190,7 +232,7 @@ describe('LibraryService', () => { .expect(200) .then(response => { expect(response.body.resourceType).toEqual('Bundle'); - expect(response.body.total).toEqual(7); + expect(response.body.total).toEqual(9); expect(response.body.entry).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -207,7 +249,7 @@ describe('LibraryService', () => { it('returns 200 and correct searchset bundle with only id element when query matches single resource', async () => { await supertest(server.app) .get('/4_0_1/Library') - .query({ _elements: 'id', status: 'active', url: 'http://example.com', id: 'testWithUrl'}) + .query({ _elements: 'id', status: 'active', url: 'http://example.com', id: 'testWithUrl' }) .set('Accept', 'application/json+fhir') .expect(200) .then(response => { @@ -225,7 +267,7 @@ describe('LibraryService', () => { .expect(200) .then(response => { expect(response.body.resourceType).toEqual('Bundle'); - expect(response.body.total).toEqual(7); + expect(response.body.total).toEqual(9); expect(response.body.entry).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -244,7 +286,7 @@ describe('LibraryService', () => { .expect(200) .then(response => { expect(response.body.resourceType).toEqual('Bundle'); - expect(response.body.total).toEqual(9); + expect(response.body.total).toEqual(11); expect(response.body.entry).toBeUndefined; }); }); @@ -315,10 +357,12 @@ describe('LibraryService', () => { id: 'publishable-active', status: 'retired', title: 'updated', - type: { coding: [{ code: 'logic-library' }], - url: 'http://example.com', - version: '1', - description: 'Sample description' } + type: { + coding: [{ code: 'logic-library' }], + url: 'http://example.com', + version: '1', + description: 'Sample description' + } }) .set('content-type', 'application/json+fhir') .expect(400); @@ -404,10 +448,15 @@ describe('LibraryService', () => { it('revise: returns 400 when status changes', async () => { await supertest(server.app) .put('/4_0_1/Library/exampleId') - .send({ resourceType: 'Library', id: 'exampleId', status: 'active', title: 'updated', + .send({ + resourceType: 'Library', + id: 'exampleId', + status: 'active', + title: 'updated', url: 'http://example.com', version: '1', - description: 'Sample description' }) + description: 'Sample description' + }) .set('content-type', 'application/json+fhir') .expect(400); }); @@ -509,6 +558,46 @@ describe('LibraryService', () => { }); }); + describe('$draft', () => { + it('returns 200 status with a Bundle result containing the created parent Library artifact and any children it has', async () => { + await supertest(server.app) + .get('/4_0_1/Library/$draft') + .query({ id: 'parentLibrary', version: '1.0.0.1' }) + .set('Accept', 'application/json+fhir') + .expect(200); + }); + + it('returns 200 status with a Bundle result containing the created parent Library artifact and any children it has for GET /Library/:id/$draft', async () => { + await supertest(server.app) + .get('/4_0_1/Library/parentLibrary/$draft') + .query({ version: '1.0.0.2' }) + .set('Accept', 'application/json+fhir') + .expect(200); + }); + + it('returns 200 status with a Bundle result containing the created parent Library artifact and any children it has for POST /Library/$draft', async () => { + await supertest(server.app) + .post('/4_0_1/Library/$draft') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'id', valueString: 'parentLibrary' }, + { name: 'version', valueString: '1.0.0.3' } + ] + }) + .set('content-type', 'application/fhir+json') + .expect(200); + }); + + it('returns 200 status with a Bundle result containing the created parent Library artifact and any children it has for POST /Library/:id/$draft', async () => { + await supertest(server.app) + .post('/4_0_1/Library/parentLibrary/$draft') + .send({ resourceType: 'Parameters', parameter: [{ name: 'version', valueString: '1.0.0.4' }] }) + .set('content-type', 'application/fhir+json') + .expect(200); + }); + }); + describe('$cqfm.package', () => { it('returns a Bundle including the Library when the Library has no dependencies and id passed through args', async () => { await supertest(server.app) diff --git a/service/test/services/MeasureService.test.ts b/service/test/services/MeasureService.test.ts index 65c33e57..026e43cb 100644 --- a/service/test/services/MeasureService.test.ts +++ b/service/test/services/MeasureService.test.ts @@ -3,6 +3,7 @@ import { serverConfig } from '../../src/config/serverConfig'; import { cleanUpTestDatabase, createTestResource, setupTestDatabase } from '../utils'; import supertest from 'supertest'; import { Calculator } from 'fqm-execution'; +import { CRMILibrary, CRMIMeasure } from '../../src/types/service-types'; let server: Server; // boiler plate required fields @@ -117,6 +118,45 @@ const LIBRARY_WITH_DEPS: fhir4.Library = { ] }; +const PARENT_ACTIVE_MEASURE: CRMIMeasure = { + resourceType: 'Measure', + status: 'active', + id: 'parentMeasure', + relatedArtifact: [ + { + type: 'composed-of', + resource: 'http://child-library.com|1', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ] + } + ], + url: 'http://parent-measure.com', + version: '1', + description: 'Example description', + title: 'Parent Active Measure' +}; + +const CHILD_ACTIVE_LIBRARY: CRMILibrary = { + resourceType: 'Library', + id: 'childLibrary', + type: { coding: [{ code: 'logic-library' }] }, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ], + status: 'active', + version: '1', + url: 'http://child-library.com', + description: 'Example description', + title: 'Child Active Library' +}; + describe('MeasureService', () => { beforeAll(() => { server = initialize(serverConfig); @@ -130,7 +170,9 @@ describe('MeasureService', () => { LIBRARY_WITH_DEPS, MEASURE_WITH_IDENTIFIER_VALUE_ROOT_LIB, MEASURE_WITH_IDENTIFIER_SYSTEM_AND_VALUE_ROOT_LIB, - MEASURE_WITH_IDENTIFIER_SYSTEM_ROOT_LIB + MEASURE_WITH_IDENTIFIER_SYSTEM_ROOT_LIB, + PARENT_ACTIVE_MEASURE, + CHILD_ACTIVE_LIBRARY ]); }); @@ -181,7 +223,7 @@ describe('MeasureService', () => { .expect(200) .then(response => { expect(response.body.resourceType).toEqual('Bundle'); - expect(response.body.total).toEqual(7); + expect(response.body.total).toEqual(8); expect(response.body.entry).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -216,7 +258,7 @@ describe('MeasureService', () => { .expect(200) .then(response => { expect(response.body.resourceType).toEqual('Bundle'); - expect(response.body.total).toEqual(7); + expect(response.body.total).toEqual(8); expect(response.body.entry).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -235,7 +277,7 @@ describe('MeasureService', () => { .expect(200) .then(response => { expect(response.body.resourceType).toEqual('Bundle'); - expect(response.body.total).toEqual(7); + expect(response.body.total).toEqual(8); expect(response.body.entry).toBeUndefined; }); }); @@ -351,13 +393,11 @@ describe('MeasureService', () => { describe('update', () => { beforeAll(() => { createTestResource( - { resourceType: 'Measure', id: 'exampleId-active', status: 'active', - ...MEASURE_BASE }, + { resourceType: 'Measure', id: 'exampleId-active', status: 'active', ...MEASURE_BASE }, 'Measure' ); return createTestResource( - { resourceType: 'Measure', id: 'exampleId', status: 'draft', - ...MEASURE_BASE }, + { resourceType: 'Measure', id: 'exampleId', status: 'draft', ...MEASURE_BASE }, 'Measure' ); }); @@ -365,10 +405,15 @@ describe('MeasureService', () => { it('revise: returns 200 when provided correct headers and a FHIR Measure whose id is in the database', async () => { await supertest(server.app) .put('/4_0_1/Measure/exampleId') - .send({ resourceType: 'Measure', id: 'exampleId', status: 'draft', title: 'updated', + .send({ + resourceType: 'Measure', + id: 'exampleId', + status: 'draft', + title: 'updated', url: 'http://example.com', version: '1', - description: 'Sample description' }) + description: 'Sample description' + }) .set('content-type', 'application/json+fhir') .expect(200) .then(response => { @@ -379,10 +424,15 @@ describe('MeasureService', () => { it('revise: returns 400 when status changes', async () => { await supertest(server.app) .put('/4_0_1/Measure/exampleId') - .send({ resourceType: 'Measure', id: 'exampleId', status: 'active', title: 'updated', + .send({ + resourceType: 'Measure', + id: 'exampleId', + status: 'active', + title: 'updated', url: 'http://example.com', version: '1', - description: 'Sample description' }) + description: 'Sample description' + }) .set('content-type', 'application/json+fhir') .expect(400); }); @@ -390,8 +440,7 @@ describe('MeasureService', () => { it('retire: returns 200 when provided updated status for retiring', async () => { await supertest(server.app) .put('/4_0_1/Measure/exampleId-active') - .send({ resourceType: 'Measure', id: 'exampleId-active', status: 'retired', - ...MEASURE_BASE}) + .send({ resourceType: 'Measure', id: 'exampleId-active', status: 'retired', ...MEASURE_BASE }) .set('content-type', 'application/json+fhir') .expect(200) .then(response => { @@ -480,6 +529,46 @@ describe('MeasureService', () => { }); }); + describe('$draft', () => { + it('returns 200 status with a Bundle result containing the created parent Measure artifact and any children it has for GET /Measure/$draft', async () => { + await supertest(server.app) + .get('/4_0_1/Measure/$draft') + .query({ id: 'parentMeasure', version: '1.0.0.1' }) + .set('Accept', 'application/json+fhir') + .expect(200); + }); + + it('returns 200 status with a Bundle result containing the created parent Measure artifact and any children it has for GET /Measure/:id/$draft', async () => { + await supertest(server.app) + .get('/4_0_1/Measure/parentMeasure/$draft') + .query({ version: '1.0.0.2' }) + .set('Accept', 'application/json+fhir') + .expect(200); + }); + + it('returns 200 status with a Bundle result containing the created parent Measure artifact and any children it has for POST /Measure/$draft', async () => { + await supertest(server.app) + .post('/4_0_1/Measure/$draft') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'id', valueString: 'parentMeasure' }, + { name: 'version', valueString: '1.0.0.3' } + ] + }) + .set('content-type', 'application/fhir+json') + .expect(200); + }); + + it('returns 200 status with a Bundle result containing the created parent Measure artifact and any children it has for POST /Measure/:id/$draft', async () => { + await supertest(server.app) + .post('/4_0_1/Measure/parentMeasure/$draft') + .send({ resourceType: 'Parameters', parameter: [{ name: 'version', valueString: '1.0.0.4' }] }) + .set('content-type', 'application/fhir+json') + .expect(200); + }); + }); + describe('$cqfm.package', () => { it('returns a Bundle including the root lib and Measure when root lib has no dependencies and id passed through args', async () => { await supertest(server.app) diff --git a/service/test/util/serviceUtils.test.ts b/service/test/util/serviceUtils.test.ts new file mode 100644 index 00000000..fa2a984d --- /dev/null +++ b/service/test/util/serviceUtils.test.ts @@ -0,0 +1,87 @@ +import { CRMILibrary } from '../../src/types/service-types'; +import { cleanUpTestDatabase, setupTestDatabase } from '../utils'; +import { getChildren, modifyResourcesForDraft } from '../../src/util/serviceUtils'; + +const PARENT_RELATED_ARTIFACTS: fhir4.RelatedArtifact[] = [ + { + type: 'composed-of', + resource: 'http://child-library-1.com|1', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ] + } +]; + +const CHILD_LIBRARY_1: CRMILibrary = { + resourceType: 'Library', + status: 'active', + type: { coding: [{ code: 'logic-library' }] }, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ], + id: 'childLibrary1', + url: 'http://child-library-1.com', + version: '1', + title: 'Child Library 1', + description: 'Child Library 1 description', + relatedArtifact: [ + { + type: 'composed-of', + resource: 'http://child-library-2.com|1', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ] + } + ] +}; + +const CHILD_LIBRARY_2: CRMILibrary = { + resourceType: 'Library', + status: 'active', + type: { coding: [{ code: 'logic-library' }] }, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned', + valueBoolean: true + } + ], + id: 'childLibrary2', + url: 'http://child-library-2.com', + version: '1', + title: 'Child Library 2', + description: 'Child Library 2 description' +}; + +describe('serviceUtils', () => { + beforeAll(() => { + return setupTestDatabase([CHILD_LIBRARY_1, CHILD_LIBRARY_2]); + }); + + describe('getChildren', () => { + it('returns an array of ChildArtifactInfo objects', async () => { + expect(await getChildren(PARENT_RELATED_ARTIFACTS)).toEqual([CHILD_LIBRARY_1, CHILD_LIBRARY_2]); + }); + }); + + describe('modifyResourcesForDraft', () => { + it('returns an array of modified (new id, new version, draft status, modified relatedArtifacts) FhirArtifacts', async () => { + const modifiedResources = await modifyResourcesForDraft([CHILD_LIBRARY_1, CHILD_LIBRARY_2], '1.0.0.1'); + expect(modifiedResources[0].status).toEqual('draft'); + expect(modifiedResources[1].status).toEqual('draft'); + expect(modifiedResources[0].version).toEqual('1.0.0.1'); + expect(modifiedResources[1].version).toEqual('1.0.0.1'); + expect(modifiedResources[0].relatedArtifact?.[0].resource).toEqual('http://child-library-2.com|1.0.0.1'); + }); + }); + + afterAll(cleanUpTestDatabase); +});