Files
HealthLog/src/lib/ai/__tests__/provider-contract.test.ts
T
Marc-André BombeckandClaude Opus 4.7 27310e4f9a refactor(ai): consolidate providers behind AIProvider interface
Phase C1 (v1.4.15) — provider abstraction review.

Adds `MockAIProvider` (`src/lib/ai/mock-client.ts`) so contract tests
can exercise the route-level interface without burning network calls
on every CI run. The mock satisfies `AIProvider`, lets tests queue a
list of canned responses, and records every `CompletionParams`
invocation for later assertion.

Adds a contract test file (`provider-contract.test.ts`) that walks
every concrete provider — Codex, OpenAI, Anthropic, Local, Mock — and
asserts each one returns a `CompletionResult` with the four
contract-required fields (`content`, `tokensUsed`, `model`,
`providerType`). Network is stubbed per provider; the test exists to
catch interface drift, not to retest each provider's wire format.

The route at `/api/insights/generate` already imports the abstract
`AIProvider` via `resolveProvider()`, so the consumer side needs no
change — this commit ratchets the contract with explicit tests so
future provider additions (medical-grounding, multi-provider redundancy
in v1.4.16) cannot silently regress the interface.

Tests: +7 (132 → 139 ai-domain). 972 / 972 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:42:22 +02:00

209 lines
6.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { MockAIProvider } from "../mock-client";
import { OpenAIClient } from "../openai-client";
import { AnthropicClient } from "../anthropic-client";
import { LocalOpenAICompatibleClient } from "../local-client";
import { CodexClient } from "../codex-client";
import type { AIProvider, CompletionResult } from "../types";
/**
* Provider-contract tests: every concrete AIProvider implementation must
* satisfy the same shape — return a `CompletionResult` with the four
* required fields populated. This is the "consolidated behind one
* interface" guarantee from Phase C1 acceptance criterion #1.
*
* Each test stubs the provider's network dependency and asserts the
* shape on a happy path. Error paths live in each provider's dedicated
* test file (codex-client.test.ts, openai-client.test.ts, …).
*/
function assertCompletionResult(result: CompletionResult): void {
expect(typeof result.content).toBe("string");
expect(result.content.length).toBeGreaterThan(0);
expect(["codex", "admin-key", "anthropic", "local"]).toContain(
result.providerType,
);
expect(typeof result.model).toBe("string");
expect(
result.tokensUsed === null || typeof result.tokensUsed === "number",
).toBe(true);
}
describe("AIProvider contract", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("MockAIProvider — default response satisfies CompletionResult shape", async () => {
const provider: AIProvider = new MockAIProvider();
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
assertCompletionResult(result);
expect(result.providerType).toBe("local");
expect(result.model).toBe("mock-model");
});
it("MockAIProvider — records calls and walks the response queue", async () => {
const mock = new MockAIProvider({
responses: ['{"a":1}', '{"a":2}'],
tokensUsed: [10, 20],
providerType: "codex",
});
const r1 = await mock.generateCompletion({
systemPrompt: "s",
userPrompt: "u1",
});
const r2 = await mock.generateCompletion({
systemPrompt: "s",
userPrompt: "u2",
});
const r3 = await mock.generateCompletion({
systemPrompt: "s",
userPrompt: "u3",
});
expect(r1.content).toBe('{"a":1}');
expect(r1.tokensUsed).toBe(10);
expect(r2.content).toBe('{"a":2}');
expect(r2.tokensUsed).toBe(20);
// Last entry repeats once the queue is exhausted.
expect(r3.content).toBe('{"a":2}');
expect(mock.callCount).toBe(3);
expect(mock.calls.map((c) => c.userPrompt)).toEqual(["u1", "u2", "u3"]);
});
it("MockAIProvider — rejectWith causes generateCompletion to throw", async () => {
const mock = new MockAIProvider({ rejectWith: new Error("boom") });
await expect(
mock.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
).rejects.toThrow("boom");
});
it("OpenAIClient — happy path returns CompletionResult shape", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"summary":"ok"}' } }],
usage: { total_tokens: 17 },
}),
}),
);
const provider: AIProvider = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
assertCompletionResult(result);
expect(result.providerType).toBe("admin-key");
expect(result.model).toBe("gpt-4o-mini");
expect(result.tokensUsed).toBe(17);
});
it("AnthropicClient — happy path returns CompletionResult shape", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
content: [{ type: "text", text: '{"summary":"ok"}' }],
usage: { input_tokens: 5, output_tokens: 9 },
}),
}),
);
const provider: AIProvider = new AnthropicClient({
apiKey: "sk-ant-test",
model: "claude-3-5-sonnet-latest",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
assertCompletionResult(result);
expect(result.providerType).toBe("anthropic");
expect(result.tokensUsed).toBe(14);
});
it("LocalOpenAICompatibleClient — happy path returns CompletionResult shape", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"summary":"ok"}' } }],
usage: { total_tokens: 11 },
}),
}),
);
const provider: AIProvider = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3:8b",
baseUrl: "http://localhost:11434/v1",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
assertCompletionResult(result);
expect(result.providerType).toBe("local");
});
it("CodexClient — happy path returns CompletionResult shape", async () => {
const encoder = new TextEncoder();
const events = [
`event: response.output_item.done\ndata: ${JSON.stringify({
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: '{"summary":"ok"}' }],
},
})}\n\n`,
`event: response.completed\ndata: ${JSON.stringify({
type: "response.completed",
response: { id: "resp1", usage: { total_tokens: 23 } },
})}\n\n`,
];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const ev of events) controller.enqueue(encoder.encode(ev));
controller.close();
},
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
),
);
const provider: AIProvider = new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
assertCompletionResult(result);
expect(result.providerType).toBe("codex");
expect(result.tokensUsed).toBe(23);
});
});