mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Pin the Coach read strip's language, and keep the default out
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.
This commit is contained in:
1 parent
4ed6f7d1ce
commit
271a49a822
10 files changed
+939
-6
No files matched your search
@@ -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<ts.TypeElement>,
|
||||
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<ts.ParameterDeclaration>,
|
||||
) => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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<typeof getSession>
|
||||
>,
|
||||
);
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
|
||||
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<typeof getSession>
|
||||
>,
|
||||
);
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<CoachReadStrip metricType={METRIC} unit="h" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -411,6 +411,7 @@ describe("generateMetricStatus — generation path", () => {
|
||||
expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"RESTING_HEART_RATE",
|
||||
"en",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
loadBaselineProfile: async () => PROFILE,
|
||||
computeCoincidentDeviation: async () => null,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived/baseline")>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -32,11 +32,14 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
|
||||
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
|
||||
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,
|
||||
|
||||
@@ -18,13 +18,16 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
|
||||
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user