/** * v1.4.25 W6 — integration coverage for the PUT * `/api/dashboard/widgets` endpoint with realistic full-layout payloads. * * Root-cause this suite pins: * Settings → Dashboard's Save button stopped persisting once the user * had touched any per-chart overlay popover. The widgets-route Zod * schema used `z.record(z.enum(CHART_OVERLAY_KEYS), …)` which under * Zod v4 demands every enum key to be present. Real-world payloads * carry one or two chart keys (the ones the user actually flipped), * so every Save click 422'd with * `expected: object, path: ["chartOverlayPrefs", ""]` * and the toast surfaced "Layout konnte nicht gespeichert werden". * * The fix switched to `z.partialRecord(...)` so partial maps round-trip * cleanly. Coverage also pins the newer nested `rangePoints` contract: * omission preserves the stored choice, valid overrides win, and values * outside the closed range-point set are rejected. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout"; 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 seedUser(): Promise<{ userId: string }> { const prisma = getPrismaClient(); const user = await prisma.user.create({ data: { username: "dashboard-save-user", email: "dashboard-save@example.test", role: "USER", }, }); const session = await prisma.session.create({ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000), }, }); cookieJar.set("healthlog_session", session.id); return { userId: user.id }; } function buildClientPayload( overrides: Partial<{ chartOverlayPrefs: Record; }>, ) { return { version: 1, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({ id: w.id, visible: w.visible, tileVisible: w.tileVisible ?? w.visible, order: w.order, })), comparisonBaseline: "none" as const, ...overrides, }; } describe("PUT /api/dashboard/widgets — Save persists full layouts", () => { it("accepts a partial chartOverlayPrefs map (single chart key)", async () => { const { userId } = await seedUser(); const { PUT, GET } = await import("@/app/api/dashboard/widgets/route"); // Realistic Save click: the user flipped the weight chart's tile // toggle from Settings → Dashboard. The cached layout has a single // per-chart entry (the BP chart popover the user opened earlier). const body = buildClientPayload({ chartOverlayPrefs: { bp: { showTrendIndicator: true, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "lastMonth", }, }, }); const putRes = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }), ); expect(putRes.status).toBe(200); // Sanity: the row actually landed in the DB. const stored = await getPrismaClient().user.findUniqueOrThrow({ where: { id: userId }, select: { dashboardWidgetsJson: true }, }); expect(stored.dashboardWidgetsJson).toBeTruthy(); // Follow-up GET surfaces the saved per-chart prefs end-to-end. const getRes = await (GET as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets"), ); const getBody = (await getRes.json()) as { data: { chartOverlayPrefs?: Record }; }; expect(getBody.data.chartOverlayPrefs).toMatchObject({ bp: { showTrendIndicator: true, showTrendArrow: false, showTargetRange: false, }, }); }); it("preserves per-chart comparisonBaseline through a Settings save", async () => { const { userId } = await seedUser(); const { PUT, GET } = await import("@/app/api/dashboard/widgets/route"); // Step 1: the user set the BP chart's comparisonBaseline = lastMonth // via the chart popover. Seed it directly to skip the popover route. const seededLayout = { version: 1, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, comparisonBaseline: "none", chartOverlayPrefs: { bp: { showTrendIndicator: false, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "lastMonth", }, }, }; await getPrismaClient().user.update({ where: { id: userId }, data: { dashboardWidgetsJson: seededLayout as never, }, }); // Step 2: the user opens Settings → Dashboard and clicks Save. // The client posts back the layout it loaded — including the BP // chart's `comparisonBaseline: "lastMonth"` (which the resolver // re-emits on every GET). const putBody = { version: 1, widgets: seededLayout.widgets, comparisonBaseline: "none", chartOverlayPrefs: { bp: { showTrendIndicator: false, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "lastMonth", }, }, }; const putRes = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(putBody), }), ); expect(putRes.status).toBe(200); // Step 3: GET — the per-chart comparisonBaseline must still be // "lastMonth". With the previous `z.record(enum, …)` schema, Zod // would either reject the save outright (incomplete record) OR // strip the `comparisonBaseline` field, both of which wiped the // user's per-chart toggle. const getRes = await (GET as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets"), ); const getBody = (await getRes.json()) as { data: { chartOverlayPrefs?: Record; }; }; expect(getBody.data.chartOverlayPrefs?.bp?.comparisonBaseline).toBe( "lastMonth", ); }); it("preserves a stored per-chart rangePoints value when the full-layout payload omits it", async () => { const { userId } = await seedUser(); const { PUT, GET } = await import("@/app/api/dashboard/widgets/route"); await getPrismaClient().user.update({ where: { id: userId }, data: { dashboardWidgetsJson: { ...DEFAULT_DASHBOARD_LAYOUT, chartOverlayPrefs: { bp: { showTrendIndicator: false, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "none", rangePoints: 90, }, }, } as never, }, }); const putRes = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify( buildClientPayload({ chartOverlayPrefs: { bp: { showTrendIndicator: true, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "lastMonth", }, }, }), ), }), ); expect(putRes.status).toBe(200); const getRes = await (GET as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets"), ); const getBody = (await getRes.json()) as { data: { chartOverlayPrefs?: Record; }; }; expect(getBody.data.chartOverlayPrefs?.bp?.rangePoints).toBe(90); }); it("honors an explicit valid per-chart rangePoints override", async () => { const { userId } = await seedUser(); const { PUT, GET } = await import("@/app/api/dashboard/widgets/route"); await getPrismaClient().user.update({ where: { id: userId }, data: { dashboardWidgetsJson: { ...DEFAULT_DASHBOARD_LAYOUT, chartOverlayPrefs: { bp: { showTrendIndicator: false, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "none", rangePoints: 90, }, }, } as never, }, }); const putRes = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify( buildClientPayload({ chartOverlayPrefs: { bp: { showTrendIndicator: false, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "none", rangePoints: 7, }, }, }), ), }), ); expect(putRes.status).toBe(200); const getRes = await (GET as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets"), ); const getBody = (await getRes.json()) as { data: { chartOverlayPrefs?: Record; }; }; expect(getBody.data.chartOverlayPrefs?.bp?.rangePoints).toBe(7); }); it("rejects an invalid per-chart rangePoints value", async () => { await seedUser(); const { PUT } = await import("@/app/api/dashboard/widgets/route"); const res = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify( buildClientPayload({ chartOverlayPrefs: { bp: { showTrendIndicator: false, showTrendArrow: false, showTargetRange: false, comparisonBaseline: "none", rangePoints: 14, }, }, }), ), }), ); expect(res.status).toBe(422); const body = (await res.json()) as { details: { issues: Array<{ path: string }> }; }; expect(body.details.issues.map((issue) => issue.path)).toContain( "chartOverlayPrefs.bp.rangePoints", ); }); it("accepts an empty chartOverlayPrefs map (fresh account)", async () => { await seedUser(); const { PUT } = await import("@/app/api/dashboard/widgets/route"); const body = buildClientPayload({ chartOverlayPrefs: {} }); const res = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }), ); expect(res.status).toBe(200); }); // v1.4.42 W2 — the route used to return only `issues[0].message`, // which forced one round-trip per wrong field during iOS contract // debugging. The route now returns every issue under `details.issues` // AND writes a `dashboard.widgets.validation-failed` row to the // append-only audit ledger so the operator can grep `/api/admin/audit`. describe("PUT 422 — multi-issue envelope (v1.4.42 W2)", () => { it("surfaces TWO simultaneous validation errors", async () => { const { userId } = await seedUser(); const { PUT } = await import("@/app/api/dashboard/widgets/route"); // version=2 (literal mismatch) + widgets=[] (min(1) violation). const res = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ version: 2, widgets: [] }), }), ); expect(res.status).toBe(422); const body = (await res.json()) as { data: null; error: string; details: { issues: Array<{ path: string; code: string; message: string }>; }; }; expect(body.data).toBeNull(); expect(body.error).toBe("Validation failed"); expect(body.details.issues.length).toBe(2); const paths = body.details.issues.map((i) => i.path).sort(); expect(paths).toEqual(["version", "widgets"]); // Audit-ledger breadcrumb. The route emits the row fire-and-forget, // so poll until it materialises instead of asserting after one fixed // tick — under container load the detached insert can land well past // 25 ms, which made this assertion flaky. Bounded at ~2 s. const deadline = Date.now() + 2_000; let audit: { details: string | null } | null = null; while (audit === null && Date.now() < deadline) { audit = await getPrismaClient().auditLog.findFirst({ where: { userId, action: "dashboard.widgets.validation-failed" }, }); if (audit === null) { await new Promise((r) => setTimeout(r, 50)); } } expect(audit).not.toBeNull(); const details = JSON.parse(audit?.details ?? "{}") as { issues: Array<{ path: string }>; }; expect(details.issues.length).toBe(2); }); it("surfaces THREE simultaneous validation errors", async () => { await seedUser(); const { PUT } = await import("@/app/api/dashboard/widgets/route"); // version wrong literal + widgets empty + comparisonBaseline not in enum. const res = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ version: 99, widgets: [], comparisonBaseline: "tomorrow", }), }), ); expect(res.status).toBe(422); const body = (await res.json()) as { details: { issues: Array<{ path: string; code: string }> }; }; expect(body.details.issues.length).toBe(3); const paths = body.details.issues.map((i) => i.path).sort(); expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]); }); it("does not echo issue.params (privacy)", async () => { await seedUser(); const { PUT } = await import("@/app/api/dashboard/widgets/route"); const res = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ version: 1, widgets: [] }), }), ); expect(res.status).toBe(422); const body = (await res.json()) as { details: { issues: Array> }; }; for (const issue of body.details.issues) { expect(issue).not.toHaveProperty("params"); expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); } }); }); it("widget toggle changes round-trip end-to-end", async () => { const { userId } = await seedUser(); const { PUT, GET } = await import("@/app/api/dashboard/widgets/route"); // The maintainer flips the sleep widget's chart visibility on. const body = buildClientPayload({ chartOverlayPrefs: { bp: { showTrendIndicator: true, showTrendArrow: false, showTargetRange: true, }, }, }); body.widgets = body.widgets.map((w) => w.id === "sleep" ? { ...w, visible: true, tileVisible: true } : w, ); const putRes = await (PUT as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }), ); expect(putRes.status).toBe(200); const getRes = await (GET as (req: Request) => Promise)( new Request("http://localhost/api/dashboard/widgets"), ); const getBody = (await getRes.json()) as { data: { widgets: Array<{ id: string; visible: boolean; tileVisible?: boolean }>; }; }; const sleep = getBody.data.widgets.find((w) => w.id === "sleep"); expect(sleep?.visible).toBe(true); expect(sleep?.tileVisible).toBe(true); // DB row writes through. const stored = await getPrismaClient().user.findUniqueOrThrow({ where: { id: userId }, select: { dashboardWidgetsJson: true }, }); expect(stored.dashboardWidgetsJson).toBeTruthy(); }); });