import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types"; import { health } from "@/lib/healthFixture"; import { EVENT_COPY } from "./eventCopy"; const NOW_S = Math.floor(Date.now() / 1000); function event(overrides: Partial = {}): DiagnosticEvent { return { id: 42, code: "blocklist.refresh", component: "blocklist", subject: "StevenBlack", severity: "warning", first_seen: NOW_S - 7200, last_seen: NOW_S - 600, occurrences: 4, resolved_at: null, detail: "download failed: ConnectionTimedOut", ...overrides, }; } /** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */ const NO_CONTENT = Symbol("204"); let responses: Record; let requested: string[]; beforeEach(() => { requested = []; responses = { "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, // The shell reads health for the Diagnostics nav badge on every route. "/api/health": health(), }; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => { const url = String(input); const method = init?.method ?? "GET"; const key = method === "GET" ? url : `${method} ${url}`; requested.push(key); const payload = responses[key]; if (payload === undefined) return new Response(JSON.stringify({ error: "no such event" }), { status: 404, headers: { "content-type": "application/json" }, }); if (payload === NO_CONTENT) return new Response(null, { status: 204 }); return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, }); }), ); }); afterEach(() => { vi.unstubAllGlobals(); }); /** * `retry` is off in the failure test: the shared client backs 5xx off for * seconds, which the render assertions would sit through for nothing. */ function renderDetail(id: number, { retry = true } = {}) { const queryClient = createQueryClient(); if (!retry) { const defaults = queryClient.getDefaultOptions(); queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } }); } const router = createAppRouter(createMemoryHistory({ initialEntries: [`/diagnostics/${id}`] }), queryClient); render( , ); return router; } test("an open episode shows its facts, its copy and the error the server sent", async () => { responses["/api/diagnostics/42"] = event(); renderDetail(42); await screen.findByRole("heading", { name: "Blocklist source failed to update" }); expect(screen.getByText("Warning")).toBeTruthy(); expect(screen.getByText("StevenBlack")).toBeTruthy(); expect(screen.getByText("Active for 2h")).toBeTruthy(); expect(screen.getByText("Not yet — still failing")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("blocklist.refresh")).toBeTruthy(); expect(screen.getByText(EVENT_COPY["blocklist.refresh"].impact)).toBeTruthy(); expect(screen.getByText(EVENT_COPY["blocklist.refresh"].remediation)).toBeTruthy(); expect(screen.getByText("download failed: ConnectionTimedOut")).toBeTruthy(); expect(screen.getByRole("link", { name: "Go to Blocklist sources" }).getAttribute("href")).toBe( "/configuration/protection?tab=sources", ); }); test("a resolved episode states how long it lasted, not how long it has run", async () => { responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 }); renderDetail(7); await screen.findByRole("heading", { name: "Blocklist source failed to update" }); expect(screen.getByText("Resolved after 1h")).toBeTruthy(); expect(screen.queryByText("Not yet — still failing")).toBeNull(); }); test("an open episode offers no purge", async () => { responses["/api/diagnostics/42"] = event(); renderDetail(42); await screen.findByRole("heading", { name: "Blocklist source failed to update" }); expect(screen.queryByRole("button", { name: "Purge" })).toBeNull(); }); test("purging a resolved episode asks first, then returns to the list", async () => { responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 }); responses["DELETE /api/diagnostics/7"] = NO_CONTENT; responses["/api/diagnostics?state=active"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 } }; responses["/api/diagnostics?state=resolved"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 }, }; const router = renderDetail(7); await screen.findByRole("heading", { name: "Blocklist source failed to update" }); fireEvent.click(screen.getByRole("button", { name: "Purge" })); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good."); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); expect(requested).not.toContain("DELETE /api/diagnostics/7"); fireEvent.click(screen.getByRole("button", { name: "Purge" })); fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/7")); // The row it was showing no longer exists, so the page it navigates to is // the list rather than a 404 of its own. await waitFor(() => expect(router.state.location.pathname).toBe("/diagnostics")); }); test("a refused purge stays on the event and shows why", async () => { responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 }); vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => { if (init?.method === "DELETE") return new Response(JSON.stringify({ error: "the event is still active" }), { status: 409, headers: { "content-type": "application/json" }, }); return new Response(JSON.stringify(responses[String(input)] ?? {}), { status: 200, headers: { "content-type": "application/json" }, }); }), ); const router = renderDetail(7, { retry: false }); await screen.findByRole("heading", { name: "Blocklist source failed to update" }); fireEvent.click(screen.getByRole("button", { name: "Purge" })); fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("the event is still active"); expect(router.state.location.pathname).toBe("/diagnostics/7"); }); test("every code renders its own title, impact and remediation", async () => { for (const [index, code] of DIAGNOSTIC_CODES.entries()) { const id = 100 + index; responses[`/api/diagnostics/${id}`] = event({ id, code, component: code.slice(0, code.indexOf(".")) }); renderDetail(id); const copy = EVENT_COPY[code]; await screen.findByRole("heading", { name: copy.title }); expect(screen.getByText(copy.impact), code).toBeTruthy(); expect(screen.getByText(copy.remediation), code).toBeTruthy(); screen.getByText(code); cleanup(); } }); test("an event retention has removed shows the server's message, not an empty page", async () => { renderDetail(999); await screen.findByText("no such event"); expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy(); }); test("an unavailable store reports the failure instead of loading forever", async () => { responses["/api/diagnostics/42"] = event(); vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => String(input).startsWith("/api/diagnostics/") ? new Response(JSON.stringify({ error: "store unavailable" }), { status: 503, headers: { "content-type": "application/json" }, }) : new Response(JSON.stringify(responses[String(input)] ?? {}), { status: 200, headers: { "content-type": "application/json" }, }), ), ); renderDetail(42, { retry: false }); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("The server is starting or degraded."); expect(screen.queryByText("Loading event…")).toBeNull(); expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy(); });