mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
When the model emits only a `---KEYVALUES---` block with no leading prose (or a malformed block missing the closing marker), the post-strip prose is empty. The previous fallback chained back to `rawReply`, surfacing raw sentinel markers to the user. Replace with a structured `coach.provider.empty` error frame so the UI renders the localised provider-error copy instead of leaking the markers. Add a regression integration test pinning that the streamed body never contains `---KEYVALUES---` for a sentinel-only reply, and that no assistant message is persisted in this branch. Closes Code-H1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
452 lines
16 KiB
TypeScript
452 lines
16 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);
|
|
});
|
|
|
|
it("strips the ---KEYVALUES--- block and surfaces keyValues in the persisted provenance", async () => {
|
|
// v1.4.22 — sentinel round-trip. The route should parse the
|
|
// model's evidence block out of the prose, persist it onto the
|
|
// assistant message's `metricSourceJson`, and emit it on the
|
|
// SSE `provenance` frame so the disclosure has data to render.
|
|
await seedUserWithSession();
|
|
runProviderMock.mockResolvedValue({
|
|
result: {
|
|
content: [
|
|
"The last week sits a touch higher than your usual run.",
|
|
"Anything different about this week — sleep, work, travel?",
|
|
"",
|
|
"---KEYVALUES---",
|
|
"avg7 systolic: 138 [mmHg] (last7days)",
|
|
"avg30 systolic: 134 [mmHg] (last30days)",
|
|
"---END---",
|
|
].join("\n"),
|
|
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);
|
|
|
|
// Streamed prose must NOT contain the raw sentinel markers.
|
|
const tokens: string[] = [];
|
|
for (const m of text.matchAll(/"type":"token","token":"([^"]*)"/g)) {
|
|
tokens.push(
|
|
m[1].replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\"),
|
|
);
|
|
}
|
|
const streamedProse = tokens.join("");
|
|
expect(streamedProse).toMatch(/sits a touch higher/);
|
|
expect(streamedProse).not.toMatch(/---KEYVALUES---/);
|
|
expect(streamedProse).not.toMatch(/---END---/);
|
|
|
|
// Provenance frame carries the parsed keyValues.
|
|
expect(text).toMatch(/"keyValues"/);
|
|
expect(text).toMatch(/avg7 systolic/);
|
|
|
|
// Persisted shape keeps the structured envelope.
|
|
const prisma = getPrismaClient();
|
|
const messages = await prisma.coachMessage.findMany({
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
expect(messages.length).toBe(2);
|
|
const assistant = messages[1];
|
|
expect(assistant.metricSourceJson).toBeTruthy();
|
|
const persisted = JSON.parse(assistant.metricSourceJson ?? "{}") as {
|
|
keyValues?: Array<{ label: string; value: string; unit?: string }>;
|
|
};
|
|
expect(persisted.keyValues?.length).toBe(2);
|
|
expect(persisted.keyValues?.[0]).toMatchObject({
|
|
label: "avg7 systolic",
|
|
value: "138",
|
|
unit: "mmHg",
|
|
window: "last7days",
|
|
});
|
|
});
|
|
|
|
it("falls through gracefully when the provider returns prose-only (no sentinel)", async () => {
|
|
// Mirrors the mock-client.ts contract — older test doubles and
|
|
// refusal fallbacks emit plain prose without the v1.4.22 sentinel.
|
|
// The route should not flag the reply malformed; the persisted
|
|
// envelope keeps the existing windows/metrics/counts without a
|
|
// `keyValues` field.
|
|
await seedUserWithSession();
|
|
runProviderMock.mockResolvedValue({
|
|
result: {
|
|
content:
|
|
"Compliance has been steady this month — that consistency is doing a lot of quiet work for you. How does that match your own sense of the month?",
|
|
tokensUsed: 128,
|
|
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 am I doing this month?" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const text = await readStream(res);
|
|
expect(text).toMatch(/"type":"done"/);
|
|
|
|
const prisma = getPrismaClient();
|
|
const messages = await prisma.coachMessage.findMany({
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
const assistant = messages.find((m) => m.role === "assistant");
|
|
const persisted = JSON.parse(assistant?.metricSourceJson ?? "{}") as {
|
|
keyValues?: unknown;
|
|
};
|
|
expect(persisted.keyValues).toBeUndefined();
|
|
});
|
|
|
|
it("never leaks raw ---KEYVALUES--- markers when the provider returns a sentinel-only reply", async () => {
|
|
// v1.4.22 W5 reconcile (Code-H1) — regression test. The model
|
|
// emits ONLY the sentinel block with no leading prose AND a
|
|
// missing close marker, so `parseKeyValuesSentinel` returns
|
|
// `{ prose: "", malformed: true }`. The route must surface the
|
|
// structured `coach.provider.empty` error frame instead of
|
|
// streaming the raw sentinel body to the client.
|
|
await seedUserWithSession();
|
|
runProviderMock.mockResolvedValue({
|
|
result: {
|
|
content: [
|
|
"---KEYVALUES---",
|
|
"avg7 systolic: 138 [mmHg] (last7days)",
|
|
// Note: no closing ---END--- and no prose before the marker.
|
|
].join("\n"),
|
|
tokensUsed: 64,
|
|
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 BP this week?" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const text = await readStream(res);
|
|
|
|
// Streamed body must NOT contain the raw sentinel markers and must
|
|
// surface the structured provider-empty error.
|
|
expect(text).not.toMatch(/---KEYVALUES---/);
|
|
expect(text).not.toMatch(/---END---/);
|
|
expect(text).toMatch(/"type":"error"/);
|
|
expect(text).toMatch(/coach\.provider\.empty/);
|
|
|
|
// No assistant message persisted — the empty-prose branch
|
|
// short-circuits before `appendMessage`.
|
|
const prisma = getPrismaClient();
|
|
const messages = await prisma.coachMessage.findMany({
|
|
where: { role: "assistant" },
|
|
});
|
|
expect(messages.length).toBe(0);
|
|
});
|
|
});
|