Files
HealthLog/tests/integration/insights-provider-chain.test.ts
T
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00

287 lines
9.5 KiB
TypeScript

/**
* Integration test for v1.4.16 phase B5b — multi-provider redundancy.
*
* Scenario: a user has an OpenAI key configured AND the operator has
* an admin-OpenAI key configured. The OpenAI primary is deliberately
* broken (its base URL points to an absent localhost port → fetch
* throws ECONNREFUSED). The chain runner must cascade past the broken
* primary to the working secondary (admin-OpenAI, mocked to return a
* valid insight payload) and the route must surface a successful
* response with `chainProviderType: "admin-openai"` recorded in the
* audit log.
*
* Counter-scenario: when both providers are broken, the response is a
* 503 with the expected error envelope.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Stub feature extraction + prompt-building so the test stays focused
// on the chain semantics rather than the prompt's real content. Both
// modules are re-exported from inside the route via @/lib/insights so
// the standard module mock works.
vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(async () => ({ stub: true })),
}));
vi.mock("@/lib/insights/prompt", () => ({
getInsightsSystemPrompt: vi.fn(() => "system"),
buildUserPrompt: vi.fn(() => "user"),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
})),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
// Wipe the in-process last-working cache so test order doesn't
// leak the fallback decision from one case into the next.
const { clearLastWorkingProviderCache } =
await import("@/lib/ai/provider-runner");
clearLastWorkingProviderCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
const VALID_LEGACY_INSIGHT = JSON.stringify({
insightType: "general",
summary: "ok",
classification: "gut",
findings: [],
correlations: [],
recommendations: [],
dataQuality: { coverage: "fair", gaps: [], confidence: "mittel" },
disclaimer: "consult your doctor",
});
async function seedUserWithKey(): Promise<{ userId: string }> {
const prisma = getPrismaClient();
const { encrypt } = await import("@/lib/crypto");
const user = await prisma.user.create({
data: {
username: "chain-user",
email: "chain@example.test",
role: "USER",
// Two user-level keys so the chain has two real candidates
// (`openai` then `anthropic`) WITHOUT touching `app_settings`.
// The testcontainer's migration history pre-dates several
// `app_settings` columns added through schema-only edits
// (default_locale, umami, ntfy_global, etc.), so triggering the
// admin-openai resolution path (which reads every column) blows
// up. Constrain the chain to user-level providers and we don't
// need to seed app_settings at all.
aiOpenaiKeyEncrypted: encrypt("sk-user-key"),
aiAnthropicKeyEncrypted: encrypt("sk-ant-secondary"),
aiProviderChain: [
{ providerType: "openai", priority: 1, enabled: true },
{ providerType: "anthropic", priority: 2, enabled: true },
] as never,
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return { userId: user.id };
}
function jsonRequest(): Request {
return new Request("http://localhost/api/insights/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ force: true }),
});
}
interface SuccessEnvelope {
data: { insights: unknown; cached: boolean };
}
interface ErrorEnvelope {
data: null;
error: string;
}
/**
* Helpers for the route-off-fetch-by-URL pattern. The OpenAIClient
* posts to `<base>/chat/completions`; the AnthropicClient posts to
* `<base>/messages`. Both target distinct hosts in production, so the
* URL prefix is enough to disambiguate which provider made the call
* regardless of header shape.
*/
function isOpenAICall(input: RequestInfo | URL): boolean {
const url =
input instanceof URL
? input.href
: typeof input === "string"
? input
: input.url;
return url.includes("/chat/completions");
}
function isAnthropicCall(input: RequestInfo | URL): boolean {
const url =
input instanceof URL
? input.href
: typeof input === "string"
? input
: input.url;
return url.includes("/messages");
}
function anthropicSuccess(): Response {
return new Response(
JSON.stringify({
content: [{ type: "text", text: VALID_LEGACY_INSIGHT }],
usage: { input_tokens: 10, output_tokens: 32 },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
describe("POST /api/insights/generate — chain fallback", () => {
it("falls back from broken OpenAI primary to Anthropic secondary when the primary fails", async () => {
await seedUserWithKey();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (input: RequestInfo | URL) => {
if (isOpenAICall(input)) {
return new Response(
JSON.stringify({ error: { code: "invalid_api_key" } }),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
if (isAnthropicCall(input)) {
return anthropicSuccess();
}
throw new Error(`unexpected URL: ${String(input)}`);
});
const { POST } = await import("@/app/api/insights/generate/route");
const res = await POST(jsonRequest() as never);
expect(res.status).toBe(200);
const body = (await res.json()) as SuccessEnvelope;
expect(body.data.cached).toBe(false);
// Both calls fired (primary 401, secondary success).
expect(fetchSpy).toHaveBeenCalledTimes(2);
// Audit log carries the working provider type + hop count.
const prisma = getPrismaClient();
const audit = await prisma.auditLog.findFirst({
where: { action: "insights.generate" },
orderBy: { createdAt: "desc" },
});
expect(audit).not.toBeNull();
const details = JSON.parse(audit!.details!);
expect(details.chainProviderType).toBe("anthropic");
expect(details.fallbackHopCount).toBe(1);
});
it("returns 503 when every chain entry fails hard", async () => {
await seedUserWithKey();
// Every fetch fails with 503 (upstream brown-out).
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("upstream down", { status: 503 }),
);
const { POST } = await import("@/app/api/insights/generate/route");
const res = await POST(jsonRequest() as never);
expect(res.status).toBe(503);
const body = (await res.json()) as ErrorEnvelope;
expect(body.error).toMatch(/temporarily unavailable/i);
});
it("caches the working provider so the next call skips the broken primary", async () => {
await seedUserWithKey();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (input: RequestInfo | URL) => {
if (isOpenAICall(input)) {
return new Response("nope", { status: 401 });
}
if (isAnthropicCall(input)) {
return anthropicSuccess();
}
throw new Error(`unexpected URL: ${String(input)}`);
});
const { POST } = await import("@/app/api/insights/generate/route");
// First call: OpenAI fails 401, Anthropic succeeds (2 fetches).
const res1 = await POST(jsonRequest() as never);
expect(res1.status).toBe(200);
const callsAfterFirst = fetchSpy.mock.calls.length;
expect(callsAfterFirst).toBe(2);
// Force a fresh generation (bypass the 24h DB cache).
const prisma = getPrismaClient();
await prisma.user.update({
where: { username: "chain-user" },
data: { insightsCachedAt: null, insightsCachedText: null },
});
// Second call: cache should reorder so Anthropic is the first
// attempt — only ONE more fetch call (no wasted OpenAI 401).
const res2 = await POST(jsonRequest() as never);
expect(res2.status).toBe(200);
expect(fetchSpy.mock.calls.length).toBe(callsAfterFirst + 1);
// Audit log carries fallback_hop_count: 0 on the second call.
const audits = await prisma.auditLog.findMany({
where: { action: "insights.generate" },
orderBy: { createdAt: "asc" },
});
expect(audits).toHaveLength(2);
const second = JSON.parse(audits[1].details!);
expect(second.fallbackHopCount).toBe(0);
expect(second.chainProviderType).toBe("anthropic");
});
});