From ce5a34093179f56147568eb308c989bec50fe977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:19:43 +0200 Subject: [PATCH] test(ai): prove Insights data, mixed reads and actions against Postgres Three files walk the AI state matrix through the real handlers: every data route answers the baseline's data under each state, and an ECG upload is stored with AI off; mixed reads null the model text, name the reason and enqueue nothing, and the dashboard hides a cached briefing on the next read after a switch flip; the briefing and Coach actions refuse with the exact code, capability and reason before any provider or cache write. Existing files that seeded provider-less accounts now give them a provider where the briefing or the Coach is under test, and the sharing front door marks the digest by a due checkup, since model text is not served inside somebody else's record. Refs #975 --- tests/integration/ai-optional-actions.test.ts | 233 +++++++++++ .../ai-optional-data-routes.test.ts | 350 +++++++++++++++++ tests/integration/ai-optional-fixtures.ts | 202 ++++++++++ .../ai-optional-mixed-routes.test.ts | 369 ++++++++++++++++++ .../integration/briefing-cache-locale.test.ts | 13 +- tests/integration/coach-chat.test.ts | 10 +- .../insights-comprehensive-cache.test.ts | 3 +- tests/integration/sharing-front-door.test.ts | 109 +++++- 8 files changed, 1268 insertions(+), 21 deletions(-) create mode 100644 tests/integration/ai-optional-actions.test.ts create mode 100644 tests/integration/ai-optional-data-routes.test.ts create mode 100644 tests/integration/ai-optional-fixtures.ts create mode 100644 tests/integration/ai-optional-mixed-routes.test.ts diff --git a/tests/integration/ai-optional-actions.test.ts b/tests/integration/ai-optional-actions.test.ts new file mode 100644 index 000000000..5ae6f9789 --- /dev/null +++ b/tests/integration/ai-optional-actions.test.ts @@ -0,0 +1,233 @@ +/** + * 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: replacing `requireAiCapability("briefing")` in the generate + * GET with the old Coach gate lets S2 (briefing off, the others on for this + * case) through to the cache and turns that case 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"; + +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()), + runRawCompletionWithFallback: runner.raw, + runStreamingRawCompletionWithFallback: runner.streaming, +})); +vi.mock("@/lib/ai/provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveProviderChain: (...args: unknown[]) => { + runner.chain(...args); + return actual.resolveProviderChain( + ...(args as Parameters), + ); + }, + }; +}); + +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 { + 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 { + 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"), + insightsCachedText: 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"); + }); +}); diff --git a/tests/integration/ai-optional-data-routes.test.ts b/tests/integration/ai-optional-data-routes.test.ts new file mode 100644 index 000000000..f668f6636 --- /dev/null +++ b/tests/integration/ai-optional-data-routes.test.ts @@ -0,0 +1,350 @@ +/** + * Data never depends on AI: the formerly AI-gated data routes, through the + * real handlers against real Postgres, under every state of the matrix. + * + * For each state the same record is seeded and every data route is read. Each + * answers 200, and its body equals the all-on baseline's once the per-request + * AI state, the clock and row ids are set aside: switching AI off, never + * configuring a provider, withdrawing consent, hiding the Coach, opting out + * of AI analysis, or reading as a delegate changes no data. An ECG upload is + * stored with the master switch off and with AI analysis opted out. + * + * Mutation check: putting `requireModuleEnabled(user.id, "insights")` back + * into `insights/ecg/route.ts` turns S6 red by route name; putting + * `await requireAiCapability("statusText")` there turns S1, S2, S3, S4, S6 + * and S8 red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + enterState, + resetWorld, + type StateName, + type World, +} from "./ai-optional-fixtures"; +import { getPrismaClient } from "./setup"; + +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), +})); + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.now(); + +/** One ECG recording, one device event, a week of pulse and a Coach fact. */ +async function seed(recordId: string): Promise { + const prisma = getPrismaClient(); + const { persistEcgRecording } = await import("@/lib/ecg/persist-recording"); + await persistEcgRecording( + { + userId: recordId, + source: "APPLE_HEALTH", + externalRecordingId: "aio-ecg-1", + recordedAt: new Date(NOW - 2 * DAY), + samples: [12, -7, 3, 240, -180, 60], + samplingFrequency: 512, + lead: "I", + averageHeartRate: 64, + rhythmClassification: "NOT_DETECTED", + }, + prisma, + ); + await prisma.measurement.create({ + data: { + userId: recordId, + type: "HIGH_HEART_RATE_EVENT", + value: 1, + unit: "event", + measuredAt: new Date(NOW - 3 * DAY), + source: "APPLE_HEALTH", + }, + }); + for (let i = 0; i < 7; i++) { + await prisma.measurement.create({ + data: { + userId: recordId, + type: "PULSE", + value: 60 + i, + unit: "bpm", + measuredAt: new Date(NOW - i * DAY), + source: "MANUAL", + }, + }); + } + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + await prisma.coachFact.create({ + data: { + userId: recordId, + factEncrypted: encryptToBytes("Prefers morning walks"), + category: "preference", + confidence: 80, + createdAt: new Date(NOW - DAY), + }, + }); +} + +type Handler = (req: NextRequest, ctx?: unknown) => Promise; + +interface DataRead { + name: string; + load: () => Promise; + url: string; + method?: "GET" | "POST"; + params?: (world: World) => Promise>; + /** Scoped to the caller rather than the record: not read in S8. */ + actorScoped?: boolean; +} + +async function firstEcgId(world: World): Promise> { + const row = await getPrismaClient().ecgRecording.findFirstOrThrow({ + where: { userId: world.recordId }, + }); + return { id: row.id }; +} + +const READS: DataRead[] = [ + { + name: "GET /api/insights/ecg", + load: async () => (await import("@/app/api/insights/ecg/route")).GET, + url: "/api/insights/ecg", + }, + { + name: "GET /api/insights/ecg/[id]", + load: async () => + (await import("@/app/api/insights/ecg/[id]/route")).GET as Handler, + url: "/api/insights/ecg/x", + params: firstEcgId, + }, + { + name: "GET /api/insights/rhythm-events", + load: async () => + (await import("@/app/api/insights/rhythm-events/route")).GET as Handler, + url: "/api/insights/rhythm-events", + }, + { + name: "GET /api/insights/derived/batch", + load: async () => + (await import("@/app/api/insights/derived/batch/route")).GET, + url: "/api/insights/derived/batch?metrics=VITALS_BASELINE:PULSE,READINESS", + }, + { + name: "GET /api/insights/cards", + load: async () => + (await import("@/app/api/insights/cards/route")).GET as Handler, + url: "/api/insights/cards", + }, + { + name: "GET /api/insights/correlations", + load: async () => + (await import("@/app/api/insights/correlations/route")).GET as Handler, + url: "/api/insights/correlations", + }, + { + name: "GET /api/insights/coach-read", + load: async () => (await import("@/app/api/insights/coach-read/route")).GET, + url: "/api/insights/coach-read?metric=PULSE", + }, + { + name: "GET /api/insights/health-status", + load: async () => + (await import("@/app/api/insights/health-status/route")).GET as Handler, + url: "/api/insights/health-status", + }, + { + name: "GET /api/insights/pulse/intraday", + load: async () => + (await import("@/app/api/insights/pulse/intraday/route")).GET as Handler, + url: "/api/insights/pulse/intraday", + }, + { + name: "GET /api/insights/patterns", + load: async () => + (await import("@/app/api/insights/patterns/route")).GET as Handler, + url: "/api/insights/patterns", + }, + { + name: "GET /api/insights/coach/facts", + load: async () => + (await import("@/app/api/insights/coach/facts/route")).GET as Handler, + url: "/api/insights/coach/facts", + actorScoped: true, + }, + { + name: "GET /api/insights/chat", + load: async () => (await import("@/app/api/insights/chat/route")).GET, + url: "/api/insights/chat", + actorScoped: true, + }, + { + name: "GET /api/daily/digest", + load: async () => + (await import("@/app/api/daily/digest/route")).GET as Handler, + url: "/api/daily/digest", + }, +]; + +/** Ids, the clock and the per-request AI state set aside. */ +function dataOnly(value: unknown): unknown { + if (Array.isArray(value)) return value.map(dataOnly); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, inner] of Object.entries(value)) { + if ( + [ + "ai", + "id", + "generatedAt", + "revalidating", + "hasProvider", + "conversationId", + "computedAt", + ].includes(key) + ) { + continue; + } + out[key] = dataOnly(inner); + } + return out; + } + return value; +} + +async function readAll( + world: World, + state: StateName, +): Promise> { + const out: Record = {}; + for (const read of READS) { + if (state === "S8" && read.actorScoped) continue; + const handler = await read.load(); + const request = new NextRequest(`http://localhost${read.url}`, { + method: read.method ?? "GET", + }); + const res = read.params + ? await handler(request, { + params: Promise.resolve(await read.params(world)), + }) + : await handler(request); + const json = (await res.json()) as { data: unknown }; + out[read.name] = { status: res.status, body: dataOnly(json.data) }; + } + return out; +} + +let baseline: Record | null = null; + +beforeEach(async () => { + await resetWorld(); +}); + +describe("data routes under every AI state", () => { + it("S0 (all on) is the baseline, and every read answers", async () => { + const world = await enterState("S0", seed); + baseline = await readAll(world, "S0"); + for (const [name, { status }] of Object.entries(baseline)) { + expect(status, name).toBe(200); + } + // The seed is visible, so equality below compares something real. + const ecg = baseline["GET /api/insights/ecg"].body as { + hasRecordings: boolean; + }; + expect(ecg.hasRecordings).toBe(true); + const events = baseline["GET /api/insights/rhythm-events"].body as { + hasEvents: boolean; + }; + expect(events.hasEvents).toBe(true); + const facts = baseline["GET /api/insights/coach/facts"].body as { + facts: unknown[]; + }; + expect(facts.facts).toHaveLength(1); + }); + + it.each(["S1", "S2", "S3", "S4", "S5", "S6", "S8"] as const)( + "%s answers every read with the baseline's data", + async (state) => { + expect(baseline, "the S0 baseline runs first").not.toBeNull(); + const world = await enterState(state, seed); + const reads = await readAll(world, state); + for (const [name, { status, body }] of Object.entries(reads)) { + expect(status, `${state} ${name}`).toBe(200); + expect(body, `${state} ${name}`).toEqual(baseline![name].body); + } + }, + ); +}); + +describe("writes that AI never refuses", () => { + async function ingest(): Promise { + const { POST } = await import("@/app/api/insights/ecg/route"); + return POST( + new NextRequest("http://localhost/api/insights/ecg", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + externalRecordingId: "aio-ingest-1", + recordedAt: new Date(NOW - DAY).toISOString(), + samplingFrequency: 512, + samples: [1, 2, 3, 4], + classification: "NOT_DETECTED", + source: "APPLE_HEALTH", + }), + }), + ); + } + + it.each(["S1", "S6"] as const)( + "stores an ECG upload in %s", + async (state) => { + const world = await enterState(state); + const res = await ingest(); + expect(res.status).toBe(201); + const rows = await getPrismaClient().ecgRecording.count({ + where: { userId: world.recordId }, + }); + expect(rows).toBe(1); + }, + ); + + it.each(["S1", "S5"] as const)( + "stamps the Coach as seen and erases a fact in %s", + async (state) => { + const world = await enterState(state, seed); + const { POST: seen } = + await import("@/app/api/insights/coach/seen/route"); + expect((await seen()).status).toBe(200); + const { DELETE: forgetAll } = + await import("@/app/api/insights/coach/facts/route"); + const res = await forgetAll(); + expect(res.status).toBe(200); + expect( + ((await res.json()) as { data: { cleared: number } }).data, + ).toEqual({ cleared: 1 }); + const live = await getPrismaClient().coachFact.count({ + where: { userId: world.recordId, deletedAt: null }, + }); + expect(live).toBe(0); + }, + ); +}); diff --git a/tests/integration/ai-optional-fixtures.ts b/tests/integration/ai-optional-fixtures.ts new file mode 100644 index 000000000..fb426b526 --- /dev/null +++ b/tests/integration/ai-optional-fixtures.ts @@ -0,0 +1,202 @@ +/** + * Shared fixtures for the `ai-optional-*` integration files: one fully set-up + * account, and one function per state in the design's state matrix, each + * changing exactly one layer against that baseline. + * + * S0 all on: own key (presence only), every receipt, every switch on + * S1 the operator's master switch off + * S2 one operator sub-switch off + * S3 no provider configured anywhere + * S4 only the operator's key, and no consent receipt + * S5 the Coach hidden by the person (`disableCoach`) + * S6 the AI analysis opt-out (`insights` module off) + * S8 a delegate with MANAGE access inside somebody else's record + * + * Not a test file: the `vi.mock` blocks stay in each file, because Vitest + * hoists them per file. + */ +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +let counter = 0; + +export async function resetWorld(): Promise { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + counter = 0; +} + +/** A person with their own Anthropic key and the `ai_full` receipt: S0. */ +export async function makeUser( + label: string, + overrides: Record = {}, +) { + const suffix = `${label}-${counter++}`; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `aio-${suffix}`, + email: `aio-${suffix}@example.test`, + role: "USER", + timezone: "UTC", + locale: "en", + onboardingCompletedAt: new Date(), + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + // Presence only: the capability probe never decrypts. + aiAnthropicKeyEncrypted: "v1:presence-only", + ...overrides, + }, + }); + await prisma.consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_full", + artefact: "test", + signedAt: new Date(), + }, + }); + return user; +} + +export async function setSwitches(values: Record) { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ...values }, + update: values, + }); +} + +export async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +export type StateName = "S0" | "S1" | "S2" | "S3" | "S4" | "S5" | "S6" | "S8"; + +export interface World { + /** The record the requests read and write. */ + recordId: string; + /** Who is signed in. */ + actorId: string; +} + +/** + * Build the world for one state and sign in. `seed` runs against the record + * before the session is opened, so every state reads the same data. + */ +export async function enterState( + state: StateName, + seed: (recordId: string) => Promise = async () => {}, +): Promise { + const prisma = getPrismaClient(); + switch (state) { + case "S0": { + const user = await makeUser("s0"); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S1": { + const user = await makeUser("s1"); + await setSwitches({ assistantEnabled: false }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S2": { + const user = await makeUser("s2"); + await setSwitches({ + assistantInsightStatusEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S3": { + const user = await makeUser("s3", { + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S4": { + const user = await makeUser("s4", { + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + await prisma.consentReceipt.deleteMany({ where: { userId: user.id } }); + await setSwitches({ + adminAiKeyEncrypted: "v1:presence-only", + adminAiModel: "gpt-4o", + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S5": { + const user = await makeUser("s5", { disableCoach: true }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S6": { + const user = await makeUser("s6", { + modulePreferencesJson: { insights: false }, + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S8": { + const owner = await makeUser("s8-owner"); + const delegate = await makeUser("s8-delegate"); + await prisma.accountGrant.create({ + data: { + grantorId: owner.id, + granteeId: delegate.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await seed(owner.id); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + return { recordId: owner.id, actorId: delegate.id }; + } + } +} + +/** + * The body with the per-request AI state and the clock stripped, so two + * states can be compared on their data alone. + */ +export function withoutAiAndClock(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutAiAndClock); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, inner] of Object.entries(value)) { + if ( + key === "ai" || + key === "generatedAt" || + key === "revalidating" || + key === "hasProvider" + ) { + continue; + } + out[key] = withoutAiAndClock(inner); + } + return out; + } + return value; +} diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts new file mode 100644 index 000000000..5083f88cc --- /dev/null +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -0,0 +1,369 @@ +/** + * 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 `ai.available` test from the narrative route + * (`existing = stored`) serves the provider row under S1 and S6 and turns + * those cases red; applying the briefing capability inside the cached builder + * instead of per read turns the snapshot flip case red. + */ +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"; + +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)(...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)(...args), +})); + +const BRIEFING = { + dailyBriefing: { + paragraph: "Model prose about your week. It reads well.", + keyFindings: [], + }, +}; + +async function seed(recordId: string): Promise { + 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: { + insightsCachedText: JSON.stringify(BRIEFING), + 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(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"); + }); +}); diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts index 56a930549..b1073e961 100644 --- a/tests/integration/briefing-cache-locale.test.ts +++ b/tests/integration/briefing-cache-locale.test.ts @@ -68,6 +68,14 @@ async function seedUserWithCachedBriefing(cachedLocale: string | null) { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING }), insightsCachedLocale: cachedLocale, + // The briefing is shown only while its AI capability is available, so + // the account has its own provider (presence only) and a receipt. + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + aiAnthropicKeyEncrypted: "v1:presence-only", + consentReceipts: { + create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, + }, }, }); } @@ -84,9 +92,8 @@ describe("dashboard snapshot — briefing cache written in another language", () expect(body.briefing).toBeNull(); expect(body.briefingStale).toBe(false); expect(body.briefingUpdatedAt).toBeNull(); - // No provider is configured on this account, so the honest empty state - // is "nothing will fill this"; with one it would be "preparing". - expect(["preparing", "no-provider"]).toContain(body.briefingState); + // A provider is configured, so a warm pass will refill the slot. + expect(body.briefingState).toBe("preparing"); }); it("serves the briefing when the tag matches the reader", async () => { diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 3a26d5c22..9ae2ae8d9 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -81,7 +81,11 @@ vi.mock("@/lib/ai/provider-runner", async () => { }; }); -vi.mock("@/lib/ai/provider", () => ({ +// The chain the turn runs through is stubbed; provider PRESENCE is the real +// probe, which reads the credential the seeded user carries, because the +// `coach` capability is resolved from it before the turn starts. +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + ...(await importOriginal()), resolveProviderChain: vi.fn(async () => [ { providerType: "openai", @@ -109,6 +113,10 @@ async function seedUserWithSession(): Promise<{ userId: string }> { username: "coach-user", email: "coach@example.test", role: "USER", + // The person's own OpenAI key (presence only): the `coach` capability + // needs a provider, and a personal key needs no consent receipt. + aiProvider: "OPENAI", + aiOpenaiKeyEncrypted: "v1:presence-only", }, }); const session = await prisma.session.create({ diff --git a/tests/integration/insights-comprehensive-cache.test.ts b/tests/integration/insights-comprehensive-cache.test.ts index e7e1395b5..acd0cced9 100644 --- a/tests/integration/insights-comprehensive-cache.test.ts +++ b/tests/integration/insights-comprehensive-cache.test.ts @@ -48,7 +48,8 @@ vi.mock("@/lib/db-compat", () => ({ // `resolveProvider` reads `app_settings.default_locale` which trails the // production schema in this integration env. Mock the leaf: the route // only uses `provider.type !== "none"` to set the `hasProvider` flag. -vi.mock("@/lib/ai/provider", () => ({ +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + ...(await importOriginal()), resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), })); diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 36c71d62d..92a5dcbe7 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -271,13 +271,19 @@ function cachedBriefing(marker: string): string { }); } +// The digest's marker is data: a checkup that is due, named per account. The +// briefing lead is model text and follows the record's `briefing` capability, +// which a delegate inside somebody else's record does not have +// (`not_permitted_for_record`), so it cannot tell the two records apart; the +// case below pins that separately. frontDoorRead("GET /api/daily/digest", { seed: async (userId, marker) => { - await getPrismaClient().user.update({ - where: { id: userId }, + await getPrismaClient().measurementReminder.create({ data: { - insightsCachedText: cachedBriefing(marker), - insightsCachedAt: new Date(), + userId, + label: `Checkup for the ${marker}`, + intervalDays: 365, + nextDueAt: new Date(Date.now() - 24 * 60 * 60 * 1000), }, }); }, @@ -285,7 +291,48 @@ frontDoorRead("GET /api/daily/digest", { const { GET } = await import("@/app/api/daily/digest/route"); return drive(GET as Handler, "/api/daily/digest"); }, - read: (data: { briefingLead: string | null }) => data.briefingLead, + read: (data: { worthALook: { kind: string; body?: string }[] }) => + data.worthALook.find((item) => item.kind === "preventive_care")?.body ?? + null, +}); + +describe("GET /api/daily/digest — model text inside somebody else's record", () => { + it("serves the owner's briefing lead to the owner and never to a delegate", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: owner.id }, + data: { + insightsCachedText: cachedBriefing("owner"), + insightsCachedAt: new Date(), + aiProvider: "ANTHROPIC", + aiAnthropicKeyEncrypted: "v1:presence-only", + consentReceipts: { + create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, + }, + }, + }); + const { GET } = await import("@/app/api/daily/digest/route"); + + await signIn(owner.id); + const own = (await ok( + await drive(GET as Handler, "/api/daily/digest"), + )) as { + briefingLead: string | null; + }; + expect(own.briefingLead).toBe("Briefing for the owner."); + + await switchInto(owner.id, delegate.id); + const switched = (await ok( + await drive(GET as Handler, "/api/daily/digest"), + )) as { + briefingLead: string | null; + ai: { briefing: { reason: string | null } }; + }; + expect(switched.briefingLead).toBeNull(); + expect(switched.ai.briefing.reason).toBe("not_permitted_for_record"); + }); }); frontDoorRead("GET /api/gamification/achievements", { @@ -340,18 +387,48 @@ async function seedAssistantMessage(userId: string, at: string) { }); } -frontDoorRead("GET /api/insights/coach/nudge-status", { - seed: async (userId, marker) => { - await seedAssistantMessage( - userId, - marker === "owner" ? "2026-07-01T09:00:00Z" : "2026-07-02T09:00:00Z", - ); - }, - call: async () => { +// The Coach is not available to a delegate inside somebody else's record, so +// the unread signal answers its quiet shape there rather than the owner's +// thread. The owner's own read, and the refusals of the front door itself, +// are unchanged. +describe("GET /api/insights/coach/nudge-status", () => { + it("answers the owner's thread to the owner and the quiet shape to a delegate", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { + aiProvider: "ANTHROPIC", + aiAnthropicKeyEncrypted: "v1:presence-only", + }, + }); + await seedAssistantMessage(owner.id, "2026-07-01T09:00:00Z"); const { GET } = await import("@/app/api/insights/coach/nudge-status/route"); - return drive(GET as Handler, "/api/insights/coach/nudge-status"); - }, - read: (data: { nudgedAt: string | null }) => data.nudgedAt, + + await signIn(owner.id); + const own = (await ok( + await drive(GET as Handler, "/api/insights/coach/nudge-status"), + )) as { nudgedAt: string | null }; + expect(own.nudgedAt).toBe("2026-07-01T09:00:00.000Z"); + + await switchInto(owner.id, delegate.id); + const switched = (await ok( + await drive(GET as Handler, "/api/insights/coach/nudge-status"), + )) as { nudgedAt: string | null; ai: { reason: string | null } }; + expect(switched.nudgedAt).toBeNull(); + expect(switched.ai.reason).toBe("not_permitted_for_record"); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + const session = await signIn(stranger.id); + await switchSessionTo(session.id, owner.id); + const { GET } = await import("@/app/api/insights/coach/nudge-status/route"); + await expectAccessDenied( + await drive(GET as Handler, "/api/insights/coach/nudge-status"), + ); + }); }); frontDoorRead("GET /api/coach/reminders", {