mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Unit tests for the turn budget and the no-progress brake, the budgeted loop (every stop reason, the round-by-round ledger, repeated calls, questions, comparisons, reasoning and provider state), the trail recorder and its screen, clarifications through the tool and the brake, the comparison table and chart, the trail column and its owner-only read, the reply frames, and the turn's frames end to end through the route. An integration test proves the trail against a real database. Eval scenarios cover a multi-round why question, a period comparison with its chart, a question asked through the tool, and keep the charts the existing table scenarios already showed. Take the effective reasoning level from the route rather than resolving it in the turn; the turn only caps it when the operator pays.
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
/**
|
|
* v1.41 — the trail of a Coach reply against a real Postgres: the model text
|
|
* is sealed in `coach_messages.trail_encrypted` (never in the plaintext
|
|
* provenance), the conversation read leaves it out, the owner reads it back
|
|
* through `GET …/messages/{messageId}/trail`, another account gets 404, and
|
|
* nothing is served while the Coach's text may not be shown.
|
|
*/
|
|
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";
|
|
|
|
const serve = vi.hoisted(() => ({ available: true }));
|
|
|
|
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),
|
|
}));
|
|
|
|
// Whether the record's Coach text may be shown is its own suite's question
|
|
// (`ai-optional-*`); here it is a switch.
|
|
vi.mock("@/lib/ai/capabilities/gate", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
aiCapabilityToServe: vi.fn(async () =>
|
|
serve.available
|
|
? { available: true, reason: null, onDeviceAllowed: true }
|
|
: {
|
|
available: false,
|
|
reason: "disabled_by_user",
|
|
onDeviceAllowed: false,
|
|
},
|
|
),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
serve.available = true;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedUserWithSession(username: string): Promise<string> {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({ data: { username, 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 user.id;
|
|
}
|
|
|
|
const TITLE = "Weighing the last two weeks";
|
|
const TEXT = "Sleep first, then the resting pulse beside it.";
|
|
|
|
async function seedReply(userId: string) {
|
|
const { appendMessage, createConversation } =
|
|
await import("@/lib/ai/coach/persistence");
|
|
const convo = await createConversation({ userId, title: "why" });
|
|
await appendMessage({
|
|
conversationId: convo.id,
|
|
role: "user",
|
|
content: "Why am I sleeping worse?",
|
|
});
|
|
const reply = await appendMessage({
|
|
conversationId: convo.id,
|
|
role: "assistant",
|
|
content: "Shorter nights since the 20th.",
|
|
metricSource: {
|
|
windows: [],
|
|
metrics: [],
|
|
activity: [
|
|
{
|
|
id: "a1",
|
|
phase: "thinking",
|
|
status: "done",
|
|
round: 1,
|
|
labelKey: "insights.coach.activity.thinking",
|
|
label: "Thinking…",
|
|
durationMs: 2100,
|
|
},
|
|
],
|
|
stop: { reason: "time", rounds: 5 },
|
|
},
|
|
providerType: "codex",
|
|
trail: { entries: [{ id: "a1", title: TITLE, text: TEXT }] },
|
|
});
|
|
return { conversationId: convo.id, messageId: reply.id };
|
|
}
|
|
|
|
type TrailGet = (
|
|
req: Request,
|
|
ctx: { params: Promise<{ id: string; messageId: string }> },
|
|
) => Promise<Response>;
|
|
|
|
async function getTrail(conversationId: string, messageId: string) {
|
|
const { GET } =
|
|
await import("@/app/api/insights/chat/[id]/messages/[messageId]/trail/route");
|
|
return (GET as unknown as TrailGet)(
|
|
new Request(
|
|
`http://localhost/api/insights/chat/${conversationId}/messages/${messageId}/trail`,
|
|
),
|
|
{ params: Promise.resolve({ id: conversationId, messageId }) },
|
|
);
|
|
}
|
|
|
|
describe("the trail of a Coach reply", () => {
|
|
it("is sealed in its own column and never in the plaintext provenance", async () => {
|
|
const userId = await seedUserWithSession("trail-owner");
|
|
const { messageId } = await seedReply(userId);
|
|
const row = await getPrismaClient().coachMessage.findUniqueOrThrow({
|
|
where: { id: messageId },
|
|
select: { trailEncrypted: true, metricSourceJson: true },
|
|
});
|
|
expect(row.trailEncrypted).not.toBeNull();
|
|
expect(Buffer.from(row.trailEncrypted!).toString("utf8")).not.toContain(
|
|
"Weighing",
|
|
);
|
|
expect(row.metricSourceJson).toContain('"phase":"thinking"');
|
|
expect(row.metricSourceJson).not.toContain("Weighing");
|
|
expect(row.metricSourceJson).not.toContain("resting pulse");
|
|
});
|
|
|
|
it("is read back by the owner, structure on the message, text on the trail route", async () => {
|
|
const userId = await seedUserWithSession("trail-owner");
|
|
const { conversationId, messageId } = await seedReply(userId);
|
|
|
|
const { GET } = await import("@/app/api/insights/chat/[id]/route");
|
|
const detail = await (
|
|
GET as unknown as (
|
|
req: Request,
|
|
ctx: { params: Promise<{ id: string }> },
|
|
) => Promise<Response>
|
|
)(new Request(`http://localhost/api/insights/chat/${conversationId}`), {
|
|
params: Promise.resolve({ id: conversationId }),
|
|
});
|
|
const body = (await detail.json()) as {
|
|
data: {
|
|
messages: Array<{ metricSource: Record<string, unknown> | null }>;
|
|
};
|
|
};
|
|
const stored = body.data.messages[1].metricSource!;
|
|
expect(stored.activity).toEqual([
|
|
expect.objectContaining({ id: "a1", phase: "thinking" }),
|
|
]);
|
|
expect(stored.stop).toEqual({ reason: "time", rounds: 5 });
|
|
expect(JSON.stringify(body)).not.toContain("Weighing");
|
|
|
|
const res = await getTrail(conversationId, messageId);
|
|
expect(res.status).toBe(200);
|
|
const trail = (await res.json()) as {
|
|
data: { trail: { entries: unknown[] } | null };
|
|
};
|
|
expect(trail.data.trail).toEqual({
|
|
entries: [{ id: "a1", title: TITLE, text: TEXT }],
|
|
});
|
|
});
|
|
|
|
it("is 404 for another account, never 403", async () => {
|
|
const ownerId = await seedUserWithSession("trail-owner");
|
|
const { conversationId, messageId } = await seedReply(ownerId);
|
|
await seedUserWithSession("trail-other");
|
|
const res = await getTrail(conversationId, messageId);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("serves no text while the Coach's text may not be shown", async () => {
|
|
const userId = await seedUserWithSession("trail-owner");
|
|
const { conversationId, messageId } = await seedReply(userId);
|
|
serve.available = false;
|
|
const res = await getTrail(conversationId, messageId);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { trail: unknown; ai: { available: boolean } };
|
|
};
|
|
expect(body.data.trail).toBeNull();
|
|
expect(body.data.ai.available).toBe(false);
|
|
});
|
|
|
|
it("goes with the conversation when it is deleted", async () => {
|
|
const userId = await seedUserWithSession("trail-owner");
|
|
const { conversationId, messageId } = await seedReply(userId);
|
|
const { deleteConversation } = await import("@/lib/ai/coach/persistence");
|
|
await expect(deleteConversation(userId, conversationId)).resolves.toBe(
|
|
true,
|
|
);
|
|
const left = await getPrismaClient().coachMessage.findUnique({
|
|
where: { id: messageId },
|
|
});
|
|
expect(left).toBeNull();
|
|
});
|
|
});
|