Merge release/v1.39.0 into feat/ai-routes-insights-coach

Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:16:53 +02:00
commit c509afeb2e
256 files changed
+13048 -4642

No files matched your search

@@ -0,0 +1,539 @@
/**
* The extraction group's capabilities, through the real routes and the real
* document pick against real Postgres, with a spy standing in for every model
* call.
*
* The unit suites prove each gate is asked. What only this file can prove is
* that the answer comes off the rows it should: the operator's switches on
* `AppSettings`, the record's module preferences, the receipts on
* `ConsentReceipt`, and the provider built from the person's own credential.
* Every state is checked twice: the response a client sees, and the spy that
* says whether anything reached a model. Each file-level state has an all-open
* twin in which the spy IS called, so a spy that could not see a call would
* turn those red.
*
* The two background paths at the end run the job handlers directly. They
* prove the wire re-check in the document pick: a job enqueued before the
* operator turned document reading off finds no provider when it runs, so
* nothing is sent even though the job itself asks nothing of the capability.
*
* Mutation checks, run against this file: replacing the `atTheWire` re-check
* in `provider-order.ts` with "nothing withheld" turns four cases red (both
* receipt cases on the vault route and both background cases). Deleting the
* `requireAiCapability("documentAi"` line from the suggest route leaves all
* seventeen green, and that is the design rather than a blind spot: the pick
* re-checks the same capability, so the switch still answers 403 with the
* same envelope. The route gate's own presence and order are pinned by the
* route's unit suite instead.
*/
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } 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 spies: every place a model would be called ─────────────────────────
vi.mock("@/lib/documents/assist", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/documents/assist")>()),
runDocumentAssist: vi.fn(async () => ({
title: "Letter",
kind: null,
documentDate: null,
})),
}));
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
runOcrExtraction: vi.fn(async () => ({ rows: [], reportDate: null })),
}));
vi.mock("@/lib/documents/describe", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/documents/describe")>()),
transcribeDocument: vi.fn(async () => ({ text: "Glucose 95 mg/dL" })),
runDocumentSummary: vi.fn(async () => ({
summary: "A laboratory report.",
blocked: false,
})),
}));
vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/provider-runner")>()),
runRawCompletionWithFallback: vi.fn(async () => ({
result: {
content: JSON.stringify({ name: "Metformin" }),
tokensUsed: 20,
model: "m",
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
})),
}));
import { runDocumentAssist } from "@/lib/documents/assist";
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
import {
runDocumentSummary,
transcribeDocument,
} from "@/lib/documents/describe";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
const SPIES = [
runDocumentAssist,
runOcrExtraction,
transcribeDocument,
runDocumentSummary,
runRawCompletionWithFallback,
] as const;
/** A 1x1 PNG, so the vision path has a real image to prepare. */
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
let counter = 0;
/** A person on their own Anthropic key: every extraction leaves the machine. */
async function makeUser(overrides: Record<string, unknown> = {}) {
const suffix = `x-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `extract-${suffix}`,
email: `extract-${suffix}@example.test`,
role: "USER",
timezone: "UTC",
locale: "en",
onboardingCompletedAt: new Date(),
aiProvider: "ANTHROPIC",
aiModel: "claude-sonnet-4-6",
aiAnthropicKeyEncrypted: encrypt("sk-ant-integration-test"),
labsLocalOcrEnabled: true,
...overrides,
},
});
}
async function grantConsent(userId: string, kind: string) {
await getPrismaClient().consentReceipt.create({
data: { userId, kind, artefact: "test", signedAt: new Date() },
});
}
async function setSwitches(values: Record<string, unknown>) {
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ...values },
update: values,
});
}
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);
}
async function storeDocument(userId: string) {
const { encryptDocumentContent } = await import("@/lib/documents/store");
const { content, codec } = encryptDocumentContent(PNG);
return getPrismaClient().inboundDocument.create({
data: {
userId,
kind: "OTHER",
filename: "letter.png",
mimeType: "image/png",
byteSize: PNG.byteLength,
status: "STORED",
contentEncrypted: content,
contentCodec: codec,
},
});
}
interface Envelope {
data: unknown;
error: string | null;
meta?: Record<string, unknown>;
}
async function suggestText(documentId: string) {
const { POST } =
await import("@/app/api/documents/inbound/[id]/suggest/route");
const { NextRequest } = await import("next/server");
const res = await POST(
new NextRequest(
`http://localhost/api/documents/inbound/${documentId}/suggest`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mode: "text", text: "Discharge letter" }),
},
) as never,
{ params: Promise.resolve({ id: documentId }) } as never,
);
return { status: res.status, body: (await res.json()) as Envelope };
}
async function labsScanText() {
const { POST } = await import("@/app/api/labs/ocr/extract/route");
const res = await POST(
new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mode: "text", text: "Glucose 95 mg/dL" }),
}) as never,
);
return { status: res.status, body: (await res.json()) as Envelope };
}
async function extractMedication() {
const { POST } = await import("@/app/api/medications/extract/route");
const { NextRequest } = await import("next/server");
const res = await POST(
new NextRequest("http://localhost/api/medications/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "Metformin 500 mg twice daily" }),
}) as never,
);
return { status: res.status, body: (await res.json()) as Envelope };
}
function expectNoModelCall() {
for (const spy of SPIES) {
expect(vi.mocked(spy)).not.toHaveBeenCalled();
}
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
counter = 0;
for (const spy of SPIES) vi.mocked(spy).mockClear();
});
describe("reading a stored document — POST /api/documents/inbound/[id]/suggest", () => {
it("reads the document when every layer is open (the spy can see a call)", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_extraction");
const document = await storeDocument(user.id);
await signIn(user.id);
const { status } = await suggestText(document.id);
expect(status).toBe(200);
expect(runDocumentAssist).toHaveBeenCalledTimes(1);
});
it("refuses with the reading-documents code and sends nothing when the operator turned it off", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_full");
await setSwitches({ assistantDocumentAiEnabled: false });
const document = await storeDocument(user.id);
await signIn(user.id);
const { status, body } = await suggestText(document.id);
expect(status).toBe(403);
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expectNoModelCall();
});
it("refuses under the master switch too", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_full");
await setSwitches({ assistantEnabled: false });
const document = await storeDocument(user.id);
await signIn(user.id);
const { status, body } = await suggestText(document.id);
expect(status).toBe(403);
expect(body.meta?.reason).toBe("operator_disabled");
expectNoModelCall();
});
it("asks for an extraction receipt before the document leaves for the person's own key", async () => {
const user = await makeUser();
const document = await storeDocument(user.id);
await signIn(user.id);
const { status, body } = await suggestText(document.id);
expect(status).toBe(403);
expect(body.meta).toEqual({
errorCode: "consent.ai.required",
capability: "documentAi",
reason: "consent_required",
});
expectNoModelCall();
});
it("does not take the AI-analysis receipt as consent to read documents", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_insights_only");
const document = await storeDocument(user.id);
await signIn(user.id);
const { status, body } = await suggestText(document.id);
expect(status).toBe(403);
expect(body.meta?.errorCode).toBe("consent.ai.required");
expectNoModelCall();
});
it("keeps the vault's own code when no provider is configured", async () => {
const user = await makeUser({
aiProvider: null,
aiModel: null,
aiAnthropicKeyEncrypted: null,
});
const document = await storeDocument(user.id);
await signIn(user.id);
const { status, body } = await suggestText(document.id);
expect(status).toBe(422);
expect(body.meta).toEqual({
errorCode: "documents.inbound.providerUnsupported",
capability: "documentAi",
reason: "no_provider",
});
expectNoModelCall();
});
});
describe("scanning a lab report — POST /api/labs/ocr/extract", () => {
it("scans when every layer is open (the spy can see a call)", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_extraction");
await signIn(user.id);
const { status } = await labsScanText();
expect(status).toBe(200);
expect(runOcrExtraction).toHaveBeenCalledTimes(1);
});
it("stops under the reading-documents switch", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_full");
await setSwitches({ assistantDocumentAiEnabled: false });
await signIn(user.id);
const { status, body } = await labsScanText();
expect(status).toBe(403);
expect(body.meta).toMatchObject({
errorCode: "assistant.disabled.documentAi",
capability: "labsOcr",
});
expectNoModelCall();
});
it("names the labs module when it is off for the record", async () => {
const user = await makeUser({
modulePreferencesJson: { labs: false },
});
await grantConsent(user.id, "ai_full");
await signIn(user.id);
const { status, body } = await labsScanText();
expect(status).toBe(403);
expect(body.meta).toEqual({
errorCode: "module.disabled",
capability: "labsOcr",
reason: "module_disabled",
module: "labs",
});
expectNoModelCall();
});
it("puts a lab report under the document consent rule", async () => {
const user = await makeUser();
// The snapshot-rule receipt that used to cover a scan no longer does: a
// lab report is a document.
await grantConsent(user.id, "ai_insights_only");
await signIn(user.id);
const { status, body } = await labsScanText();
expect(status).toBe(403);
expect(body.meta?.errorCode).toBe("consent.ai.required");
expectNoModelCall();
});
});
describe("turning medication text into a schedule — POST /api/medications/extract", () => {
it("extracts when every layer is open (the spy can see a call)", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_extraction");
await signIn(user.id);
const { status } = await extractMedication();
expect(status).toBe(200);
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
});
it("stops under the reading-documents switch, whatever the Coach switch says", async () => {
const user = await makeUser();
await grantConsent(user.id, "ai_full");
await setSwitches({
assistantDocumentAiEnabled: false,
assistantCoachEnabled: true,
});
await signIn(user.id);
const { status, body } = await extractMedication();
expect(status).toBe(403);
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "medicationExtract",
reason: "operator_disabled",
});
expectNoModelCall();
});
it("keeps 503 for no provider and names it", async () => {
const user = await makeUser({
aiProvider: null,
aiModel: null,
aiAnthropicKeyEncrypted: null,
});
await signIn(user.id);
const { status, body } = await extractMedication();
expect(status).toBe(503);
expect(body.meta).toEqual({
errorCode: "ai.provider.none",
capability: "medicationExtract",
reason: "no_provider",
});
expectNoModelCall();
});
});
describe("document auto-read mints the extraction receipt", () => {
it("writes ai_extraction, not ai_full", async () => {
const user = await makeUser();
await signIn(user.id);
const { PATCH } =
await import("@/app/api/auth/me/documents-auto-ai-read/route");
const res = await PATCH(
new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentsAutoAiRead: true }),
}),
);
expect(res.status).toBe(200);
const receipts = await getPrismaClient().consentReceipt.findMany({
where: { userId: user.id, revokedAt: null },
select: { kind: true },
});
expect(receipts.map((r) => r.kind)).toEqual(["ai_extraction"]);
});
});
describe("background reads stop at the wire", () => {
it("indexes through the text layer, not a model, once document reading is off", async () => {
const user = await makeUser({ documentsAutoAiRead: true });
await grantConsent(user.id, "ai_extraction");
const document = await storeDocument(user.id);
const { indexDocumentContent } =
await import("@/lib/documents/index-document");
// Open: the provider path is taken (the spy can see a call).
await indexDocumentContent(user.id, document.id);
expect(transcribeDocument).toHaveBeenCalledTimes(1);
// The operator turns document reading off; the same job runs again.
vi.mocked(transcribeDocument).mockClear();
await getPrismaClient().documentContentIndex.deleteMany({
where: { documentId: document.id },
});
await setSwitches({ assistantDocumentAiEnabled: false });
await indexDocumentContent(user.id, document.id);
expectNoModelCall();
});
it("leaves a summary job enqueued before the switch flipped with no provider to call", async () => {
const user = await makeUser({ documentsAutoAiRead: true });
await grantConsent(user.id, "ai_extraction");
const document = await storeDocument(user.id);
await setSwitches({ assistantDocumentAiEnabled: false });
const { runDocumentSummaryJob } =
await import("@/lib/jobs/document-summary");
await runDocumentSummaryJob({
userId: user.id,
documentId: document.id,
enqueuedAt: new Date().toISOString(),
});
expectNoModelCall();
const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({
where: { id: document.id },
select: { summaryEncrypted: true },
});
expect(row.summaryEncrypted).toBeNull();
});
it("summarises the same document with the switch on (the spy can see a call)", async () => {
const user = await makeUser({ documentsAutoAiRead: true });
await grantConsent(user.id, "ai_extraction");
const document = await storeDocument(user.id);
const { runDocumentSummaryJob } =
await import("@/lib/jobs/document-summary");
await runDocumentSummaryJob({
userId: user.id,
documentId: document.id,
enqueuedAt: new Date().toISOString(),
});
expect(runDocumentSummary).toHaveBeenCalledTimes(1);
});
});
+289
View File
@@ -0,0 +1,289 @@
/**
* The AI background jobs, run directly against real Postgres under the states
* that must stop them, with a spy in place of the provider.
*
* For every worker and every refusing state: the spy is never called, the
* worker writes no model text, and a skip annotation is recorded. Under the
* all-on baseline the spy IS called, which is what proves the test can see a
* call at all (a spy that no path reaches would pass every refusing case).
*
* States (design "AI optional", 6.2):
* S0 all on: the person's own key, every switch on.
* S1 the operator's master switch off.
* S3 no provider configured anywhere.
* S4 only the operator's key, and no consent receipt.
* S5 the Coach hidden (`disableCoach`), which stops only the Coach.
* S6 AI analysis switched off (the `insights` module), which stops
* everything but the Coach.
*
* Only the provider chain is replaced; the capability resolution, the
* presence probe, the budget ledger and the writes all run for real.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const providerSpy = vi.fn(async () => ({
content: JSON.stringify({ summary: "A calm and steady stretch." }),
model: "spy-model",
tokensUsed: 12,
}));
vi.mock("@/lib/ai/provider", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/ai/provider")>();
const chain = () => [
{
providerType: "anthropic",
instance: { type: "anthropic", generateCompletion: providerSpy },
},
];
return {
...actual,
resolveProviderChain: vi.fn(async () => chain()),
resolveProvider: vi.fn(async () => ({ type: "none" })),
};
});
const annotations: string[] = [];
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn((event: { action?: { name?: string } }) => {
if (event?.action?.name) annotations.push(event.action.name);
return actual.annotate(event as never);
}),
};
});
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker";
import { generateStatusBatchForUser } from "@/lib/insights/status-batch";
import { runInsightStatusGenerate } from "@/lib/jobs/insight-status-generate";
import { runReactionLine } from "@/lib/jobs/reaction-line";
import { runWorkoutInsightGenerate } from "@/lib/jobs/workout-insight-generate";
import { userDayKey } from "@/lib/tz/resolver";
type State = "S0" | "S1" | "S3" | "S4" | "S5" | "S6";
let counter = 0;
/** One account in the given state, with the data every worker below reads. */
async function seedState(state: State) {
const prisma = getPrismaClient();
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
assistantEnabled: state !== "S1",
adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null,
},
update: {
assistantEnabled: state !== "S1",
adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null,
},
});
const ownKey = state !== "S3" && state !== "S4";
const suffix = `${state.toLowerCase()}-${counter++}`;
const user = await prisma.user.create({
data: {
username: `jobs-${suffix}`,
email: `jobs-${suffix}@example.test`,
timezone: "UTC",
locale: "en",
heightCm: 180,
disableCoach: state === "S5",
modulePreferencesJson: state === "S6" ? { insights: false } : undefined,
aiProviderChain: ownKey
? [{ providerType: "anthropic", enabled: true }]
: undefined,
aiAnthropicKeyEncrypted: ownKey ? "key-present" : null,
},
});
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
await prisma.measurement.createMany({
data: Array.from({ length: 30 }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
value: 82 - i * 0.05,
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - i * DAY),
})),
});
const workout = await prisma.workout.create({
data: {
userId: user.id,
sportType: "cycling",
startedAt: new Date(now - 2 * 60 * 60 * 1000),
endedAt: new Date(now - 60 * 60 * 1000),
durationSec: 3600,
source: "APPLE_HEALTH",
externalId: `workout-${suffix}`,
},
});
const occurredAt = new Date(now - 5 * 60 * 1000);
const localDate = userDayKey(new Date(now), "UTC");
await prisma.arrivalReaction.create({
data: { userId: user.id, kind: "weight", localDate, occurredAt },
});
// Long enough that the rolling summary has turns to fold.
const conversation = await prisma.coachConversation.create({
data: { userId: user.id, title: "long conversation" },
});
await prisma.coachMessage.createMany({
data: Array.from({ length: 40 }, (_, i) => ({
conversationId: conversation.id,
role: i % 2 === 0 ? "user" : "assistant",
encryptedContent: encryptToBytes(`turn ${i}: about sleep and walks`),
createdAt: new Date(now - (40 - i) * 60_000),
})),
});
return {
userId: user.id,
workoutId: workout.id,
reaction: {
userId: user.id,
kind: "weight" as const,
localDate,
revision: occurredAt.toISOString(),
},
conversationId: conversation.id,
};
}
type Seeded = Awaited<ReturnType<typeof seedState>>;
interface Worker {
name: string;
/** Which states must stop it. */
refusedIn: readonly State[];
run: (s: Seeded) => Promise<unknown>;
/** Whether the worker wrote model text for this account. */
wroteText: (s: Seeded) => Promise<boolean>;
/** The skip annotation it records when refused. */
skipAnnotation: string;
}
const ANALYSIS_REFUSED: readonly State[] = ["S1", "S3", "S4", "S6"];
const COACH_REFUSED: readonly State[] = ["S1", "S3", "S4", "S5"];
const WORKERS: Worker[] = [
{
name: "insight-status-generate (statusText)",
refusedIn: ANALYSIS_REFUSED,
run: (s) =>
runInsightStatusGenerate({
userId: s.userId,
metric: "weight",
locale: "en",
authority: {
origin: "owner",
recordUserId: s.userId,
actorUserId: s.userId,
grantId: null,
},
}),
wroteText: async (s) =>
(await getPrismaClient().insightStatusCache.count({
where: { userId: s.userId, textEncrypted: { not: null } },
})) > 0,
skipAnnotation: "insights.status.generate.skipped",
},
{
name: "nightly status batch (statusText)",
refusedIn: ANALYSIS_REFUSED,
run: (s) => generateStatusBatchForUser(s.userId, { locale: "en" }),
wroteText: async (s) =>
(await getPrismaClient().insightStatusCache.count({
where: { userId: s.userId, textEncrypted: { not: null } },
})) > 0,
skipAnnotation: "insights.status.batch.skipped",
},
{
name: "workout-insight-generate (workoutInsights)",
refusedIn: ANALYSIS_REFUSED,
run: (s) =>
runWorkoutInsightGenerate({ userId: s.userId, workoutId: s.workoutId }),
wroteText: async (s) =>
(await getPrismaClient().workoutInsight.count({
where: { userId: s.userId },
})) > 0,
skipAnnotation: "workouts.insight.skipped",
},
{
name: "reaction-line (reactionLines)",
refusedIn: ANALYSIS_REFUSED,
run: (s) => runReactionLine(s.reaction),
wroteText: async (s) =>
(await getPrismaClient().arrivalReaction.count({
where: { userId: s.userId, lineEncrypted: { not: null } },
})) > 0,
skipAnnotation: "arrival.reaction.skipped",
},
{
name: "coach-memory-refresh (coach)",
refusedIn: COACH_REFUSED,
run: (s) =>
runCoachMemoryRefresh({
conversationId: s.conversationId,
userId: s.userId,
locale: "en",
}),
wroteText: async (s) =>
(await getPrismaClient().coachConversation.count({
where: { id: s.conversationId, summaryEncrypted: { not: null } },
})) > 0,
skipAnnotation: "coach.memory.refresh.skipped",
},
];
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
providerSpy.mockClear();
annotations.length = 0;
});
describe.each(WORKERS)("$name", (worker) => {
it("calls the provider under S0 (the spy can see a call)", async () => {
const seeded = await seedState("S0");
await worker.run(seeded);
expect(providerSpy).toHaveBeenCalled();
expect(annotations).not.toContain(worker.skipAnnotation);
});
it.each(worker.refusedIn)(
"never reaches the provider and writes nothing under %s",
async (state) => {
const seeded = await seedState(state);
await worker.run(seeded);
expect(providerSpy).not.toHaveBeenCalled();
expect(await worker.wroteText(seeded)).toBe(false);
expect(annotations).toContain(worker.skipAnnotation);
},
);
});
describe("the Coach and the analysis are separate opt-outs", () => {
it("hiding the Coach does not stop the status notes", async () => {
const seeded = await seedState("S5");
await WORKERS[0].run(seeded);
expect(providerSpy).toHaveBeenCalled();
});
it("switching AI analysis off does not stop the Coach memory", async () => {
const seeded = await seedState("S6");
await WORKERS[4].run(seeded);
expect(providerSpy).toHaveBeenCalled();
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* The chart's own request, against the real route, on a dense stream.
*
* The 7-day range used to ask for raw rows oldest-first with a 5 000-row
* limit. Per-minute heart rate puts 10 080 rows in seven days, so the answer
* stopped about three and a half days in and the chart silently lost the
* most recent days. Every range now asks for one row per local day, so the
* whole window arrives whatever the sampling rate.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { chartSeriesParams } from "@/components/charts/chart-series-request";
import { userDayKey } from "@/lib/tz/format";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
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 USER = "chart-range-dense-user";
const TZ = "Europe/Berlin";
const TO = new Date("2026-09-20T18:00:00.000Z");
const MINUTES = 7 * 24 * 60;
beforeAll(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
await prisma.user.create({
data: { id: USER, username: USER, timezone: TZ },
});
invalidateUserTimezone(USER);
const session = await prisma.session.create({
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (
id, user_id, type, value, unit, source, measured_at,
created_at, updated_at, sync_version)
SELECT 'cr' || lpad(g::text, 6, '0'), $1, 'PULSE'::measurement_type,
60 + (g % 30), 'bpm', 'GOOGLE_HEALTH'::measurement_source,
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
now(), now(), 1
FROM generate_series(0, $3::int - 1) g`,
USER,
TO.toISOString(),
MINUTES,
);
}, 120_000);
describe("7-day chart range on per-minute pulse", () => {
it("returns every day of the window, the newest included", async () => {
const from = new Date(TO.getTime() - 7 * 86_400_000);
const params = chartSeriesParams("PULSE", {
from: from.toISOString(),
to: TO.toISOString(),
});
const { GET } = await import("@/app/api/measurements/route");
const res = await GET(
new NextRequest(`http://localhost/api/measurements?${params}`),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<{ measuredAt: string }> };
};
const days = new Set(
json.data.measurements.map((m) => userDayKey(new Date(m.measuredAt), TZ)),
);
// 20 Sep 20:00 Berlin back to 13 Sep 20:00: eight calendar days.
expect([...days].sort()).toEqual([
"2026-09-13",
"2026-09-14",
"2026-09-15",
"2026-09-16",
"2026-09-17",
"2026-09-18",
"2026-09-19",
"2026-09-20",
]);
});
});
@@ -0,0 +1,321 @@
/**
* Consent withdrawal deletes the regenerable model-written text, against real
* Postgres, and keeps the person's own records.
*
* What is pinned here and nowhere else:
*
* - the purge reaches every surface the analysis consent covered: status
* notes, the cached briefing, model-written narratives (the deterministic
* one stays), reaction lines (the marker stays), workout paragraphs;
* - Coach conversations and facts, and a document's stored summary, stay;
* - revoke and purge are one transaction: a purge that fails leaves the
* receipt active and every row in place;
* - a withdrawal that leaves the analysis covered, or that never touched
* it, deletes nothing.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
purgeRegenerableAiText,
withdrawConsent,
} from "@/lib/consent/withdrawal";
const BYTES = Buffer.from("ciphertext-stand-in", "utf8");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seed(username: string, kinds: string[]) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
insightsCachedText: JSON.stringify({ briefing: "model prose" }),
insightsCachedAt: new Date(),
insightsSnapshotHash: "hash",
},
});
for (const kind of kinds) {
await prisma.consentReceipt.create({
data: { userId: user.id, kind, artefact: "{}", signedAt: new Date() },
});
}
await prisma.insightStatusCache.createMany({
data: [
{
userId: user.id,
metric: "weight",
locale: "en",
dateKey: "2026-09-20",
textEncrypted: BYTES,
},
{
userId: user.id,
metric: "metric:RESTING_HEART_RATE",
locale: "en",
dateKey: "2026-09-20",
textEncrypted: BYTES,
},
],
});
await prisma.insightNarrative.createMany({
data: [
{
userId: user.id,
period: "week",
locale: "en",
dateKey: "2026-09-20",
encryptedContent: BYTES,
providerType: "anthropic",
},
{
userId: user.id,
period: "month",
locale: "en",
dateKey: "2026-09-20",
encryptedContent: BYTES,
providerType: "deterministic",
},
],
});
await prisma.arrivalReaction.create({
data: {
userId: user.id,
kind: "weight",
localDate: "2026-09-20",
occurredAt: new Date(),
lineEncrypted: BYTES,
generatedAt: new Date(),
},
});
const workout = await prisma.workout.create({
data: {
userId: user.id,
sportType: "cycling",
startedAt: new Date("2026-09-20T06:00:00Z"),
endedAt: new Date("2026-09-20T06:45:00Z"),
durationSec: 2700,
source: "APPLE_HEALTH",
externalId: `ext-${username}`,
},
});
await prisma.workoutInsight.create({
data: {
userId: user.id,
workoutId: workout.id,
paragraphEncrypted: BYTES,
inputHash: "h",
promptVersion: "1",
providerType: "anthropic",
locale: "en",
generatedAt: new Date(),
},
});
const conversation = await prisma.coachConversation.create({
data: { userId: user.id, title: "t", summaryEncrypted: BYTES },
});
await prisma.coachFact.create({
data: {
userId: user.id,
factEncrypted: BYTES,
category: "goal",
sourceConversationId: conversation.id,
},
});
await prisma.inboundDocument.create({
data: {
userId: user.id,
mimeType: "image/png",
byteSize: BYTES.byteLength,
contentEncrypted: BYTES,
summaryEncrypted: BYTES,
summaryState: "READY",
},
});
return user;
}
async function snapshot(userId: string) {
const prisma = getPrismaClient();
const [
user,
notes,
narratives,
reaction,
workouts,
conversations,
facts,
docs,
] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
insightsCachedText: true,
insightsCachedAt: true,
insightsSnapshotHash: true,
},
}),
prisma.insightStatusCache.count({ where: { userId } }),
prisma.insightNarrative.findMany({
where: { userId },
select: { providerType: true },
}),
prisma.arrivalReaction.findFirstOrThrow({
where: { userId },
select: { lineEncrypted: true, generatedAt: true },
}),
prisma.workoutInsight.count({ where: { userId } }),
prisma.coachConversation.findMany({
where: { userId },
select: { summaryEncrypted: true },
}),
prisma.coachFact.count({ where: { userId } }),
prisma.inboundDocument.findMany({
where: { userId },
select: { summaryEncrypted: true },
}),
]);
return {
user,
notes,
narratives,
reaction,
workouts,
conversations,
facts,
docs,
};
}
describe("withdrawing the analysis consent", () => {
it("deletes every regenerable text and keeps the person's own records", async () => {
const user = await seed("purge-all", ["ai_full"]);
const result = await withdrawConsent(user.id, ["ai_full"]);
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_full"]);
expect(result.purged).toEqual({
statusNotes: 2,
briefing: 1,
narratives: 1,
reactionLines: 1,
workoutParagraphs: 1,
});
const after = await snapshot(user.id);
expect(after.user).toEqual({
insightsCachedText: null,
insightsCachedAt: null,
insightsSnapshotHash: null,
});
expect(after.notes).toBe(0);
expect(after.narratives).toEqual([{ providerType: "deterministic" }]);
// The marker stays and stays "generated", so nothing writes it again.
expect(after.reaction.lineEncrypted).toBeNull();
expect(after.reaction.generatedAt).not.toBeNull();
expect(after.workouts).toBe(0);
// The person's own records.
expect(after.conversations).toHaveLength(1);
expect(after.conversations[0].summaryEncrypted).not.toBeNull();
expect(after.facts).toBe(1);
expect(after.docs[0].summaryEncrypted).not.toBeNull();
const receipts = await getPrismaClient().consentReceipt.findMany({
where: { userId: user.id },
});
expect(receipts).toHaveLength(1);
expect(receipts[0].revokedAt).not.toBeNull();
});
it("is atomic: a failing purge leaves the receipt active and every row in place", async () => {
const user = await seed("purge-atomic", ["ai_insights_only"]);
const before = await snapshot(user.id);
await expect(
withdrawConsent(
user.id,
["ai_insights_only"],
new Date(),
async (tx, id) => {
// Do part of the work, then fail: the part must roll back too.
await purgeRegenerableAiText(tx, id);
throw new Error("purge failed");
},
),
).rejects.toThrow("purge failed");
const receipt = await getPrismaClient().consentReceipt.findFirstOrThrow({
where: { userId: user.id },
});
expect(receipt.revokedAt).toBeNull();
expect(await snapshot(user.id)).toEqual(before);
});
it("deletes nothing while another receipt still covers the analysis", async () => {
const user = await seed("purge-covered", ["ai_full", "ai_insights_only"]);
const before = await snapshot(user.id);
const result = await withdrawConsent(user.id, ["ai_full"]);
expect(result.purged).toBeNull();
expect(await snapshot(user.id)).toEqual(before);
});
it("deletes nothing for a withdrawal of the Coach or extraction consent alone", async () => {
const user = await seed("purge-other", ["ai_coach", "ai_extraction"]);
const before = await snapshot(user.id);
const result = await withdrawConsent(user.id, [
"ai_coach",
"ai_extraction",
]);
expect(result.revoked).toHaveLength(2);
expect(result.purged).toBeNull();
expect(await snapshot(user.id)).toEqual(before);
});
it("touches no other account", async () => {
const user = await seed("purge-me", ["ai_full"]);
const other = await seed("purge-other-account", ["ai_full"]);
const otherBefore = await snapshot(other.id);
await withdrawConsent(user.id, ["ai_full"]);
expect(await snapshot(other.id)).toEqual(otherBefore);
});
});
describe("a revoked extraction receipt wins over the auto-read toggle", () => {
it("refuses external document egress once the receipt is revoked, toggle still on", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "k1-toggle",
email: "k1-toggle@example.test",
documentsAutoAiRead: true,
},
});
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "{}",
signedAt: new Date(),
},
});
const { aiEgressRefusal } = await import("@/lib/ai/capabilities/egress");
expect(await aiEgressRefusal("documentAi", user.id, ["codex"])).toBeNull();
await withdrawConsent(user.id, ["ai_extraction"]);
const refusal = await aiEgressRefusal("documentAi", user.id, ["codex"]);
expect(refusal?.reason).toBe("consent_required");
// A local pick never leaves the machine and stays admitted.
expect(await aiEgressRefusal("documentAi", user.id, ["local"])).toBeNull();
});
});
@@ -0,0 +1,114 @@
/**
* The cycle-phase crosstab reads per-day, per-source aggregates instead of a
* year of raw readings (#1023). This pins that the day values it tests are
* the ones the raw readings give: for every crosstab metric, `metricDayMap`
* over the aggregates equals `metricDayMap` over the raw rows, on a fixture
* with two sources on the same days, two device types within one source,
* dense CGM glucose, summed steps and readings next to Berlin midnight.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { metricDayMap } from "@/lib/insights/mood-crosstab";
import { PHASE_CROSSTAB_METRIC_TYPES } from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const prisma = getPrismaClient();
const USER = "cycle-crosstab-agg";
const SINCE = new Date("2026-06-01T00:00:00Z");
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({ data: { id: USER, username: USER } });
// CGM glucose every 5 minutes for 60 days from two sources.
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'cg' || s || g, $1, 'BLOOD_GLUCOSE'::measurement_type, 80 + (g % 60), 'mg/dL',
(CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source,
timestamp '2026-06-02 00:00:00' + (g * interval '5 minutes') + (s * interval '1 second'),
now(), now(), 1
FROM generate_series(0, 60 * 288 - 1) g, generate_series(0, 1) s`,
USER,
);
// Steps every 15 minutes from a watch and a phone, plus Google totals on
// some days; resting heart rate once a day near Berlin midnight.
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version)
SELECT 'st' || d || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count',
'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END,
timestamp '2026-06-02 00:00:00' + (g * interval '15 minutes') + (d * interval '2 seconds'),
now(), now(), 1
FROM generate_series(0, 60 * 96 - 1) g, generate_series(0, 1) d
WHERE d = 0 OR g % 3 = 0`,
USER,
);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'gs' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 5000 + g, 'count',
'GOOGLE_HEALTH'::measurement_source,
timestamp '2026-06-02 12:00:00' + (g * interval '1 day'), now(), now(), 1
FROM generate_series(0, 59, 4) g`,
USER,
);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'rh' || g, $1, 'RESTING_HEART_RATE'::measurement_type, 55 + (g % 9), 'bpm',
'WITHINGS'::measurement_source,
timestamp '2026-06-02 22:30:00' + (g * interval '1 day'), now(), now(), 1
FROM generate_series(0, 59) g`,
USER,
);
}, 120_000);
describe("cycle crosstab day values (#1023)", () => {
it("per-source day aggregates give the raw rows' day values for every metric", async () => {
const raw = await prisma.measurement.findMany({
where: {
userId: USER,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: { gte: SINCE },
},
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
});
const agg = await readSourceDayAggregates({
userId: USER,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: SINCE,
timeZone: "Europe/Berlin",
});
const aggRows: CrossMetricMeasurement[] = agg.map((a) => ({
type: a.type,
value: a.sum,
count: a.n,
measuredAt: a.firstAt,
source: a.source,
deviceType: a.deviceType,
}));
expect(raw.length).toBeGreaterThan(40_000);
expect(aggRows.length).toBeLessThan(500);
for (const type of PHASE_CROSSTAB_METRIC_TYPES) {
const expected = metricDayMap(raw, type, null);
const actual = metricDayMap(aggRows, type, null);
expect([...actual.keys()].sort(), type).toEqual(
[...expected.keys()].sort(),
);
for (const [day, value] of expected) {
expect(actual.get(day), `${type} ${day}`).toBeCloseTo(value, 9);
}
}
}, 60_000);
});
@@ -253,6 +253,205 @@ describe("a bleeding day opens a cycle", () => {
expect(may).toEqual({ endDate: null, lengthDays: null });
});
it("moves a start earlier when an earlier first day is remembered later (#1004)", async () => {
// Day two logged first, day one filled in afterwards. The period began a
// day earlier; it did not begin twice. The old rule only looked at the
// cycle BEFORE the day and opened a one-day cycle in front of the other.
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-02", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
const rows = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
expect(rows).toEqual([
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
{ startDate: "2026-06-01", endDate: null, lengthDays: null },
]);
});
it("lands on the same history whichever order the periods are entered in", async () => {
// September first, then January to August, is what a person back-filling
// a year does. The result must not depend on it.
const starts = [
"2026-01-05",
"2026-02-03",
"2026-03-04",
"2026-04-02",
"2026-05-01",
];
for (const d of [starts[4], ...starts.slice(0, 4)]) {
// Day two of each period first, then its first day.
const dayTwo = new Date(`${d}T12:00:00Z`);
dayTwo.setUTCDate(dayTwo.getUTCDate() + 1);
await logDay(dayTwo.toISOString().slice(0, 10), { flow: "MEDIUM" });
await logDay(d, { flow: "HEAVY" });
}
const outOfOrder = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "cycle-flow",
email: "cycle-flow@example.test",
gender: "FEMALE",
timezone: "UTC",
},
});
await loginAs(USER_ID);
for (const d of starts) {
await logDay(d, { flow: "HEAVY" });
}
const inOrder = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
expect(outOfOrder).toEqual(inOrder);
expect(inOrder.map((r) => r.startDate)).toEqual(starts);
});
it("does not guess a start from a bleed too far before the next period to be its first day", async () => {
await logDay("2026-06-15", { flow: "HEAVY" });
// Twelve days earlier: too close to be a cycle of its own, too far to be
// the same period. Nothing is inferred; the one-tap start still decides.
await logDay("2026-06-03", { flow: "HEAVY" });
expect(await cycleStarts()).toEqual(["2026-06-15"]);
});
it("re-attributes the days a back-filled start now owns", async () => {
await logDay("2026-05-01", { flow: "HEAVY" });
await logDay("2026-06-05", { basalBodyTempC: 36.4 });
// A June start remembered afterwards: 5 June belongs to it, not to May.
await logDay("2026-06-01", { flow: "HEAVY" });
const june = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-06-01", deletedAt: null },
select: { id: true },
});
const day = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-05" },
select: { cycleId: true },
});
expect(day.cycleId).toBe(june.id);
});
it("hands a removed start's days back to the cycle before it", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
await logDay("2026-06-05", { basalBodyTempC: 36.4 });
const start = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-01" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
await PATCH(
jsonRequest(`/api/cycle/day-logs/${start.id}`, "PATCH", { flow: null }),
{ params: Promise.resolve({ id: start.id }) },
);
const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
select: { id: true },
});
const day = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-05" },
select: { cycleId: true },
});
expect(day.cycleId).toBe(may.id);
});
it("repairs a one-day cycle the old rule left, with one tap on the real first day", async () => {
// The state the previous rule produced when day two of a period was
// logged before day one: a cycle one day long in front of the real one.
// Seeded directly, because the current code no longer creates it.
const prisma = getPrismaClient();
const may = await prisma.menstrualCycle.create({
data: {
userId: USER_ID,
startDate: "2026-05-04",
endDate: "2026-05-31",
lengthDays: 28,
},
});
const stub = await prisma.menstrualCycle.create({
data: {
userId: USER_ID,
startDate: "2026-06-01",
endDate: "2026-06-01",
lengthDays: 1,
},
});
const real = await prisma.menstrualCycle.create({
data: { userId: USER_ID, startDate: "2026-06-02" },
});
await prisma.cycleDayLog.createMany({
data: [
{
userId: USER_ID,
date: "2026-06-01",
flow: "HEAVY",
cycleId: stub.id,
},
{
userId: USER_ID,
date: "2026-06-02",
flow: "MEDIUM",
cycleId: real.id,
},
{
userId: USER_ID,
date: "2026-06-06",
flow: "LIGHT",
cycleId: real.id,
},
],
});
// What the release note tells the person to do.
const { POST } = await import("@/app/api/cycle/period/route");
const res = await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-06-01",
loggedAt: new Date().toISOString(),
}),
);
expect(res.status).toBe(200);
const rows = await prisma.menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { id: true, startDate: true, endDate: true, lengthDays: true },
});
expect(rows.map(({ id: _id, ...r }) => r)).toEqual([
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
{ startDate: "2026-06-01", endDate: null, lengthDays: null },
]);
expect(rows[0].id).toBe(may.id);
const june = rows[1].id;
const days = await prisma.cycleDayLog.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { date: "asc" },
select: { date: true, cycleId: true },
});
expect(days).toEqual([
{ date: "2026-06-01", cycleId: june },
{ date: "2026-06-02", cycleId: june },
{ date: "2026-06-06", cycleId: june },
]);
});
it("gives the record a forecast and a cycle day it never had", async () => {
// What the person actually sees. Four periods 28 days apart, logged as
// flow and nothing else, and today inside the last one.
@@ -0,0 +1,127 @@
/**
* Issue #1026 — a daily total must land on the same calendar day whichever
* chart range reads it.
*
* The 7-day chart pulls raw rows and buckets them by the user's own calendar
* day. The 30-day chart reads the daily series from the rollup tier, whose
* DAY buckets are cut at UTC midnight, and the chart then labels each bucket's
* UTC-midnight start in the user's zone. East of UTC a sample shortly after
* local midnight was counted on the previous day; west of UTC every bucket
* was labelled one day early. Steps, active energy, flights and distance are
* summed per day, so the drift showed up as whole totals moving between
* neighbouring days when the range changed.
*
* This file seeds cumulative samples right next to local midnight for a
* positive, a half-hour and a negative offset, warms the rollup tier the way
* the live instance has it, and asserts that the daily series (both the
* rollup-backed read and the live aggregate) buckets by the user's day.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
readDailySeries,
readLiveBuckets,
} from "@/lib/measurements/daily-series-read";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { localHmAsUtc } from "@/lib/tz/local-day";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
const DAY = new Date("2026-09-10T12:00:00Z");
const NEXT_DAY = new Date("2026-09-11T12:00:00Z");
async function seed(tz: string): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `tz-${tz.replace(/\W/g, "-").toLowerCase()}`,
email: `tz-${tz.replace(/\W/g, "-").toLowerCase()}@example.test`,
role: "USER",
timezone: tz,
},
});
invalidateUserTimezone(user.id);
const rows = [
// Just after local midnight: belongs to DAY.
{ at: localHmAsUtc(DAY, tz, 0, 20), value: 100 },
// Just before the next local midnight: still DAY.
{ at: localHmAsUtc(DAY, tz, 23, 40), value: 1000 },
// A daily total stamped at the start of the next local day.
{ at: localHmAsUtc(NEXT_DAY, tz, 0, 0), value: 7 },
{ at: localHmAsUtc(NEXT_DAY, tz, 12, 0), value: 3 },
];
await prisma.measurement.createMany({
data: rows.map((r) => ({
userId: user.id,
type: "ACTIVITY_STEPS" as const,
value: r.value,
unit: "count",
measuredAt: r.at,
source: "APPLE_HEALTH" as const,
})),
});
return user.id;
}
function byLocalDay(
rows: Array<{ measuredAt: string; value: number }>,
tz: string,
): Record<string, number> {
const out: Record<string, number> = {};
for (const r of rows) {
const key = userDayKey(new Date(r.measuredAt), tz);
out[key] = (out[key] ?? 0) + r.value;
}
return out;
}
const EXPECTED = { "2026-09-10": 1100, "2026-09-11": 10 };
describe("daily series buckets by the user's calendar day (#1026)", () => {
for (const tz of ["Europe/Berlin", "Asia/Kolkata", "America/New_York"]) {
it(`chart daily read agrees with the local day (${tz})`, async () => {
const userId = await seed(tz);
const from = new Date("2026-09-05T00:00:00Z");
const to = new Date("2026-09-15T00:00:00Z");
// Warm the rollup tier exactly as the write hook / backfill does.
await recomputeUserRollups(userId, {
types: ["ACTIVITY_STEPS"],
granularities: ["DAY"],
from,
to,
});
const rows = await readDailySeries({
userId,
type: "ACTIVITY_STEPS",
from,
to,
priorityJson: null,
});
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
});
it(`live daily aggregate agrees with the local day (${tz})`, async () => {
const userId = await seed(tz);
const rows = await readLiveBuckets({
userId,
type: "ACTIVITY_STEPS",
from: new Date("2026-09-05T00:00:00Z"),
to: new Date("2026-09-15T00:00:00Z"),
cap: 366,
priorityJson: null,
grain: "daily",
timeZone: tz,
});
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
});
}
});
@@ -0,0 +1,209 @@
/**
* #1023 — a status card over a dense heart-rate stream, measured.
*
* A single account syncing heart rate from a watch killed the worker within
* half an hour: the pulse status card's recent-window read was bounded in
* time (91 days) but not in rows, so every refresh materialised the whole
* stream as objects, and the read itself ran past the statement timeout.
* The read now folds per day in Postgres.
*
* The fixture is one reading a minute for 91 days, 131 040 rows, seeded in
* one `INSERT … generate_series`. Each half of the memory test reads the
* same window: the per-day fold the card now uses, and the raw `findMany` it
* used to issue. Without the second half the budget could be any number and
* the first half would still pass. Every reading of the heap is taken after a
* forced collection, so what is compared is what each read HOLDS; the
* transient figure (taken before collecting) is reported alongside.
*
* The parity case pins that the SQL fold and the in-memory fold the unit
* tests use agree on real Postgres: same days in a non-UTC zone, same
* segment edges, same plausibility filter.
*/
import v8 from "node:v8";
import vm from "node:vm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { buildGradedSeriesWithRollups } from "@/lib/insights/graded-series";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
import { localHmAsUtc } from "@/lib/tz/local-day";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const prisma = getPrismaClient();
const DENSE_USER = "dense-pulse-owner";
const PARITY_USER = "dense-parity-owner";
const DAYS = 91;
const ROWS = DAYS * 24 * 60;
const NOW = new Date("2026-09-20T18:00:00.000Z");
const forceGc = ((): (() => void) => {
v8.setFlagsFromString("--expose-gc");
const gc = vm.runInNewContext("gc") as () => void;
v8.setFlagsFromString("--no-expose-gc");
return gc;
})();
function liveHeapBytes(): number {
forceGc();
forceGc();
return process.memoryUsage().heapUsed;
}
const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1);
function report(line: string): void {
process.stderr.write(`[dense-read] ${line}\n`);
}
/** Run `read`, returning what it holds, its transient peak and its time. */
async function measure<T>(read: () => Promise<T>): Promise<{
result: T;
heldBytes: number;
transientBytes: number;
ms: number;
}> {
const before = liveHeapBytes();
const t0 = performance.now();
const result = await read();
const ms = performance.now() - t0;
const transientBytes = process.memoryUsage().heapUsed - before;
const heldBytes = liveHeapBytes() - before;
return { result, heldBytes, transientBytes, ms };
}
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: DENSE_USER,
username: DENSE_USER,
timezone: "Europe/Berlin",
},
});
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (
id, user_id, type, value, unit, source, measured_at,
created_at, updated_at, sync_version)
SELECT
'dp' || lpad(g::text, 8, '0'),
$1,
'PULSE'::measurement_type,
55 + (g % 50),
'bpm',
'GOOGLE_HEALTH'::measurement_source,
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
now(), now(), 1
FROM generate_series(1, $3::int) g`,
DENSE_USER,
NOW.toISOString(),
ROWS,
);
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
}, 300_000);
afterAll(async () => {
await truncateAllTables(prisma);
});
describe("pulse status over a dense stream (#1023)", () => {
it("builds the graded series without materialising the stream", async () => {
const graded = await measure(() =>
buildGradedSeriesWithRollups(DENSE_USER, "PULSE", NOW),
);
const series = graded.result;
// Every reading in the 91-day window is accounted for, one bucket per day
// or week, not one object per reading. The oldest reading sits exactly 91
// days back, which the graded split files under the older slice.
const counted = [...series.recent, ...series.weekly].reduce(
(sum, b) => sum + b.n,
0,
);
expect(counted).toBe(ROWS - 1);
expect(series.recent.length).toBeLessThanOrEqual(22);
expect(series.weekly.length).toBeLessThanOrEqual(12);
const raw = await measure(() =>
prisma.measurement.findMany({
where: {
userId: DENSE_USER,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: new Date(NOW.getTime() - DAYS * 86_400_000) },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
);
expect(raw.result.length).toBe(ROWS);
report(
`graded series (per-day fold, cold tier): held ${mb(graded.heldBytes)} MB, ` +
`transient ${mb(graded.transientBytes)} MB, ${graded.ms.toFixed(0)} ms`,
);
report(
`raw 91-day findMany (the old read): held ${mb(raw.heldBytes)} MB, ` +
`transient ${mb(raw.transientBytes)} MB, ${raw.ms.toFixed(0)} ms, ` +
`${raw.result.length} rows`,
);
// The fold holds a few kilobytes; the raw read holds the stream. The
// transient figure is the one that killed the worker: the old builder
// held little once it returned but allocated the whole stream on the
// way (62 MB on this fixture, against 1.6 MB for the fold).
expect(graded.heldBytes).toBeLessThan(2 * 1024 * 1024);
expect(graded.transientBytes).toBeLessThan(12 * 1024 * 1024);
expect(raw.heldBytes).toBeGreaterThan(20 * Math.max(graded.heldBytes, 1));
}, 120_000);
it("the SQL fold agrees with the in-memory fold on real Postgres", async () => {
const tz = "Asia/Kolkata";
await prisma.user.create({
data: { id: PARITY_USER, username: PARITY_USER, timezone: tz },
});
const day = new Date("2026-09-10T12:00:00Z");
const edge = new Date(NOW.getTime() - 21 * 86_400_000);
const rows = [
{ at: localHmAsUtc(day, tz, 0, 5), value: 61 },
{ at: localHmAsUtc(day, tz, 23, 55), value: 99 },
{ at: localHmAsUtc(day, tz, 12, 0), value: 111_287_531 }, // impossible
{ at: edge, value: 70 },
{ at: new Date(edge.getTime() + 1), value: 72 },
{ at: new Date(NOW.getTime() - 200 * 86_400_000), value: 58 },
];
await prisma.measurement.createMany({
data: rows.map((r, i) => ({
id: `parity-${i}`,
userId: PARITY_USER,
type: "PULSE" as const,
value: r.value,
unit: "bpm",
source: "MANUAL" as const,
measuredAt: r.at,
})),
});
const opts = {
since: new Date(NOW.getTime() - 400 * 86_400_000),
until: NOW,
timeZone: tz,
valueRange: { min: 20, max: 300 },
segmentStarts: [edge, new Date(NOW.getTime() - 91 * 86_400_000)],
};
const sql = await readDayAggregates({
userId: PARITY_USER,
type: "PULSE",
...opts,
});
const memory = foldDayAggregates(
rows.map((r) => ({ measuredAt: r.at, value: r.value })),
opts,
);
expect(sql).toEqual(memory);
// Sanity: the impossible value was dropped and the edge row split off.
expect(sql.reduce((n, r) => n + r.n, 0)).toBe(5);
});
});
@@ -0,0 +1,125 @@
/**
* Migration 0344: status notes move out of the audit log.
*
* The container this suite runs on has every migration applied already, so
* the new table is asserted as it landed (columns, the one-row-per-note
* unique key, the cascade from the user), and the destructive data step is
* pulled out of the `.sql` file and run again against rows seeded the way an
* upgrading instance holds them: the old status rows go, and every other
* audit row stays, including the ones whose action merely starts with
* `insights.`.
*
* The step runs twice; the second run must change nothing.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const MIGRATION = readFileSync(
path.join(
process.cwd(),
"prisma",
"migrations",
"0344_insight_status_cache",
"migration.sql",
),
"utf8",
);
/** Every `DELETE … ;` statement in the migration, comments stripped. */
function dataSteps(): string[] {
const sql = MIGRATION.split("\n")
.filter((line) => !line.trimStart().startsWith("--"))
.join("\n");
const steps = [...sql.matchAll(/DELETE\s+FROM\s+"[a-z_]+"[\s\S]*?;/gi)].map(
(m) => m[0],
);
// The data step is the point; an empty match is a failure, not a pass.
expect(steps.length).toBe(1);
return steps;
}
async function runDataSteps() {
const prisma = getPrismaClient();
for (const step of dataSteps()) await prisma.$executeRawUnsafe(step);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("the new table", () => {
it("keys one note per (user, metric, locale) and cascades from the user", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "mig-0344", email: "mig-0344@example.test" },
});
await prisma.insightStatusCache.create({
data: {
userId: user.id,
metric: "weight",
locale: "en",
dateKey: "2026-09-20",
textEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
},
});
await expect(
prisma.insightStatusCache.create({
data: {
userId: user.id,
metric: "weight",
locale: "en",
dateKey: "2026-09-21",
},
}),
).rejects.toThrow();
await prisma.user.delete({ where: { id: user.id } });
expect(await prisma.insightStatusCache.count()).toBe(0);
});
});
describe("the audit-log purge", () => {
it("deletes every status note row and nothing else", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "mig-0344-audit", email: "mig-0344-a@example.test" },
});
const statusActions = [
"insights.weight-status.en",
"insights.blood-pressure-status.de",
"insights.metric:RESTING_HEART_RATE-status.fr",
"insights.derived-score:READINESS-status.en",
"insights.biomarker:ldl-status.it",
"insights.medication-compliance-status.pl",
];
const kept = [
"insights.generate",
"insights.coach.fence_drift",
"insights.recommendation.feedback",
"auth.login",
"consent.ai.revoke",
];
for (const action of [...statusActions, ...kept]) {
await prisma.auditLog.create({
data: {
userId: user.id,
action,
details: JSON.stringify({ text: "plaintext" }),
},
});
}
await runDataSteps();
await runDataSteps();
const remaining = await prisma.auditLog.findMany({
select: { action: true },
orderBy: { action: "asc" },
});
expect(remaining.map((r) => r.action)).toEqual([...kept].sort());
});
});
@@ -8,9 +8,9 @@
* the generic no-key fallback). This is the regression the
* sticky-stub bug used to break.
*
* 2. A timeout-stub cache row keyed to today is NOT served — the
* generator skips it and re-generates, proving the stub no longer
* pins the fallback for the day.
* 2. A negative-cache window opened by an earlier stall never stands in
* for a note: with no note stored for today, the generator regenerates
* and stores the real text, and writing it closes the window.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -24,7 +24,10 @@ vi.mock("@/lib/db-compat", () => ({
// provider to walk. `runRawCompletionWithFallback` runs for real on top
// of this — exercising the same plumbing the live status path uses.
const KNOWN_TEXT = "Your weight has trended down steadily over the past month.";
vi.mock("@/lib/ai/provider", () => ({
vi.mock("@/lib/ai/provider", async (importOriginal) => ({
// The presence probe the capability reads stays real; only the chain the
// generation walks is replaced.
...(await importOriginal<typeof import("@/lib/ai/provider")>()),
resolveProviderChain: vi.fn().mockResolvedValue([
{
providerType: "anthropic",
@@ -41,7 +44,7 @@ vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
}));
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
@@ -60,6 +63,9 @@ async function seedRichWeightUser(username: string) {
role: "USER",
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
// Presence of a provider for the capability; never decrypted here.
aiProviderChain: [{ providerType: "anthropic", enabled: true }],
aiAnthropicKeyEncrypted: "key-present",
},
});
const now = Date.now();
@@ -90,39 +96,40 @@ describe("status path delivers real provider text", () => {
expect(result.cached).toBe(false);
expect(result.text).toBe(KNOWN_TEXT);
// The real assessment was persisted (not a stub).
// The real assessment was stored, encrypted, as the weight note.
const prisma = getPrismaClient();
const cached = await prisma.auditLog.findFirst({
where: { userId: user.id, action: "insights.weight-status.en" },
orderBy: { createdAt: "desc" },
select: { details: true },
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
where: {
userId_metric_locale: {
userId: user.id,
metric: "weight",
locale: "en",
},
},
});
const parsed = JSON.parse(cached?.details ?? "{}") as {
text: string;
model: string;
timeout?: boolean;
};
expect(parsed.text).toBe(KNOWN_TEXT);
expect(parsed.model).toBe("test-model");
expect(parsed.timeout).toBeUndefined();
expect(decryptFromBytes(stored.textEncrypted!)).toBe(KNOWN_TEXT);
expect(stored.retryAt).toBeNull();
// Nothing about the note lands in the audit log any more.
expect(
await prisma.auditLog.count({
where: { userId: user.id, action: { contains: "-status." } },
}),
).toBe(0);
});
it("skips a timeout-stub cache row and regenerates the real text", async () => {
it("regenerates past a negative-cache window and closes it", async () => {
const user = await seedRichWeightUser("status-stub-skip-user");
const prisma = getPrismaClient();
// Pre-seed a stub row keyed to today — the bug used to serve this.
await prisma.auditLog.create({
// An earlier stall opened a window, with no note stored.
await prisma.insightStatusCache.create({
data: {
userId: user.id,
action: "insights.weight-status.en",
details: JSON.stringify({
dateKey: toBerlinDayKey(new Date()),
locale: "en",
text: "Generic fallback advice that must not be served.",
model: "timeout-stub",
timeout: true,
}),
metric: "weight",
locale: "en",
dateKey: "2026-01-01",
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
},
});
@@ -133,8 +140,18 @@ describe("status path delivers real provider text", () => {
locale: "en",
});
// The stub is skipped; the real provider text is returned + cached.
expect(result.text).toBe(KNOWN_TEXT);
expect(result.cached).toBe(false);
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
where: {
userId_metric_locale: {
userId: user.id,
metric: "weight",
locale: "en",
},
},
});
expect(stored.retryAt).toBeNull();
expect(stored.negativeReason).toBeNull();
});
});
@@ -14,6 +14,7 @@
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { userDayKey } from "@/lib/tz/format";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -128,11 +129,15 @@ describe("GET /api/measurements?aggregate=… (real Postgres)", () => {
expect(json.data.meta.aggregate).toBe("daily");
expect(json.data.measurements).toHaveLength(2);
const may1 = json.data.measurements.find((m) =>
m.measuredAt.startsWith("2026-05-01"),
// Buckets are the account's own calendar days (Europe/Berlin, the
// column default), keyed by the instant of local midnight (#1026).
const may1 = json.data.measurements.find(
(m) =>
userDayKey(new Date(m.measuredAt), "Europe/Berlin") === "2026-05-01",
);
const may2 = json.data.measurements.find((m) =>
m.measuredAt.startsWith("2026-05-02"),
const may2 = json.data.measurements.find(
(m) =>
userDayKey(new Date(m.measuredAt), "Europe/Berlin") === "2026-05-02",
);
expect(may1).toBeDefined();
expect(may1!.count).toBe(2);
+16 -93
View File
@@ -271,19 +271,13 @@ 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().measurementReminder.create({
await getPrismaClient().user.update({
where: { id: userId },
data: {
userId,
label: `Checkup for the ${marker}`,
intervalDays: 365,
nextDueAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
insightsCachedText: cachedBriefing(marker),
insightsCachedAt: new Date(),
},
});
},
@@ -291,48 +285,7 @@ frontDoorRead("GET /api/daily/digest", {
const { GET } = await import("@/app/api/daily/digest/route");
return drive(GET as Handler, "/api/daily/digest");
},
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");
});
read: (data: { briefingLead: string | null }) => data.briefingLead,
});
frontDoorRead("GET /api/gamification/achievements", {
@@ -387,48 +340,18 @@ async function seedAssistantMessage(userId: string, at: string) {
});
}
// 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");
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/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 () => {
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,
});
frontDoorRead("GET /api/coach/reminders", {
@@ -28,6 +28,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { invalidateServerDefaultTimezone } from "@/lib/tz/resolver";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -166,6 +167,36 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
expect(body.data?.timezone).toBe("Pacific/Auckland");
});
// A stored zone the server cannot use falls back to the instance
// default for every server-side day cut. `/api/auth/me` reports that
// same resolved zone, so a client (the charts' day buckets among them)
// never cuts days in a different zone than the server did.
it("GET /api/auth/me reports the resolved zone when the stored one is unusable", async () => {
const user = await seedAucklandUser();
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: user.id },
data: { timezone: "" },
});
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", defaultUserTimezone: "Asia/Kolkata" },
update: { defaultUserTimezone: "Asia/Kolkata" },
});
invalidateServerDefaultTimezone();
try {
const { GET } = await import("@/app/api/auth/me/route");
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { timezone?: string } | null;
};
expect(body.data?.timezone).toBe("Asia/Kolkata");
} finally {
invalidateServerDefaultTimezone();
}
});
it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => {
await seedAucklandUser();