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
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:19:43 +02:00
1 parent 3309ac177c
commit ce5a340931
8 files changed
+1268 -21

No files matched your search

@@ -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<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"),
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");
});
});
@@ -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<void> {
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<Response>;
interface DataRead {
name: string;
load: () => Promise<Handler>;
url: string;
method?: "GET" | "POST";
params?: (world: World) => Promise<Record<string, string>>;
/** Scoped to the caller rather than the record: not read in S8. */
actorScoped?: boolean;
}
async function firstEcgId(world: World): Promise<Record<string, string>> {
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<string, unknown> = {};
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<Record<string, { status: number; body: unknown }>> {
const out: Record<string, { status: number; body: unknown }> = {};
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<string, { status: number; body: unknown }> | 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<Response> {
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);
},
);
});
+202
View File
@@ -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<void> {
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<string, unknown> = {},
) {
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<string, unknown>) {
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<void> = async () => {},
): Promise<World> {
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<string, unknown> = {};
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;
}
@@ -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<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: {
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<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");
});
});
@@ -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 () => {
+9 -1
View File
@@ -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<typeof import("@/lib/ai/provider")>()),
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({
@@ -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<typeof import("@/lib/ai/provider")>()),
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
}));
+93 -16
View File
@@ -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", {