mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user