import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider, resetAuthProbeForTests } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import { formatClock } from "@/lib/format"; import { health } from "@/lib/healthFixture"; import type { Health } from "@/lib/types"; const NAV_LABELS = [ "Overview", "Activity", "Clients", "Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Diagnostics", "Settings", ]; const RESPONSES: Record = { "/api/stats?period=24h": { period: "24h", since: 0, until: 86400, queries: 0, blocked: 0, clients: 0, avg_response_time_us: null, coverage: { complete: true, available_since: 0 }, }, "/api/stats/timeseries?period=24h": { period: "24h", since: 0, until: 86400, bucket_seconds: 1800, buckets: [], coverage: { complete: true, available_since: 0 }, }, "/api/stats/clients?period=24h": { period: "24h", since: 0, until: 86400, bucket_seconds: 1800, clients: [], other: [], coverage: { complete: true, available_since: 0 }, }, "/api/stats/types?period=24h": { period: "24h", since: 0, until: 86400, types: [], coverage: { complete: true, available_since: 0 }, }, "/api/stats/routes?period=24h": { period: "24h", since: 0, until: 86400, routes: [], coverage: { complete: true, available_since: 0 }, }, "/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } }, "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, }; /** Null makes the health poll fail, which the nav badge has to treat as unknown. */ let healthBody: Health | null; function stubFetch(extra: (url: string) => Response | null = () => null) { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); const override = extra(url); if (override !== null) return override; if (url === "/api/health") { const failed = healthBody === null; return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), { status: failed ? 503 : 200, headers: { "content-type": "application/json" }, }); } const payload = RESPONSES[url]; if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, }); }), ); } function renderShell() { const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient); render( , ); return router; } function diagnosticsBadgeText(): string | null { const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0]; const badge = link.querySelector("[aria-label]"); return badge === null ? null : (badge.textContent ?? ""); } beforeEach(() => { sessionStorage.clear(); resetAuthProbeForTests(); healthBody = health(); stubFetch(); }); afterEach(() => { vi.unstubAllGlobals(); }); test("shell renders the overview route with all nav links", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); const nav = screen.getByRole("navigation", { name: "Main" }); expect(nav).toBeTruthy(); for (const label of NAV_LABELS) { expect(screen.getByRole("link", { name: label })).toBeTruthy(); } }); test("mount probe reveals the logout button and a failed logout surfaces inline", async () => { stubFetch((url) => { if (url === "/api/auth/login") return new Response(JSON.stringify({ error: "password required" }), { status: 401, headers: { "content-type": "application/json" }, }); if (url === "/api/auth/logout") return new Response(JSON.stringify({ error: "rate limited" }), { status: 429, headers: { "content-type": "application/json", "retry-after": "7" }, }); return null; }); renderShell(); fireEvent.click(await screen.findByRole("button", { name: "Log out" })); await screen.findByText("Rate limited. Try again in 7s."); expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy(); }); test("the header carries no protection display at all any more", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); for (const gone of [/^Protection/, /^Paused/]) { expect(screen.queryByRole("link", { name: gone })).toBeNull(); } }); test("Pause sits at the foot of the sidebar, above the version label", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); const aside = document.querySelector("aside") as HTMLElement; const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" })); const version = within(aside).getByText(/^nxdns v/); // Node order, not styling: the control precedes the version footer. expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); test("the mobile drawer carries the same control, not a header one it lost", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1)); fireEvent.click(screen.getByRole("button", { name: "Menu" })); // Both renderings are mounted; the viewport decides which is painted. await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2)); const drawer = document.getElementById("mobile-nav") as HTMLElement; const pause = within(drawer).getByRole("button", { name: "Pause" }); const version = within(drawer).getByText(/^nxdns v/); expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); test("a paused resolver says so in both renderings, not only on Diagnostics", async () => { // The trace a pause leaves on every page. With the header indicator and the // Overview status row both gone, a reader who is not on Diagnostics has only // this line to tell them filtering is off. const until = Math.floor(Date.now() / 1000) + 90; healthBody = health({ protection: { state: "paused", until } }); renderShell(); await screen.findByRole("heading", { name: "Overview" }); const aside = document.querySelector("aside") as HTMLElement; await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "Menu" })); const drawer = document.getElementById("mobile-nav") as HTMLElement; await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy()); }); test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => { renderShell(); await screen.findByRole("heading", { name: "Overview" }); await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics")); expect(diagnosticsBadgeText()).toBeNull(); }); test("open episodes are counted on the nav item", async () => { healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } }); renderShell(); await screen.findByRole("heading", { name: "Overview" }); await waitFor(() => expect(diagnosticsBadgeText()).toBe("3")); expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0); }); test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => { healthBody = health({ status: "degraded" }); renderShell(); await screen.findByRole("heading", { name: "Overview" }); await waitFor(() => expect(diagnosticsBadgeText()).toBe("!")); expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0); }); test("a health poll that failed is marked unknown rather than left looking healthy", async () => { healthBody = null; renderShell(); await screen.findByRole("heading", { name: "Overview" }); await waitFor(() => expect(diagnosticsBadgeText()).toBe("!")); expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0); });