test(insights): pin the reader's locale from entry point to prompt

Covers the assertion that would have caught the narrowing: normalizeLocale
passes all six locales through and defaults an unknown value to English, the
de/en cache keys stay byte-identical while the four widened locales get their
own, and a fr locale entering a status generator reaches the prompt builder
as fr and writes the fr cache row. The forced-warm case pins the same for the
comprehensive and per-status warms.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 23:30:57 +02:00
1 parent 37c33d0205
commit 81065f3f9d
2 files changed
+207

No files matched your search

@@ -0,0 +1,177 @@
/**
* The assessment pipeline must carry the reader's locale end-to-end.
*
* The bug these tests would have caught: every stage typed its locale as
* `de | en` and narrowed on the way in, so a French account's locale was
* destroyed before any prompt saw it. The output-language directive in the
* prompt layer is INERT unless `fr` actually arrives there, so the assertion
* that matters is not "the pipeline compiles" but "the locale handed to the
* prompt builder is the one the reader has".
*
* Three things are pinned here:
* 1. `normalizeLocale` passes all six through and defaults unknown values to
* ENGLISH — a German default is the whole bug class.
* 2. Cache-key stability: an existing de/en account keeps reading its
* existing `insights.<scope>-status.<locale>` rows; only the four new
* locales get new (cold) keys.
* 3. End-to-end: a `fr` locale entering a generator reaches the prompt
* builder as `fr`, and a `fr` forced-warm job reaches `forceWarmUser`
* as `fr` (that site previously defaulted to German).
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { locales } from "@/lib/i18n/config";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { statusCacheAction } from "@/lib/insights/status-cache";
describe("normalizeLocale", () => {
it("passes every shipped UI locale through unchanged", () => {
for (const locale of locales) {
expect(normalizeLocale(locale)).toBe(locale);
}
// The four that used to be flattened away are explicitly covered.
expect(normalizeLocale("fr")).toBe("fr");
expect(normalizeLocale("es")).toBe("es");
expect(normalizeLocale("it")).toBe("it");
expect(normalizeLocale("pl")).toBe("pl");
});
it("defaults an unknown, empty, or missing locale to English, never German", () => {
for (const value of [null, undefined, "", "xx", "de-DE", "en_US", "🙂"]) {
expect(normalizeLocale(value)).toBe("en");
expect(normalizeLocale(value)).not.toBe("de");
}
});
});
describe("status cache keys", () => {
it("leaves the de/en cache keys byte-identical (no drift for existing accounts)", () => {
expect(statusCacheAction("weight", normalizeLocale("de"))).toBe(
"insights.weight-status.de",
);
expect(statusCacheAction("weight", normalizeLocale("en"))).toBe(
"insights.weight-status.en",
);
});
it("gives the four widened locales their own keys instead of reusing the English row", () => {
expect(statusCacheAction("weight", normalizeLocale("fr"))).toBe(
"insights.weight-status.fr",
);
// Before the widening this collapsed onto the English row.
expect(statusCacheAction("weight", normalizeLocale("fr"))).not.toBe(
statusCacheAction("weight", "en"),
);
});
});
// ── end-to-end: entry point → prompt builder ──────────────────────────────
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
/**
* The capture seam. `getGeneralStatusSystemPrompt` is the LAST hop before the
* provider call — whatever locale arrives here is the locale the prompt (and
* therefore its output-language directive) is composed for.
*/
const systemPromptLocales: string[] = [];
vi.mock("@/lib/ai/prompts/general-status", () => ({
getGeneralStatusSystemPrompt: (locale: string) => {
systemPromptLocales.push(locale);
return "SYSTEM";
},
getGeneralStatusUserPrompt: () => "USER",
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
const dayMs = 24 * 60 * 60 * 1000;
beforeEach(() => {
// Clear recorded calls so each case asserts only its own cache writes.
vi.clearAllMocks();
systemPromptLocales.length = 0;
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
heightCm: null,
insightsExcludeMetrics: [],
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
const now = Date.now();
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
Array.from({ length: 40 }, (_, day) => ({
type: "WEIGHT",
value: 80 + (day % 3),
measuredAt: new Date(now - day * dayMs),
})) as never,
);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "ok",
content: JSON.stringify({ summary: "Résumé." }),
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never);
});
describe("a fr locale survives from the generator entry point to the prompt", () => {
it("hands 'fr' to the prompt builder rather than collapsing it to en or de", async () => {
await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true });
expect(systemPromptLocales).toContain("fr");
// The two failure modes the old binary produced.
expect(systemPromptLocales).not.toContain("de");
expect(systemPromptLocales).not.toContain("en");
});
it("still hands 'de' to a German reader and 'en' to an English one", async () => {
await generateGeneralStatusForUser("u-de", { locale: "de", force: true });
expect(systemPromptLocales).toEqual(["de"]);
systemPromptLocales.length = 0;
await generateGeneralStatusForUser("u-en", { locale: "en", force: true });
expect(systemPromptLocales).toEqual(["en"]);
});
it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => {
await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true });
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(call) => (call[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("insights.general-status.fr");
expect(actions).not.toContain("insights.general-status.en");
});
});
@@ -783,6 +783,36 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
});
});
it("carries a fr locale into the comprehensive and status warms instead of collapsing it", async () => {
// The regression this pins: the warm typed its locale `de | en`, so a
// French account's warm ran (and cached) in the wrong language — and the
// prompt layer's output-language directive never saw `fr` to act on.
const { prisma } = makePrisma([]);
const generate = vi
.fn()
.mockResolvedValue({ status: "generated", providerType: "x" });
const statusGenerators = Array.from({ length: 7 }, () =>
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
);
const warmGenericMetrics = vi.fn().mockResolvedValue(3);
await forceWarmUser(prisma as never, "u1", "fr", {
generate,
statusGenerators,
warmGenericMetrics,
});
expect(generate).toHaveBeenCalledWith("u1", {
locale: "fr",
force: true,
signal: expect.any(AbortSignal),
});
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledWith("u1", { locale: "fr", force: false });
}
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["fr"]);
});
it("skips the comprehensive when the cache was warmed within the freshness window (idempotent re-enqueue)", async () => {
// A revalidation poll that outlives the enqueue singleton can stack
// several force jobs; the job-start freshness re-check collapses the