mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
/**
|
|
* 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. 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<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("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<Response>)(
|
|
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<Response>)(
|
|
new Request("http://localhost/api/dashboard/widgets"),
|
|
);
|
|
const getBody = (await getRes.json()) as {
|
|
data: {
|
|
chartOverlayPrefs?: Record<string, { rangePoints?: number }>;
|
|
};
|
|
};
|
|
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<Response>)(
|
|
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<Response>)(
|
|
new Request("http://localhost/api/dashboard/widgets"),
|
|
);
|
|
const getBody = (await getRes.json()) as {
|
|
data: {
|
|
chartOverlayPrefs?: Record<string, { rangePoints?: number }>;
|
|
};
|
|
};
|
|
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<Response>)(
|
|
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<Response>)(
|
|
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<Response>)(
|
|
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<Response>)(
|
|
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<Response>)(
|
|
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<Record<string, unknown>> };
|
|
};
|
|
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<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();
|
|
});
|
|
});
|