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 */
2118import { 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
2424const 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
7673describe ( "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
196203describe ( "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