Files
HealthLog/tests/integration/ai-optional-mixed-routes.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

498 lines
16 KiB
TypeScript

/**
* Mixed reads: data plus optional model text, through the real handlers
* against real Postgres.
*
* Each read answers 200 in every state. Model-written text is served only
* while its capability is available; otherwise the field is null (or the
* deterministic stand-in), `ai` names the reason, the stored text is not
* served however fresh, and nothing is enqueued to warm it. The baseline (S0)
* proves each assertion can see the text when it is allowed to.
*
* Mutation check: dropping the capability test from the narrative route
* (`existing = stored`) serves the provider row and turns all four hiding
* states red. The per-read snapshot gate is mutation-checked by
* `snapshot-read-briefing-capability.test.ts`.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
enterState,
resetWorld,
setSwitches,
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),
}));
// The two enqueue seams a mixed read may reach. Spied, never run: the test
// asserts they are not called while a capability is unavailable.
const enqueueNarrativeWarm = vi.fn(async () => undefined);
vi.mock("@/lib/jobs/period-narrative-shared", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/jobs/period-narrative-shared")
>()),
enqueueNarrativeWarm: (...args: unknown[]) =>
(enqueueNarrativeWarm as (...a: unknown[]) => Promise<void>)(...args),
}));
const enqueueForceWarm = vi.fn(async () => undefined);
vi.mock("@/lib/jobs/insight-pregenerate-shared", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/jobs/insight-pregenerate-shared")
>()),
enqueueForceWarm: (...args: unknown[]) =>
(enqueueForceWarm as (...a: unknown[]) => Promise<void>)(...args),
}));
const BRIEFING = {
dailyBriefing: {
paragraph: "Model prose about your week. It reads well.",
keyFindings: [],
},
};
async function seed(recordId: string): Promise<void> {
const prisma = getPrismaClient();
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
// A model-written narrative, fresh.
await prisma.insightNarrative.create({
data: {
userId: recordId,
period: "week",
locale: "en",
dateKey: "2026-09-20",
encryptedContent: encryptToBytes("A model wrote this retrospective."),
providerType: "anthropic",
},
});
// A fresh cached briefing in the reader's language.
await prisma.user.update({
where: { id: recordId },
data: {
// Generated now: only text written today is served as today's read.
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
...BRIEFING,
briefingGeneratedAt: new Date().toISOString(),
}),
),
insightsCachedAt: new Date(),
insightsCachedLocale: "en",
},
});
// A Coach thread whose newest message the person has not opened.
const conversation = await prisma.coachConversation.create({
data: { userId: recordId, title: "Week" },
});
await prisma.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes("Hello from the Coach."),
},
});
// A workout with a stored Activity Insight paragraph.
const startedAt = new Date(Date.now() - 2 * 60 * 60 * 1000);
const workout = await prisma.workout.create({
data: {
userId: recordId,
sportType: "RUNNING",
startedAt,
endedAt: new Date(startedAt.getTime() + 30 * 60 * 1000),
durationSec: 1800,
source: "APPLE_HEALTH",
},
});
await prisma.workoutInsight.create({
data: {
userId: recordId,
workoutId: workout.id,
paragraphEncrypted: encryptToBytes("A steady run, model-written."),
inputHash: "h",
promptVersion: "p",
providerType: "anthropic",
locale: "en",
generatedAt: new Date(),
},
});
}
async function json<T>(res: Response): Promise<{ status: number; data: T }> {
return {
status: res.status,
data: ((await res.json()) as { data: T }).data,
};
}
async function readNarrative() {
const { GET } = await import("@/app/api/insights/narrative/route");
return json<{
narrative: { text: string } | null;
revalidating: boolean;
ai: { available: boolean; reason: string | null };
}>(
await GET(
new NextRequest("http://localhost/api/insights/narrative?period=week"),
),
);
}
async function readSnapshot() {
const { GET } = await import("@/app/api/dashboard/snapshot/route");
return json<{
briefing: unknown;
briefingState: string;
briefingUpdatedAt: string | null;
briefingAi: { available: boolean; reason: string | null };
}>(await GET());
}
async function readDigest() {
const { GET } = await import("@/app/api/daily/digest/route");
return json<{
briefingLead: string | null;
ai: { briefing: { reason: string | null } };
}>(await GET());
}
async function readNudge() {
const { GET } = await import("@/app/api/insights/coach/nudge-status/route");
return json<{
unread: boolean;
nudgedAt: string | null;
ai: { reason: string | null };
}>(await GET());
}
async function readWorkout(recordId: string) {
const workout = await getPrismaClient().workout.findFirstOrThrow({
where: { userId: recordId },
});
const { GET } = await import("@/app/api/workouts/[id]/route");
return json<{
aiInsight: { paragraph: string } | null;
ai: { reason: string | null };
}>(
await GET(
new NextRequest(`http://localhost/api/workouts/${workout.id}?compact=1`),
{ params: Promise.resolve({ id: workout.id }) },
),
);
}
async function readComprehensive() {
const { GET } = await import("@/app/api/insights/comprehensive/route");
return json<{
ai: {
briefing: { reason: string | null };
statusText: { reason: string | null };
};
}>(await GET());
}
async function pregenerate() {
const { POST } = await import("@/app/api/insights/pregenerate/route");
return json<{ queued: boolean; ai: unknown }>(
await POST(
new NextRequest("http://localhost/api/insights/pregenerate", {
method: "POST",
}),
),
);
}
beforeEach(async () => {
await resetWorld();
enqueueNarrativeWarm.mockClear();
enqueueForceWarm.mockClear();
});
describe("the baseline sees the model text", () => {
it("S0 serves every model-written field", async () => {
const world = await enterState("S0", seed);
const narrative = await readNarrative();
expect(narrative.status).toBe(200);
expect(narrative.data.narrative?.text).toBe(
"A model wrote this retrospective.",
);
expect(narrative.data.ai.available).toBe(true);
const snapshot = await readSnapshot();
expect(snapshot.data.briefing).not.toBeNull();
expect(snapshot.data.briefingAi.available).toBe(true);
const digest = await readDigest();
expect(digest.data.briefingLead).toBe("Model prose about your week.");
const nudge = await readNudge();
expect(nudge.data.unread).toBe(true);
const workout = await readWorkout(world.recordId);
expect(workout.data.aiInsight?.paragraph).toBe(
"A steady run, model-written.",
);
const comprehensive = await readComprehensive();
expect(comprehensive.status).toBe(200);
expect(comprehensive.data.ai.briefing.reason).toBeNull();
const warm = await pregenerate();
expect(warm.data.queued).toBe(true);
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
});
});
describe.each([
["S1", "operator_disabled"],
["S3", "no_provider"],
["S4", "consent_required"],
["S6", "user_disabled"],
] as const)("%s hides the model text", (state: StateName, reason) => {
it("answers 200 with the text nulled, the reason named and nothing enqueued", async () => {
const world = await enterState(state, seed);
const narrative = await readNarrative();
expect(narrative.status).toBe(200);
expect(narrative.data.narrative).toBeNull();
expect(narrative.data.ai.reason).toBe(reason);
// Nothing servable: the warm is still allowed, because the worker then
// writes the deterministic narrative. It is the only enqueue allowed.
const snapshot = await readSnapshot();
expect(snapshot.status).toBe(200);
expect(snapshot.data.briefing).toBeNull();
expect(snapshot.data.briefingUpdatedAt).toBeNull();
expect(snapshot.data.briefingAi.reason).toBe(reason);
expect(snapshot.data.briefingState).toBe(
reason === "no_provider" ? "no-provider" : "disabled",
);
const digest = await readDigest();
expect(digest.status).toBe(200);
expect(digest.data.briefingLead).toBeNull();
expect(digest.data.ai.briefing.reason).toBe(reason);
const workout = await readWorkout(world.recordId);
expect(workout.status).toBe(200);
expect(workout.data.aiInsight).toBeNull();
const comprehensive = await readComprehensive();
expect(comprehensive.status).toBe(200);
expect(comprehensive.data.ai.statusText.reason).toBe(reason);
const warm = await pregenerate();
expect(warm.status).toBe(200);
expect(warm.data.queued).toBe(false);
expect(enqueueForceWarm).not.toHaveBeenCalled();
});
});
describe("the Coach half", () => {
it.each([
["S1", "operator_disabled"],
["S5", "user_disabled"],
["S3", "no_provider"],
] as const)(
"nudge-status answers the quiet shape in %s",
async (state, reason) => {
await enterState(state, seed);
const nudge = await readNudge();
expect(nudge.status).toBe(200);
expect(nudge.data.unread).toBe(false);
expect(nudge.data.nudgedAt).toBeNull();
expect(nudge.data.ai.reason).toBe(reason);
},
);
it("hiding the Coach hides the Coach, not the briefing", async () => {
await enterState("S5", seed);
const snapshot = await readSnapshot();
expect(snapshot.data.briefing).not.toBeNull();
const digest = await readDigest();
expect(digest.data.briefingLead).toBe("Model prose about your week.");
});
});
describe("the narrative's deterministic text is data", () => {
it("serves a deterministic row with the master switch off, and does not re-warm it", async () => {
await enterState("S1", async (recordId) => {
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
await getPrismaClient().insightNarrative.create({
data: {
userId: recordId,
period: "week",
locale: "en",
dateKey: "2026-09-20",
encryptedContent: encryptToBytes("Your week, in numbers."),
providerType: "deterministic",
updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000),
},
});
});
const narrative = await readNarrative();
expect(narrative.data.narrative?.text).toBe("Your week, in numbers.");
expect(narrative.data.revalidating).toBe(false);
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
});
});
describe("the snapshot applies the capability per read", () => {
it("hides a cached briefing on the very next read after the operator switches it off", async () => {
await enterState("S0", seed);
const before = await readSnapshot();
expect(before.data.briefing).not.toBeNull();
await setSwitches({ assistantBriefingEnabled: false });
const after = await readSnapshot();
expect(after.data.briefing).toBeNull();
expect(after.data.briefingAi.reason).toBe("operator_disabled");
});
});
describe("the status family", () => {
async function readStatus(path: string) {
const route =
path === "weight"
? await import("@/app/api/insights/weight-status/route")
: await import("@/app/api/insights/metric-status/route");
const url =
path === "weight"
? "http://localhost/api/insights/weight-status"
: "http://localhost/api/insights/metric-status?metric=RESTING_HEART_RATE";
return json<Record<string, unknown>>(await route.GET(new NextRequest(url)));
}
it("S0 publishes the capability beside the note", async () => {
const world = await enterState("S0");
// The available path resolves (never calls) the provider chain, so the
// key has to decrypt.
const { encrypt } = await import("@/lib/crypto");
await getPrismaClient().user.update({
where: { id: world.recordId },
data: { aiAnthropicKeyEncrypted: encrypt("sk-ant-integration") },
});
const status = await readStatus("weight");
expect(status.status).toBe(200);
expect(status.data.ai).toMatchObject({ available: true });
});
it.each([
["S1", "operator_disabled", true],
["S3", "no_provider", false],
["S6", "user_disabled", true],
] as const)(
"%s answers 200 with no note and provider presence in hasProvider",
async (state, reason, hasProvider) => {
await enterState(state);
for (const path of ["weight", "metric"]) {
const status = await readStatus(path);
expect(status.status, path).toBe(200);
expect(status.data, path).toMatchObject({
text: null,
preparing: false,
hasProvider,
ai: { available: false, reason },
});
}
const metric = await readStatus("metric");
// No readings were seeded: the data half still says so.
expect(metric.data.insufficient).toBe(true);
},
);
});
describe("inside somebody else's record (S8, a MANAGE delegate)", () => {
async function seedOwnerText(recordId: string): Promise<void> {
await seed(recordId);
const prisma = getPrismaClient();
// The narrative is stale, so an owner's read would warm it.
await prisma.insightNarrative.updateMany({
where: { userId: recordId },
data: { updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000) },
});
const { writeStatusNote, statusCacheAction } =
await import("@/lib/insights/status-cache");
const { userDayKey } = await import("@/lib/tz/format");
await writeStatusNote({
userId: recordId,
cacheAction: statusCacheAction("weight", "en"),
todayKey: userDayKey(new Date(), "UTC"),
text: "The owner's weight note.",
});
}
it("reads the owner's stored model text exactly as the owner would", async () => {
await enterState("S8", seedOwnerText);
const narrative = await readNarrative();
expect(narrative.data.narrative?.text).toBe(
"A model wrote this retrospective.",
);
expect(narrative.data.ai.available).toBe(true);
const snapshot = await readSnapshot();
expect(snapshot.data.briefing).not.toBeNull();
const digest = await readDigest();
expect(digest.data.briefingLead).toBe("Model prose about your week.");
const { GET } = await import("@/app/api/insights/weight-status/route");
const status = await json<{ text: string | null }>(
await GET(new NextRequest("http://localhost/api/insights/weight-status")),
);
expect(status.status).toBe(200);
expect(status.data.text).toBe("The owner's weight note.");
});
it("starts no model work on the owner's record", async () => {
await enterState("S8", seedOwnerText);
// A stale narrative the owner's read would warm: the delegate's does not.
const narrative = await readNarrative();
expect(narrative.data.revalidating).toBe(false);
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
// Every generating route refuses a delegate before any work.
const { POST: chat } = await import("@/app/api/insights/chat/route");
const chatRes = await chat(
new NextRequest("http://localhost/api/insights/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: "How was my week?" }),
}),
);
expect(chatRes.status).toBe(403);
const warm = await pregenerate();
expect(warm.status).toBe(403);
expect(enqueueForceWarm).not.toHaveBeenCalled();
expect(await getPrismaClient().coachConversation.count()).toBe(1);
});
});