mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
The labs list layout gains categoryOrder, a bounded allow-list of category keys (200 entries, 64 characters each, deduplicated, malformed values dropped on read). GET /api/labs/overview reads the caller's own layout and answers categories already in that order, so every client renders them as given. Listed categories come first; keys the data does not have are ignored; a category the order does not name follows in the default order (catalog panels, then the person's own by label, then other). Other left at the end of the order stays last, behind any category added later; moved anywhere else, it stays there. Settings > Arrangement > Labs shows a category order card while categories are on. It lists the overview's categories under their localised labels, sends the whole order after every move, moves the cached overview at once and refetches it. The card says how the default order works. The OpenAPI document carries the new field.
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
/**
|
|
* The per-user list layout, a Settings preference that lives outside the
|
|
* account payload, end to end against Postgres:
|
|
* `/api/settings/module-list-layout` answers defaults without a write,
|
|
* changes one list per PUT with preserve-when-absent fields and untouched
|
|
* neighbours, refuses unknown values with the multi-issue 422, and stores
|
|
* only what was chosen.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, switchSessionTo, 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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: "settings-prefs-user",
|
|
email: "settings-prefs@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 };
|
|
}
|
|
|
|
type Handler = (req: Request) => Promise<Response>;
|
|
|
|
function json(method: string, url: string, body: unknown): Request {
|
|
return new Request(url, {
|
|
method,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("/api/settings/module-list-layout — integration", () => {
|
|
const URL = "http://localhost/api/settings/module-list-layout";
|
|
|
|
it("answers the defaults without writing a row", async () => {
|
|
const { userId } = await seedUser();
|
|
const { GET } = await import("@/app/api/settings/module-list-layout/route");
|
|
const res = await (GET as Handler)(new Request(URL));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { lists: Record<string, { view: string; saved: boolean }> };
|
|
};
|
|
expect(body.data.lists.labs).toMatchObject({ view: "list", saved: false });
|
|
expect(body.data.lists.illness).toMatchObject({
|
|
view: "cards",
|
|
saved: false,
|
|
});
|
|
const row = await getPrismaClient().user.findUnique({
|
|
where: { id: userId },
|
|
select: { moduleListLayoutJson: true },
|
|
});
|
|
expect(row?.moduleListLayoutJson).toBeNull();
|
|
});
|
|
|
|
it("changes one list per PUT and keeps the fields and lists it does not name", async () => {
|
|
const { userId } = await seedUser();
|
|
const { GET, PUT } =
|
|
await import("@/app/api/settings/module-list-layout/route");
|
|
|
|
const first = await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "labs", order: ["m2", "m1"] }),
|
|
);
|
|
expect(first.status).toBe(200);
|
|
const second = await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "labs", sortDir: "manual" }),
|
|
);
|
|
expect(second.status).toBe(200);
|
|
await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "illness", view: "list" }),
|
|
);
|
|
|
|
const res = await (GET as Handler)(new Request(URL));
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
lists: Record<
|
|
string,
|
|
{ view: string; order: string[]; sortDir: string; saved: boolean }
|
|
>;
|
|
};
|
|
};
|
|
expect(body.data.lists.labs).toEqual({
|
|
view: "list",
|
|
order: ["m2", "m1"],
|
|
sortDir: "manual",
|
|
groupBy: "none",
|
|
groupsOpen: "expanded",
|
|
categoryOrder: [],
|
|
saved: true,
|
|
});
|
|
expect(body.data.lists.illness).toMatchObject({
|
|
view: "list",
|
|
order: [],
|
|
saved: true,
|
|
});
|
|
expect(body.data.lists.vorsorge.saved).toBe(false);
|
|
|
|
// Only chosen fields are stored, so a default can still move later.
|
|
const row = await getPrismaClient().user.findUnique({
|
|
where: { id: userId },
|
|
select: { moduleListLayoutJson: true },
|
|
});
|
|
expect(row?.moduleListLayoutJson).toEqual({
|
|
version: 1,
|
|
labs: { order: ["m2", "m1"], sortDir: "manual" },
|
|
illness: { view: "list" },
|
|
});
|
|
});
|
|
|
|
it("stores the category choices and reads them back", async () => {
|
|
const { userId } = await seedUser();
|
|
const { GET, PUT } =
|
|
await import("@/app/api/settings/module-list-layout/route");
|
|
const put = await (PUT as Handler)(
|
|
json("PUT", URL, {
|
|
version: 1,
|
|
list: "labs",
|
|
groupBy: "category",
|
|
groupsOpen: "flagged",
|
|
}),
|
|
);
|
|
expect(put.status).toBe(200);
|
|
// A later write of another field keeps them.
|
|
await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "labs", view: "cards" }),
|
|
);
|
|
|
|
const res = await (GET as Handler)(new Request(URL));
|
|
const body = (await res.json()) as {
|
|
data: { lists: Record<string, Record<string, unknown>> };
|
|
};
|
|
expect(body.data.lists.labs).toMatchObject({
|
|
view: "cards",
|
|
groupBy: "category",
|
|
groupsOpen: "flagged",
|
|
saved: true,
|
|
});
|
|
const row = await getPrismaClient().user.findUnique({
|
|
where: { id: userId },
|
|
select: { moduleListLayoutJson: true },
|
|
});
|
|
expect(row?.moduleListLayoutJson).toEqual({
|
|
version: 1,
|
|
labs: { groupBy: "category", groupsOpen: "flagged", view: "cards" },
|
|
});
|
|
|
|
const refused = await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "labs", groupsOpen: "all" }),
|
|
);
|
|
expect(refused.status).toBe(422);
|
|
});
|
|
|
|
it("stores the category order deduplicated and keeps it through other writes", async () => {
|
|
const { userId } = await seedUser();
|
|
const { GET, PUT } =
|
|
await import("@/app/api/settings/module-list-layout/route");
|
|
const put = await (PUT as Handler)(
|
|
json("PUT", URL, {
|
|
version: 1,
|
|
list: "labs",
|
|
categoryOrder: ["thyroid", "other", "thyroid", "lipids"],
|
|
}),
|
|
);
|
|
expect(put.status).toBe(200);
|
|
await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "labs", groupBy: "category" }),
|
|
);
|
|
const res = await (GET as Handler)(new Request(URL));
|
|
const body = (await res.json()) as {
|
|
data: { lists: Record<string, Record<string, unknown>> };
|
|
};
|
|
expect(body.data.lists.labs).toMatchObject({
|
|
categoryOrder: ["thyroid", "other", "lipids"],
|
|
groupBy: "category",
|
|
saved: true,
|
|
});
|
|
const row = await getPrismaClient().user.findUnique({
|
|
where: { id: userId },
|
|
select: { moduleListLayoutJson: true },
|
|
});
|
|
expect(row?.moduleListLayoutJson).toEqual({
|
|
version: 1,
|
|
labs: {
|
|
categoryOrder: ["thyroid", "other", "lipids"],
|
|
groupBy: "category",
|
|
},
|
|
});
|
|
|
|
// An empty order is a choice too: back to the default order.
|
|
await (PUT as Handler)(
|
|
json("PUT", URL, { version: 1, list: "labs", categoryOrder: [] }),
|
|
);
|
|
const reset = (await (await (GET as Handler)(new Request(URL))).json()) as {
|
|
data: { lists: { labs: { categoryOrder: string[] } } };
|
|
};
|
|
expect(reset.data.lists.labs.categoryOrder).toEqual([]);
|
|
|
|
const refused = await (PUT as Handler)(
|
|
json("PUT", URL, {
|
|
version: 1,
|
|
list: "labs",
|
|
categoryOrder: ["x".repeat(65), ""],
|
|
}),
|
|
);
|
|
expect(refused.status).toBe(422);
|
|
});
|
|
|
|
it("refuses unknown lists, values and keys with every issue named", async () => {
|
|
await seedUser();
|
|
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
|
|
const res = await (PUT as Handler)(
|
|
json("PUT", URL, {
|
|
version: 1,
|
|
list: "weight",
|
|
view: "grid",
|
|
extra: true,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("refuses a caller without a session", async () => {
|
|
const { GET } = await import("@/app/api/settings/module-list-layout/route");
|
|
const res = await (GET as Handler)(new Request(URL));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("settings presentation follows the person, also while acting for a record", () => {
|
|
const LAYOUT = "http://localhost/api/settings/module-list-layout";
|
|
|
|
async function actingForAnotherRecord() {
|
|
const prisma = getPrismaClient();
|
|
const owner = await prisma.user.create({
|
|
data: {
|
|
username: "prefs-record-owner",
|
|
email: "prefs-owner@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const actor = await prisma.user.create({
|
|
data: {
|
|
username: "prefs-delegate",
|
|
email: "prefs-delegate@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
await prisma.accountGrant.create({
|
|
data: {
|
|
grantorId: owner.id,
|
|
granteeId: actor.id,
|
|
access: "WRITE",
|
|
acceptedAt: new Date(),
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: actor.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
await switchSessionTo(session.id, owner.id);
|
|
return { ownerId: owner.id, actorId: actor.id };
|
|
}
|
|
|
|
it("reads and saves the actor's own layout", async () => {
|
|
const { ownerId, actorId } = await actingForAnotherRecord();
|
|
const layout = await import("@/app/api/settings/module-list-layout/route");
|
|
|
|
expect((await (layout.GET as Handler)(new Request(LAYOUT))).status).toBe(
|
|
200,
|
|
);
|
|
const put = await (layout.PUT as Handler)(
|
|
json("PUT", LAYOUT, { version: 1, list: "labs", view: "cards" }),
|
|
);
|
|
expect(put.status).toBe(200);
|
|
|
|
const rows = await getPrismaClient().user.findMany({
|
|
where: { id: { in: [ownerId, actorId] } },
|
|
select: {
|
|
id: true,
|
|
moduleListLayoutJson: true,
|
|
},
|
|
});
|
|
const actor = rows.find((r) => r.id === actorId)!;
|
|
const owner = rows.find((r) => r.id === ownerId)!;
|
|
expect(actor.moduleListLayoutJson).toMatchObject({
|
|
labs: { view: "cards" },
|
|
});
|
|
expect(owner.moduleListLayoutJson).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("layout saves for different lists do not overwrite each other", () => {
|
|
const URL = "http://localhost/api/settings/module-list-layout";
|
|
|
|
it("keeps every list a burst of concurrent saves touched", async () => {
|
|
const { userId } = await seedUser();
|
|
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
|
|
const lists = ["labs", "illness", "vorsorge"] as const;
|
|
await Promise.all(
|
|
Array.from({ length: 9 }, (_, i) =>
|
|
(PUT as Handler)(
|
|
json("PUT", URL, {
|
|
version: 1,
|
|
list: lists[i % 3],
|
|
...(i < 3 ? { view: "cards" } : { order: [`item-${i}`] }),
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
const row = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: userId },
|
|
select: { moduleListLayoutJson: true },
|
|
});
|
|
const stored = row.moduleListLayoutJson as Record<
|
|
string,
|
|
{ view?: string; order?: string[] }
|
|
>;
|
|
for (const list of lists) {
|
|
expect(stored[list]?.view, list).toBe("cards");
|
|
expect(stored[list]?.order, list).toBeDefined();
|
|
}
|
|
});
|
|
});
|