Files
HealthLog/tests/integration/insights-status-real-text.test.ts
T
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

201 lines
6.6 KiB
TypeScript

/**
* Wave 1D — the per-card status path delivers the REAL provider text.
*
* Pins two contracts against a real Postgres + a seeded rich account:
*
* 1. With a provider chain stubbed to return a known assessment, the
* status generator returns that text (the real path is reached, not
* the generic no-key fallback). This is the regression the
* sticky-stub bug used to break.
*
* 2. A negative-cache window opened by an earlier stall never stands in
* for a note: once it lapses, with no note stored for today, the
* generator regenerates and stores the real text, and writing it closes
* the window and resets the failure streak. While it is open (#1279),
* no provider is asked and the window stays as it was.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
// Stub the provider resolution so the chain runner has a deterministic
// provider to walk. `runRawCompletionWithFallback` runs for real on top
// of this — exercising the same plumbing the live status path uses.
const KNOWN_TEXT = "Your weight has trended down steadily over the past month.";
vi.mock("@/lib/ai/provider", async (importOriginal) => ({
// The presence probe the capability reads stays real; only the chain the
// generation walks is replaced.
...(await importOriginal<typeof import("@/lib/ai/provider")>()),
resolveProviderChain: vi.fn().mockResolvedValue([
{
providerType: "anthropic",
instance: {
type: "anthropic",
generateCompletion: vi.fn().mockResolvedValue({
content: JSON.stringify({ summary: KNOWN_TEXT }),
model: "test-model",
tokensUsed: 42,
}),
},
},
]),
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
}));
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
afterEach(() => {
vi.restoreAllMocks();
});
async function seedRichWeightUser(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
// Presence of a provider for the capability; never decrypted here.
aiProviderChain: [{ providerType: "anthropic", enabled: true }],
aiAnthropicKeyEncrypted: "key-present",
},
});
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
const rows = Array.from({ length: 120 }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
value: 82 - i * 0.02,
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - i * DAY_MS),
}));
await prisma.measurement.createMany({ data: rows });
return user;
}
describe("status path delivers real provider text", () => {
it("returns the provider's assessment, not the no-key fallback", async () => {
const user = await seedRichWeightUser("status-real-text-user");
const { generateWeightStatusForUser } =
await import("@/lib/insights/weight-status");
const result = await generateWeightStatusForUser(user.id, {
locale: "en",
});
expect(result.hasProvider).toBe(true);
expect(result.cached).toBe(false);
expect(result.text).toBe(KNOWN_TEXT);
// The real assessment was stored, encrypted, as the weight note.
const prisma = getPrismaClient();
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
where: {
userId_metric_locale: {
userId: user.id,
metric: "weight",
locale: "en",
},
},
});
expect(decryptFromBytes(stored.textEncrypted!)).toBe(KNOWN_TEXT);
expect(stored.retryAt).toBeNull();
// Nothing about the note lands in the audit log any more.
expect(
await prisma.auditLog.count({
where: { userId: user.id, action: { contains: "-status." } },
}),
).toBe(0);
});
it("regenerates past a lapsed negative-cache window and closes it", async () => {
const user = await seedRichWeightUser("status-stub-skip-user");
const prisma = getPrismaClient();
// Earlier stalls opened a window, now lapsed, with no note stored.
await prisma.insightStatusCache.create({
data: {
userId: user.id,
metric: "weight",
locale: "en",
dateKey: "2026-01-01",
retryAt: new Date(Date.now() - 1_000),
negativeReason: "timeout",
failureStreak: 3,
},
});
const { generateWeightStatusForUser } =
await import("@/lib/insights/weight-status");
const result = await generateWeightStatusForUser(user.id, {
locale: "en",
});
expect(result.text).toBe(KNOWN_TEXT);
expect(result.cached).toBe(false);
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
where: {
userId_metric_locale: {
userId: user.id,
metric: "weight",
locale: "en",
},
},
});
expect(stored.retryAt).toBeNull();
expect(stored.negativeReason).toBeNull();
expect(stored.failureStreak).toBe(0);
});
it("asks no provider while the window is open (#1279)", async () => {
const user = await seedRichWeightUser("status-cooling-user");
const prisma = getPrismaClient();
const retryAt = new Date(Date.now() + 60_000);
await prisma.insightStatusCache.create({
data: {
userId: user.id,
metric: "weight",
locale: "en",
dateKey: "2026-01-01",
retryAt,
negativeReason: "error",
failureStreak: 2,
},
});
const { resolveProviderChain } = await import("@/lib/ai/provider");
const [entry] = await vi.mocked(resolveProviderChain)(user.id);
const generate = vi.mocked(entry.instance.generateCompletion);
generate.mockClear();
const { generateWeightStatusForUser } =
await import("@/lib/insights/weight-status");
const result = await generateWeightStatusForUser(user.id, {
locale: "en",
});
expect(generate).not.toHaveBeenCalled();
expect(result.text).not.toBe(KNOWN_TEXT);
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
where: {
userId_metric_locale: {
userId: user.id,
metric: "weight",
locale: "en",
},
},
});
// The window neither moved nor counted the refused generation.
expect(stored.retryAt?.getTime()).toBe(retryAt.getTime());
expect(stored.failureStreak).toBe(2);
expect(stored.textEncrypted).toBeNull();
});
});