mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The route gates on requireAuth (cookie or bearer), enforces the daily token budget, runs the prompt-injection / off-topic refusal scanner, and only then walks the user's provider chain. The conversation is created via withIdempotency() when no conversationId is supplied, so a retry under the same Idempotency-Key replays the original assistant reply rather than spinning up a second thread. Existing-thread turns skip the wrap. Replies stream as SSE: one "token" frame per word-sized chunk, then a "provenance" frame describing the windows + metrics the assistant could see, then a "done" frame carrying the persisted message id. Provider failure cascades to a single "error" frame and the assistant message is never persisted. Conversation history is capped at 20 turns in the prompt window via a synthetic summary placeholder so cost stays bounded. `buildCoachSnapshot()` reuses the analytics features pipeline so the Coach narrates the same numbers the dashboard tiles render. `getCoachSystemPrompt()` extends the strict-insight ground rules with conversational framing while keeping the zero-hallucination rule. 5 integration tests pin the round-trip, refusal short-circuit, budget 429, foreign-conversation 404, and validation 422 paths against the postgres testcontainer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
9.5 KiB
TypeScript
286 lines
9.5 KiB
TypeScript
/**
|
|
* Integration coverage for POST /api/insights/chat — the v1.4.20 AI
|
|
* Coach SSE-streaming endpoint.
|
|
*
|
|
* Scenarios pinned end-to-end against the postgres testcontainer:
|
|
*
|
|
* 1. Refusal: an off-topic message ("what is the weather") returns a
|
|
* stream containing exactly the localised refusal text and never
|
|
* reaches the provider chain. The user message and the refusal
|
|
* message both persist on disk so the rail shows the attempt.
|
|
* 2. Round-trip: a valid health question lands a mocked provider
|
|
* reply which is persisted (encrypted) and decrypts cleanly when
|
|
* the conversation is fetched back.
|
|
* 3. Budget cap: when CoachUsage already shows the day's cap spent,
|
|
* the route returns 429 with `coach.budget.exceeded` and no
|
|
* provider call happens.
|
|
* 4. Existence-leak guard: a foreign user's conversationId returns
|
|
* 404, not 403.
|
|
*/
|
|
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";
|
|
import { MAX_TOKENS_PER_USER_PER_DAY } from "@/lib/ai/coach/budget";
|
|
|
|
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 so we don't need a full measurement seed.
|
|
vi.mock("@/lib/insights/features", () => ({
|
|
extractFeatures: vi.fn(async () => ({
|
|
bloodPressure: {
|
|
avgSys30: 130,
|
|
avgDia30: 84,
|
|
coverage: { count: 14, spanDays: 30 },
|
|
},
|
|
})),
|
|
}));
|
|
|
|
vi.mock("@/lib/i18n/server-locale", () => ({
|
|
resolveServerLocale: vi.fn(async () => "en"),
|
|
}));
|
|
|
|
// Mock the provider chain runner so test cases can drive the assistant
|
|
// reply without touching a real LLM. By default the runner returns a
|
|
// canned reply; tests can replace the implementation per case.
|
|
const runProviderMock = vi.fn();
|
|
vi.mock("@/lib/ai/provider-runner", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("@/lib/ai/provider-runner")
|
|
>("@/lib/ai/provider-runner");
|
|
return {
|
|
...actual,
|
|
runRawCompletionWithFallback: (...args: unknown[]) =>
|
|
runProviderMock(...args),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/ai/provider", () => ({
|
|
resolveProviderChain: vi.fn(async () => [
|
|
{
|
|
providerType: "openai",
|
|
instance: { type: "openai", generateCompletion: vi.fn() },
|
|
},
|
|
]),
|
|
resolveProvider: vi.fn(async () => ({ type: "none" })),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
runProviderMock.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedUserWithSession(): Promise<{ userId: string }> {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "coach-user",
|
|
email: "coach@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
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 };
|
|
}
|
|
|
|
async function readStream(res: Response): Promise<string> {
|
|
if (!res.body) return "";
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let out = "";
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
out += decoder.decode(value, { stream: true });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("POST /api/insights/chat — integration", () => {
|
|
it("refuses out-of-scope messages without hitting a provider", async () => {
|
|
await seedUserWithSession();
|
|
const { POST } = await import("@/app/api/insights/chat/route");
|
|
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/chat", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
message: "What is the weather forecast for Berlin tomorrow?",
|
|
}),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const text = await readStream(res);
|
|
expect(text).toMatch(/I can only help with the health metrics/);
|
|
expect(runProviderMock).not.toHaveBeenCalled();
|
|
|
|
const prisma = getPrismaClient();
|
|
const messages = await prisma.coachMessage.findMany();
|
|
// user message + assistant refusal both persisted
|
|
expect(messages.length).toBe(2);
|
|
const refusal = messages.find((m) => m.role === "assistant");
|
|
expect(refusal?.providerType).toBe("refusal");
|
|
});
|
|
|
|
it("round-trips an assistant reply with encrypted persistence", async () => {
|
|
await seedUserWithSession();
|
|
runProviderMock.mockResolvedValue({
|
|
result: {
|
|
content:
|
|
"Your 30-day systolic average is 130 mmHg, in the elevated band — please consult your doctor.",
|
|
tokensUsed: 256,
|
|
model: "mock",
|
|
providerType: "openai",
|
|
},
|
|
workingProvider: { providerType: "openai", instance: {} },
|
|
fallbackHops: [],
|
|
});
|
|
const { POST } = await import("@/app/api/insights/chat/route");
|
|
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/chat", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
message: "How is my blood pressure trending this month?",
|
|
}),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const text = await readStream(res);
|
|
// Tokens are streamed word-by-word — concatenate the token bodies
|
|
// to recover the assistant reply for the substring assertion.
|
|
const tokens: string[] = [];
|
|
for (const m of text.matchAll(/"type":"token","token":"([^"]*)"/g)) {
|
|
tokens.push(m[1]);
|
|
}
|
|
expect(tokens.join("")).toMatch(/Your 30-day systolic average is 130/);
|
|
expect(text).toMatch(/"type":"done"/);
|
|
expect(text).toMatch(/"type":"provenance"/);
|
|
|
|
const prisma = getPrismaClient();
|
|
const messages = await prisma.coachMessage.findMany({
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
expect(messages.length).toBe(2);
|
|
expect(messages[0].role).toBe("user");
|
|
expect(messages[1].role).toBe("assistant");
|
|
// Encrypted bytes: must NOT contain the cleartext substring.
|
|
const buf = Buffer.from(messages[1].encryptedContent);
|
|
expect(buf.toString("utf8")).not.toMatch(/elevated band/);
|
|
|
|
// CoachUsage row bumped with the provider's token figure.
|
|
const usage = await prisma.coachUsage.findFirst();
|
|
expect(usage?.totalTokens).toBe(256);
|
|
expect(usage?.messageCount).toBe(1);
|
|
});
|
|
|
|
it("returns 429 when daily token budget is exhausted", async () => {
|
|
const { userId } = await seedUserWithSession();
|
|
const prisma = getPrismaClient();
|
|
const dateKey = new Date().toISOString().slice(0, 10);
|
|
await prisma.coachUsage.create({
|
|
data: {
|
|
userId,
|
|
dateKey,
|
|
totalTokens: MAX_TOKENS_PER_USER_PER_DAY,
|
|
messageCount: 50,
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/insights/chat/route");
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/chat", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
message: "Why did my pulse spike yesterday?",
|
|
}),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(429);
|
|
const env = (await res.json()) as { error: string };
|
|
expect(env.error).toBe("coach.budget.exceeded");
|
|
expect(runProviderMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 (not 403) when conversationId belongs to another user", async () => {
|
|
// Seed a foreign user's conversation
|
|
const prisma = getPrismaClient();
|
|
const foreign = await prisma.user.create({
|
|
data: { username: "foreign", role: "USER" },
|
|
});
|
|
const foreignConvo = await prisma.coachConversation.create({
|
|
data: { userId: foreign.id, title: "secret" },
|
|
});
|
|
|
|
// Authenticate as a different user
|
|
await seedUserWithSession();
|
|
|
|
const { POST } = await import("@/app/api/insights/chat/route");
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/chat", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
message: "Check my blood pressure trend.",
|
|
conversationId: foreignConvo.id,
|
|
}),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("rejects malformed body with 422", async () => {
|
|
await seedUserWithSession();
|
|
const { POST } = await import("@/app/api/insights/chat/route");
|
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/insights/chat", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ message: "" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
});
|