Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions tests/ocl/ocl-cm-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ describe('OCLConceptMapProvider', () => {
];

const getMock = jest.fn().mockImplementation((url) => {
// {source}/mappings/ — one request returns every mapping the source owns.
// Must precede the '/sources/' branch below, which would otherwise swallow it.
if (url.endsWith('/mappings/') && !url.includes('/concepts/')) {
return Promise.resolve({ data: mappings });
}
// source search — resolve canonical for SourceA
if (url.includes('/sources/') && !url.includes('/concepts/')) {
return Promise.resolve({
Expand Down Expand Up @@ -411,3 +416,181 @@ describe('OCLConceptMapProvider', () => {
});
});
});

// ---------------------------------------------------------------
// $resolveReference integration
// ---------------------------------------------------------------
describe('OCLConceptMapProvider $resolveReference integration', () => {
const { OclReferenceResolver } = require('../../tx/ocl/resolve/reference-resolver');

const SEARCH_ENDPOINT = '/orgs/TestOrg/sources/';

function makeProvider({ token = 'Token abc', post } = {}) {
const provider = new OCLConceptMapProvider({ org: 'TestOrg', token });
const httpClient = {
get: jest.fn(async () => ({ data: [] })),
post: post || jest.fn(async () => ({ data: [] }))
};
// The provider captures httpClient at construction, so rebuild the resolver
// on the mock the same way the other tests swap httpClient.
provider.httpClient = httpClient;
provider.referenceResolver = new OclReferenceResolver({
httpClient,
token,
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }
});
return { provider, httpClient };
}

function resolveReferenceReply(url) {
return jest.fn(async () => ({
data: [
{
reference_type: 'canonical',
resolved: true,
request: null,
resolution_url: url,
url_registry_entry: null,
result: { type: 'Source', short_code: 'S', url, canonical_url: 'http://x.org/cs', owner_type: 'Organization' }
}
]
}));
}

function getPaths(httpClient) {
return httpClient.get.mock.calls.map(call => call[0]);
}

const searchParams = [{ name: 'source-system', value: 'http://x.org/cs' }];

it('uses the resolved repo and skips the heuristic source search', async () => {
const { provider, httpClient } = makeProvider({
post: resolveReferenceReply('/orgs/OtherOrg/sources/S/')
});

await provider.searchConceptMaps(searchParams);

expect(httpClient.post).toHaveBeenCalledTimes(1);
// The authoritative answer makes the q= text search unnecessary.
expect(getPaths(httpClient)).not.toContain(SEARCH_ENDPOINT);
expect(getPaths(httpClient)).toContain('/orgs/OtherOrg/sources/S/mappings/');
});

it('falls back to the source search when the canonical resolves to a user-owned repo', async () => {
// Org-only policy: user artifacts are experimental and not visible through
// the terminology service, so the resolver reports them as unresolved.
const post = jest.fn(async () => ({
data: [{
reference_type: 'canonical',
resolved: true,
result: { url: '/users/joe/sources/S/', owner_type: 'User', canonical_url: 'http://x.org/cs' }
}]
}));
const { provider, httpClient } = makeProvider({ post });

await provider.searchConceptMaps(searchParams);

expect(getPaths(httpClient)).not.toContain('/users/joe/sources/S/mappings/');
expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('falls back to the source search when no token is configured', async () => {
const { provider, httpClient } = makeProvider({ token: null });

await provider.searchConceptMaps(searchParams);

expect(httpClient.post).not.toHaveBeenCalled();
expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('falls back to the source search when OCL cannot resolve the canonical', async () => {
const post = jest.fn(async () => ({
data: [{ reference_type: 'canonical', resolved: false, result: null }]
}));
const { provider, httpClient } = makeProvider({ post });

await provider.searchConceptMaps(searchParams);

expect(post).toHaveBeenCalledTimes(1);
expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('falls back to the source search when $resolveReference is unavailable', async () => {
const error = new Error('Request failed with status code 404');
error.response = { status: 404 };
const { provider, httpClient } = makeProvider({ post: jest.fn().mockRejectedValue(error) });

await provider.searchConceptMaps(searchParams);

expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('resolves mapping source canonicals in one batch, not one GET per source', async () => {
// Flow: resolve source-system (1 ref) -> fetch {source}/mappings/ -> resolve
// the from/to source canonicals of the mappings in a single batched POST.
const post = jest.fn(async (path, body) => ({
data: body.map(ref => {
const url = typeof ref === 'string' ? ref : ref.url;
const repo = url.startsWith('/') ? url : '/orgs/TestOrg/sources/A/';
return {
reference_type: url.startsWith('/') ? 'relative' : 'canonical',
resolved: true,
result: {
url: repo,
owner_type: 'Organization',
type: 'Source',
canonical_url: `http://canon.example.org${repo}`
}
};
})
}));
const get = jest.fn(async (url) => {
if (url.endsWith('/mappings/')) {
return {
data: [makeMapping({
from_source_url: '/orgs/TestOrg/sources/SourceA/',
to_source_url: '/orgs/TestOrg/sources/SourceB/'
})]
};
}
return { data: [] };
});
const { provider, httpClient } = makeProvider({ post });
httpClient.get = get;
provider.httpClient.get = get;

const results = await provider.searchConceptMaps(searchParams);

// POST #1 resolves the source-system; POST #2 is the batch with BOTH
// mapping source paths in one request.
expect(post).toHaveBeenCalledTimes(2);
expect(post.mock.calls[1][1]).toEqual([
'/orgs/TestOrg/sources/SourceA/',
'/orgs/TestOrg/sources/SourceB/'
]);
// No per-source detail GETs: only the mappings listing was fetched.
const detailGets = get.mock.calls.filter(c => /\/sources\/Source[AB]\/$/.test(c[0]));
expect(detailGets).toHaveLength(0);
// Aggregation used the canonicals from the batch.
expect(results).toHaveLength(1);
expect(results[0].jsonObj.group[0].source).toBe('http://canon.example.org/orgs/TestOrg/sources/SourceA/');
});

// Regression: searchConceptMaps used to lower-case every param value. The old
// text search tolerated it (#norm lower-cases anyway), but $resolveReference
// matches the canonical exactly, so a lower-cased URL never resolved.
it('sends the canonical to $resolveReference with its original casing', async () => {
const { provider, httpClient } = makeProvider({
post: resolveReferenceReply('/orgs/Mangara/sources/S/')
});

await provider.searchConceptMaps([
{ name: 'source-system', value: 'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara' }
]);

expect(httpClient.post).toHaveBeenCalledTimes(1);
expect(httpClient.post.mock.calls[0][1]).toEqual([
'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara'
]);
});
});
141 changes: 141 additions & 0 deletions tests/ocl/ocl-cs-default-version.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Default-version resolution for OCL CodeSystems: OCL's own resolution says the
// latest RELEASE is a source's default version (HEAD only when nothing is
// released), but discovery listings only ever report HEAD. With a token, the
// provider batch-resolves every canonical via $resolveReference and registers
// BOTH versions — the release as the default (what a versionless request gets)
// and HEAD as an explicit |HEAD variant.

const { OCLCodeSystemProvider } = require('../../tx/ocl/cs-ocl');
const { OclReferenceResolver } = require('../../tx/ocl/resolve/reference-resolver');

const CANONICAL = 'https://gov.br/anvisa/fhir/CodeSystem/cmed';

function cmedSource() {
return {
id: 'cmed',
short_code: 'cmed',
owner: 'ANVISA',
owner_type: 'Organization',
url: '/orgs/ANVISA/sources/cmed/',
canonical_url: CANONICAL,
version: 'HEAD',
concepts_url: '/orgs/ANVISA/sources/cmed/concepts/',
checksums: { standard: 'x' },
updated_at: '2026-01-01T00:00:00Z'
};
}

// $resolveReference reply: default version is the released 20230109.
function releaseReply() {
return {
reference_type: 'canonical',
resolved: true,
result: {
url: '/orgs/ANVISA/sources/cmed/',
owner: 'ANVISA',
owner_type: 'Organization',
version: '20230109',
type: 'Source Version',
canonical_url: CANONICAL
}
};
}

function makeProvider({ token = 'Token x', post } = {}) {
const provider = new OCLCodeSystemProvider({ baseUrl: 'https://ocl.example.org', token });
const httpClient = {
get: jest.fn(async (url) => {
if (url === '/sources/') {
return { data: { results: [cmedSource()], num_found: 1 } };
}
return { data: [] };
}),
post: post || jest.fn(async () => ({ data: [releaseReply()] }))
};
provider.httpClient = httpClient;
provider.referenceResolver = new OclReferenceResolver({
httpClient, token, logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }
});
return { provider, httpClient };
}

describe('OCL CodeSystem default-version resolution', () => {
it('registers the release as default and keeps HEAD as an explicit variant', async () => {
const { provider, httpClient } = makeProvider();

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

// One batched $resolveReference for the discovered canonicals.
expect(httpClient.post).toHaveBeenCalledTimes(1);
expect(httpClient.post.mock.calls[0][1]).toEqual([CANONICAL]);

// The listed CodeSystem is the release, not the HEAD draft.
expect(listed).toHaveLength(1);
expect(listed[0].jsonObj.version).toBe('20230109');

// Both versions exist; the release comes FIRST so registerProvider's
// first-wins unversioned key makes it the versionless default.
expect(metas.map(m => m.version)).toEqual(['20230109', 'HEAD']);
expect(metas[0].conceptsUrl).toBe('/orgs/ANVISA/sources/cmed/20230109/concepts/');
expect(metas[1].conceptsUrl).toBe('/orgs/ANVISA/sources/cmed/concepts/');
expect(metas[0].canonicalUrl).toBe(CANONICAL);
});

it('stays HEAD-only without a token (behaviour unchanged)', async () => {
const { provider, httpClient } = makeProvider({ token: null });

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

expect(httpClient.post).not.toHaveBeenCalled();
expect(listed[0].jsonObj.version).toBe('HEAD');
expect(metas.map(m => m.version)).toEqual(['HEAD']);
});

it('stays HEAD-only when HEAD is the default (nothing released)', async () => {
const post = jest.fn(async () => ({
data: [{
reference_type: 'canonical',
resolved: true,
result: { url: '/orgs/ANVISA/sources/cmed/', owner_type: 'Organization', version: 'HEAD', type: 'Source', canonical_url: CANONICAL }
}]
}));
const { provider } = makeProvider({ post });

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

expect(listed[0].jsonObj.version).toBe('HEAD');
expect(metas.map(m => m.version)).toEqual(['HEAD']);
});

it('does not re-resolve unchanged canonicals on refresh (steady state costs nothing)', async () => {
const { provider, httpClient } = makeProvider();

await provider.listCodeSystems('5.0', null);
expect(httpClient.post).toHaveBeenCalledTimes(1);

// Minute refresh with an unchanged listing: same checksum, no new resolve.
provider.getCodeSystemChanges('5.0', null);
await new Promise(resolve => setTimeout(resolve, 150));

expect(httpClient.get.mock.calls.filter(c => c[0] === '/sources/').length).toBeGreaterThanOrEqual(2);
expect(httpClient.post).toHaveBeenCalledTimes(1);
// The release default survived the refresh.
expect(provider.getSourceMetas().map(m => m.version)).toEqual(['20230109', 'HEAD']);
});

it('keeps discovery working when $resolveReference is unavailable', async () => {
const error = new Error('Request failed with status code 404');
error.response = { status: 404 };
const { provider } = makeProvider({ post: jest.fn().mockRejectedValue(error) });

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

// Falls back to HEAD-only — never blocks discovery.
expect(listed).toHaveLength(1);
expect(metas.map(m => m.version)).toEqual(['HEAD']);
});
});
Loading
Loading