Files
HealthLog/tests/integration/coach-prefs.test.ts
T
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00

140 lines
4.7 KiB
TypeScript

/**
* v1.4.23 H4 — per-user Coach prompt-tuning preferences route.
*
* Round-trips through GET → PUT → GET and asserts that:
* - the GET returns the documented defaults when the row is null
* - the PUT persists the supplied shape and returns the canonical
* defaulted form
* - subsequent GETs reflect the persisted values
* - malformed input is rejected with 422
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function seedSession(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username, email: `${username}@example.test` },
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
describe("GET /api/auth/me/coach-prefs", () => {
it("returns documented defaults when the user has never saved prefs", async () => {
await seedSession("coach-prefs-defaults");
const { GET } = await import("@/app/api/auth/me/coach-prefs/route");
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof DEFAULT_COACH_PREFS };
expect(env.data).toEqual(DEFAULT_COACH_PREFS);
});
});
describe("PUT /api/auth/me/coach-prefs", () => {
it("persists the supplied shape and round-trips through GET", async () => {
const user = await seedSession("coach-prefs-roundtrip");
const prisma = getPrismaClient();
const { PUT, GET } = await import("@/app/api/auth/me/coach-prefs/route");
// The request body omits `defaultWindow`; the schema (v1.4.25 W5)
// defaults it to "allTime", so the canonical PUT response and the
// persisted row both carry the defaulted field even when the
// caller didn't pass it.
const body = {
tone: "concise" as const,
verbosity: "brief" as const,
excludeMetrics: ["mood" as const, "compliance" as const],
showEvidenceByDefault: true,
};
const expected = { ...body, defaultWindow: "allTime" as const };
const putRes = await (PUT as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(putRes.status).toBe(200);
const putEnv = (await putRes.json()) as { data: typeof expected };
expect(putEnv.data).toEqual(expected);
// DB row reflects the canonical defaulted form.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true },
});
expect(row?.coachPrefsJson).toEqual(expected);
// GET reads the saved values back.
const getRes = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs"),
);
expect(getRes.status).toBe(200);
const getEnv = (await getRes.json()) as { data: typeof expected };
expect(getEnv.data).toEqual(expected);
});
it("rejects unknown tone values with 422", async () => {
await seedSession("coach-prefs-invalid");
const { PUT } = await import("@/app/api/auth/me/coach-prefs/route");
const res = await (PUT as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tone: "stoic" }),
}),
);
expect(res.status).toBe(422);
});
});