mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user