Skip to content

Commit 9ea9736

Browse files
Seryizaipasechnikov
authored andcommitted
fix terminology tests on ci
1 parent 17ea41d commit 9ea9736

3 files changed

Lines changed: 131 additions & 113 deletions

File tree

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,19 @@ function extractDesignation(
7777
}
7878

7979
export async function searchLoincCodes(
80-
query: string
80+
query: string,
81+
// Injectable for tests — defaults to the real aidboxFetch. This is
82+
// NOT for general dependency-injection use; it exists specifically
83+
// because `mock.module`-based stubbing of this file's aidbox import
84+
// is unreliable across Bun versions / test-ordering on CI (see
85+
// test/unit/code-mapping/terminology-api.test.ts for details).
86+
fetchFn: typeof aidboxFetch = aidboxFetch,
8187
): Promise<LoincSearchResult[]> {
8288
const encodedQuery = encodeURIComponent(query);
8389
const path = `/fhir/ValueSet/$expand?url=http://loinc.org/vs&filter=${encodedQuery}&count=10`;
8490

8591
const response = await withRetry(() =>
86-
aidboxFetch<ValueSetExpansion>(path)
92+
fetchFn<ValueSetExpansion>(path)
8793
);
8894

8995
const contains = response.expansion?.contains || [];
@@ -104,13 +110,14 @@ export interface LoincValidationResult {
104110
}
105111

106112
export async function validateLoincCode(
107-
code: string
113+
code: string,
114+
fetchFn: typeof aidboxFetch = aidboxFetch,
108115
): Promise<LoincValidationResult | null> {
109116
const path = `/fhir/CodeSystem/$lookup?system=http://loinc.org&code=${encodeURIComponent(code)}`;
110117

111118
try {
112119
const response = await withRetry(() =>
113-
aidboxFetch<CodeSystemLookupResult>(path)
120+
fetchFn<CodeSystemLookupResult>(path)
114121
);
115122

116123
const displayParam = response.parameter?.find((p) => p.name === "display");
Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
/**
22
* Public entry for Terminology API (LOINC search + validation).
33
*
4-
* The real implementation lives in `./terminology-api-impl`. This file
5-
* is a thin re-export so tests that stub this module (via `mock.module`
6-
* — process-wide in Bun) don't also stub the implementation behind it:
7-
* `test/unit/code-mapping/terminology-api.test.ts` imports the
8-
* implementation directly (`./terminology-api-impl`) to sidestep that
9-
* stub. Everything else in the codebase imports from here.
4+
* The real implementation lives in `./loinc-terminology`. Keeping the
5+
* implementation under a dissimilar filename lets test code import the
6+
* impl directly without colliding with any `mock.module` that stubs the
7+
* public `terminology-api` path. The impl also exposes an optional
8+
* `fetchFn` parameter on each function for tests that want to bypass
9+
* module-level mocking entirely — see the docs in
10+
* `src/code-mapping/loinc-terminology.ts` and
11+
* `test/unit/code-mapping/terminology-api.test.ts`.
1012
*/
1113
export {
1214
searchLoincCodes,
1315
validateLoincCode,
1416
type LoincSearchResult,
1517
type LoincValidationResult,
16-
} from "./terminology-api-impl";
18+
} from "./loinc-terminology";
Lines changed: 111 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
/**
22
* Tests for Terminology API (LOINC search + validation).
33
*
4-
* Imports from `terminology-api-impl` (not `terminology-api`) on purpose:
5-
* `test/unit/api/terminology-suggest.test.ts` installs a process-wide
6-
* `mock.module("../../../src/code-mapping/terminology-api", ...)` that
7-
* stubs `searchLoincCodes`. In Bun 1.3.12+, `mock.restore()` doesn't
8-
* reliably revert a file-level `mock.module` once the module is cached,
9-
* so any test file that runs after terminology-suggest.test.ts and
10-
* imports from the public `terminology-api` path gets the stub rather
11-
* than the real implementation. Importing the impl module directly
12-
* bypasses that stub.
4+
* Why this file does NOT use `mock.module`:
5+
* Other test files install process-wide `mock.module` stubs that can
6+
* bleed into this file's imports. In Bun 1.3.12 the effect is
7+
* test-order- and filesystem-dependent: CI (Ubuntu) consistently
8+
* inherits `test/unit/api/terminology-suggest.test.ts`'s stub on
9+
* `terminology-api` even when we import from a different path, and
10+
* even with `afterAll(mock.restore)`. Re-registering `mock.module`
11+
* per-test is also unreliable after the target module has been cached.
1312
*
14-
* Uses the mutable-factory mock pattern (same as
15-
* test/unit/ui/unmapped.test.ts): `mock.module` runs once at file load
16-
* with a factory whose `aidboxFetch` delegates to a mutable `fetchImpl`.
17-
* Tests swap `fetchImpl` per case instead of re-registering `mock.module`
18-
* per test — re-registering is unreliable under Bun 1.3.12+ once the
19-
* dependent module has been imported.
13+
* Instead we test the impl module (`loinc-terminology.ts`) via its
14+
* injectable `fetchFn` parameter — each test passes an in-place fake
15+
* and asserts on its behavior. No module mocking, no cross-file
16+
* pollution, no ordering sensitivity.
2017
*/
2118
import { describe, test, expect, mock, beforeEach } from "bun:test";
22-
import * as realAidbox from "../../../src/aidbox";
19+
import {
20+
searchLoincCodes,
21+
validateLoincCode,
22+
} from "../../../src/code-mapping/loinc-terminology";
2323

2424
const sampleValueSetExpansion = {
2525
expansion: {
@@ -60,33 +60,29 @@ const sampleCodeSystemLookup = {
6060
],
6161
};
6262

63-
// Mutable fetch implementation swapped per test.
64-
let fetchImpl: (path: string) => Promise<unknown> = () =>
65-
Promise.reject(new Error("aidboxFetch not stubbed for this test"));
66-
67-
mock.module("../../../src/aidbox", () => ({
68-
...realAidbox,
69-
aidboxFetch: (path: string) => fetchImpl(path),
70-
}));
71-
72-
const { searchLoincCodes, validateLoincCode } = await import(
73-
"../../../src/code-mapping/terminology-api-impl"
74-
);
63+
/**
64+
* Build a minimal fake aidboxFetch whose T-parameter is discarded —
65+
* good enough for stubbing the two callsites inside loinc-terminology.
66+
*/
67+
function fakeFetch<T>(handler: (path: string) => Promise<unknown>) {
68+
return (async (path: string) => handler(path)) as unknown as <U = T>(
69+
path: string,
70+
) => Promise<U>;
71+
}
7572

7673
describe("searchLoincCodes", () => {
74+
let calledPath = "";
75+
7776
beforeEach(() => {
78-
fetchImpl = () => Promise.reject(new Error("fetchImpl not set in test"));
77+
calledPath = "";
7978
});
8079

8180
test("searches by text query and returns up to 10 results", async () => {
82-
let calledPath = "";
8381
const spy = mock((path: string) => {
8482
calledPath = path;
8583
return Promise.resolve(sampleValueSetExpansion);
8684
});
87-
fetchImpl = spy;
88-
89-
const results = await searchLoincCodes("potassium");
85+
const results = await searchLoincCodes("potassium", fakeFetch(spy));
9086

9187
expect(spy).toHaveBeenCalled();
9288
expect(calledPath).toContain("ValueSet/$expand");
@@ -96,21 +92,22 @@ describe("searchLoincCodes", () => {
9692
});
9793

9894
test("searches by code (numeric-looking query)", async () => {
99-
let calledPath = "";
100-
fetchImpl = (path: string) => {
101-
calledPath = path;
102-
return Promise.resolve(sampleValueSetExpansion);
103-
};
104-
105-
await searchLoincCodes("2823");
95+
await searchLoincCodes(
96+
"2823",
97+
fakeFetch((path) => {
98+
calledPath = path;
99+
return Promise.resolve(sampleValueSetExpansion);
100+
}),
101+
);
106102

107103
expect(calledPath).toContain("filter=2823");
108104
});
109105

110106
test("returns results with code, display, and optional component/property/timing/scale", async () => {
111-
fetchImpl = () => Promise.resolve(sampleValueSetExpansion);
112-
113-
const results = await searchLoincCodes("potassium");
107+
const results = await searchLoincCodes(
108+
"potassium",
109+
fakeFetch(() => Promise.resolve(sampleValueSetExpansion)),
110+
);
114111

115112
expect(results[0]!.code).toBe("2823-3");
116113
expect(results[0]!.display).toBe("Potassium [Moles/volume] in Serum or Plasma");
@@ -121,9 +118,10 @@ describe("searchLoincCodes", () => {
121118
});
122119

123120
test("handles results without designation (optional fields)", async () => {
124-
fetchImpl = () => Promise.resolve(sampleValueSetExpansion);
125-
126-
const results = await searchLoincCodes("potassium");
121+
const results = await searchLoincCodes(
122+
"potassium",
123+
fakeFetch(() => Promise.resolve(sampleValueSetExpansion)),
124+
);
127125
const resultWithoutDesignation = results.find((r) => r.code === "39789-3");
128126

129127
expect(resultWithoutDesignation).toBeDefined();
@@ -132,80 +130,86 @@ describe("searchLoincCodes", () => {
132130
});
133131

134132
test("returns empty array when no results found", async () => {
135-
fetchImpl = () => Promise.resolve({ expansion: { contains: [] } });
136-
137-
const results = await searchLoincCodes("nonexistent");
133+
const results = await searchLoincCodes(
134+
"nonexistent",
135+
fakeFetch(() => Promise.resolve({ expansion: { contains: [] } })),
136+
);
138137

139138
expect(results).toEqual([]);
140139
});
141140

142141
test("returns empty array when expansion.contains is undefined", async () => {
143-
fetchImpl = () => Promise.resolve({ expansion: {} });
144-
145-
const results = await searchLoincCodes("test");
142+
const results = await searchLoincCodes(
143+
"test",
144+
fakeFetch(() => Promise.resolve({ expansion: {} })),
145+
);
146146

147147
expect(results).toEqual([]);
148148
});
149149

150150
test("retries on transient failure (2 retries)", async () => {
151151
let callCount = 0;
152-
fetchImpl = () => {
153-
callCount++;
154-
if (callCount < 3) {
155-
return Promise.reject(new Error("HTTP 503: Service Unavailable"));
156-
}
157-
return Promise.resolve(sampleValueSetExpansion);
158-
};
159-
160-
const results = await searchLoincCodes("potassium");
152+
const results = await searchLoincCodes(
153+
"potassium",
154+
fakeFetch(() => {
155+
callCount++;
156+
if (callCount < 3) {
157+
return Promise.reject(new Error("HTTP 503: Service Unavailable"));
158+
}
159+
return Promise.resolve(sampleValueSetExpansion);
160+
}),
161+
);
161162

162163
expect(callCount).toBe(3);
163164
expect(results.length).toBeGreaterThan(0);
164165
});
165166

166167
test("throws after max retries exceeded", async () => {
167-
fetchImpl = () => Promise.reject(new Error("HTTP 503: Service Unavailable"));
168-
169-
await expect(searchLoincCodes("potassium")).rejects.toThrow();
168+
await expect(
169+
searchLoincCodes(
170+
"potassium",
171+
fakeFetch(() => Promise.reject(new Error("HTTP 503: Service Unavailable"))),
172+
),
173+
).rejects.toThrow();
170174
});
171175

172176
test("does not retry on 4xx errors", async () => {
173177
let callCount = 0;
174-
fetchImpl = () => {
175-
callCount++;
176-
return Promise.reject(new Error("HTTP 400: Bad Request"));
177-
};
178-
179-
await expect(searchLoincCodes("potassium")).rejects.toThrow("400");
178+
await expect(
179+
searchLoincCodes(
180+
"potassium",
181+
fakeFetch(() => {
182+
callCount++;
183+
return Promise.reject(new Error("HTTP 400: Bad Request"));
184+
}),
185+
),
186+
).rejects.toThrow("400");
180187
expect(callCount).toBe(1);
181188
});
182189

183190
test("encodes special characters in query", async () => {
184-
let calledPath = "";
185-
fetchImpl = (path: string) => {
186-
calledPath = path;
187-
return Promise.resolve({ expansion: {} });
188-
};
189-
190-
await searchLoincCodes("test & query");
191+
await searchLoincCodes(
192+
"test & query",
193+
fakeFetch((path) => {
194+
calledPath = path;
195+
return Promise.resolve({ expansion: {} });
196+
}),
197+
);
191198

192199
expect(calledPath).toContain("filter=test%20%26%20query");
193200
});
194201
});
195202

196203
describe("validateLoincCode", () => {
197-
beforeEach(() => {
198-
fetchImpl = () => Promise.reject(new Error("fetchImpl not set in test"));
199-
});
200-
201204
test("returns code details when valid", async () => {
202205
let calledPath = "";
203-
fetchImpl = (path: string) => {
204-
calledPath = path;
205-
return Promise.resolve(sampleCodeSystemLookup);
206-
};
207-
208-
const result = await validateLoincCode("2823-3");
206+
const result = await validateLoincCode(
207+
"2823-3",
208+
fakeFetch((path) => {
209+
calledPath = path;
210+
return Promise.resolve(sampleCodeSystemLookup);
211+
}),
212+
);
209213

210214
expect(calledPath).toContain("CodeSystem/$lookup");
211215
expect(calledPath).toContain("system=http://loinc.org");
@@ -216,32 +220,37 @@ describe("validateLoincCode", () => {
216220
});
217221

218222
test("returns null for invalid code", async () => {
219-
fetchImpl = () => Promise.reject(new Error("HTTP 404: Not Found"));
220-
221-
const result = await validateLoincCode("INVALID-CODE");
223+
const result = await validateLoincCode(
224+
"INVALID-CODE",
225+
fakeFetch(() => Promise.reject(new Error("HTTP 404: Not Found"))),
226+
);
222227

223228
expect(result).toBeNull();
224229
});
225230

226231
test("retries on transient failure", async () => {
227232
let callCount = 0;
228-
fetchImpl = () => {
229-
callCount++;
230-
if (callCount < 2) {
231-
return Promise.reject(new Error("HTTP 503: Service Unavailable"));
232-
}
233-
return Promise.resolve(sampleCodeSystemLookup);
234-
};
235-
236-
const result = await validateLoincCode("2823-3");
233+
const result = await validateLoincCode(
234+
"2823-3",
235+
fakeFetch(() => {
236+
callCount++;
237+
if (callCount < 2) {
238+
return Promise.reject(new Error("HTTP 503: Service Unavailable"));
239+
}
240+
return Promise.resolve(sampleCodeSystemLookup);
241+
}),
242+
);
237243

238244
expect(callCount).toBe(2);
239245
expect(result).toBeDefined();
240246
});
241247

242248
test("throws after max retries on non-404 errors", async () => {
243-
fetchImpl = () => Promise.reject(new Error("HTTP 500: Internal Server Error"));
244-
245-
await expect(validateLoincCode("2823-3")).rejects.toThrow("500");
249+
await expect(
250+
validateLoincCode(
251+
"2823-3",
252+
fakeFetch(() => Promise.reject(new Error("HTTP 500: Internal Server Error"))),
253+
),
254+
).rejects.toThrow("500");
246255
});
247256
});

0 commit comments

Comments
 (0)