mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(settings): dashboard section save actually persists
The Settings → Dashboard sub-section's Save button silently 422'd once the user had touched any per-chart overlay popover. Root cause was a Zod v4 semantics change: `z.record(z.enum(KEYS), ...)` now requires every enum key to be present in the input, where Zod v3 treated the same shape as a partial record. Real-world Save payloads only carry the chart keys the user actually toggled, so every Settings → Dashboard Save attempt with one or two `chartOverlayPrefs` entries failed with `expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and the toast surfaced "Layout konnte nicht gespeichert werden". Fix uses `z.partialRecord(z.enum(KEYS), ...)` which preserves the original v1.4.18 intent — per-chart opt-in overlay prefs, missing keys filled by the resolver. The inner object now also accepts the `comparisonBaseline` field so a Settings save preserves the per-chart comparison toggle the user set via the chart-card popover (otherwise Zod's default strip behaviour wiped it on every save). New integration suite pins: - partial chartOverlayPrefs round-trips - per-chart comparisonBaseline survives a Settings save - empty chartOverlayPrefs still persists (fresh accounts) - widget toggle changes land in the DB
This commit is contained in:
1 parent
d94981e010
commit
c2fc95f815
2 files changed
+308
-1
No files matched your search
@@ -58,13 +58,36 @@ const layoutSchema = z.object({
|
||||
// v1.4.18 — per-chart overlay prefs (3 toggles per chart card).
|
||||
// Optional so older clients that don't know the field can still PUT;
|
||||
// the resolver coerces malformed values away from the layout blob.
|
||||
//
|
||||
// v1.4.25 W6 — switched from `z.record(enum, …)` to `z.partialRecord(…)`
|
||||
// because Zod v4 changed the semantics of `z.record(enum, …)`: it now
|
||||
// requires every enum key to be present (a breaking change from
|
||||
// Zod v3 which behaved like a partial record). With the strict variant
|
||||
// any PUT that carried `chartOverlayPrefs` with fewer than ALL nine
|
||||
// chart keys (i.e. every real-world Save click once a user had touched
|
||||
// a per-chart overlay popover) 422'd with
|
||||
// `expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
|
||||
// surfaced as the toast `Layout konnte nicht gespeichert werden`.
|
||||
// Partial-record matches the original intent — overlay prefs are
|
||||
// per-chart opt-in, the resolver fills in defaults for missing keys.
|
||||
//
|
||||
// The inner object also documents `comparisonBaseline` so the
|
||||
// per-chart `<ChartOverlayControls>` popover (which calls
|
||||
// `/api/dashboard/chart-overlay-prefs`) and a full-layout PUT from
|
||||
// Settings → Dashboard can both round-trip the field. Without it Zod
|
||||
// would silently strip the per-chart `comparisonBaseline` on every
|
||||
// Save click in Settings, wiping any per-chart toggle the user had
|
||||
// set via the chart-card popover.
|
||||
chartOverlayPrefs: z
|
||||
.record(
|
||||
.partialRecord(
|
||||
z.enum(CHART_OVERLAY_KEYS),
|
||||
z.object({
|
||||
showTrendIndicator: z.boolean(),
|
||||
showTrendArrow: z.boolean(),
|
||||
showTargetRange: z.boolean(),
|
||||
comparisonBaseline: z
|
||||
.enum(["none", "lastMonth", "lastYear"])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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", "<missing-key>"]`
|
||||
* and the toast surfaced "Layout konnte nicht gespeichert werden".
|
||||
*
|
||||
* The fix switched to `z.partialRecord(...)` so partial maps round-trip
|
||||
* cleanly. We exercise:
|
||||
* 1. Full layout with a single `chartOverlayPrefs` entry persists.
|
||||
* 2. Per-chart `comparisonBaseline` survives a Settings save (the
|
||||
* previous schema silently stripped it).
|
||||
* 3. Empty `chartOverlayPrefs` still persists (parity with fresh
|
||||
* accounts that have never opened a chart popover).
|
||||
*/
|
||||
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<string, unknown>;
|
||||
}>) {
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets"),
|
||||
);
|
||||
const getBody = (await getRes.json()) as {
|
||||
data: { chartOverlayPrefs?: Record<string, unknown> };
|
||||
};
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets"),
|
||||
);
|
||||
const getBody = (await getRes.json()) as {
|
||||
data: {
|
||||
chartOverlayPrefs?: Record<string, { comparisonBaseline?: string }>;
|
||||
};
|
||||
};
|
||||
expect(getBody.data.chartOverlayPrefs?.bp?.comparisonBaseline).toBe(
|
||||
"lastMonth",
|
||||
);
|
||||
});
|
||||
|
||||
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<Response>)(
|
||||
new Request("http://localhost/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("widget toggle changes round-trip end-to-end", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");
|
||||
|
||||
// Marc 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<Response>)(
|
||||
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<Response>)(
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user