Merge work/coach-read-locale

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 11:48:21 +02:00
commit 2d01b9846d
43 files changed
+1636 -169

No files matched your search

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* The cycle insights route has to say who is reading, too.
*
* Each surviving lagged row carries a finished `interpretation` sentence that
* this route serves straight to the client. The engine underneath can write it
* in any of the six languages, and it only writes the right one if the route
* resolves the reader's language and hands it over — the same handoff the
* metric page's "Coach read" strip was missing when it answered a German page
* in English.
*
* So what is pinned here is the handoff itself: the resolved locale arriving at
* `discoverPhaseCorrelations`, from the cookie a language switch sets and from
* the stored preference when there is no cookie. Without this the route could
* go back to naming one language outright and nothing in the suite would
* notice.
*/
/** Stands in for the discovery engine so the ARGUMENTS are what is inspected. */
const discoverPhaseCorrelations = vi.fn<
(args: { locale?: unknown }) => {
discovered: never[];
pairsTested: number;
fdrQ: number;
minPairs: number;
}
>(() => ({
discovered: [],
pairsTested: 0,
fdrQ: 0.1,
minPairs: 20,
}));
vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/cycle/phase-crosstab")>()),
discoverPhaseCorrelations: (args: { locale?: unknown }) =>
discoverPhaseCorrelations(args),
}));
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
user: {
findUnique: vi.fn().mockResolvedValue({ sourcePriorityJson: null }),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cycle/gate", () => ({
requireCycleEnabled: vi.fn(async () => ({
enabled: true,
profile: { lutealPhaseLength: 14, averageCycleLength: 28 },
})),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
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";
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,
gender: "FEMALE" as const,
timezone: "Europe/Berlin",
locale,
},
};
}
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(
sessionWithLocale(null) as unknown as Awaited<
ReturnType<typeof getSession>
>,
);
discoverPhaseCorrelations.mockClear();
localeCookie.mockReturnValue(undefined);
});
/** The locale the route handed the discovery engine on the last call. */
function passedLocale(): unknown {
return discoverPhaseCorrelations.mock.calls.at(-1)?.[0]?.locale;
}
describe("GET /api/cycle/insights — the reader's language", () => {
it("passes the language the switcher's cookie names", async () => {
localeCookie.mockReturnValue("de");
const res = await GET();
expect(res.status).toBe(200);
expect(passedLocale()).toBe("de");
});
it("falls back to the stored preference when there is no cookie", async () => {
vi.mocked(getSession).mockResolvedValue(
sessionWithLocale("pl") as unknown as Awaited<
ReturnType<typeof getSession>
>,
);
await GET();
expect(passedLocale()).toBe("pl");
});
it("never hard-codes a language of its own", async () => {
// Two readers, two languages, from the same handler. A route that named
// one outright would pass this only by accident.
localeCookie.mockReturnValue("it");
await GET();
const first = passedLocale();
localeCookie.mockReturnValue("fr");
await GET();
expect([first, passedLocale()]).toEqual(["it", "fr"]);
});
});
+7
View File
@@ -20,6 +20,7 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
import {
@@ -208,10 +209,16 @@ export const GET = apiHandler(async () => {
userPriorityJson,
});
const headline = selectHeadlinePhaseRow(rows);
// The lagged rows are served with their narrated `interpretation` intact, so
// the reader's language is resolved here the same way the correlations route
// resolves it. This surface had the same silent-English gap the metric-page
// strip had.
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
const lagged = discoverPhaseCorrelations({
phaseByDay,
measurements,
userPriorityJson,
locale,
});
// Cycle-NATIVE insight: where each logged symptom clusters across the phases.
@@ -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",
]);
});
});
+10 -1
View File
@@ -23,6 +23,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
@@ -60,12 +61,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const metric = parsed.data.metric as MeasurementType;
const strip = await buildCoachReadStrip(user.id, metric);
// Line 2 of the strip is a finished sentence the clients print verbatim, so
// the reader's language is decided here — same resolution the correlations
// and analytics routes use (cookie, then the stored preference, then the
// Accept-Language header). Without it the strip answered a German page in
// English, one line under the other.
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
const strip = await buildCoachReadStrip(user.id, metric, locale);
annotate({
action: { name: "insights.coach-read" },
meta: {
metric,
locale,
has_baseline: strip.baseline !== null,
learning: strip.learning,
has_driver: strip.driver !== null,