From 3cae7beda8a1f4a768a6ef065e0f6554d23c3138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 22:37:34 +0200 Subject: [PATCH] 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. --- .../__tests__/modules-section.test.tsx | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 src/components/settings/__tests__/modules-section.test.tsx diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx new file mode 100644 index 000000000..02cc42844 --- /dev/null +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -0,0 +1,187 @@ +/** + * v1.18.0 — Settings → Module ("Was du trackst") hub contract. + * + * `` lists every toggleable module with a `` 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; + 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( + "@/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( + + + , + ); +} + +beforeEach(() => { + lastMutation = null; + mutateSpy.mockClear(); + invalidateSpy.mockClear(); + authSpy.mockImplementation(() => ({ + user: buildUser({}), + isAuthenticated: true, + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("", () => { + 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( + /]*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( + /]*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(`]*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(), + }); + }); +});