Files
HealthLog/src/lib/__tests__/module-list-prefs.test.ts
T
Marc-André Bombeck 93198017d6 feat(settings): keep list order and preview opt-ins on the server
Add per-user columns for the labs, illness and checkups list presentation
and for preview-feature opt-ins, each with a GET plus a preserve-when-absent
write under /api/settings. The list hook now reads and writes the server
value and carries a browser-stored order up once when the server holds
none, so an existing arrangement follows the person to every device instead
of resetting.
2026-10-21 19:11:23 +02:00

225 lines
6.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
}));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: vi.fn(),
}));
vi.mock("@/lib/errors/error-toast", () => ({
showErrorToast: vi.fn(),
}));
import { apiPut } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
applyOrder,
carryOverLegacyModuleListPrefs,
legacyModuleListPatch,
legacyModuleListStorageKey,
parseModuleListPrefs,
resetModuleListCarryOverForTests,
runSetModuleListPrefs,
} from "@/lib/module-list-prefs";
import {
moduleListDefaults,
resolveModuleListLayout,
} from "@/lib/settings/module-list-layout";
import { queryKeys } from "@/lib/query-keys";
const store = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
};
beforeEach(() => {
vi.resetAllMocks();
store.clear();
resetModuleListCarryOverForTests();
vi.stubGlobal("window", { localStorage: fakeStorage });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("parseModuleListPrefs", () => {
const defaults = moduleListDefaults("illness");
it("returns defaults for null / malformed input", () => {
expect(parseModuleListPrefs(null, defaults)).toEqual(defaults);
expect(parseModuleListPrefs("{not json", defaults)).toEqual(defaults);
});
it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
expect(
parseModuleListPrefs(
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
defaults,
),
).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] });
});
it("applies the labs default (list view, #40) when nothing is stored", () => {
const labs = moduleListDefaults("labs");
expect(parseModuleListPrefs(null, labs).view).toBe("list");
expect(
parseModuleListPrefs(JSON.stringify({ view: "cards" }), labs).view,
).toBe("cards");
});
});
describe("legacyModuleListPatch", () => {
it("carries only what differs from the default", () => {
expect(
legacyModuleListPatch(
"labs",
JSON.stringify({ view: "list", order: [], sortDir: "manual" }),
),
).toEqual({ sortDir: "manual" });
expect(
legacyModuleListPatch(
"illness",
JSON.stringify({ view: "list", order: ["e2", "e1"] }),
),
).toEqual({ view: "list", order: ["e2", "e1"] });
expect(legacyModuleListPatch("vorsorge", null)).toEqual({});
});
});
describe("carryOverLegacyModuleListPrefs", () => {
it("sends a browser-stored order up once and removes the browser copy after the server confirmed it", async () => {
store.set(
legacyModuleListStorageKey("illness"),
JSON.stringify({ view: "cards", order: ["e2", "e1"] }),
);
const saved = resolveModuleListLayout({ illness: { order: ["e2", "e1"] } });
vi.mocked(apiPut).mockResolvedValue(saved);
const queryClient = new QueryClient();
await carryOverLegacyModuleListPrefs({
list: "illness",
layout: resolveModuleListLayout(null),
queryClient,
});
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
version: 1,
list: "illness",
order: ["e2", "e1"],
});
expect(store.has(legacyModuleListStorageKey("illness"))).toBe(false);
expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
saved,
);
// A second mount in the same page load does not send it again.
await carryOverLegacyModuleListPrefs({
list: "illness",
layout: saved,
queryClient,
});
expect(apiPut).toHaveBeenCalledTimes(1);
});
it("keeps the browser copy when the write fails, so the order is never lost", async () => {
store.set(
legacyModuleListStorageKey("labs"),
JSON.stringify({ order: ["m1"], sortDir: "manual" }),
);
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
await carryOverLegacyModuleListPrefs({
list: "labs",
layout: resolveModuleListLayout(null),
queryClient: new QueryClient(),
});
expect(store.has(legacyModuleListStorageKey("labs"))).toBe(true);
});
it("leaves a list the server already holds alone and drops the stale browser copy", async () => {
store.set(
legacyModuleListStorageKey("vorsorge"),
JSON.stringify({ order: ["r9"] }),
);
await carryOverLegacyModuleListPrefs({
list: "vorsorge",
layout: resolveModuleListLayout({ vorsorge: { order: ["r1"] } }),
queryClient: new QueryClient(),
});
expect(apiPut).not.toHaveBeenCalled();
expect(store.has(legacyModuleListStorageKey("vorsorge"))).toBe(false);
});
});
describe("runSetModuleListPrefs", () => {
const t = (key: string) => key;
it("applies the change at once and confirms a view change with a toast", async () => {
const queryClient = new QueryClient();
const saved = resolveModuleListLayout({ labs: { view: "cards" } });
vi.mocked(apiPut).mockResolvedValue(saved);
await runSetModuleListPrefs({
list: "labs",
patch: { view: "cards" },
queryClient,
t,
});
expect(toastWrittenOutcome).toHaveBeenCalledWith(
"success",
"settings.saved",
);
expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
saved,
);
});
it("rolls back and says so when the write fails", async () => {
const queryClient = new QueryClient();
const before = resolveModuleListLayout(null);
queryClient.setQueryData(queryKeys.moduleListLayout(), before);
vi.mocked(apiPut).mockRejectedValue(new Error("nope"));
await runSetModuleListPrefs({
list: "illness",
patch: { order: ["x"] },
queryClient,
t,
});
expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual(
before,
);
expect(showErrorToast).toHaveBeenCalled();
});
});
describe("applyOrder", () => {
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
const idOf = (i: { id: string }) => i.id;
it("keeps the input order with no saved order", () => {
expect(applyOrder(items, [], idOf)).toEqual(items);
});
it("puts ordered ids first and the rest after in input order", () => {
expect(applyOrder(items, ["c", "a"], idOf).map(idOf)).toEqual([
"c",
"a",
"b",
]);
});
it("drops unknown ids", () => {
expect(applyOrder(items, ["z", "b"], idOf).map(idOf)).toEqual([
"b",
"a",
"c",
]);
});
});