mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in new issue
Block a user