Files
HealthLog/tests/integration/settings-prefs-routes.test.ts
T
Marc-André Bombeck 2ae23f4ca8 refactor(settings): remove the preview-feature opt-ins
The per-account preview switch was kept for the topic overview pages,
which were dropped before they shipped. Its one flag was stored, served
and backed up, and read by nothing; the column-reader audit listed it as
a pending consumer that will not come.

Removed completely:

- `/api/settings/preview-features` (GET, PATCH) is retired: it answers
  410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`,
  and the contract publishes it under Retired. The route, its validation
  schema, the `PreviewFeatures` OpenAPI schemas and the settings module
  are deleted.
- Migration 0485 drops `users.preview_features_json` under a bounded lock
  wait, with the retry marker.
- The backup no longer writes the field. A file from v1.44 or v1.45 that
  carries it still restores: the schema accepts it and no codec reads it,
  so nothing is refused or written for it. The backup-parity goldens
  lose the field and apply one setting fewer.
- The data-wipe plan entry and the sharing-surface actor entry go with
  the route (frozen count 273 -> 272).

No client build reads the route: neither iOS repository nor any local
checkout references it.
2026-10-25 00:44:15 +02:00

268 lines
8.4 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);
},
})),
};
});
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: "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",
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("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();
}
});
});