/** * 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. The two delegated * modules (coach, cycle) render a live switch too, but their flip drives the * canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing * per-column endpoints — never the module-allowlist the gate ignores for them. * * 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, isCodeDisabledModule } 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, variables?: 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() }; }); // The hub now renders a live coach switch, so it reads the operator assistant // flag matrix (`flags.coach`) to decide whether the operator killed Coach // server-wide. Mock the hook directly so the SSR pass doesn't need a // ``; default all-on. import type { AssistantFlagSet } from "@/hooks/use-feature-flags"; const ALL_ON_FLAGS: AssistantFlagSet = { enabled: true, coach: true, briefing: true, insightStatus: true, correlations: true, healthScoreExplainer: true, }; const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS); vi.mock("@/hooks/use-feature-flags", () => ({ useFeatureFlags: () => flagsSpy(), })); function buildUser( modules: AuthUser["modules"], overrides: Partial = {}, ): AuthUser { return { id: "user-1", username: "testuser", email: null, role: "USER", heightCm: null, dateOfBirth: null, gender: null, timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, modules, moduleAvailability: {}, ...overrides, }; } 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, })); flagsSpy.mockImplementation(() => ALL_ON_FLAGS); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("", () => { it("renders a live Switch for every toggleable module, including the delegated ones", () => { // v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain // that can't be turned off doesn't need listing), so this section now // only renders the toggleable modules. const html = render(); for (const key of MODULE_KEYS) { if (isCodeDisabledModule(key)) { // v1.25.3 — a module switched off in code (pending a rebuild) drops // out of the hub entirely: no Switch, no deep-link, no row. expect(html, `code-disabled has no switch ${key}`).not.toContain( `id="module-toggle-${key}"`, ); } else { // Delegated modules (coach, cycle) now render a real Switch too — it // drives their canonical column — alongside a "manage" deep-link. expect(html, `toggleable switch ${key}`).toContain( `id="module-toggle-${key}"`, ); } } }); it("keeps a manage deep-link beside the delegated coach + cycle switches", () => { const html = render(); // Coach → Coach settings; cycle → the Account cycle-tracking card. expect(html).toContain('href="/settings/coach"'); expect(html).toContain('href="/settings/account#cycle-tracking"'); }); it("no longer renders the 'Always on' core-domains card", () => { // v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was // removed; the core domains are always active regardless. const html = render(); for (const key of ["weight", "bloodPressure", "pulse"]) { expect(html, `core ${key} not rendered`).not.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("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"); // onSuccess reads `variables.key` to decide the extra evictions; a // non-delegated key only touches authMe. lastMutation!.onSuccess!(undefined, { key: "glucose", enabled: false }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.authMe(), }); }); describe("delegated coach row", () => { it("reflects `!disableCoach`: checked when Coach is active, unchecked when hidden", () => { authSpy.mockImplementation(() => ({ user: buildUser({}, { disableCoach: false }), isAuthenticated: true, })); let coachTag = render().match( /]*id="module-toggle-coach"[^>]*>/, )?.[0]; expect(coachTag).toMatch(/data-state="checked"/); authSpy.mockImplementation(() => ({ user: buildUser({}, { disableCoach: true }), isAuthenticated: true, })); coachTag = render().match( /]*id="module-toggle-coach"[^>]*>/, )?.[0]; expect(coachTag).toMatch(/data-state="unchecked"/); }); it("writes the inverted `disableCoach` to the canonical endpoint", async () => { render(); const fetchSpy = vi.fn( async () => new Response(JSON.stringify({ data: { disableCoach: true } }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchSpy); // Turning the hub switch OFF (enabled:false) writes disableCoach:true. await lastMutation!.mutationFn!({ key: "coach", enabled: false }); const [url, init] = fetchSpy.mock.calls[0] as unknown as [ string, RequestInit, ]; expect(url).toContain("/api/auth/me/disable-coach"); expect(init.method).toBe("PATCH"); expect(JSON.parse(init.body as string)).toEqual({ disableCoach: true }); }); it("disables the switch + shows a hint when the operator killed Coach", () => { // Operator assistant master flag off → coach is unavailable server-wide. flagsSpy.mockImplementation(() => ({ ...ALL_ON_FLAGS, coach: false })); const html = render(); const coachTag = html.match( /]*id="module-toggle-coach"[^>]*>/, )?.[0]; expect(coachTag).toMatch(/disabled/); expect(html).toContain("Disabled server-wide"); }); it("also disables the switch when the module-availability blob kills Coach", () => { authSpy.mockImplementation(() => ({ user: buildUser({}, { moduleAvailability: { coach: false } }), isAuthenticated: true, })); const coachTag = render().match( /]*id="module-toggle-coach"[^>]*>/, )?.[0]; expect(coachTag).toMatch(/disabled/); }); }); describe("delegated cycle row", () => { it("reflects the resolved `cycleTrackingEnabled`", () => { authSpy.mockImplementation(() => ({ user: buildUser({}, { cycleTrackingEnabled: true }), isAuthenticated: true, })); const cycleTag = render().match( /]*id="module-toggle-cycle"[^>]*>/, )?.[0]; expect(cycleTag).toMatch(/data-state="checked"/); }); it("writes `{ enabled }` to the cycle-prefs endpoint", async () => { render(); const fetchSpy = vi.fn( async () => new Response(JSON.stringify({ data: { enabled: true } }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchSpy); await lastMutation!.mutationFn!({ key: "cycle", enabled: true }); const [url, init] = fetchSpy.mock.calls[0] as unknown as [ string, RequestInit, ]; expect(url).toContain("/api/auth/me/cycle-prefs"); expect(init.method).toBe("PATCH"); expect(JSON.parse(init.body as string)).toEqual({ enabled: true }); }); it("evicts cyclePrefs + cycle + authMe on a successful cycle toggle", () => { render(); lastMutation!.onSuccess!(undefined, { key: "cycle", enabled: true }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.authMe(), }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.cyclePrefs(), }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.cycle(), }); }); it("disables the switch + shows a hint when the operator killed cycle", () => { authSpy.mockImplementation(() => ({ user: buildUser({}, { moduleAvailability: { cycle: false } }), isAuthenticated: true, })); const html = render(); const cycleTag = html.match( /]*id="module-toggle-cycle"[^>]*>/, )?.[0]; expect(cycleTag).toMatch(/disabled/); expect(html).toContain("Disabled server-wide"); }); }); });