diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts new file mode 100644 index 000000000..356539dc0 --- /dev/null +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -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.-status.` 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"); + }); +}); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index d3b359212..e8bb06131 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -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