mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Tag the briefing cache with its language and refuse a foreign one
The comprehensive-briefing cache (User.insightsCachedText) is one slot per account with no record of the language it was written in. Every reader served whatever was there, so a German-language account whose nightly warm had written English kept seeing English on the dashboard lead and the Today briefing. The request path only replaced it once the row was a day old and the provider answered, which with a provider at its usage limit was never. Add User.insightsCachedLocale (migration 0335). Both writers store the locale they generated in; the timestamp-only refresh on an unchanged snapshot tags the row too, since the fingerprint covers the generation locale. Every reset that nulls the text nulls the tag. The snapshot briefing lift and GET /api/insights/generate treat a tag that differs from the reader's locale as an empty cache: no prose, no timestamp, the preparing / no-provider state, and the out-of-band warm enqueued in the reader's locale through the existing path. The generator's own short-circuit and the POST short-circuit refuse to call a foreign-language row fresh. A NULL tag (rows written before this change) is served as before until the next write tags it. The Today digest derives from the snapshot and follows. Covered by unit tests on the lift, the route and the generator, and an integration test that writes the row through Prisma and reads the snapshot for a reader in another language.
This commit is contained in:
1 parent
d5cf82fb6e
commit
ba4e34f09e
21 files changed
+404
-26
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- Tag the comprehensive-briefing cache with the language it was written in.
|
||||
-- One slot per user; readers in another language treat a differing tag as an
|
||||
-- empty cache. NULL on existing rows: treated as matching until the next write.
|
||||
ALTER TABLE "users" ADD COLUMN "insights_cached_locale" TEXT;
|
||||
@@ -119,6 +119,13 @@ model User {
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
// Language the cached text was generated in. The cache is one slot per
|
||||
// user, so a reader in another language must not be served it: the
|
||||
// snapshot briefing lift and the advisor read treat a tag that differs
|
||||
// from the reader's locale as an empty cache and re-warm in the reader's.
|
||||
// NULL on rows written before the tag existed (treated as matching; the
|
||||
// next write tags it).
|
||||
insightsCachedLocale String? @map("insights_cached_locale")
|
||||
// v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the
|
||||
// cached comprehensive text was generated from. Nightly / forced / lazy
|
||||
// regenerations compare the fresh snapshot's hash against this and skip
|
||||
|
||||
@@ -756,9 +756,12 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
userUpdate,
|
||||
"a scope-narrowing restore must clear the cached briefing",
|
||||
).toBeDefined();
|
||||
// The language tag is cleared with the text: an empty slot carries
|
||||
// no language, and the next write tags it afresh.
|
||||
expect(userUpdate!.data).toEqual({
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsCachedLocale: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
codexConnectionStatus: "disconnected",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ describe("about-me module gate", () => {
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
expect(invalidateInsights).toHaveBeenCalledWith("u1");
|
||||
|
||||
@@ -271,6 +271,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -447,6 +447,9 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
expect(args.data.insightsCachedText).toEqual(expect.any(String));
|
||||
// SHA-256 hex digest of the compacted feature snapshot.
|
||||
expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
// The cache row records the language it was written in, so a reader
|
||||
// in another language is never served this text.
|
||||
expect(args.data.insightsCachedLocale).toBe("en");
|
||||
});
|
||||
|
||||
it("refuses a stale cache write when AI profile inclusion changes during generation", async () => {
|
||||
@@ -797,6 +800,55 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
});
|
||||
|
||||
// `resolveServerLocale` is mocked to "en" in this suite, so the reader is
|
||||
// an English session throughout; the cache tag is what varies.
|
||||
it("does NOT serve a fresh cache tagged with another language; warms in the reader's", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } };
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedLocale: "de",
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean; revalidating: boolean };
|
||||
};
|
||||
expect(body.data.cached).toBe(false);
|
||||
expect(body.data.insights).toBeNull();
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledWith({
|
||||
userId: "u-1",
|
||||
locale: "en",
|
||||
});
|
||||
expect(resolveProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves a fresh cache whose tag matches the reader's language", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } };
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean; revalidating: boolean };
|
||||
};
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(body.data.insights).toEqual(cached);
|
||||
expect(body.data.revalidating).toBe(false);
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
|
||||
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -154,13 +154,38 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
select: {
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve the locale the caller is actually reading (cookie /
|
||||
// Accept-Language fall-back when `User.locale` is unset) and validate it
|
||||
// against the shipped list — the same convention the nightly's
|
||||
// `normalizeLocale` and the status routes follow. The previous
|
||||
// `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale`
|
||||
// to German even for a client reading the app in English, so the
|
||||
// visit-triggered warm filled the wrong cache family.
|
||||
const readerLocale = normalizeLocale(
|
||||
await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
// The cache is one slot per user. Text written in another language is
|
||||
// never the reader's briefing, however fresh: treat the slot as empty for
|
||||
// prose, and re-warm in the reader's language below. An untagged row
|
||||
// predates the tag and is served as before.
|
||||
const languageMismatch =
|
||||
dbUser?.insightsCachedLocale != null &&
|
||||
dbUser.insightsCachedLocale !== readerLocale;
|
||||
|
||||
const cachedAt = dbUser?.insightsCachedAt ?? null;
|
||||
const isFresh =
|
||||
cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
!languageMismatch &&
|
||||
cachedAt !== null &&
|
||||
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
|
||||
// v1.18.9 (#4) — resolve provider availability once, up front. The
|
||||
// read path never blocks on or generates through the provider, but it
|
||||
@@ -204,23 +229,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// empty / connect-AI state instead of a wasted enqueue).
|
||||
let revalidating = false;
|
||||
if (!isFresh && hasProvider) {
|
||||
// Resolve the locale the caller is actually reading (cookie /
|
||||
// Accept-Language fall-back when `User.locale` is unset) and narrow
|
||||
// non-German to English — the same convention the nightly's
|
||||
// `normalizeLocale` and the status routes follow. The previous
|
||||
// `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale`
|
||||
// to German even for a client reading the app in English, so the
|
||||
// visit-triggered warm filled the wrong cache family.
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
const locale = normalizeLocale(resolved);
|
||||
void enqueueForceWarm({ userId, locale });
|
||||
void enqueueForceWarm({ userId, locale: readerLocale });
|
||||
revalidating = true;
|
||||
}
|
||||
|
||||
if (dbUser?.insightsCachedText) {
|
||||
if (dbUser?.insightsCachedText && !languageMismatch) {
|
||||
try {
|
||||
const cached = JSON.parse(dbUser.insightsCachedText);
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
@@ -260,7 +273,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.generate.read" },
|
||||
meta: { cached: false, revalidating, hasProvider },
|
||||
meta: {
|
||||
cached: false,
|
||||
revalidating,
|
||||
hasProvider,
|
||||
languageMismatch,
|
||||
readerLocale,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: null,
|
||||
@@ -298,6 +317,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
|
||||
// settings. Filtered off `features` before serialisation so the
|
||||
// LLM never sees the excluded blocks.
|
||||
@@ -366,10 +386,16 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
legacyPayload: boolean;
|
||||
cachedAt: Date;
|
||||
} | null = null;
|
||||
// A cache tagged with another language is not this reader's cache and
|
||||
// never short-circuits; the generation below writes the reader's own.
|
||||
const cachedLocaleMatches =
|
||||
dbUser?.insightsCachedLocale == null ||
|
||||
dbUser.insightsCachedLocale === locale;
|
||||
if (
|
||||
!forceRefresh &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
dbUser.insightsCachedText &&
|
||||
cachedLocaleMatches &&
|
||||
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
|
||||
) {
|
||||
try {
|
||||
@@ -999,6 +1025,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
insightsCachedLocale: locale,
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
aboutMe: aboutMe ?? null,
|
||||
|
||||
@@ -77,6 +77,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
data.insightsPrivacyMode = mode;
|
||||
data.insightsCachedAt = null;
|
||||
data.insightsCachedText = null;
|
||||
data.insightsCachedLocale = null;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
|
||||
@@ -287,6 +287,7 @@ function user(healthScoreConfigJson: unknown): SnapshotUserInput {
|
||||
disableCoach: false,
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsCachedLocale: null,
|
||||
dashboardWidgetsJson: null,
|
||||
thresholdsJson: null,
|
||||
healthScoreConfigJson,
|
||||
|
||||
@@ -194,6 +194,7 @@ function baseUser(
|
||||
disableCoach: false,
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsCachedLocale: null,
|
||||
dashboardWidgetsJson: null,
|
||||
thresholdsJson: null,
|
||||
healthScoreConfigJson: null,
|
||||
@@ -694,6 +695,78 @@ describe("buildDashboardSnapshot — briefingState matrix", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDashboardSnapshot — briefing locale tag", () => {
|
||||
beforeEach(() => {
|
||||
probeRollupCoverage.mockResolvedValue(new Map());
|
||||
isFullyCovered.mockReturnValue(false);
|
||||
});
|
||||
|
||||
const briefing = {
|
||||
greeting: "Hello",
|
||||
paragraph: "Everything is in the green.",
|
||||
keyFindings: [],
|
||||
};
|
||||
|
||||
it("a cache tagged 'en' read by a 'de' reader serves NO prose and reports preparing", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: briefing }),
|
||||
insightsCachedLocale: "en",
|
||||
}),
|
||||
{ locale: "de" },
|
||||
);
|
||||
expect(snap.briefingState).toBe("preparing");
|
||||
expect(snap.briefing).toBeNull();
|
||||
expect(snap.briefingStale).toBe(false);
|
||||
expect(snap.briefingUpdatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("a mismatched tag with no provider anywhere reports no-provider, still without prose", async () => {
|
||||
hasAnyConfiguredProvider.mockResolvedValue(false);
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: briefing }),
|
||||
insightsCachedLocale: "en",
|
||||
}),
|
||||
{ locale: "de" },
|
||||
);
|
||||
expect(snap.briefingState).toBe("no-provider");
|
||||
expect(snap.briefing).toBeNull();
|
||||
});
|
||||
|
||||
it("an untagged cache (written before the tag existed) is served as before", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: briefing }),
|
||||
insightsCachedLocale: null,
|
||||
}),
|
||||
{ locale: "de" },
|
||||
);
|
||||
expect(snap.briefingState).toBe("ready");
|
||||
expect(snap.briefing?.paragraph).toBe(briefing.paragraph);
|
||||
});
|
||||
|
||||
it("a matching tag is served", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: briefing }),
|
||||
insightsCachedLocale: "de",
|
||||
}),
|
||||
{ locale: "de" },
|
||||
);
|
||||
expect(snap.briefingState).toBe("ready");
|
||||
expect(snap.briefing?.paragraph).toBe(briefing.paragraph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDashboardSnapshot — no LLM in the path", () => {
|
||||
it("the builder module never imports the provider chain", async () => {
|
||||
// Structural guard — the builder must assemble from rollup / mood /
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type SnapshotUserInput,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Per-key TTL for the snapshot cache entry. Strictly greater than the
|
||||
@@ -50,7 +51,16 @@ export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
|
||||
export interface SnapshotReadResult {
|
||||
body: DashboardSnapshot;
|
||||
/** The locale the snapshot was resolved (and cache-keyed) under. */
|
||||
locale: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export interface SnapshotReadOptions {
|
||||
/**
|
||||
* A locale already resolved by the caller. Background paths (the morning
|
||||
* briefing push) have no request to resolve one from and pass the job-side
|
||||
* resolution here so the digest and the push copy agree on a language.
|
||||
*/
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +71,7 @@ export interface SnapshotReadResult {
|
||||
export async function readDashboardSnapshotCached(
|
||||
user: User,
|
||||
time?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
|
||||
options: SnapshotReadOptions = {},
|
||||
): Promise<SnapshotReadResult> {
|
||||
// `User` row is already in hand at both call sites — no extra round-trip.
|
||||
const snapshotUser: SnapshotUserInput = {
|
||||
@@ -76,6 +87,7 @@ export async function readDashboardSnapshotCached(
|
||||
disableCoach: user.disableCoach,
|
||||
insightsCachedText: user.insightsCachedText,
|
||||
insightsCachedAt: user.insightsCachedAt,
|
||||
insightsCachedLocale: user.insightsCachedLocale,
|
||||
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
||||
sourcePriorityJson: user.sourcePriorityJson,
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
@@ -87,7 +99,10 @@ export async function readDashboardSnapshotCached(
|
||||
// a DE session never share a snapshot cell (and never see each other's
|
||||
// memory wording). The `${user.id}|` prefix still covers the key under
|
||||
// the measurement-write stale-sweep.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale });
|
||||
const locale = await resolveServerLocale({
|
||||
userLocale: user.locale,
|
||||
override: options.locale ?? null,
|
||||
});
|
||||
|
||||
// Stale-while-revalidate: a measurement write marks the analytics
|
||||
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
|
||||
|
||||
@@ -508,6 +508,12 @@ export interface SnapshotUserInput {
|
||||
disableCoach: boolean;
|
||||
insightsCachedText: string | null;
|
||||
insightsCachedAt: Date | null;
|
||||
/**
|
||||
* Language the cached text was generated in. `null` on rows written before
|
||||
* the tag existed (served as-is). A tag that differs from the reader's
|
||||
* locale makes the cache count as absent for prose purposes.
|
||||
*/
|
||||
insightsCachedLocale: string | null;
|
||||
dashboardWidgetsJson: unknown;
|
||||
/** Raw source-priority settings already loaded with the authenticated user. */
|
||||
sourcePriorityJson?: unknown;
|
||||
@@ -908,6 +914,7 @@ async function liftBriefing(
|
||||
user: SnapshotUserInput,
|
||||
coachEnabled: boolean,
|
||||
hasProvider: () => Promise<boolean>,
|
||||
readerLocale: Locale,
|
||||
): Promise<{
|
||||
briefing: DailyBriefing | null;
|
||||
briefingState: BriefingState;
|
||||
@@ -923,10 +930,21 @@ async function liftBriefing(
|
||||
};
|
||||
}
|
||||
|
||||
const cachedAt = user.insightsCachedAt;
|
||||
// The cache is one slot per user with no per-language copy. Prose written
|
||||
// in another language is never the reader's briefing, stale or not, so a
|
||||
// differing tag makes the slot count as never generated: no text, no
|
||||
// timestamp. The advisor read (`GET /api/insights/generate`) enqueues the
|
||||
// re-warm in the reader's language on the same condition. An untagged row
|
||||
// predates the tag and is served as before.
|
||||
const cachedLocale = user.insightsCachedLocale;
|
||||
const languageMismatch =
|
||||
cachedLocale !== null && cachedLocale !== readerLocale;
|
||||
const cachedAt = languageMismatch ? null : user.insightsCachedAt;
|
||||
const updatedAt = cachedAt?.toISOString() ?? null;
|
||||
const stale = !cachedAt || Date.now() - cachedAt.getTime() >= BRIEFING_TTL_MS;
|
||||
const cachedBriefing = parseCachedBriefing(user.insightsCachedText);
|
||||
const cachedBriefing = languageMismatch
|
||||
? null
|
||||
: parseCachedBriefing(user.insightsCachedText);
|
||||
|
||||
if (!stale && cachedBriefing) {
|
||||
return {
|
||||
@@ -1375,10 +1393,15 @@ export async function buildDashboardSnapshot(
|
||||
// `briefingState: "disabled"` empty surface the existing flags produce
|
||||
// (the raw weight/BP/pulse data stays untouched — `insights` is the
|
||||
// narrative layer, not the data layer).
|
||||
// The reader's locale: the route resolves it from the request; the builder
|
||||
// defaults to English. It decides which cached briefing counts as the
|
||||
// reader's and which narrative row the briefing memory reads.
|
||||
const locale = options.locale ?? "en";
|
||||
const briefing = await liftBriefing(
|
||||
user,
|
||||
flags.briefing && modules.insights !== false,
|
||||
options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)),
|
||||
locale,
|
||||
);
|
||||
|
||||
// v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict +
|
||||
@@ -1386,8 +1409,7 @@ export async function buildDashboardSnapshot(
|
||||
// briefing on the `insights` module so a narrative-off account carries none.
|
||||
// Both are fail-soft — a transient derived-engine read resolves to null and
|
||||
// never sinks the snapshot. The narrative block rides the warm phase's
|
||||
// healthScore; memory rides the briefing card. The locale defaults to English.
|
||||
const locale = options.locale ?? "en";
|
||||
// healthScore; memory rides the briefing card.
|
||||
const narrativeSex = binaryReferenceSex(user.gender);
|
||||
const narrativeProfile = {
|
||||
ageYears: getAgeFromDateOfBirth(user.dateOfBirth),
|
||||
|
||||
@@ -316,6 +316,7 @@ export const USER_RESET = {
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
insightsSnapshotHash: null,
|
||||
insightsWarmFailedAt: null,
|
||||
insightsBriefingRerollDate: null,
|
||||
|
||||
@@ -913,7 +913,11 @@ export async function restoreProfileData(
|
||||
// pre-restore briefing against a narrower post-restore scope.
|
||||
await tx.user.update({
|
||||
where: { id: ownerId },
|
||||
data: { insightsCachedText: null, insightsCachedAt: null },
|
||||
data: {
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -226,10 +226,31 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
|
||||
expect(write).toBeTruthy();
|
||||
const data = (write![0] as { data: Record<string, unknown> }).data;
|
||||
expect(data.insightsSnapshotHash).toBe(FEATURES_HASH);
|
||||
// The cache row records the language it was generated in.
|
||||
expect(data.insightsCachedLocale).toBe("de");
|
||||
// v1.16.8 — the blanket per-status eviction is gone.
|
||||
expect(auditDeleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a fresh cache tagged with another language does not short-circuit an unforced run", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(Date.now() - 1 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "english" } }),
|
||||
insightsCachedLocale: "en",
|
||||
insightsExcludeMetrics: [],
|
||||
insightsSnapshotHash: null,
|
||||
});
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", {
|
||||
locale: "de",
|
||||
});
|
||||
|
||||
// Past the short-circuit → provider chain resolved → no provider.
|
||||
expect(resolveProviderChain).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "no-provider" });
|
||||
});
|
||||
|
||||
it("generates when no fingerprint is stored yet (first run after the gate shipped)", async () => {
|
||||
makeByokChain();
|
||||
findUnique.mockResolvedValue({
|
||||
|
||||
@@ -612,6 +612,7 @@ export async function generateComprehensiveInsight(
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
insightsExcludeMetrics: true,
|
||||
insightsSnapshotHash: true,
|
||||
insightsBriefingRerollDate: true,
|
||||
@@ -639,10 +640,18 @@ export async function generateComprehensiveInsight(
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
|
||||
// A cache written in another language is not this locale's cache: it
|
||||
// must not satisfy an unforced run, or a reader in the requested language
|
||||
// keeps being told the slot is warm while it holds someone else's prose.
|
||||
// An untagged row predates the tag and counts as matching.
|
||||
const cachedLocaleMatches =
|
||||
dbUser?.insightsCachedLocale == null ||
|
||||
dbUser.insightsCachedLocale === locale;
|
||||
if (
|
||||
!force &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
dbUser.insightsCachedText &&
|
||||
cachedLocaleMatches &&
|
||||
Date.now() - dbUser.insightsCachedAt.getTime() < CACHE_TTL_MS
|
||||
) {
|
||||
return { status: "cached" };
|
||||
@@ -896,6 +905,7 @@ export async function generateComprehensiveInsight(
|
||||
{
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: rerolled.text,
|
||||
insightsCachedLocale: locale,
|
||||
insightsBriefingRerollDate: todayKey,
|
||||
},
|
||||
);
|
||||
@@ -920,6 +930,10 @@ export async function generateComprehensiveInsight(
|
||||
locale,
|
||||
{
|
||||
insightsCachedAt: new Date(),
|
||||
// The fingerprint covers the generation locale, so a matching hash
|
||||
// means the stored text is in `locale`; tag it (a pre-tag row gets
|
||||
// its label on its first unchanged night).
|
||||
insightsCachedLocale: locale,
|
||||
insightsBriefingRerollDate: todayKey,
|
||||
},
|
||||
);
|
||||
@@ -939,6 +953,7 @@ export async function generateComprehensiveInsight(
|
||||
locale,
|
||||
{
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedLocale: locale,
|
||||
},
|
||||
);
|
||||
const skipped = scopeChangedSkip(stamped, locale);
|
||||
@@ -1282,6 +1297,7 @@ export async function generateComprehensiveInsight(
|
||||
{
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
insightsCachedLocale: locale,
|
||||
insightsSnapshotHash: snapshotHash,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("encrypted health profile fact revisions", () => {
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
expect(mocks.invalidateInsights).toHaveBeenCalledWith("user-1");
|
||||
@@ -128,7 +129,11 @@ describe("encrypted health profile fact revisions", () => {
|
||||
expect(mocks.transaction).toHaveBeenCalledOnce();
|
||||
expect(mocks.userUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { insightsCachedAt: null, insightsCachedText: null },
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(mocks.create.mock.calls[0][0].data)).not.toContain(
|
||||
'"value":"FORMER"',
|
||||
@@ -189,7 +194,11 @@ describe("encrypted health profile fact revisions", () => {
|
||||
expect(mocks.transaction).toHaveBeenCalledOnce();
|
||||
expect(mocks.userUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { insightsCachedAt: null, insightsCachedText: null },
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +241,11 @@ describe("encrypted health profile fact revisions", () => {
|
||||
expect(mocks.transaction).toHaveBeenCalledOnce();
|
||||
expect(mocks.userUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { insightsCachedAt: null, insightsCachedText: null },
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function invalidateHealthProfileFactConsumers(
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
});
|
||||
invalidateUserInsights(userId);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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(),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING }),
|
||||
insightsCachedLocale: cachedLocale,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
// No provider is configured on this account, so the honest empty state
|
||||
// is "nothing will fill this"; with one it would be "preparing".
|
||||
expect(["preparing", "no-provider"]).toContain(body.briefingState);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user