mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test(settings): cover the Module hub
Render every toggleable and core switch, confirm a disabled module reads unchecked while an absent key stays default-on, assert the core domains are locked, and verify a toggle PATCHes only the flipped key as a disabled-allowlist entry and invalidates the auth/me query.
This commit is contained in:
1 parent
f50f92902e
commit
3cae7beda8
1 file changed
+187
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* v1.18.0 — Settings → Module ("Was du trackst") hub contract.
|
||||
*
|
||||
* `<ModulesSection>` lists every toggleable module with a `<Switch>` bound
|
||||
* to the resolved module map (`useAuth().user.modules`), plus a read-only
|
||||
* "always on" group for the four core domains. Flipping a toggle PATCHes
|
||||
* `/api/auth/me/modules` (a DISABLED allowlist) and invalidates the
|
||||
* `authMe()` query so nav / pills / tiles re-gate live.
|
||||
*
|
||||
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
|
||||
* `@tanstack/react-query` so the rendered tree executes under SSR
|
||||
* (`renderToStaticMarkup`), capture the `useMutation` config to assert the
|
||||
* PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to
|
||||
* drive the disabled-module case.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MODULE_KEYS, CORE_DOMAIN_KEYS } from "@/lib/modules/registry";
|
||||
|
||||
// Capture the latest useMutation config + a shared mutate spy so we can
|
||||
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
|
||||
// real DOM click).
|
||||
type MutationConfig = {
|
||||
mutationFn?: (vars: unknown) => Promise<unknown>;
|
||||
onSuccess?: (data?: unknown) => void;
|
||||
onError?: (err?: unknown) => void;
|
||||
};
|
||||
let lastMutation: MutationConfig | null = null;
|
||||
const mutateSpy = vi.fn();
|
||||
const invalidateSpy = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
|
||||
useMutation: (config: MutationConfig) => {
|
||||
lastMutation = config;
|
||||
return { mutate: mutateSpy, isPending: false };
|
||||
},
|
||||
}));
|
||||
|
||||
const authSpy = vi.fn<() => { user: AuthUser | null; isAuthenticated: boolean }>(
|
||||
() => ({ user: buildUser({}), isAuthenticated: true }),
|
||||
);
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return { ...actual, useAuth: () => authSpy() };
|
||||
});
|
||||
|
||||
function buildUser(modules: AuthUser["modules"]): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
timeFormat: "AUTO",
|
||||
disableCoach: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules,
|
||||
};
|
||||
}
|
||||
|
||||
import { ModulesSection } from "../modules-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ModulesSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lastMutation = null;
|
||||
mutateSpy.mockClear();
|
||||
invalidateSpy.mockClear();
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({}),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("<ModulesSection>", () => {
|
||||
it("renders a Switch for every toggleable + core module", () => {
|
||||
const html = render();
|
||||
for (const key of MODULE_KEYS) {
|
||||
expect(html, `toggleable switch ${key}`).toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
}
|
||||
for (const key of CORE_DOMAIN_KEYS) {
|
||||
expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds an enabled module checked (default-on) and a disabled one unchecked", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({ sleep: false }),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
|
||||
// The disabled `sleep` toggle surfaces data-state="unchecked".
|
||||
const sleepTag = html.match(
|
||||
/<button[^>]*id="module-toggle-sleep"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(sleepTag).toBeDefined();
|
||||
expect(sleepTag).toMatch(/data-state="unchecked"/);
|
||||
|
||||
// An absent key (`mood`) reads as enabled → checked.
|
||||
const moodTag = html.match(
|
||||
/<button[^>]*id="module-toggle-mood"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(moodTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the core domains locked (checked + disabled)", () => {
|
||||
const html = render();
|
||||
for (const key of CORE_DOMAIN_KEYS) {
|
||||
const tag = html.match(
|
||||
new RegExp(`<button[^>]*id="module-toggle-${key}"[^>]*>`),
|
||||
)?.[0];
|
||||
expect(tag, `core ${key}`).toBeDefined();
|
||||
expect(tag).toMatch(/data-state="checked"/);
|
||||
expect(tag).toMatch(/disabled/);
|
||||
}
|
||||
});
|
||||
|
||||
it("PATCHes only the flipped key as a disabled-allowlist entry", async () => {
|
||||
render();
|
||||
expect(lastMutation?.mutationFn).toBeTypeOf("function");
|
||||
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: { modules: {} } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await lastMutation!.mutationFn!({ key: "glucose", enabled: false });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toContain("/api/auth/me/modules");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ glucose: false });
|
||||
});
|
||||
|
||||
it("invalidates the authMe query on a successful toggle", () => {
|
||||
render();
|
||||
expect(lastMutation?.onSuccess).toBeTypeOf("function");
|
||||
|
||||
lastMutation!.onSuccess!();
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.authMe(),
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user