mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge work/coach-read-locale
This commit is contained in:
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user