From f7192611010d216838f02f52620a00668afa91bf Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:02:52 +0100 Subject: [PATCH 1/4] minor fix in display --- tx/library/renderer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tx/library/renderer.js b/tx/library/renderer.js index 1719c7ff..c77c86c1 100644 --- a/tx/library/renderer.js +++ b/tx/library/renderer.js @@ -438,7 +438,7 @@ class Renderer { } } } else { - li.tx(this.translate('VALUE_SET_CODES_FROM')); + li.tx(this.translate('VALUE_SET_CODES_FROM')+" "); await this.renderLink(li,inc.system+(inc.version ? "|"+inc.version : "")); li.tx(" "+ this.translate('VALUE_SET_WHERE')+" "); li.startCommaList("and"); From 75e3490d0429783e8960c7b2dfd0179186767963 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:05:10 +0100 Subject: [PATCH 2/4] search: improved _summary, add SUBSETTED tag --- tests/tx/search.test.js | 48 +++++++++++++++++++++++++++++++++++++++++ tx/workers/search.js | 41 ++++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/tests/tx/search.test.js b/tests/tx/search.test.js index ebecde6f..fe7dd36b 100644 --- a/tests/tx/search.test.js +++ b/tests/tx/search.test.js @@ -262,6 +262,54 @@ describe('Search Worker', () => { expect(r4Bundle.entry).toBeUndefined(); }); + test('should include SUBSETTED tag with _summary=true', async () => { + const response = await request(app) + .get('/tx/r5/CodeSystem') + .query({ _summary: 'true', _count: 5 }) + .set('Accept', 'application/json'); + + expect(response.status).toBe(200); + + if (response.body.entry && response.body.entry.length > 0) { + const resource = response.body.entry[0].resource; + expect(resource.meta).toBeDefined(); + expect(resource.meta.tag).toBeDefined(); + const subsetted = resource.meta.tag.find(t => t.code === 'SUBSETTED'); + expect(subsetted).toBeDefined(); + expect(subsetted.system).toBe('http://terminology.hl7.org/CodeSystem/v3-ObservationValue'); + } + }); + + test('should include content element in CodeSystem summary', async () => { + const response = await request(app) + .get('/tx/r5/CodeSystem') + .query({ _summary: 'true', url: 'http://hl7.org/fhir/administrative-gender' }) + .set('Accept', 'application/json'); + + expect(response.status).toBe(200); + + if (response.body.entry && response.body.entry.length > 0) { + const resource = response.body.entry[0].resource; + expect(resource.content).toBeDefined(); + } + }); + + test('should include SUBSETTED tag with _elements', async () => { + const response = await request(app) + .get('/tx/r5/CodeSystem') + .query({ _elements: 'url,name', _count: 5 }) + .set('Accept', 'application/json'); + + expect(response.status).toBe(200); + + if (response.body.entry && response.body.entry.length > 0) { + const resource = response.body.entry[0].resource; + expect(resource.meta).toBeDefined(); + const subsetted = resource.meta.tag.find(t => t.code === 'SUBSETTED'); + expect(subsetted).toBeDefined(); + } + }); + test('should return full resources with _summary=false', async () => { const response = await request(app) .get('/tx/r5/CodeSystem') diff --git a/tx/workers/search.js b/tx/workers/search.js index 06b6cd89..8b44f91a 100644 --- a/tx/workers/search.js +++ b/tx/workers/search.js @@ -31,15 +31,18 @@ class SearchWorker extends TerminologyWorker { // Allowed search parameters static ALLOWED_PARAMS = [ - '_offset', '_count', '_elements', '_sort', '_summary', '_total', + '_offset', '_count', '_elements', '_sort', '_summary', '_total', '_format', 'url', 'version', 'content-mode', 'date', 'description', 'supplements', 'identifier', 'jurisdiction', 'name', 'publisher', 'status', 'system', 'title', 'text' ]; - // Summary elements for _summary=true (common metadata fields) - static SUMMARY_ELEMENTS = ['resourceType', 'id', 'meta', 'url', 'version', - 'name', 'title', 'status', 'date', 'publisher', 'description']; + // Summary elements for _summary=true (marked elements per resource type) + static SUMMARY_ELEMENTS = { + CodeSystem: ['meta', 'url', 'version', 'name', 'title', 'status', 'experimental', 'date', 'publisher', 'description', 'jurisdiction', 'content'], + ValueSet: ['meta', 'url', 'version', 'name', 'title', 'status', 'experimental', 'date', 'publisher', 'description', 'jurisdiction'], + ConceptMap: ['meta', 'url', 'version', 'name', 'title', 'status', 'experimental', 'date', 'publisher', 'description', 'jurisdiction'] + }; // Sortable fields static SORT_FIELDS = ['id', 'url', 'version', 'date', 'name', 'vurl']; @@ -66,7 +69,7 @@ class SearchWorker extends TerminologyWorker { let elements; switch (summary) { case 'true': - elements = SearchWorker.SUMMARY_ELEMENTS; + elements = SearchWorker.SUMMARY_ELEMENTS[resourceType] || []; break; case 'text': elements = ['resourceType', 'id', 'meta', 'text']; @@ -109,7 +112,7 @@ class SearchWorker extends TerminologyWorker { const bundle = this.buildSearchBundle( req, resourceType, matches, offset, count, elements, summary, totalMode ); - req.logInfo = summary === 'count' ? `count: ${bundle.total}` : `${bundle.entry.length} matches`; + req.logInfo = `${bundle.entry ? bundle.entry.length : 0} matches`; return res.json(bundle); } catch (error) { @@ -303,15 +306,15 @@ class SearchWorker extends TerminologyWorker { /** * Build a FHIR search Bundle with pagination */ - buildSearchBundle(req, resourceType, allMatches, offset, count, elements, summary = 'false', totalMode = 'accurate') { - const total = allMatches.length; + buildSearchBundle(req, resourceType, allMatches, offset, count, elements, summary, totalParam) { + const totalCount = allMatches.length; - // For _summary=count, return just the count + // Handle _summary=count - only return total, no entries if (summary === 'count') { return { resourceType: 'Bundle', type: 'searchset', - total: total + total: totalCount }; } @@ -363,7 +366,7 @@ class SearchWorker extends TerminologyWorker { } // Next link (if more results) - if (offset + count < total) { + if (offset + count < totalCount) { const nextParams = new URLSearchParams(searchParams); nextParams.set('_offset', offset + count); links.push({ @@ -373,7 +376,7 @@ class SearchWorker extends TerminologyWorker { } // Last link - const lastOffset = Math.max(0, Math.floor((total - 1) / count) * count); + const lastOffset = Math.max(0, Math.floor((totalCount - 1) / count) * count); const lastParams = new URLSearchParams(searchParams); lastParams.set('_offset', lastOffset); links.push({ @@ -383,7 +386,7 @@ class SearchWorker extends TerminologyWorker { // Build entries const entries = pageResults.map(resource => { - // Apply _elements filter if specified + // Apply _elements or _summary filter if specified let filteredResource = resource; if (elements) { filteredResource = this.filterElements(resource, elements); @@ -404,8 +407,9 @@ class SearchWorker extends TerminologyWorker { link: links, entry: entries }; - if (totalMode !== 'none') { - bundle.total = total; + // Add total unless _total=none + if (totalParam !== 'none') { + bundle.total = totalCount; } return bundle; } @@ -426,6 +430,13 @@ class SearchWorker extends TerminologyWorker { } } + // Mark as SUBSETTED per FHIR spec + filtered.meta = filtered.meta ? { ...filtered.meta } : {}; + filtered.meta.tag = [ + ...(filtered.meta.tag || []), + { system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue', code: 'SUBSETTED' } + ]; + return filtered; } } From e0cf3c2f509b1cffdcdbef02347cec334198e0b0 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:22:10 +0100 Subject: [PATCH 3/4] fix minor error in serving home page The '/' handler rendered the page inline (with about-box) then unconditionally called serveFhirsmithHome which rendered it again, causing ERR_HTTP_HEADERS_SENT. Added else so serveFhirsmithHome only handles JSON requests. Also added headersSent guard in sendErrorResponse as a safety net. --- library/html-server.js | 10 ++++- server.js | 3 +- tests/server/headers-sent.test.js | 75 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tests/server/headers-sent.test.js diff --git a/library/html-server.js b/library/html-server.js index fc54436d..e3fa93cc 100644 --- a/library/html-server.js +++ b/library/html-server.js @@ -100,20 +100,26 @@ class HtmlServer { } sendErrorResponse(res, templateName, error, statusCode = 500) { + if (res.headersSent) { + this.log.error('[HtmlServer] Cannot send error response - headers already sent:', error.message || error); + return; + } const errorContent = `

Error

${escape(error.message || error)}

`; - + try { const html = this.renderPage(templateName, 'Error', errorContent); res.status(statusCode).setHeader('Content-Type', 'text/html'); res.send(html); } catch (renderError) { this.log.error('[HtmlServer] Error rendering error page:', renderError); - res.status(statusCode).send(`

Error

Failed to render error page: ${escape(renderError.message)}

`); + if (!res.headersSent) { + res.status(statusCode).send(`

Error

Failed to render error page: ${escape(renderError.message)}

`); + } } } diff --git a/server.js b/server.js index 13d136f5..e2da4294 100644 --- a/server.js +++ b/server.js @@ -440,8 +440,9 @@ app.get('/', async (req, res) => { serverLog.error('Error rendering root page:', error); htmlServer.sendErrorResponse(res, 'root', error); } + } else { + return serveFhirsmithHome(req, res); } - return serveFhirsmithHome(req, res); }); app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res)); diff --git a/tests/server/headers-sent.test.js b/tests/server/headers-sent.test.js new file mode 100644 index 00000000..d1443a59 --- /dev/null +++ b/tests/server/headers-sent.test.js @@ -0,0 +1,75 @@ +const path = require('path'); + +const HtmlServer = require('../../library/html-server').constructor; + +describe('sendErrorResponse headers-sent guard', () => { + let htmlServer; + let mockLog; + + beforeAll(() => { + htmlServer = new HtmlServer(); + mockLog = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; + htmlServer.useLog(mockLog); + + const templatePath = path.join(__dirname, '../../root-template.html'); + htmlServer.loadTemplate('root', templatePath); + }); + + beforeEach(() => { + mockLog.error.mockClear(); + }); + + test('does not throw when headers already sent', () => { + const res = { + headersSent: true, + status: jest.fn().mockReturnThis(), + setHeader: jest.fn(), + send: jest.fn(), + }; + + // Should not throw + htmlServer.sendErrorResponse(res, 'root', new Error('test error')); + + // Should log the error instead of trying to send + expect(mockLog.error).toHaveBeenCalledWith( + expect.stringContaining('headers already sent'), + expect.stringContaining('test error') + ); + // Should NOT attempt to send a response + expect(res.send).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('sends error response normally when headers not yet sent', () => { + const res = { + headersSent: false, + status: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + htmlServer.sendErrorResponse(res, 'root', new Error('something broke')); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalled(); + const html = res.send.mock.calls[0][0]; + expect(html).toContain('something broke'); + }); + + test('falls back to plain HTML when template rendering fails', () => { + const res = { + headersSent: false, + status: jest.fn().mockReturnThis(), + setHeader: jest.fn(() => { throw new Error('setHeader fail'); }), + send: jest.fn(), + }; + + // 'nonexistent' template will cause renderPage to produce output, + // but setHeader throws, so it falls to the inner catch + htmlServer.sendErrorResponse(res, 'nonexistent', new Error('original error')); + + expect(mockLog.error).toHaveBeenCalled(); + // The fallback send should still be attempted + expect(res.send).toHaveBeenCalled(); + }); +}); From 48d5e0615318613422898d186f4bac4cd2f26ddb Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:00:36 +0100 Subject: [PATCH 4/4] Fix crawler #157 Fix: crawler stored dependencies with @ instead of # May need re-crawling or fixing with UPDATE PackageDependencies SET Dependency = REPLACE(Dependency, '@', '#') WHERE Dependency LIKE '%@%'; --- packages/package-crawler.js | 2 +- tests/packages/package-dependencies.test.js | 134 ++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/packages/package-dependencies.test.js diff --git a/packages/package-crawler.js b/packages/package-crawler.js index 4bf46ea6..78592a84 100644 --- a/packages/package-crawler.js +++ b/packages/package-crawler.js @@ -519,7 +519,7 @@ class PackageCrawler { const dependencies = []; if (packageJson.dependencies) { for (const [dep, ver] of Object.entries(packageJson.dependencies)) { - dependencies.push(`${dep}@${ver}`); + dependencies.push(`${dep}#${ver}`); } } diff --git a/tests/packages/package-dependencies.test.js b/tests/packages/package-dependencies.test.js new file mode 100644 index 00000000..79633dd5 --- /dev/null +++ b/tests/packages/package-dependencies.test.js @@ -0,0 +1,134 @@ +const sqlite3 = require('sqlite3').verbose(); + +describe('Package dependencies format', () => { + + // Test that the crawler dependency format matches what the reader expects + describe('crawler dependency extraction', () => { + test('dependencies use # separator (FHIR convention)', () => { + // Simulate what the crawler does at package-crawler.js:518-524 + const packageJson = { + dependencies: { + 'hl7.fhir.r4.core': '4.0.1', + 'hl7.fhir.uv.ips': '1.1.0' + } + }; + + const dependencies = []; + for (const [dep, ver] of Object.entries(packageJson.dependencies)) { + dependencies.push(`${dep}#${ver}`); + } + + expect(dependencies).toEqual([ + 'hl7.fhir.r4.core#4.0.1', + 'hl7.fhir.uv.ips#1.1.0' + ]); + }); + + test('empty dependencies produces empty array', () => { + const packageJson = {}; + const dependencies = []; + if (packageJson.dependencies) { + for (const [dep, ver] of Object.entries(packageJson.dependencies)) { + dependencies.push(`${dep}#${ver}`); + } + } + expect(dependencies).toEqual([]); + }); + }); + + // Test that the reader (getPackageDependencies) correctly parses # format + describe('dependency parsing with # separator', () => { + let db; + + beforeAll((done) => { + db = new sqlite3.Database(':memory:', (err) => { + if (err) return done(err); + db.run(`CREATE TABLE PackageDependencies ( + PackageVersionKey INTEGER NOT NULL, + Dependency TEXT(128) NOT NULL + )`, done); + }); + }); + + afterAll((done) => { + db.close(done); + }); + + test('parses dependencies stored with # separator', (done) => { + const deps = [ + [1, 'hl7.fhir.r4.core#4.0.1'], + [1, 'hl7.fhir.uv.ips#1.1.0'], + [2, 'hl7.fhir.r5.core#5.0.0'] + ]; + + const inserts = deps.map(([key, dep]) => + new Promise((resolve, reject) => { + db.run('INSERT INTO PackageDependencies VALUES (?, ?)', [key, dep], + (err) => err ? reject(err) : resolve()); + }) + ); + + Promise.all(inserts).then(() => { + // Replicate getPackageDependencies logic from packages.js:1702-1734 + const packageVersionKeys = [1, 2]; + const placeholders = packageVersionKeys.map(() => '?').join(','); + const sql = `SELECT PackageVersionKey, Dependency FROM PackageDependencies WHERE PackageVersionKey IN (${placeholders})`; + + db.all(sql, packageVersionKeys, (err, rows) => { + if (err) return done(err); + + const result = {}; + for (const row of rows) { + if (!result[row.PackageVersionKey]) { + result[row.PackageVersionKey] = {}; + } + const dependency = row.Dependency; + const hashIndex = dependency.indexOf('#'); + if (hashIndex > 0) { + const depName = dependency.substring(0, hashIndex); + const depVersion = dependency.substring(hashIndex + 1); + result[row.PackageVersionKey][depName] = depVersion; + } + } + + expect(result[1]).toEqual({ + 'hl7.fhir.r4.core': '4.0.1', + 'hl7.fhir.uv.ips': '1.1.0' + }); + expect(result[2]).toEqual({ + 'hl7.fhir.r5.core': '5.0.0' + }); + done(); + }); + }); + }); + + test('dependencies with @ separator are NOT parsed (old bug format)', (done) => { + db.run('INSERT INTO PackageDependencies VALUES (?, ?)', [3, 'hl7.fhir.r4.core@4.0.1'], (err) => { + if (err) return done(err); + + db.all('SELECT PackageVersionKey, Dependency FROM PackageDependencies WHERE PackageVersionKey = 3', [], (err, rows) => { + if (err) return done(err); + + const result = {}; + for (const row of rows) { + if (!result[row.PackageVersionKey]) { + result[row.PackageVersionKey] = {}; + } + const dependency = row.Dependency; + const hashIndex = dependency.indexOf('#'); + if (hashIndex > 0) { + const depName = dependency.substring(0, hashIndex); + const depVersion = dependency.substring(hashIndex + 1); + result[row.PackageVersionKey][depName] = depVersion; + } + } + + // @ format should NOT be parsed — this was the bug + expect(result[3]).toEqual({}); + done(); + }); + }); + }); + }); +});