Skip to content

Commit 47cd264

Browse files
committed
test(models): cover ModelsDev Service with mocked HttpClient
Mocks HttpClient via HttpClient.makeWith and pre-populates the on-disk cache to drive every code path: disk-hit, disk-empty fallback, single-flight dedup, in-memory cache stickiness, refresh fetch, fresh/stale TTL skip, and HTTP-error swallow. Layer.fresh is required to defeat the process-global MemoMap so each test gets its own cachedInvalidateWithTTL state.
1 parent fdaf026 commit 47cd264

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, expect, beforeEach, afterAll } from "bun:test"
2+
import { Effect, Layer, Ref } from "effect"
3+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
4+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
5+
import { Flag } from "@opencode-ai/core/flag/flag"
6+
import { Global } from "@opencode-ai/core/global"
7+
import { ModelsDev } from "../../src/provider/models"
8+
import { it } from "../lib/effect"
9+
import { rm, writeFile, utimes, mkdir } from "fs/promises"
10+
import path from "path"
11+
12+
// test/preload.ts sets OPENCODE_MODELS_PATH to a static fixture and leaves
13+
// MODELS_FETCH enabled — both flags are captured at flag-module load time,
14+
// which is too early for env mutation to take effect. Mutate the resolved Flag
15+
// values directly; they're read inside Layer.effect when each test builds.
16+
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
17+
Flag.OPENCODE_MODELS_PATH = undefined
18+
19+
const cacheFile = path.join(Global.Path.cache, "models.json")
20+
21+
const fixture: Record<string, ModelsDev.Provider> = {
22+
acme: {
23+
id: "acme",
24+
name: "Acme",
25+
env: ["ACME_API_KEY"],
26+
models: {
27+
"acme-1": {
28+
id: "acme-1",
29+
name: "Acme One",
30+
release_date: "2026-01-01",
31+
attachment: false,
32+
reasoning: false,
33+
temperature: true,
34+
tool_call: true,
35+
limit: { context: 128000, output: 8192 },
36+
},
37+
},
38+
},
39+
}
40+
41+
const fixture2: Record<string, ModelsDev.Provider> = {
42+
beta: {
43+
id: "beta",
44+
name: "Beta",
45+
env: ["BETA_API_KEY"],
46+
models: {
47+
"beta-1": {
48+
id: "beta-1",
49+
name: "Beta One",
50+
release_date: "2026-02-01",
51+
attachment: false,
52+
reasoning: true,
53+
temperature: false,
54+
tool_call: false,
55+
limit: { context: 64000, output: 4096 },
56+
},
57+
},
58+
},
59+
}
60+
61+
interface MockState {
62+
body: string
63+
status: number
64+
calls: Array<{ url: string }>
65+
}
66+
67+
const makeMockClient = (state: Ref.Ref<MockState>): HttpClient.HttpClient =>
68+
HttpClient.makeWith(
69+
Effect.fnUntraced(function* (requestEffect) {
70+
const request = yield* requestEffect
71+
yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] }))
72+
const s = yield* Ref.get(state)
73+
return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
74+
}),
75+
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>,
76+
)
77+
78+
const buildLayer = (state: Ref.Ref<MockState>) =>
79+
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
80+
// and Effect.provide uses a process-global MemoMap by default — without fresh,
81+
// every test would reuse the cachedInvalidateWithTTL state from the first run.
82+
Layer.fresh(ModelsDev.layer).pipe(
83+
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
84+
Layer.provide(AppFileSystem.defaultLayer),
85+
)
86+
87+
const writeCache = (data: object, mtimeMs?: number) =>
88+
Effect.promise(async () => {
89+
await mkdir(Global.Path.cache, { recursive: true })
90+
await writeFile(cacheFile, JSON.stringify(data))
91+
if (mtimeMs !== undefined) {
92+
const t = mtimeMs / 1000
93+
await utimes(cacheFile, t, t)
94+
}
95+
})
96+
97+
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
98+
eff.pipe(Effect.provide(buildLayer(state)))
99+
100+
beforeEach(async () => {
101+
await rm(cacheFile, { force: true })
102+
})
103+
104+
afterAll(async () => {
105+
await rm(cacheFile, { force: true })
106+
})
107+
108+
const initialState: MockState = {
109+
body: JSON.stringify(fixture),
110+
status: 200,
111+
calls: [],
112+
}
113+
114+
describe("ModelsDev Service", () => {
115+
it.live("get() returns providers from disk when cache file exists", () =>
116+
Effect.gen(function* () {
117+
yield* writeCache(fixture)
118+
const state = yield* Ref.make(initialState)
119+
const result = yield* provided(
120+
state,
121+
ModelsDev.Service.use((s) => s.get()),
122+
)
123+
expect(result).toEqual(fixture)
124+
const final = yield* Ref.get(state)
125+
expect(final.calls).toEqual([])
126+
}),
127+
)
128+
129+
it.live("get() returns {} when disk empty and fetch disabled", () =>
130+
Effect.gen(function* () {
131+
const state = yield* Ref.make(initialState)
132+
const result = yield* provided(
133+
state,
134+
ModelsDev.Service.use((s) => s.get()),
135+
)
136+
expect(result).toEqual({})
137+
const final = yield* Ref.get(state)
138+
expect(final.calls).toEqual([])
139+
}),
140+
)
141+
142+
it.live("get() is single-flight under concurrent calls", () =>
143+
Effect.gen(function* () {
144+
yield* writeCache(fixture)
145+
const state = yield* Ref.make(initialState)
146+
const results = yield* provided(
147+
state,
148+
Effect.gen(function* () {
149+
const svc = yield* ModelsDev.Service
150+
return yield* Effect.all(
151+
[svc.get(), svc.get(), svc.get(), svc.get(), svc.get()],
152+
{ concurrency: "unbounded" },
153+
)
154+
}),
155+
)
156+
for (const result of results) expect(result).toEqual(fixture)
157+
}),
158+
)
159+
160+
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
161+
Effect.gen(function* () {
162+
yield* writeCache(fixture)
163+
const state = yield* Ref.make(initialState)
164+
const first = yield* provided(
165+
state,
166+
Effect.gen(function* () {
167+
const svc = yield* ModelsDev.Service
168+
const a = yield* svc.get()
169+
// mutate disk between calls — cache should mask the change
170+
yield* writeCache(fixture2)
171+
const b = yield* svc.get()
172+
return { a, b }
173+
}),
174+
)
175+
expect(first.a).toEqual(fixture)
176+
expect(first.b).toEqual(fixture)
177+
}),
178+
)
179+
180+
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
181+
Effect.gen(function* () {
182+
yield* writeCache(fixture)
183+
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
184+
const result = yield* provided(
185+
state,
186+
Effect.gen(function* () {
187+
const svc = yield* ModelsDev.Service
188+
const before = yield* svc.get()
189+
yield* svc.refresh(true)
190+
const after = yield* svc.get()
191+
return { before, after }
192+
}),
193+
)
194+
expect(result.before).toEqual(fixture)
195+
expect(result.after).toEqual(fixture2)
196+
const final = yield* Ref.get(state)
197+
expect(final.calls.length).toBe(1)
198+
expect(final.calls[0].url).toContain("/api.json")
199+
}),
200+
)
201+
202+
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
203+
Effect.gen(function* () {
204+
// Fresh: mtime within the 5-minute TTL.
205+
yield* writeCache(fixture, Date.now() - 1000)
206+
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
207+
yield* provided(
208+
state,
209+
ModelsDev.Service.use((s) => s.refresh(false)),
210+
)
211+
const final = yield* Ref.get(state)
212+
expect(final.calls).toEqual([])
213+
}),
214+
)
215+
216+
it.live("refresh(false) fetches when on-disk file is stale", () =>
217+
Effect.gen(function* () {
218+
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
219+
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
220+
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
221+
const after = yield* provided(
222+
state,
223+
Effect.gen(function* () {
224+
const svc = yield* ModelsDev.Service
225+
yield* svc.refresh(false)
226+
return yield* svc.get()
227+
}),
228+
)
229+
const final = yield* Ref.get(state)
230+
expect(final.calls.length).toBe(1)
231+
expect(after).toEqual(fixture2)
232+
}),
233+
)
234+
235+
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
236+
Effect.gen(function* () {
237+
yield* writeCache(fixture)
238+
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
239+
const result = yield* provided(
240+
state,
241+
Effect.gen(function* () {
242+
const svc = yield* ModelsDev.Service
243+
yield* svc.refresh(true)
244+
return yield* svc.get()
245+
}),
246+
)
247+
// refresh logged + ignored; the cache map is whatever loadFromDisk yields next.
248+
// With the failing fetch the disk file is unchanged → still the original fixture.
249+
expect(result).toEqual(fixture)
250+
// withTransientReadRetry retries 5xx, so calls > 1 is expected.
251+
const final = yield* Ref.get(state)
252+
expect(final.calls.length).toBeGreaterThanOrEqual(1)
253+
}),
254+
)
255+
})

0 commit comments

Comments
 (0)