mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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.
225 lines
6.7 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|