From 271a49a8222c9e5d21d847e2113329b948bba55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 09:07:50 +0200 Subject: [PATCH] Pin the Coach read strip's language, and keep the default out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things are held down here. The route hands the reader's language to the strip builder, for a cookie and for a stored preference. The pattern sentence comes back in German for a German reader, asserted word for word, through the real chain rather than the engine on its own — the engine could always write German, it was simply never asked, so an engine-only test would have stayed green the whole time it was broken. And a cached strip stays with the language it was written in. The guard reads the declarations rather than grepping them, so renaming the argument or moving the fallback into a variable does not slip past it. Its counterpart runs the same detector against those disguises and insists each one is caught, because a check that cannot fail reads like proof and is not. --- src/__tests__/helpers/narrated-locale.ts | 237 ++++++++++++++++++ .../narrated-locale-guard-defeat.test.ts | 185 ++++++++++++++ .../narrated-locale-required-guard.test.ts | 80 ++++++ .../coach-read/__tests__/route.test.ts | 131 ++++++++++ .../coach-read-strip-locale-cache.test.tsx | 108 ++++++++ .../ai/coach/__tests__/tools-executor.test.ts | 13 +- .../insights/__tests__/metric-status.test.ts | 1 + .../__tests__/coach-read-locale.test.ts | 173 +++++++++++++ .../__tests__/rich-reads-module-gate.test.ts | 7 +- src/lib/mcp/__tests__/rich-reads.test.ts | 10 +- 10 files changed, 939 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/helpers/narrated-locale.ts create mode 100644 src/__tests__/narrated-locale-guard-defeat.test.ts create mode 100644 src/__tests__/narrated-locale-required-guard.test.ts create mode 100644 src/app/api/insights/coach-read/__tests__/route.test.ts create mode 100644 src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx create mode 100644 src/lib/insights/derived/__tests__/coach-read-locale.test.ts diff --git a/src/__tests__/helpers/narrated-locale.ts b/src/__tests__/helpers/narrated-locale.ts new file mode 100644 index 000000000..7389fb835 --- /dev/null +++ b/src/__tests__/helpers/narrated-locale.ts @@ -0,0 +1,237 @@ +/** + * Detector for the "a narrated sentence's locale stays required" guard. + * + * Lives beside the guard rather than inside it so the guard's own + * counter-tests can run this exact code against disguised sources without + * re-running the guard itself. See `narrated-locale-required-guard.test.ts` + * for why the rule exists and `narrated-locale-guard-defeat.test.ts` for the + * disguises it has to see through. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import ts from "typescript"; + +const REPO_ROOT = resolve(__dirname, "../../.."); + +/** + * The chain. Each entry is a declaration whose output is, or becomes, prose a + * person reads. Add to it when a new one joins the chain; never remove one + * because the check became inconvenient. + */ +export const CHAIN: ReadonlyArray<{ file: string; declaration: string }> = [ + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverEmergingCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverLabOutcomeCorrelations", + }, + { + file: "src/lib/ai/coach/tools/correlations-read.ts", + declaration: "readCoachCorrelations", + }, + { + file: "src/lib/insights/derived/coach-read.ts", + declaration: "buildCoachReadStrip", + }, + { + file: "src/lib/insights/metric-correlation-context.ts", + declaration: "getRelevantCorrelationsForMetric", + }, + { + file: "src/lib/cycle/phase-crosstab.ts", + declaration: "discoverPhaseCorrelations", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "buildPeriodNarrativeContext", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "assemblePeriodNarrativeContext", + }, + { + file: "src/lib/ai/coach/correlations-snapshot.ts", + declaration: "buildCorrelationsSnapshotBlock", + }, +]; + +export const WHY_NO_DEFAULT = + "A locale on this chain must be REQUIRED, with no `?` and no default. " + + "These functions return finished sentences that a page prints verbatim. " + + "An optional locale lets a caller say nothing and still get a sentence — " + + "which is exactly how the metric page's Coach-read strip came to answer a " + + "German page in English, under a German first line, for months. A default " + + "cannot fail loudly; a missing argument can. Pass the reader's locale " + + "instead of restoring the fallback."; + +export interface LocaleSite { + /** Where it sits, for the failure message. */ + where: string; + optional: boolean; + hasInitializer: boolean; +} + +/** The local name `Locale` is bound to in this file (it may be aliased). */ +function localeTypeName(source: ts.SourceFile): string { + let name = "Locale"; + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + if ((element.propertyName ?? element.name).text === "Locale") { + name = element.name.text; + } + } + } + return name; +} + +/** Does this type node denote the locale type (possibly a union with it)? */ +function isLocaleType(node: ts.TypeNode | undefined, alias: string): boolean { + if (!node) return false; + if (ts.isTypeReferenceNode(node)) return node.typeName.getText() === alias; + if (ts.isUnionTypeNode(node)) + return node.types.some((t) => isLocaleType(t, alias)); + return false; +} + +/** Find a top-level function/interface declaration by name. */ +function findDeclaration( + source: ts.SourceFile, + name: string, +): ts.FunctionDeclaration | ts.InterfaceDeclaration | undefined { + for (const statement of source.statements) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isInterfaceDeclaration(statement)) && + statement.name?.text === name + ) { + return statement; + } + } + return undefined; +} + +/** + * Every place a locale is accepted by `declaration` — as a parameter in its own + * right, as a member of an inline options object, or as a member of a + * same-file interface used as a parameter type. Keyed on the TYPE, so renaming + * the parameter changes nothing. + */ +export function localeSites( + source: ts.SourceFile, + declarationName: string, +): LocaleSite[] { + const alias = localeTypeName(source); + const declaration = findDeclaration(source, declarationName); + if (!declaration) { + throw new Error( + `${WHY_NO_DEFAULT}\n\nGuard could not find \`${declarationName}\` in ` + + `${source.fileName}. If it was renamed or moved, update the chain ` + + `list — do not delete the entry.`, + ); + } + + const sites: LocaleSite[] = []; + + const collectFromMembers = ( + members: ts.NodeArray, + owner: string, + ownerOptional: boolean, + ownerHasInitializer: boolean, + ) => { + for (const member of members) { + if (!ts.isPropertySignature(member)) continue; + if (!isLocaleType(member.type, alias)) continue; + sites.push({ + where: `${owner}.${member.name.getText()}`, + // The property itself may be required while the object it lives on is + // optional or defaulted — which puts the silence right back. + optional: member.questionToken !== undefined || ownerOptional, + hasInitializer: ownerHasInitializer, + }); + } + }; + + const visitParameters = ( + parameters: ts.NodeArray, + ) => { + for (const parameter of parameters) { + const owner = parameter.name.getText(); + const optional = parameter.questionToken !== undefined; + const hasInitializer = parameter.initializer !== undefined; + if (isLocaleType(parameter.type, alias)) { + sites.push({ where: owner, optional, hasInitializer }); + continue; + } + const type = parameter.type; + if (type && ts.isTypeLiteralNode(type)) { + collectFromMembers(type.members, owner, optional, hasInitializer); + } else if (type && ts.isTypeReferenceNode(type)) { + const referenced = findDeclaration(source, type.typeName.getText()); + if (referenced && ts.isInterfaceDeclaration(referenced)) { + collectFromMembers( + referenced.members, + type.typeName.getText(), + optional, + hasInitializer, + ); + } + } + } + }; + + if (ts.isInterfaceDeclaration(declaration)) { + collectFromMembers( + declaration.members, + declaration.name.text, + false, + false, + ); + } else { + visitParameters(declaration.parameters); + } + + return sites; +} + +/** + * Every `??` / `||` in the file whose left side reads a locale. This is the + * "move the default into a variable" escape: `opts.locale ?? fallback` is the + * same bug as `opts.locale ?? defaultLocale`, and neither is allowed here. + */ +export function localeFallbacks(source: ts.SourceFile): string[] { + const found: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node)) { + const op = node.operatorToken.kind; + if ( + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.BarBarToken + ) { + const left = node.left.getText(); + if (/locale/i.test(left)) found.push(node.getText()); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(source, visit); + return found; +} + +export function parse(file: string, text?: string): ts.SourceFile { + return ts.createSourceFile( + file, + text ?? readFileSync(resolve(REPO_ROOT, file), "utf8"), + ts.ScriptTarget.Latest, + true, + ); +} diff --git a/src/__tests__/narrated-locale-guard-defeat.test.ts b/src/__tests__/narrated-locale-guard-defeat.test.ts new file mode 100644 index 000000000..475068986 --- /dev/null +++ b/src/__tests__/narrated-locale-guard-defeat.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; + +import { localeSites, localeFallbacks, parse } from "./helpers/narrated-locale"; + +/** + * The guard checked against a guard. + * + * A structural check that cannot fail is worse than no check: it reads as + * proof and is not. So each case below is a disguise someone would plausibly + * reach for while putting the English default back — renaming the parameter, + * aliasing the type, hiding the fallback behind a variable, defaulting the + * whole options object instead of the field — and each one is asserted to be + * caught by the SAME detector the real guard runs. + * + * These sources are strings on purpose. They are the shapes that must not + * ship, so nothing here may live in a file the app can import. + */ + +const FILE = "disguise.ts"; + +describe("the locale guard cannot be talked around", () => { + it("catches the plain optional it was written for", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a renamed parameter — it reads the type, not the name", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.where).toBe("opts.lang"); + expect(site.optional).toBe(true); + }); + + it("catches an aliased type import", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale as L } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: L }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a required field on a DEFAULTED options object", () => { + // The field says `locale: Locale`, so a field-only check reads as clean — + // but `= {}` means the caller can still say nothing. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale } = {} as never) { return opts; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("catches a required field on an OPTIONAL options object", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts?: { locale: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches an optional locale hidden in a same-file interface", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export interface Opts { period: string; locale?: Locale } + export function narrate(opts: Opts) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a union that merely tolerates undefined", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale | undefined }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("refuses to pass a declaration that dropped the locale entirely", () => { + expect(() => + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).not.toThrow(); + expect( + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).toEqual([]); // the guard's own `length > 0` assertion is what fails on this + }); + + it("throws rather than quietly passing when the declaration is gone", () => { + expect(() => + localeSites(parse(FILE, `export const x = 1;`), "narrate"), + ).toThrow(/could not find/); + }); + + it("catches the default moved into a variable", () => { + expect( + localeFallbacks( + parse( + FILE, + `const fallback = "en"; + export function narrate(opts: { locale?: string }) { + return opts.locale ?? fallback; + }`, + ), + ), + ).not.toEqual([]); + }); + + it("catches the default written with || instead of ??", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { locale?: string }) => o.locale || "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("catches a renamed field's fallback when it still reads like a locale", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { readerLocale?: string }) => o.readerLocale ?? "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("does not fire on a fallback that has nothing to do with language", () => { + // A guard that flags everything gets silenced, so it has to stay quiet + // where it should. + expect( + localeFallbacks( + parse( + FILE, + `export const n = (o: { minPairs?: number }) => o.minPairs ?? 20;`, + ), + ), + ).toEqual([]); + }); +}); diff --git a/src/__tests__/narrated-locale-required-guard.test.ts b/src/__tests__/narrated-locale-required-guard.test.ts new file mode 100644 index 000000000..a9fceaee7 --- /dev/null +++ b/src/__tests__/narrated-locale-required-guard.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { queryKeys } from "@/lib/query-keys"; +import { + CHAIN, + WHY_NO_DEFAULT, + localeFallbacks, + localeSites, + parse, +} from "./helpers/narrated-locale"; + +/** + * A function that writes a sentence for a person must be told which language. + * + * The metric page's "Coach read" strip printed its own summary in German and + * the pattern line under it in English, one under the other, for months. The + * translations existed in all six languages the whole time. What broke was the + * shape of the call: the discovery engine took `locale?: Locale` and fell back + * to English, and the route that knew who was reading simply never said. Nobody + * had to write anything wrong — staying quiet was enough, and the result read + * like a finished sentence, so nothing looked broken from the code's side. + * + * A default is what made silence survivable. So on this chain there is none: + * every locale a narrated sentence depends on is a REQUIRED parameter, and a + * caller that cannot name one does not compile. This guard is what keeps that + * true, because "optional with a sensible default" reads like a kindness every + * time someone adds the next caller. + * + * It reads types, not names. Renaming the parameter, or lifting the default + * into a variable, does not get past it — see the disguises in + * `narrated-locale-guard-defeat.test.ts`, which run this same detector. + */ + +describe("a narrated sentence's locale stays required", () => { + for (const { file, declaration } of CHAIN) { + it(`${declaration} (${file}) accepts a locale, and it is required`, () => { + const sites = localeSites(parse(file), declaration); + + // Deleting the parameter must not pass as "nothing optional found". + expect( + sites.length, + `${WHY_NO_DEFAULT}\n\n\`${declaration}\` no longer accepts a locale at ` + + `all. It writes prose; it has to be told the language.`, + ).toBeGreaterThan(0); + + for (const site of sites) { + expect( + site.optional, + `${WHY_NO_DEFAULT}\n\nOptional locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + expect( + site.hasInitializer, + `${WHY_NO_DEFAULT}\n\nDefaulted locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + } + }); + } + + for (const file of [...new Set(CHAIN.map((entry) => entry.file))]) { + it(`${file} falls back to no language of its own`, () => { + const fallbacks = localeFallbacks(parse(file)); + expect( + fallbacks, + `${WHY_NO_DEFAULT}\n\nA locale fallback in ${file}: ` + + `${fallbacks.join(", ")}. Naming the fallback something else does ` + + `not change what it does.`, + ).toEqual([]); + }); + } + + it("keys the strip's browser cache by language", () => { + // The server-side fix is invisible to anyone who switches language rather + // than reloads if the cached entry is shared across languages. + const german = queryKeys.insightsCoachRead("WEIGHT", "de"); + const english = queryKeys.insightsCoachRead("WEIGHT", "en"); + expect(german).not.toEqual(english); + expect(german).toContain("de"); + expect(english).toContain("en"); + }); +}); diff --git a/src/app/api/insights/coach-read/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts new file mode 100644 index 000000000..13d083137 --- /dev/null +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * The route has to say who is reading. + * + * The strip's second line is a finished sentence written server-side. The + * builder can write it in any of the six languages; for months it wrote + * English on a German page because this route never passed a language and the + * engine underneath quietly defaulted. So what is pinned here is the handoff + * itself: the resolved locale reaching `buildCoachReadStrip`, from the cookie a + * language switch sets and from the stored preference when there is no cookie. + */ + +const buildCoachReadStrip = vi.fn(); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: (userId: string, metric: string, locale: string) => + buildCoachReadStrip(userId, metric, locale), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale, + }, + }; +} + +const REQUEST = () => + new NextRequest("http://localhost/api/insights/coach-read?metric=WEIGHT"); + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + } as unknown as Awaited>); + buildCoachReadStrip.mockReset().mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + localeCookie.mockReturnValue(undefined); +}); + +describe("GET /api/insights/coach-read — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(REQUEST()); + + expect(res.status).toBe(200); + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("fr") as unknown as Awaited< + ReturnType + >, + ); + + await GET(REQUEST()); + + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "fr"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that pinned + // one would pass this only by accident. + localeCookie.mockReturnValue("es"); + await GET(REQUEST()); + localeCookie.mockReturnValue("de"); + await GET(REQUEST()); + + expect(buildCoachReadStrip.mock.calls.map((call) => call[2])).toEqual([ + "es", + "de", + ]); + }); +}); diff --git a/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx new file mode 100644 index 000000000..d52646886 --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * A cached strip belongs to ONE language. + * + * The strip's second line is prose the server writes in the reader's language. + * The browser cache used to key it by metric alone, so after a language switch + * the page re-used the sentence written for the previous language and the + * server-side fix would have been invisible to anyone who switched rather than + * reloaded. `/api/analytics` carries the same warning in its own cache key. + * + * Both entries are seeded, so a key that has stopped separating them shows up + * as the WRONG sentence rather than as an empty page — a test that only proved + * "the other locale misses" would also pass if the key stopped matching + * anything at all. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +// The strip paints nothing until it is client-mounted (React #418). +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +import { CoachReadStrip } from "../coach-read-strip"; + +const GERMAN_NOTE = + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am Folgetag " + + "nach bisheriger Datenlage eher niedriger auszufallen — ein Muster, das " + + "Beachtung verdient, keine Ursache."; +const ENGLISH_NOTE = + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth watching, " + + "not a cause."; + +function payload(note: string): CoachReadStripData { + return { + baseline: null, + learning: true, + driver: { note, behaviour: "symptom severity", outcome: "sleep duration" }, + }; +} + +const METRIC = "SLEEP_DURATION"; + +/** + * One client holding BOTH languages' entries — the state a person is in the + * instant after switching language on a page they had already loaded. + */ +function seededClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "de"), + payload(GERMAN_NOTE), + ); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "en"), + payload(ENGLISH_NOTE), + ); + return client; +} + +function renderStrip(client: QueryClient, locale: "de" | "en"): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("CoachReadStrip — a cached strip is not served to another language", () => { + it("reads the German entry for a German reader", () => { + const markup = renderStrip(seededClient(), "de"); + expect(markup).toContain(GERMAN_NOTE); + expect(markup).not.toContain(ENGLISH_NOTE); + }); + + it("reads the English entry for an English reader", () => { + const markup = renderStrip(seededClient(), "en"); + expect(markup).toContain(ENGLISH_NOTE); + expect(markup).not.toContain(GERMAN_NOTE); + }); + + it("does not carry one language's cached sentence into the other on a switch", () => { + // One client, both renders — exactly what a language switch does to a page + // that is already warm. + const client = seededClient(); + const german = renderStrip(client, "de"); + const english = renderStrip(client, "en"); + + expect(german).toContain(GERMAN_NOTE); + expect(english).toContain(ENGLISH_NOTE); + expect(english).not.toContain(GERMAN_NOTE); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 930c09401..ff751c4ab 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -56,7 +56,15 @@ function emptyAggregate() { const readCoachCorrelations = vi.fn(); vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ - readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), + readCoachCorrelations: (userId: string, locale: string) => + readCoachCorrelations(userId, locale), +})); + +// The executor asks for the account's language before it asks for a narrated +// correlation, so the sentences it hands the model are not silently English. +const resolveLocaleForUser = vi.fn(async (_userId: string) => "de"); +vi.mock("@/lib/i18n/user-locale", () => ({ + resolveLocaleForUser: (userId: string) => resolveLocaleForUser(userId), })); // v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks @@ -357,6 +365,9 @@ describe("executeCoachTool", () => { drivers: [{ behaviour: "time in daylight", n: 42 }], pairsTested: 18, }); + // The account's language reached the reader — the notes are finished + // sentences, and an MCP client shows some of them as they stand. + expect(readCoachCorrelations).toHaveBeenCalledWith("u1", "de"); }); it("returns a clean { present: false } for get_correlations on no pattern", async () => { diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 122f7478a..d930ac00f 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -411,6 +411,7 @@ describe("generateMetricStatus — generation path", () => { expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith( "user-1", "RESTING_HEART_RATE", + "en", ); }); diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts new file mode 100644 index 000000000..504d628af --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -0,0 +1,173 @@ +/** + * The "Coach read" strip's second line has to arrive in the reader's language. + * + * On a German metric page the strip used to print its own summary in German + * and the pattern line under it in English, one under the other. Nothing was + * mistranslated: the pattern line is a finished sentence the server writes, + * the route never said who was reading, and the engine's optional locale + * quietly answered in English. + * + * So this drives the REAL pipe — `buildCoachReadStrip` → `readCoachCorrelations` + * → `discoverCorrelations` — with only the database reads faked, and asserts the + * German sentence the German templates produce. Testing the engine alone would + * have stayed green through the whole outage: the engine could always speak + * German, it was simply never asked. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const measurementFindMany = vi.fn(); +const measurementFindFirst = vi.fn(); +const moodFindMany = vi.fn(); +const userFindUnique = vi.fn(); +const medicationFindMany = vi.fn(); +const intakeEventFindMany = vi.fn(); +const illnessEpisodeFindMany = vi.fn(); +const illnessDayLogFindMany = vi.fn(); +const labResultFindMany = vi.fn(); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (a: unknown) => measurementFindMany(a), + findFirst: (a: unknown) => measurementFindFirst(a), + }, + moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, + user: { findUnique: (a: unknown) => userFindUnique(a) }, + medication: { findMany: (a: unknown) => medicationFindMany(a) }, + medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) }, + illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, + illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, + labResult: { findMany: (a: unknown) => labResultFindMany(a) }, + }, +})); + +// The band engine is `baseline.test.ts`'s subject; the strip's FIRST line is +// not what is under test here. Only the second line is, so the band degrades to +// "still learning" and the driver line stands alone. +const PROFILE = { ageYears: 40, sex: "MALE", heightCm: 180 }; +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: async () => PROFILE, + computeCoincidentDeviation: async () => null, + }; +}); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: async () => PROFILE, + computeVitalsBaseline: async () => ({ + status: "insufficient" as const, + reason: "not under test", + }), + }; +}); + +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; + +const DAYS = 45; +const BASE = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → a tz-stable day key + +function dayKey(offset: number): string { + const d = new Date(BASE + offset * 86_400_000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; +} + +/** + * 45 days where today's symptom burden runs against tomorrow's sleep, strongly + * enough to clear n ≥ 20, p < 0.05, the FDR control and the effect-size floor. + * Same fixture shape the correlations reader's own real-engine test uses. + */ +function seedSymptomDrivesSleep() { + const dayLogs: Array<{ + date: string; + functionalImpact: number; + symptomLinks: Array<{ severity: number }>; + }> = []; + const measurements: Array<{ type: string; value: number; measuredAt: Date }> = + []; + for (let i = 0; i < DAYS; i++) { + const burden = i % 4; + dayLogs.push({ + date: dayKey(i), + functionalImpact: burden, + symptomLinks: [], + }); + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - burden, + measuredAt: new Date(BASE + (i + 1) * 86_400_000), + }); + } + measurementFindMany.mockResolvedValue(measurements); + illnessEpisodeFindMany.mockResolvedValue([ + { + id: "ep1", + onsetAt: new Date(BASE), + resolvedAt: new Date(BASE + DAYS * 86_400_000), + }, + ]); + illnessDayLogFindMany.mockResolvedValue(dayLogs); +} + +describe("buildCoachReadStrip — the pattern line speaks the reader's language", () => { + beforeEach(() => { + measurementFindMany.mockReset().mockResolvedValue([]); + measurementFindFirst.mockReset().mockResolvedValue({ value: 7 }); + moodFindMany.mockReset().mockResolvedValue([]); + userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + medicationFindMany.mockReset().mockResolvedValue([]); + intakeEventFindMany.mockReset().mockResolvedValue([]); + illnessEpisodeFindMany.mockReset().mockResolvedValue([]); + illnessDayLogFindMany.mockReset().mockResolvedValue([]); + labResultFindMany.mockReset().mockResolvedValue([]); + seedSymptomDrivesSleep(); + }); + + it("writes the driver note in German for a German reader", async () => { + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + + expect(strip.driver).not.toBeNull(); + const note = strip.driver!.note; + + // The exact German sentence, not merely "different from the English one". + // A sentence that is different but wrong is still a broken page. + expect(note).toBe( + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am " + + "Folgetag nach bisheriger Datenlage eher niedriger auszufallen — ein " + + "Muster, das Beachtung verdient, keine Ursache.", + ); + }); + + it("writes the same driver note in English for an English reader", async () => { + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + + expect(strip.driver).not.toBeNull(); + expect(strip.driver!.note).toBe( + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth " + + "watching, not a cause.", + ); + }); + + it("keeps the driver's matching keys language-independent", async () => { + // Only the sentence is localised. `outcome` is how the strip finds the + // driver that belongs to THIS metric, so it has to stay the same string in + // every language or a German page would find no driver at all. + const de = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + const en = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + + expect(de.driver!.outcome).toBe("sleep duration"); + expect(en.driver!.outcome).toBe("sleep duration"); + expect(de.driver!.behaviour).toBe(en.driver!.behaviour); + expect(de.driver!.note).not.toBe(en.driver!.note); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts index be6811fc2..8e942c4a5 100644 --- a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts +++ b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts @@ -32,11 +32,14 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); -const { labResult, measurement } = vi.hoisted(() => ({ +const { labResult, measurement, user } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, measurement: { count: vi.fn(async () => 0), groupBy: vi.fn(async () => []) }, + // The rich reads resolve the account's language before asking for a + // narrated sentence, which reads the stored preference. + user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); -vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { compareMetric, diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index e6a4f1460..907d9722f 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -18,13 +18,16 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); -const { labResult, measurement } = vi.hoisted(() => ({ +const { labResult, measurement, user } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, // `withHrvFallback`'s presence probe (`count`) + the discovery presence // query (`groupBy`). Unused by metrics that don't exercise them. measurement: { count: vi.fn(), groupBy: vi.fn(async () => []) }, + // The rich reads resolve the account's language before asking for a + // narrated sentence, which reads the stored preference. + user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); -vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { getCorrelation, @@ -396,7 +399,7 @@ describe("get_metric_baseline", () => { const res = await getMetricBaseline(USER, { metric: "hrv" }); expect(res.present).toBe(true); - expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD"); + expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD", "de"); }); it("stays on SDNN when the user has any HEART_RATE_VARIABILITY rows", async () => { @@ -419,6 +422,7 @@ describe("get_metric_baseline", () => { expect(buildCoachReadStrip).toHaveBeenCalledWith( USER, "HEART_RATE_VARIABILITY", + "de", ); }); });