Files
HealthLog/tests/integration/ai-optional-actions.test.ts
T
Marc-André Bombeck 9941db1549 refactor(insights): drop the readable cached-text column
Every write has gone to insights_cached_text_encrypted since 0411, and
the boot-time free-text backfill sealed the rows written before it.
Migration 0430 drops insights_cached_text and the backfill index that
served it. A host that skipped the intervening releases loses only a
cache of model output; the next warm writes a fresh, sealed payload.

The cipher module now reads and writes the sealed column only, the
backfill job loses its insights step, and consent withdrawal, the wipe
plan, the backup plan and the session projection follow the schema.
Backups never carried the column, and restore ignores unknown account
settings; a round-trip test now proves an old backup naming the field
still restores cleanly. The access guard pins exact counts for where
the sealed name may appear. Fixtures, the demo seed and the demo
migration script seed the sealed column instead of plaintext.
2026-10-21 19:24:21 +02:00

237 lines
7.9 KiB
TypeScript

/**
* AI actions refuse with the capability envelope, through the real handlers
* and the real `apiHandler` against real Postgres.
*
* Each case asserts the exact status, `meta.errorCode`, `meta.capability`
* and `meta.reason`, and that nothing downstream ran: no provider was asked
* (the runner and the chain resolver are spies), no cached text was written.
* The chat keeps its `coach.provider.none` SSE frame for a missing provider,
* now carrying `reason`.
*
* Mutation check: removing `requireAiCapability("briefing")` from the
* generate GET lets every state through to the cache read and turns all five
* briefing cases red.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
enterState,
makeUser,
resetWorld,
setSwitches,
signIn,
type StateName,
} from "./ai-optional-fixtures";
import { getPrismaClient } from "./setup";
import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher";
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),
}));
// Provider spies: a refusal must never reach either.
const runner = vi.hoisted(() => ({
raw: vi.fn(),
streaming: vi.fn(),
chain: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/provider-runner")>()),
runRawCompletionWithFallback: runner.raw,
runStreamingRawCompletionWithFallback: runner.streaming,
}));
vi.mock("@/lib/ai/provider", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/ai/provider")>();
return {
...actual,
resolveProviderChain: (...args: unknown[]) => {
runner.chain(...args);
return actual.resolveProviderChain(
...(args as Parameters<typeof actual.resolveProviderChain>),
);
},
};
});
interface Envelope {
data: unknown;
error: string | null;
meta?: {
errorCode?: string;
capability?: string;
reason?: string;
module?: string;
};
}
async function generateGet(): Promise<{ status: number; body: Envelope }> {
const { GET } = await import("@/app/api/insights/generate/route");
const res = await GET(
new NextRequest("http://localhost/api/insights/generate"),
);
return { status: res.status, body: (await res.json()) as Envelope };
}
async function generatePost(): Promise<{ status: number; body: Envelope }> {
const { POST } = await import("@/app/api/insights/generate/route");
const res = await POST(
new NextRequest("http://localhost/api/insights/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ force: true }),
}),
);
return { status: res.status, body: (await res.json()) as Envelope };
}
async function chatPost(): Promise<Response> {
const { POST } = await import("@/app/api/insights/chat/route");
return POST(
new NextRequest("http://localhost/api/insights/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: "How was my week?" }),
}),
);
}
async function cachedAt(userId: string): Promise<Date | null> {
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: { insightsCachedAt: true },
});
return row.insightsCachedAt;
}
beforeEach(async () => {
await resetWorld();
runner.raw.mockReset();
runner.streaming.mockReset();
runner.chain.mockReset();
});
const BRIEFING_REFUSALS: ReadonlyArray<
[StateName, number, string, string, string | undefined]
> = [
["S1", 403, "assistant.disabled.briefing", "operator_disabled", undefined],
["S2", 403, "assistant.disabled.briefing", "operator_disabled", undefined],
["S3", 422, "ai.provider.none", "no_provider", undefined],
["S4", 403, "consent.ai.required", "consent_required", undefined],
["S6", 403, "module.disabled", "user_disabled", "insights"],
];
describe("insights/generate refuses by the briefing capability", () => {
it.each(BRIEFING_REFUSALS)(
"%s: GET and POST answer %i %s",
async (state, status, errorCode, reason, module) => {
const world = await enterState(state);
const before = await cachedAt(world.recordId);
for (const call of [generateGet, generatePost]) {
const { status: got, body } = await call();
expect(got, `${state} ${call.name}`).toBe(status);
expect(body.data).toBeNull();
expect(body.meta).toMatchObject({
errorCode,
capability: "briefing",
reason,
...(module ? { module } : {}),
});
}
expect(runner.raw).not.toHaveBeenCalled();
expect(runner.chain).not.toHaveBeenCalled();
expect(await cachedAt(world.recordId)).toEqual(before);
},
);
it("hiding the Coach does not refuse the briefing (S5)", async () => {
const world = await enterState("S5");
const { encrypt } = await import("@/lib/crypto");
await getPrismaClient().user.update({
where: { id: world.recordId },
data: {
// The read resolves (never calls) the chain, so the key must decrypt.
aiAnthropicKeyEncrypted: encrypt("sk-ant-integration"),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
dailyBriefing: { paragraph: "Held text.", keyFindings: [] },
}),
),
insightsCachedAt: new Date(),
insightsCachedLocale: "en",
},
});
const { status } = await generateGet();
expect(status).toBe(200);
});
});
describe("insights/chat refuses by the coach capability", () => {
it.each([
["S1", 403, "assistant.disabled.coach", "operator_disabled", undefined],
["S5", 403, "module.disabled", "user_disabled", "coach"],
["S4", 403, "consent.ai.required", "consent_required", undefined],
] as const)(
"%s: POST answers %i %s before any stream opens",
async (state, status, errorCode, reason, module) => {
await enterState(state);
const res = await chatPost();
expect(res.status).toBe(status);
const body = (await res.json()) as Envelope;
expect(body.meta).toMatchObject({
errorCode,
capability: "coach",
reason,
...(module ? { module } : {}),
});
expect(runner.raw).not.toHaveBeenCalled();
expect(runner.streaming).not.toHaveBeenCalled();
expect(runner.chain).not.toHaveBeenCalled();
const conversations = await getPrismaClient().coachConversation.count();
expect(conversations).toBe(0);
},
);
it("S3: keeps the coach.provider.none frame, now with the reason", async () => {
await enterState("S3");
const res = await chatPost();
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/event-stream");
const text = await res.text();
expect(text).toContain('"code":"coach.provider.none"');
expect(text).toContain('"reason":"no_provider"');
expect(runner.chain).not.toHaveBeenCalled();
});
it("the Coach module off at the operator is the operator's switch", async () => {
const user = await makeUser("operator-coach-off");
await setSwitches({ assistantCoachEnabled: false });
await signIn(user.id);
const res = await chatPost();
expect(res.status).toBe(403);
const body = (await res.json()) as Envelope;
expect(body.meta?.errorCode).toBe("assistant.disabled.coach");
});
});