import { afterEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; // `` reads the active route from `usePathname()`. Stub // next/navigation so the SSR test render works without an App-Router // runtime. const mockPathnameRef = { value: "/" }; vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), })); // Each test mutates `mockUserRef.value.role` before rendering so we can // exercise both regular-user and admin layouts from the same suite. const mockUserRef = { value: { id: "u1", username: "testuser", email: "user@example.com", role: "USER" as "USER" | "ADMIN", avatarUrl: null, // v1.18.0 — nav gating reads the resolved per-user module map; cycle is // the delegated `cycle` key on it (no bespoke boolean). An absent key // fails open (entry shows), matching the gate's default-on contract. modules: {} as Record, }, }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: mockUserRef.value, isAuthenticated: true, isLoading: false, refetch: vi.fn(), }), useLogout: () => ({ mutate: vi.fn(), isPending: false }), })); vi.mock("@/components/providers", () => ({ useTheme: () => ({ theme: "system" as const, resolvedTheme: "dark" as const, setTheme: vi.fn(), }), })); // Every suite but the collapsed-rail one renders the pre-hydration paint, where // `useMounted()` is false. The collapsed-rail suite flips this to render the // post-mount shell, which is the only state the stored preference applies in. const mockMountedRef = { value: false }; vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => mockMountedRef.value, })); import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SIDEBAR_EXPANDED_CONTENT_WIDTH, SidebarCollapseToggle, SidebarNav, readSidebarCollapsedPref, writeSidebarCollapsedPref, } from "../sidebar-nav"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; import { visibleUtilityDestinations } from "../nav-model"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import { readSource } from "@/__tests__/helpers/source-files"; function render({ pathname = "/", role = "USER" as "USER" | "ADMIN", modules = {} as Record, }: { pathname?: string; role?: "USER" | "ADMIN"; modules?: Record; } = {}) { mockPathnameRef.value = pathname; mockUserRef.value = { ...mockUserRef.value, role, modules }; return renderToStaticMarkup( // The nav reads `useQueryClient()` for the medications intent // prefetch (v1.16.7); a fresh client per render keeps the SSR // markup assertions isolated. , ); } // v1.25.12 — these suites render the FIRST-PAINT (pre-hydration) markup. // `renderToStaticMarkup` drives `useMounted()` to its server snapshot // (`false`), so the sidebar applies its fail-CLOSED gate: only CORE // (non-module-gated) destinations render and EVERY module-gated entry is // absent regardless of the module map. This is the fix for the disabled- // module flicker — a gated entry can no longer paint for one frame before the // `/api/auth/me` query resolves. The resolved, post-mount visible set // ("cycle shows when its module is on", "a disabled module drops out") is the // pure `visibleNavDestinations(modules, true)` contract verified in // `nav-model.test.ts`; the mounted render is exercised in e2e. // CORE destinations have no owner in the surface map and always render. // `/insights` is one of them: the `insights` key is AI analysis only. const CORE_HREFS = ["/", "/measurements", "/checkups", "/insights"] as const; // A representative slice of the module-gated entries that must stay out of // the first-paint markup whatever the module map says. const GATED_HREFS = [ "/mood", "/cycle", "/medications", "/labs", "/illness", "/coach", "/achievements", ] as const; describe(" first-paint fail-closed module gate (v1.25.12)", () => { it("renders core destinations on first paint regardless of the module map", () => { const maps: Record[] = [ {}, { cycle: true, mood: true, labs: true, coach: true }, { cycle: false, mood: false }, ]; for (const modules of maps) { const html = render({ modules }); for (const href of CORE_HREFS) { expect(html).toContain(`href="${href}"`); } } }); it("keeps every module-gated entry OUT of the first-paint markup, even when its module is enabled", () => { // The flicker bug: a gated entry painted before the module query // resolved. Pre-mount it must be absent even with the module turned on. const html = render({ modules: { cycle: true, mood: true, labs: true, coach: true, achievements: true, insights: true, }, }); for (const href of GATED_HREFS) { expect(html).not.toContain(`href="${href}"`); } }); it("keeps gated entries out when the module map is empty (no fail-open flicker)", () => { const html = render({ modules: {} }); for (const href of GATED_HREFS) { expect(html).not.toContain(`href="${href}"`); } }); it("keeps gated entries out when the module is explicitly disabled", () => { const html = render({ modules: { mood: false, labs: false, coach: false, achievements: false }, }); for (const href of GATED_HREFS) { expect(html).not.toContain(`href="${href}"`); } }); it("does not render the deprecated /insights/workouts sub-link", () => { const html = render(); expect(html).not.toContain('href="/insights/workouts"'); }); }); describe(" targets deprecation (v1.8.6)", () => { it("no longer renders the deprecated /targets entry", () => { // The Targets (Zielwerte) page is deprecated; target editing moved // inline into Insights, so the sidebar drops the entry. Assert absence // against a stable CORE sibling that renders on first paint // (Measurements stays; Insights is module-gated and pre-mount absent). const html = render(); expect(html).not.toContain('href="/targets"'); expect(html).toContain('href="/measurements"'); }); }); describe(" shared utility tail (v1.17.1 N-1 parity guard)", () => { // The footer renders the shared utility tail EXCEPT Notifications, which // lives in the avatar dropdown (a Radix menu that is collapsed — and so // not in the SSR markup — until opened). The footer-visible utilities are // therefore the shared list minus `/notifications`. Asserting they all // render straight from the shared list is the parity net that keeps the // sidebar footer from drifting away from the mobile More-hub tail. const footerUtilities = () => visibleUtilityDestinations().filter((d) => d.href !== "/notifications"); it("renders every footer utility destination straight from the shared list", () => { const html = render(); for (const dest of footerUtilities()) { expect(html).toContain(`href="${dest.href}"`); } // Settings is the footer utility entry; it is present. expect(html).toContain('href="/settings/account"'); }); }); describe(" admin entry mirrors Settings (no sub-item expansion)", () => { // v1.4.16: the maintainer reported the global sidebar expanding admin // sub-items on `/admin/*` was unwanted UX — the in-shell `` // already provides per-section nav inside the page. The Admin entry // must behave EXACTLY like the Settings entry: a single link with no // sub-list at any route, regardless of where the avatar dropdown is. it("does not render admin entries at all for a regular user", () => { const html = render({ role: "USER", pathname: "/admin/system-status" }); expect(html).not.toContain('href="/admin"'); for (const section of ADMIN_SECTIONS) { expect(html).not.toContain(`href="/admin/${section.slug}"`); } }); it("for an admin off /admin/* shows a single Admin link without sub-items", () => { const html = render({ role: "ADMIN", pathname: "/" }); expect(html).toContain('href="/admin"'); // No sub-route links — sidebar collapses into a single Admin entry. for (const section of ADMIN_SECTIONS) { expect(html).not.toContain(`href="/admin/${section.slug}"`); } }); it("for an admin on /admin/* still shows ONLY the single Admin link (no sub-list)", () => { const html = render({ role: "ADMIN", pathname: "/admin/system-status" }); expect(html).toContain('href="/admin"'); // Sub-section links must NOT appear in the global sidebar — they // belong to ``'s in-page nav. the maintainer reported the // expansion as broken UX in v1.4.16; this guard keeps it gone. for (const section of ADMIN_SECTIONS) { expect(html).not.toContain(`href="/admin/${section.slug}"`); } }); it("on the /admin overview page also shows ONLY the single Admin link", () => { const html = render({ role: "ADMIN", pathname: "/admin" }); expect(html).toContain('href="/admin"'); for (const section of ADMIN_SECTIONS) { expect(html).not.toContain(`href="/admin/${section.slug}"`); } }); it("Admin entry markup mirrors Settings entry markup (same shape, no expansion)", () => { // Both Settings and Admin should render as a single with no // adjacent