diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbf902bc..63a6bccc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- IPS: `modules.ips.validator.jar` - a validator jar of the module's own (a local build of org.hl7.fhir.core, say), used as it is, never downloaded or updated
+- IPS: national profiles - the profile on Bundle that a national IG defines for an IPS document (its jurisdiction - an ISO 3166 country code, or a UN M49 region - the IG's package id and version, and the profile's url). Shared across all projects at `/ips/national-profiles`; only the administrator adds, edits and deletes them. A new processing step, after validation, validates each document against each national profile (loading the IG's package into the validator), and keeps the outcome and whether it passed (no errors); the document's new National Profiles view lists them, and shows each one's validation as the Validation view does
- Registry: `GET /tx-reg/choose-server` - given the code systems a request involves and the client's default server, which one server to send it to, and a log of why. The same choice the Java tooling makes, for clients that aren't Java
- SNOMED CT ECL: member filters (`^ refset {{ M ... }}`) - `effectiveTime` (with a time value or set), `active`, `moduleId` (any sub expression) and the reference set's own fields (`targetComponentId = << X`, `mapTarget = "..."`, `mapGroup >= #2`, ...), comma-separated and repeatable. A member filter anywhere other than directly after a memberOf constraint is a syntax error (`INVALID_ECL`).
- SNOMED CT ECL: concept filters (`{{ C ... }}`) - `active`, `definitionStatus` (primitive/defined), `definitionStatusId`, `moduleId` (a concept, a bracketed concept list or any sub expression) and `effectiveTime`
@@ -27,10 +29,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- IPS: a clinical profile's page is the same for everyone. The required sections and entries are a table of Expectation, Section and Entry (`Code is system|code`, or `Code is in` a value set, with `[m..n]` when the cardinality isn't 1..1), and the rules about resources a table of Expectation, Resource Set (`Conditions where Code is ...`) and Rule (in words for `.exists()`, `.x.exists()`, `.x.y.exists()`, `.all(sameSubject())`, `.conformsTo(url)` and `.category.hasCoding(system, code)`). The administrator adds, edits and deletes the rules on that page one at a time, in a dialog, each saved by itself; the full editor is now Edit all
- Move the hard-coded link about adding to the terminology server ecosystem to the registry control file
+- Code systems, value sets and concept maps that both hl7.terminology and an endpoint's FHIR core package provide: THO's is still always the default for the url, but the core versions are no longer dropped - a reference that names a core version (`url|5.0.0`) gets it, where THO doesn't have that version. Previously they weren't available at all, so a reference to one failed, including THO's own value sets that pin a core version (`ValueSet/concept-property-type` 6.0.1 includes `concept-property-type` 5.0.0); and `valueSetVersion=5.0.0` found nothing
### Fixed
+- VSAC: the cache folder `terminology-cache/vsac` is created if it doesn't exist. Only its parent was being created (the folder name was passed as if it were a file name), so on a fresh install the VSAC database could not be created (`SQLITE_CANTOPEN`) and every VSAC value set failed
+- CodeSystem resources: the `notSelectable` property is recognised as the tx-ecosystem requirements say - by the `concept-properties#notSelectable` uri whatever local code the code system gives it, and by the code `notSelectable` only where the code system doesn't declare that code with a different uri. A code system that used `notSelectable` for a property of its own had its concepts marked `abstract` in expansions and treated as not selectable (`notSelectable-unprop-all`, `notSelectable-unprop-true`); a filter on that code is still a filter on the code system's own property
+- LOINC: `$lookup` returns `CLASS` as a string, the class name (e.g. `CHEM`), with the Part code for the class as `partCode`; it returned the Part code as a code
+- IPS: the pages link the module's browser scripts with their version (`?v=`), so a browser picks up a changed script as soon as the server is restarted with it, instead of using its cached copy for up to an hour
- SNOMED CT ECL: a hierarchy operator on a bracketed expression was silently dropped - `< (A OR B)` returned A and B themselves rather than their descendants. It now applies to every member of the set, as do operators on `*` and on `^ refset`, which were previously rejected as unsupported
- SNOMED CT ECL: `^` on a reference set whose members are descriptions (a language reference set) returned an empty expansion; it is now reported as `UNSUPPORTED_ECL` when the reference set is named, and skipped when it merely falls inside a computed operand such as `^ *`
- SNOMED CT ECL: an attribute name with no comparison (`: 363698007` with nothing after it) was accepted by the parser and failed later as unsupported; it is a syntax error
diff --git a/ips/ips.js b/ips/ips.js
index 73ffaaf7..353219c4 100644
--- a/ips/ips.js
+++ b/ips/ips.js
@@ -39,10 +39,12 @@
* @module ips/ips
*/
+const http = require('http');
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
+const scripts = require('./scripts');
const rateLimit = require('express-rate-limit');
const escape = require('escape-html');
const session = require('express-session');
@@ -79,7 +81,69 @@ const TX_STARTUP_GRACE_MS = 5 * 60 * 1000;
const FHIR_JSON = 'application/fhir+json; charset=utf-8';
const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
// paths at the root that can't be project codes
-const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup']);
+const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
+const LOAD_IG_TIMEOUT_MS = 300000;
+const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
+
+/**
+ * Ask the validator service to load a package (id#version) - POST /loadIG with {"ig": ...} as
+ * the validator's LoadIGHTTPHandler takes it. fhir-validator-wrapper's own loadIG() (to 1.6.0
+ * at least) sends the package id and version as query parameters with no body, which the
+ * service refuses ("Error parsing JSON source: ... Eof at Line 1"), so it is only used for a
+ * validator with no address of its own (as the tests' are).
+ * @returns {Promise
`;
if (projects.length === 0) {
return h + '
There are no projects yet' + (ctx.user?.isAdmin ? `: create one` : '') + '.
';
}
@@ -1074,9 +1100,21 @@ function renderValidation(row, options = {}) {
if (!r || (r.result.status && r.result.status !== 'done')) {
return noResultNote(row, 'validation', 'There is no validation result for this document', options);
}
- const v = r.result;
+ return validationOutcome(r.made, r.result, { ...options, hidden: { view: 'validation' } });
+}
+
+/**
+ * What the validator made of the document, once: when, with what, the issue counts, the
+ * issues (as filtered by options.level) and the OperationOutcome. The validation view and
+ * each national profile's page show it.
+ * @param {string} made - when the validation was done
+ * @param {{counts?: Object, outcome?: Object, profiles?: string[], validatorVersion?: string, ms?: number}} v
+ * @param {{level?: string, docUrl?: string, hidden?: Object}} options - hidden: the fields the
+ * level form keeps (the view, and which national profile)
+ */
+function validationOutcome(made, v, options = {}) {
const counts = v.counts || {};
- let h = `
';
@@ -1089,7 +1127,8 @@ function renderValidation(row, options = {}) {
const min = SEVERITY_RANK[level];
const issues = all.filter(i => (SEVERITY_RANK[text(i.severity)] ?? 3) >= min);
// the minimum level: a form that submits itself on change (and has a button without script)
- h += `' : '') + '';
+ const constraints = (sd.differential?.element?.[0]?.constraint || []).filter(c => c && typeof c === 'object');
+ // the actions on a row, for the administrator
+ const actions = (c) => !admin ? '' : '
' : '';
+ const ADD = { section: 'Add a required section/entry', resource: 'Add a rule about resources', other: 'Add another rule' };
+ for (const [kind, heading] of PROFILE_PARTS) {
+ const these = constraints.filter(c => profiles.kindOf(c.expression || '') === kind);
+ if (!these.length && !admin) {
+ continue;
+ }
+ // the heading, and for the administrator the part's Add button, to its right
+ h += `
${escape(heading)}
` +
+ (admin ? `` : '') + '
';
+ if (kind === 'section') {
+ h += `
Expectation
Section
Entry
${actionsHead}
`;
+ for (const c of these) {
+ const [section, entry] = sectionColumns(profiles.parseShape(c.expression || ''), ctx.valueSetTitle);
+ h += `
${escape(c.human || '')}
${section}
${entry}
${actions(c)}
`;
+ }
+ } else if (kind === 'resource') {
+ h += `
Expectation
Resource Set
Rule
${actionsHead}
`;
+ for (const c of these) {
+ const [set, rule] = resourceColumns(profiles.parseResourceShape(c.expression || ''), ctx);
+ h += `
';
+ }
+ if (constraints.length === 0 && !admin) {
+ h += '
This profile has no constraints yet.
';
}
h += '
Each rule is checked against the document (the Bundle). section(system, code, min, max) finds the sections of the document\'s Composition ' +
'(nested ones too) with that code, when there are min..max of them; entry(system, code, min, max) finds a section\'s entries with that code ' +
'(their code, medicationCodeableConcept or vaccineCode), when there are min..max of them; ' +
'resources(system, code, type) finds the resources in the document with that code (of that type, when one is given); ' +
- 'entryIn(valueSet, min, max) and resourcesIn(valueSet, type) are the same with a code in a value set, asked of the terminology server.
';
+ 'entryIn(valueSet, min, max) and resourcesIn(valueSet, type) are the same with a code in a value set, asked of the terminology server. ' +
+ 'A cardinality [m..n] after a section or an entry is how many there may be; with none, there must be at least one.';
+ if (admin) {
+ const cfg = {
+ mode: 'rows', profile: { id: sd.id, title: sd.title, constraints: editor.constraints },
+ sections: editor.sections, systems: editor.systems, resourceTypes: editor.resourceTypes, sectionSystem: editor.sectionSystem, csrf: ctx.csrf || '',
+ parts: PROFILE_PARTS,
+ urls: {
+ constraint: `${ctx.base}/StructureDefinition/${sd.id}/constraint`,
+ lookup: editor.canLookup ? `${ctx.base}/lookup` : null,
+ lookupVs: `${ctx.base}/lookup-vs`
+ },
+ // the code systems offered for a resource's category, first the default
+ categorySystem: CATEGORY_SYSTEMS[0].url,
+ categorySystems: CATEGORY_SYSTEMS
+ };
+ h += '';
+ h += ``;
+ h += ``;
+ }
return h;
}
@@ -1286,7 +1554,7 @@ function renderProfileEditor(sd, ctx, options) {
}
};
h += ``;
- h += ``;
+ h += ``;
return h;
}
@@ -1362,6 +1630,7 @@ const VIEWS = [
['translation', 'Translation'],
['generated', 'Generated Narrative'],
['validation', 'Validation'],
+ ['national', 'National Profiles'],
['terminology', 'Terminology'],
['profiles', 'Clinical Profiles'],
['ai', 'AI Assessment'],
@@ -1469,6 +1738,8 @@ function renderDocument(project, row, view, ctx) {
h += renderAi(row, { canManage: ctx.canManage });
} else if (view === 'validation') {
h += renderValidation(row, { level: ctx.level, docUrl: durl, canManage: ctx.canManage });
+ } else if (view === 'national') {
+ h += renderNational(row, { np: ctx.np, level: ctx.level, docUrl: durl, base: ctx.base, canManage: ctx.canManage });
} else {
h += assembledNarrative(row.bundle);
}
@@ -1477,6 +1748,6 @@ function renderDocument(project, row, view, ctx) {
module.exports = {
renderHome, renderLogin, renderProjects, renderUsers, renderProject, renderSubmit, renderSettings, renderDocument,
- assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderShare, renderCompose, renderProfileChecks,
- renderProfiles, renderProfile, renderProfileEditor, VIEWS
+ assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderNational, renderShare, renderCompose, renderProfileChecks,
+ renderProfiles, renderProfile, renderProfileEditor, renderNationalProfiles, VIEWS
};
diff --git a/ips/scripts.js b/ips/scripts.js
new file mode 100644
index 00000000..c393b5f2
--- /dev/null
+++ b/ips/scripts.js
@@ -0,0 +1,46 @@
+//
+// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
+//
+// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
+//
+
+/*
+ * The module's browser scripts: its own files, read once and served from memory (so no
+ * request reaches the file system), with an ETag. A page links a script with its version
+ * (?v=, from the script's content), so a browser that has kept a copy picks up a changed
+ * script as soon as the server serves it, not when its cached copy expires.
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const SCRIPTS = new Map();
+
+function load(name) {
+ if (!SCRIPTS.has(name)) {
+ const body = fs.readFileSync(path.join(__dirname, name), 'utf8');
+ const hash = crypto.createHash('sha256').update(body).digest('base64url');
+ SCRIPTS.set(name, { body, etag: `"${hash.substring(0, 27)}"`, version: hash.substring(0, 12) });
+ }
+ return SCRIPTS.get(name);
+}
+
+/** The route handler that serves the script. */
+function handler(name) {
+ const s = load(name);
+ return (req, res) => {
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.setHeader('ETag', s.etag);
+ if (req.get('If-None-Match') === s.etag) {
+ return res.status(304).end();
+ }
+ res.type('application/javascript').send(s.body);
+ };
+}
+
+/** The script's URL, for a page: base/name?v=version (not escaped). */
+function src(base, name) {
+ return `${base}/${name}?v=${load(name).version}`;
+}
+
+module.exports = { handler, src };
diff --git a/ips/store.js b/ips/store.js
index 63096660..7c59ca0d 100644
--- a/ips/store.js
+++ b/ips/store.js
@@ -102,6 +102,17 @@ const SCHEMA = `
json TEXT NOT NULL
);
+ -- national profiles: the profile a national IG defines for an IPS document (the Bundle), across all projects
+ CREATE TABLE IF NOT EXISTS national_profiles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ package_id TEXT NOT NULL, -- the IG's package id
+ version TEXT NOT NULL, -- the package version
+ url TEXT NOT NULL, -- the canonical URL of the IG's profile on Bundle
+ jurisdiction TEXT, -- the country (ISO 3166 two-letter code) or region (UN M49 three digits)
+ updated_ms INTEGER NOT NULL,
+ UNIQUE (package_id, version)
+ );
+
CREATE TABLE IF NOT EXISTS ips_index (
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
res TEXT NOT NULL, -- Bundle | Composition | Patient
@@ -158,6 +169,11 @@ function summaryOf(bundle) {
};
}
+/** A national_profiles row, as the store gives it. */
+function nationalProfileOf(r) {
+ return { id: r.id, jurisdiction: r.jurisdiction || null, packageId: r.package_id, version: r.version, url: r.url, updated_ms: r.updated_ms };
+}
+
class IpsStore {
/**
* @param {string} dbPath - file name, or ':memory:'
@@ -203,6 +219,9 @@ class IpsStore {
if (!has('documents', 'analysed_json')) {
this.db.exec('ALTER TABLE documents ADD COLUMN analysed_json TEXT');
}
+ if (!has('national_profiles', 'jurisdiction')) {
+ this.db.exec('ALTER TABLE national_profiles ADD COLUMN jurisdiction TEXT');
+ }
if (!has('projects', 'ai_json')) {
// the project's AI assessment settings (JSON; the API key encrypted)
this.db.exec('ALTER TABLE projects ADD COLUMN ai_json TEXT');
@@ -261,6 +280,42 @@ class IpsStore {
return this.db.prepare('DELETE FROM clinical_profiles WHERE id = ?').run(id).changes > 0;
}
+ // ---- national profiles ----
+
+ /** All the national profiles: {id, jurisdiction, packageId, version, url, updated_ms}, by package id and version. */
+ nationalProfiles() {
+ return this.db.prepare('SELECT id, package_id, version, url, jurisdiction, updated_ms FROM national_profiles ORDER BY package_id, version, id').all()
+ .map(nationalProfileOf);
+ }
+
+ /** @returns {Object|null} {id, jurisdiction, packageId, version, url, updated_ms} */
+ nationalProfile(id) {
+ const r = this.db.prepare('SELECT id, package_id, version, url, jurisdiction, updated_ms FROM national_profiles WHERE id = ?').get(id);
+ return r ? nationalProfileOf(r) : null;
+ }
+
+ /** The national profile for a package id and version, or null. */
+ nationalProfileByPackage(packageId, version) {
+ const r = this.db.prepare('SELECT id FROM national_profiles WHERE package_id = ? AND version = ?').get(packageId, version);
+ return r ? this.nationalProfile(r.id) : null;
+ }
+
+ /** @returns {number} the new national profile's id */
+ createNationalProfile(packageId, version, url, jurisdiction, nowMs = Date.now()) {
+ return Number(this.db.prepare('INSERT INTO national_profiles (package_id, version, url, jurisdiction, updated_ms) VALUES (?, ?, ?, ?, ?)')
+ .run(packageId, version, url, jurisdiction || null, nowMs).lastInsertRowid);
+ }
+
+ /** @returns {boolean} whether there was such a national profile */
+ updateNationalProfile(id, packageId, version, url, jurisdiction, nowMs = Date.now()) {
+ return this.db.prepare('UPDATE national_profiles SET package_id = ?, version = ?, url = ?, jurisdiction = ?, updated_ms = ? WHERE id = ?')
+ .run(packageId, version, url, jurisdiction || null, nowMs, id).changes > 0;
+ }
+
+ deleteNationalProfile(id) {
+ return this.db.prepare('DELETE FROM national_profiles WHERE id = ?').run(id).changes > 0;
+ }
+
// ---- projects ----
/** All projects, with their document counts and managers; by name. */
diff --git a/package-lock.json b/package-lock.json
index cbf73b64..35ddb996 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,7 +24,7 @@
"express-rate-limit": "^7.4.1",
"express-session": "^1.19.0",
"fast-xml-parser": "^5.3.4",
- "fhir-validator-wrapper": "1.5.0",
+ "fhir-validator-wrapper": "1.6.1",
"fhirpath": "^5.2.1",
"fs-extra": "^11.3.3",
"ini": "^6.0.0",
@@ -4758,9 +4758,9 @@
"license": "MIT"
},
"node_modules/fhir-validator-wrapper": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/fhir-validator-wrapper/-/fhir-validator-wrapper-1.5.0.tgz",
- "integrity": "sha512-Ug7PsSnHIpLlgLCn164YvjgC9K1j+JJz51rxp8hCAQD+zifkbTTS/wtMeq7i+DbLL6GC8HwyAfq+MtxtrtB7tA==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/fhir-validator-wrapper/-/fhir-validator-wrapper-1.6.1.tgz",
+ "integrity": "sha512-cxx0Dh3nyMJ5TcvyhULvqkAku0xCDJ7K8NArDBjQk7fVO9emj69WsxvxqkoHThaQ7HGEvJEzbaFQ30y9DLE3kg==",
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
diff --git a/package.json b/package.json
index b4590633..ea7aa018 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"express-rate-limit": "^7.4.1",
"express-session": "^1.19.0",
"fast-xml-parser": "^5.3.4",
- "fhir-validator-wrapper": "1.5.0",
+ "fhir-validator-wrapper": "1.6.1",
"fhirpath": "^5.2.1",
"fs-extra": "^11.3.3",
"ini": "^6.0.0",
diff --git a/tests/cm/cm-package.test.js b/tests/cm/cm-package.test.js
index 97e81bf6..f2c7b748 100644
--- a/tests/cm/cm-package.test.js
+++ b/tests/cm/cm-package.test.js
@@ -294,7 +294,7 @@ describe('PackageConceptMapProvider', () => {
});
});
- describe('excludeUrls', () => {
+ describe('keepPinnedOnly', () => {
let reduced;
let gone;
@@ -303,15 +303,30 @@ describe('PackageConceptMapProvider', () => {
await reduced.initialize();
reduced.assignIds('core');
gone = [...reduced.conceptMapMap.values()][0];
- reduced.excludeUrls(new Set([gone.url]));
+ reduced.keepPinnedOnly(new Map([[gone.url, new Set(['999.0.0'])]]));
});
- test('an excluded concept map is not fetched, by url or by id', async () => {
+ test('a restricted concept map is still fetched by its own version, where THO does not have that version', async () => {
+ expect(gone.version).toBeTruthy();
+ expect(await reduced.fetchConceptMap(gone.url, gone.version)).toBe(gone);
+ });
+
+ test('a restricted concept map is not fetched by a version that THO has', async () => {
+ const thoToo = new PackageConceptMapProvider(reduced.packageLoader);
+ await thoToo.initialize();
+ thoToo.assignIds('core');
+ const cm = [...thoToo.conceptMapMap.values()].find(r => r.url === gone.url);
+ thoToo.keepPinnedOnly(new Map([[cm.url, new Set([cm.version])]]));
+ expect(await thoToo.fetchConceptMap(cm.url, cm.version)).toBeNull();
+ expect(await thoToo.fetchConceptMap(cm.url, null)).toBeNull();
+ });
+
+ test('a restricted concept map is not fetched without a version, or by id', async () => {
expect(await reduced.fetchConceptMap(gone.url, null)).toBeNull();
expect(await reduced.fetchConceptMapById(gone.id)).toBeNull();
});
- test('an excluded concept map is not found by search', async () => {
+ test('a restricted concept map is not found by search', async () => {
for (const elements of [null, ['id', 'url']]) {
const results = await reduced.searchConceptMaps([], elements);
expect(results.some(r => r.id === gone.id)).toBe(false);
diff --git a/tests/cs/cs-cs-notselectable.test.js b/tests/cs/cs-cs-notselectable.test.js
new file mode 100644
index 00000000..7af15827
--- /dev/null
+++ b/tests/cs/cs-cs-notselectable.test.js
@@ -0,0 +1,92 @@
+//
+// How a CodeSystem's notSelectable property is recognised (tx-ecosystem requirements,
+// Concept Properties): by the canonical uri whatever local code it has, by the standard
+// code where the code system declares nothing, and never where the code system declares
+// the code with a different uri - that is a different property
+//
+
+const { CodeSystem } = require('../../tx/library/codesystem');
+const { FhirCodeSystemProvider } = require('../../tx/cs/cs-cs');
+const { FilterExecutionContext } = require('../../tx/cs/cs-api');
+const { OperationContext } = require('../../tx/operation-context');
+const { TestUtilities } = require('../test-utilities');
+
+const NS_URI = 'http://hl7.org/fhir/concept-properties#notSelectable';
+
+function codeSystem(code, uri) {
+ const prop = (value) => ({ code, valueBoolean: value });
+ const cs = {
+ resourceType: 'CodeSystem',
+ url: 'http://example.org/fhir/CodeSystem/ns-' + code + '-' + (uri ? uri.split('#')[1] : 'none'),
+ version: '0.1.0',
+ status: 'active',
+ content: 'complete',
+ concept: [
+ { code: 'codeU', display: 'Unknown Selectable State' },
+ { code: 'codeS', display: 'Selectable Code', property: [prop(false)] },
+ { code: 'codeNS', display: 'Not Selectable Code', property: [prop(true)] }
+ ]
+ };
+ if (uri !== undefined) {
+ cs.property = [{ code, type: 'boolean' }];
+ if (uri) {
+ cs.property[0].uri = uri;
+ }
+ }
+ return cs;
+}
+
+describe('recognising the notSelectable property', () => {
+ let opContext;
+
+ beforeEach(async () => {
+ opContext = new OperationContext('en-US',
+ await TestUtilities.loadTranslations(await TestUtilities.loadLanguageDefinitions()));
+ });
+
+ const providerFor = (json) => new FhirCodeSystemProvider(opContext, new CodeSystem(json), []);
+
+ async function abstractCodes(provider) {
+ const result = [];
+ for (const c of ['codeU', 'codeS', 'codeNS']) {
+ if (await provider.isAbstract(c)) {
+ result.push(c);
+ }
+ }
+ return result;
+ }
+
+ async function filterCodes(provider, value) {
+ const set = await provider.filter(new FilterExecutionContext(), true, 'notSelectable', '=', value);
+ return set.concepts.map(c => c.concept.code).sort();
+ }
+
+ test('declared with the standard code and uri', async () => {
+ const provider = providerFor(codeSystem('notSelectable', NS_URI));
+ expect(await abstractCodes(provider)).toEqual(['codeNS']);
+ expect(await filterCodes(provider, 'true')).toEqual(['codeNS']);
+ expect(await filterCodes(provider, 'false')).toEqual(['codeS']);
+ });
+
+ test('not declared at all: recognised by the standard code', async () => {
+ const provider = providerFor(codeSystem('notSelectable', undefined));
+ expect(await abstractCodes(provider)).toEqual(['codeNS']);
+ expect(await filterCodes(provider, 'true')).toEqual(['codeNS']);
+ });
+
+ test('declared with the standard uri and a local code: recognised by the uri', async () => {
+ const provider = providerFor(codeSystem('selectable-not', NS_URI));
+ expect(await abstractCodes(provider)).toEqual(['codeNS']);
+ expect(await filterCodes(provider, 'true')).toEqual(['codeNS']);
+ expect(await filterCodes(provider, 'false')).toEqual(['codeS']);
+ });
+
+ test('declared with the standard code and a different uri: a different property', async () => {
+ const provider = providerFor(codeSystem('notSelectable', NS_URI + 'X'));
+ // nothing is abstract
+ expect(await abstractCodes(provider)).toEqual([]);
+ // but a filter on the code is still a filter on the code system's own property
+ expect(await filterCodes(provider, 'true')).toEqual(['codeNS']);
+ expect(await filterCodes(provider, 'false')).toEqual(['codeS']);
+ });
+});
diff --git a/tests/ips/ips.test.js b/tests/ips/ips.test.js
index b1599d2a..eb79dd46 100644
--- a/tests/ips/ips.test.js
+++ b/tests/ips/ips.test.js
@@ -955,6 +955,138 @@ describe('the validation step', () => {
});
});
+describe('the national profiles step', () => {
+ let app;
+ let mod;
+ const AU = 'http://hl7.org.au/fhir/ips/StructureDefinition/au-ips-bundle';
+ const NZ = 'http://hl7.org.nz/fhir/ips/StructureDefinition/nz-ips-bundle';
+ const XX = 'http://example.org/fhir/StructureDefinition/xx-bundle';
+ const failing = { resourceType: 'OperationOutcome', issue: [
+ { severity: 'error', code: 'structure', details: { text: 'Bundle.identifier: minimum required = 1' }, expression: ['Bundle'] },
+ { severity: 'information', code: 'informational', details: { text: 'Just a note' } }] };
+ const passing = { resourceType: 'OperationOutcome', issue: [{ severity: 'warning', code: 'business-rule', details: { text: 'A warning only' } }] };
+ beforeEach(async () => {
+ ({ app, mod } = await makeApp());
+ mod.store.createProject('demo', 'Demo', null);
+ mod.steps = STEPS.filter(s => s.name === 'national');
+ });
+ afterEach(() => mod.shutdown());
+
+ const fakeValidator = () => ({
+ calls: [],
+ loads: [],
+ isRunning: () => true,
+ jarVersion: () => '6.5.0',
+ async loadIG(packageId, version) {
+ this.loads.push(`${packageId}#${version}`);
+ return packageId === 'hl7.fhir.xx.ips'
+ ? { resourceType: 'OperationOutcome', issue: [{ severity: 'error', code: 'not-found', details: { text: 'Package not found' } }] }
+ : { resourceType: 'OperationOutcome', issue: [{ severity: 'information', code: 'informational', details: { text: 'Loaded' } }] };
+ },
+ async validate(resource, options) {
+ this.calls.push(options);
+ return options.profiles[0] === AU ? failing : passing;
+ },
+ async stop() {}
+ });
+
+ test('with no national profiles, there is nothing to do', async () => {
+ await post(app, 'demo', ips());
+ await mod.processQueue();
+ expect(mod.store.read(1)).toMatchObject({ status: 'processed', status_message: 'national profiles: none' });
+ expect(mod.store.readResult(1, 'national').result).toMatchObject({ profiles: [] });
+ const page = await request(app).get('/ips/demo/Bundle/1?view=national').set('Accept', 'text/html');
+ expect(page.text).toContain('
(not validated)');
+
+ // one profile's validation, as the validation view shows it
+ const au = await request(app).get('/ips/demo/Bundle/1?view=national&np=0').set('Accept', 'text/html');
+ expect(au.text).toContain('National profiles » hl7.fhir.au.ips#1.0.0');
+ expect(au.text).toContain(`against ${AU}`);
+ expect(au.text).toContain('1 error, 1 note');
+ expect(au.text).toContain('Bundle.identifier: minimum required = 1');
+ expect(au.text).toContain('');
+ expect(au.text).toContain('Show the OperationOutcome');
+ const filtered = await request(app).get('/ips/demo/Bundle/1?view=national&np=0&level=error').set('Accept', 'text/html');
+ expect(filtered.text).toContain('Showing 1 of 2 issues (errors and above)');
+ // the one that wasn't validated: why, for managers
+ const xx = await request(app).get('/ips/demo/Bundle/1?view=national&np=2').set('Accept', 'text/html');
+ expect(xx.text).toContain("could not be validated against this profile (the reason is shown to the project's managers)");
+ const { agent } = await loginAs(app, 'admin', 'admin-secret');
+ expect((await agent.get('/ips/demo/Bundle/1?view=national&np=2').set('Accept', 'text/html')).text).toContain('Package not found');
+ // a profile that isn't there: the list
+ expect((await request(app).get('/ips/demo/Bundle/1?view=national&np=7').set('Accept', 'text/html')).text).toContain('np=1">hl7.fhir.nz.ips#2.0.0');
+
+ // changing the national profiles: a package that failed to load is tried again
+ expect((await form(agent, '/ips/national-profiles', { jurisdiction: 'GB', packageId: 'hl7.fhir.yy.ips', version: '1.0.0', url: XX })).status).toBe(302);
+ mod.store.requeue(1, 1);
+ await mod.processQueue();
+ expect(mod.validator.loads.filter(l => l === 'hl7.fhir.xx.ips#0.1.0')).toHaveLength(2);
+ });
+
+ test('a package is loaded by posting {ig: id#version} to the validator service\'s /loadIG', async () => {
+ // the validator service: what it was asked, and what it answers
+ const http = require('http');
+ const seen = [];
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', c => { body += c; });
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, type: req.headers['content-type'], body });
+ res.setHeader('Content-Type', 'application/fhir+json');
+ res.end(JSON.stringify({ resourceType: 'OperationOutcome', issue: [{ severity: 'information', code: 'informational', details: { text: 'IG loaded successfully' } }] }));
+ });
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ try {
+ mod.validator = { baseUrl: `http://127.0.0.1:${server.address().port}`, loadIG: () => { throw new Error('not this one'); } };
+ await mod.loadNationalPackage('hl7.fhir.au.ps', 'current');
+ expect(seen).toEqual([{ method: 'POST', url: '/loadIG', type: 'application/json', body: '{"ig":"hl7.fhir.au.ps#current"}' }]);
+ // once
+ await mod.loadNationalPackage('hl7.fhir.au.ps', 'current');
+ expect(seen).toHaveLength(1);
+ } finally {
+ await new Promise(r => server.close(r));
+ }
+ });
+
+ test('without a validator, documents fail processing (when there are national profiles)', async () => {
+ mod.store.createNationalProfile('hl7.fhir.au.ips', '1.0.0', AU, 'AU');
+ await post(app, 'demo', ips());
+ await mod.processQueue();
+ expect(mod.store.read(1)).toMatchObject({ status: 'error', status_message: 'national: The validator is not configured (modules.ips.validator)' });
+ });
+});
+
describe('the terminology step', () => {
let app;
let mod;
@@ -1258,7 +1390,7 @@ describe('the terminology step', () => {
test('the translate step adds the translations of concepts that fail the check, before validation, and the check then passes', async () => {
mod.steps = STEPS.filter(s => s.name === 'translate' || s.name === 'terminology');
- expect(STEPS.map(s => s.name)).toEqual(['translate', 'narrative', 'validation', 'terminology', 'profiles', 'ai']);
+ expect(STEPS.map(s => s.name)).toEqual(['translate', 'narrative', 'validation', 'national', 'terminology', 'profiles', 'ai']);
await post(app, 'demo', withCondition({ coding: [{ system: 'http://loinc.org', code: 'LA-mod', display: 'Moderate' }] }));
await mod.processQueue();
const row = mod.store.read(1);
@@ -1873,7 +2005,8 @@ describe('the editor', () => {
expect(page.status).toBe(200);
expect(page.text).toContain('
Compose a document
');
expect(page.text).toContain('');
- expect(page.text).toContain('');
+ // with its version, so a changed script isn't taken from a browser's cache
+ expect(page.text).toMatch(/