mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Every write has gone to insights_cached_text_encrypted since 0411, and the boot-time free-text backfill sealed the rows written before it. Migration 0430 drops insights_cached_text and the backfill index that served it. A host that skipped the intervening releases loses only a cache of model output; the next warm writes a fresh, sealed payload. The cipher module now reads and writes the sealed column only, the backfill job loses its insights step, and consent withdrawal, the wipe plan, the backup plan and the session projection follow the schema. Backups never carried the column, and restore ignores unknown account settings; a round-trip test now proves an old backup naming the field still restores cleanly. The access guard pins exact counts for where the sealed name may appear. Fixtures, the demo seed and the demo migration script seed the sealed column instead of plaintext.
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
/**
|
|
* The comprehensive-briefing cache is one slot per user. When it holds prose
|
|
* in a language other than the reader's, the dashboard snapshot serves no
|
|
* briefing text at all rather than the wrong language.
|
|
*
|
|
* This runs the real Prisma path end to end: the cache row is written with
|
|
* its locale tag through the client, and the snapshot is read through the
|
|
* same `readDashboardSnapshotCached` the dashboard route and the Today
|
|
* digest use, with the reader's locale resolved from the cookie the client
|
|
* sets when the user switches language.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher";
|
|
|
|
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 BRIEFING = {
|
|
greeting: "Good morning",
|
|
paragraph: "Your readings this week stayed inside their usual range.",
|
|
keyFindings: [],
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function seedUserWithCachedBriefing(cachedLocale: string | null) {
|
|
const prisma = getPrismaClient();
|
|
return prisma.user.create({
|
|
data: {
|
|
username: "briefing-user",
|
|
email: "briefing@example.test",
|
|
role: "USER",
|
|
// A German-language account: the stored preference is what the
|
|
// snapshot read resolves the reader's locale from.
|
|
locale: "de",
|
|
insightsCachedAt: new Date(),
|
|
// Generated now: only text written today is served as today's read.
|
|
insightsCachedTextEncrypted: sealInsightsCachedText(
|
|
JSON.stringify({
|
|
dailyBriefing: BRIEFING,
|
|
briefingGeneratedAt: new Date().toISOString(),
|
|
}),
|
|
),
|
|
insightsCachedLocale: cachedLocale,
|
|
// The briefing is shown only while its AI capability is available, so
|
|
// the account has its own provider (presence only) and a receipt.
|
|
aiProvider: "ANTHROPIC",
|
|
aiModel: "claude-sonnet-4-6",
|
|
aiAnthropicKeyEncrypted: "v1:presence-only",
|
|
consentReceipts: {
|
|
create: { kind: "ai_full", artefact: "test", signedAt: new Date() },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("dashboard snapshot — briefing cache written in another language", () => {
|
|
it("serves NO briefing prose to a 'de' reader from a cache tagged 'en'", async () => {
|
|
const user = await seedUserWithCachedBriefing("en");
|
|
const { readDashboardSnapshotCached } =
|
|
await import("@/lib/dashboard/snapshot-read");
|
|
|
|
const { body, locale } = await readDashboardSnapshotCached(user);
|
|
|
|
expect(locale).toBe("de");
|
|
expect(body.briefing).toBeNull();
|
|
expect(body.briefingStale).toBe(false);
|
|
expect(body.briefingUpdatedAt).toBeNull();
|
|
// A provider is configured, so a warm pass will refill the slot.
|
|
expect(body.briefingState).toBe("preparing");
|
|
});
|
|
|
|
it("serves the briefing when the tag matches the reader", async () => {
|
|
const user = await seedUserWithCachedBriefing("de");
|
|
const { readDashboardSnapshotCached } =
|
|
await import("@/lib/dashboard/snapshot-read");
|
|
|
|
const { body } = await readDashboardSnapshotCached(user);
|
|
|
|
expect(body.briefingState).toBe("ready");
|
|
expect(body.briefing?.paragraph).toBe(BRIEFING.paragraph);
|
|
});
|
|
|
|
it("serves an untagged row written before the tag existed", async () => {
|
|
const user = await seedUserWithCachedBriefing(null);
|
|
const { readDashboardSnapshotCached } =
|
|
await import("@/lib/dashboard/snapshot-read");
|
|
|
|
const { body } = await readDashboardSnapshotCached(user);
|
|
|
|
expect(body.briefingState).toBe("ready");
|
|
expect(body.briefing?.paragraph).toBe(BRIEFING.paragraph);
|
|
});
|
|
});
|