import { afterEach, describe, expect, it, vi } from "vitest"; import { extract, configureAzureSession } from "../server/model"; import { CASES } from "../server/cases"; const sources = [ { id: "synthetic", title: "Test note", kind: "Synthetic", date: "2026-10-23", text: "Patient: Amina Stone\nShe takes Metformin 500 mg twice daily.", }, ]; const valid = { facts: [[2, "Medication: Metformin; strength: 500 mg; frequency: 2/day"]], }; const reply = (content: unknown, finish = "stop") => ({ ok: true, status: 200, headers: new Headers(), json: async () => ({ choices: [{ finish_reason: finish, message: { content } }], usage: { prompt_tokens: 100, completion_tokens: 30 }, }), }); const configured = () => configureAzureSession({ endpoint: "https://synthetic.services.ai.azure.com/openai/v1/chat/completions", key: "synthetic-test-key", model: "test-Llama", auth: "api-key", }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); describe("compact provider contract", () => { it("makes no provider call for complete labelled records", async () => { configured(); const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher); const d = await extract(CASES[0].sources, true, true); expect(d.strategy).toBe("structured"); expect(d.mode).toBe("local"); expect(d.metrics?.providerCalls).toBe(0); expect(fetcher).not.toHaveBeenCalled(); }); it("sends only unparsed lines, reattaches original provenance and preserves structured fields", async () => { configured(); const fetcher = vi.fn().mockResolvedValue(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); const d = await extract(sources, true, true); const request = JSON.parse(fetcher.mock.calls[0][1].body); expect(JSON.parse(request.messages[1].content)).toEqual([ [2, sources[0].text.split("\n")[1]], ]); expect(d.facts).toHaveLength(2); expect(d.facts[1].quote).toBe(sources[0].text.split("\n")[1]); expect(d.metrics?.outputTokens).toBe(30); expect(fetcher.mock.calls[0][1].headers["api-key"]).toBe( "synthetic-test-key", ); }); it("retries malformed JSON once and records calls and total token usage", async () => { configured(); const fetcher = vi .fn() .mockResolvedValueOnce(reply("not JSON")) .mockResolvedValueOnce(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); const d = await extract(sources, true, true); expect(d.attempts).toBe(2); expect(d.metrics?.providerCalls).toBe(2); expect(d.metrics?.outputTokens).toBe(60); }); it("does not report partial token totals after a network failure", async () => { configured(); const fetcher = vi .fn() .mockRejectedValueOnce(new Error("network")) .mockResolvedValueOnce(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); const d = await extract(sources, true, true); expect(d.metrics?.providerCalls).toBe(2); expect(d.metrics?.inputTokens).toBeNull(); expect(d.metrics?.outputTokens).toBeNull(); }); it("stops after two malformed replies", async () => { configured(); const fetcher = vi.fn().mockResolvedValue(reply("not JSON")); vi.stubGlobal("fetch", fetcher); await expect(extract(sources, true, true)).rejects.toThrow("both attempts"); expect(fetcher).toHaveBeenCalledTimes(2); }); it("retries a schema mismatch without accepting partial values", async () => { configured(); const fetcher = vi .fn() .mockResolvedValueOnce(reply('{"facts":[{"bad":"shape"}]}')) .mockResolvedValueOnce(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); expect((await extract(sources, true, true)).attempts).toBe(2); }); it.each([ ["content_filter", "blocked"], ["length", "output limit"], ])("never retries %s responses", async (finish, message) => { configured(); const fetcher = vi.fn().mockResolvedValue(reply('{"facts":[]}', finish)); vi.stubGlobal("fetch", fetcher); await expect(extract(sources, true, true)).rejects.toThrow(message); expect(fetcher).toHaveBeenCalledTimes(1); }); it("rejects a non-string provider response", async () => { configured(); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(reply({ unexpected: "object" })), ); await expect(extract(sources, true, true)).rejects.toThrow("no text"); }); it("retries transient network failure once without returning provider secrets", async () => { configured(); const fetcher = vi.fn().mockRejectedValue(new Error("secret response")); vi.stubGlobal("fetch", fetcher); await expect(extract(sources, true, true)).rejects.toThrow("bounded retry"); expect(fetcher).toHaveBeenCalledTimes(2); }); it("retries a transient HTTP failure once", async () => { configured(); const fetcher = vi .fn() .mockResolvedValueOnce({ ok: false, status: 503, headers: new Headers() }) .mockResolvedValueOnce(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); const d = await extract(sources, true, true); expect(d.metrics?.providerCalls).toBe(2); expect(d.metrics?.inputTokens).toBeNull(); }); it.each([401, 403])( "never retries authorization failure %s", async (status) => { configured(); const fetcher = vi .fn() .mockResolvedValue({ ok: false, status, headers: new Headers() }); vi.stubGlobal("fetch", fetcher); await expect(extract(sources, true, true)).rejects.toThrow("rejected"); expect(fetcher).toHaveBeenCalledTimes(1); }, ); it("honors rate limiting beyond the retry budget", async () => { configured(); const fetcher = vi .fn() .mockResolvedValue({ ok: false, status: 429, headers: new Headers({ "Retry-After": "10" }), }); vi.stubGlobal("fetch", fetcher); await expect(extract(sources, true, true)).rejects.toThrow("busy"); expect(fetcher).toHaveBeenCalledTimes(1); }); it("bounds retries when transport consumes the total time budget", async () => { configured(); const fetcher = vi.fn().mockImplementation(async () => { vi.spyOn(performance, "now").mockReturnValue(99999999); throw new Error("timeout"); }); vi.stubGlobal("fetch", fetcher); try { await expect(extract(sources, true, true)).rejects.toThrow("time budget"); expect(fetcher).toHaveBeenCalledTimes(1); } finally { vi.restoreAllMocks(); } }); it("does not retry invalid source references", async () => { configured(); const fetcher = vi .fn() .mockResolvedValue(reply('{"facts":[[99,"Age: 62"]]}')); vi.stubGlobal("fetch", fetcher); await expect(extract(sources, true, true)).rejects.toThrow( "unknown source", ); expect(fetcher).toHaveBeenCalledTimes(1); }); it("reuses a validated draft only for unchanged sources and records a cache hit", async () => { configured(); const fetcher = vi.fn().mockResolvedValue(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); const first = await extract(sources, true); const second = await extract(sources, true); expect(first.mode).toBe("model"); expect(second.strategy).toBe("cached"); expect(second.metrics?.providerCalls).toBe(0); expect(fetcher).toHaveBeenCalledTimes(1); second.facts[0].label = "Mutated"; expect((await extract(sources, true)).facts[0].label).toBe("Amina Stone"); await extract(sources, true, true); expect(fetcher).toHaveBeenCalledTimes(2); }); it("invalidates cached results after changing sources or reconnecting", async () => { configured(); const fetcher = vi.fn().mockResolvedValue(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); await extract(sources, true); await extract( sources.map((s) => ({ ...s, title: "Revised title" })), true, ); configured(); await extract(sources, true); expect(fetcher).toHaveBeenCalledTimes(3); }); it("expires cached results after three minutes", async () => { configured(); vi.useFakeTimers(); const fetcher = vi.fn().mockResolvedValue(reply(JSON.stringify(valid))); vi.stubGlobal("fetch", fetcher); await extract(sources, true); vi.setSystemTime(Date.now() + 181000); await extract(sources, true); expect(fetcher).toHaveBeenCalledTimes(2); }); });