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 } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import { health } from "@/lib/healthFixture"; import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types"; // Ages are rendered against the wall clock, so the fixtures are anchored to it // rather than to a frozen instant: faking time here would fight the query // client's own timers for no gain. const NOW_S = Math.floor(Date.now() / 1000); function event(id: number, overrides: Partial = {}): DiagnosticEvent { return { id, code: "blocklist.refresh", component: "blocklist", subject: "StevenBlack", severity: "warning", first_seen: NOW_S - 3600, last_seen: NOW_S - 300, occurrences: 3, resolved_at: null, detail: "download failed: ConnectionTimedOut", ...overrides, }; } function page(events: DiagnosticEvent[], nextBefore: number | null = null): DiagnosticsPage { return { events, next_before: nextBefore, active: { warnings: 1, errors: 1 } }; } const ACTIVE = page([ event(42), event(41, { code: "upstream.exchange", component: "upstream", subject: "tls://dns.example:853", severity: "error", occurrences: 1, }), ]); const RESOLVED = page([ event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }), ]); /** A stubbed response that carries a non-200 status instead of a payload. */ class Failure { constructor( readonly status: number, readonly body: unknown, ) {} } function fail(status: number, message: string): Failure { return new Failure(status, { error: message }); } /** 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 health strip at the top of the page; quiet on a healthy box, which is // what every test below wants it to be. "/api/health": health(), "/api/diagnostics?state=active": ACTIVE, "/api/diagnostics?state=resolved": RESOLVED, }; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => { const url = String(input); const method = init?.method ?? "GET"; // Reads stay keyed by url alone, so the assertions below read as the // request line they are; writes carry their method. const key = method === "GET" ? url : `${method} ${url}`; requested.push(key); const payload = responses[key]; if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); if (payload === NO_CONTENT) return new Response(null, { status: 204 }); if (payload instanceof Failure) { return new Response(JSON.stringify(payload.body), { status: payload.status, headers: { "content-type": "application/json" }, }); } return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, }); }), ); }); afterEach(() => { vi.unstubAllGlobals(); }); /** * `retry` is off in the failure tests: the shared client backs 5xx off for * seconds, which the render assertions would sit through for nothing. */ function renderRoute(path = "/diagnostics", { retry = true } = {}) { const queryClient = createQueryClient(); if (!retry) { const defaults = queryClient.getDefaultOptions(); queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } }); } const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient); render( , ); return router; } /** A RAC Select names its trigger with the current value and then the label. */ function trigger(label: string): HTMLElement { return screen.getByRole("button", { name: new RegExp(`${label}$`) }); } async function pick(label: string, option: string) { fireEvent.click(trigger(label)); fireEvent.click(await screen.findByRole("option", { name: option })); await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); } test("active episodes come first, each with its title, subject, age and count", async () => { renderRoute(); await screen.findByRole("heading", { name: "Diagnostics" }); const active = screen.getByText("Blocklist source failed to update").closest("li")!; expect(within(active).getByText("Warning")).toBeTruthy(); expect(within(active).getByText("StevenBlack")).toBeTruthy(); expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy(); const failing = screen.getByText("Upstream failing").closest("li")!; expect(within(failing).getByText("Error")).toBeTruthy(); expect(within(failing).getByText(/1 occurrence(?!s)/)).toBeTruthy(); // The resolved history is a separate section, below the active list. const table = within(screen.getByRole("table")); expect(table.getByText("Disk space low")).toBeTruthy(); expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy(); }); test("nothing open reads as good news, not as a broken page", async () => { responses["/api/diagnostics?state=active"] = page([]); renderRoute(); await screen.findByRole("heading", { name: "Diagnostics" }); const healthy = await screen.findByText("No active operational issues."); expect(healthy.getAttribute("role")).toBe("status"); // Quiet: no alert anywhere on the page, and no empty table standing in. expect(screen.queryByRole("alert")).toBeNull(); }); test("a filter lands in the url and refetches both sections through it", async () => { responses["/api/diagnostics?severity=error&state=active"] = page([ event(41, { code: "upstream.exchange", component: "upstream", severity: "error" }), ]); responses["/api/diagnostics?severity=error&state=resolved"] = page([]); const router = renderRoute(); await screen.findByRole("heading", { name: "Diagnostics" }); await pick("Severity", "Errors"); await waitFor(() => expect(router.state.location.search).toEqual({ severity: "error" })); await waitFor(() => expect(screen.queryByText("Blocklist source failed to update")).toBeNull()); expect(requested).toContain("/api/diagnostics?severity=error&state=active"); expect(requested).toContain("/api/diagnostics?severity=error&state=resolved"); }); test("the state filter hides the section it excludes", async () => { const router = renderRoute(); await screen.findByRole("heading", { name: "Diagnostics" }); await pick("Show", "Active only"); await waitFor(() => expect(router.state.location.search).toEqual({ state: "active" })); expect(screen.queryByRole("heading", { name: "Resolved" })).toBeNull(); expect(screen.getByRole("heading", { name: "Active" })).toBeTruthy(); }); test("a url written by hand starts on the filters it names", async () => { responses["/api/diagnostics?component=disk&state=resolved"] = RESOLVED; renderRoute("/diagnostics?state=resolved&component=disk"); await screen.findByRole("heading", { name: "Diagnostics" }); await screen.findByText("Disk space low"); expect(screen.queryByRole("heading", { name: "Active" })).toBeNull(); expect(requested).toContain("/api/diagnostics?component=disk&state=resolved"); }); test("load more appends the next page of resolved history", async () => { responses["/api/diagnostics?state=resolved"] = page( [event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 })], 30, ); responses["/api/diagnostics?state=resolved&before=30"] = page([ event(12, { code: "certificate.reload", component: "certificate", subject: "doh", resolved_at: NOW_S - 90_000, }), ]); renderRoute(); await screen.findByText("Disk space low"); fireEvent.click(screen.getByRole("button", { name: "Load more" })); await screen.findByText("TLS certificate reload failed"); expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy(); }); test("an unavailable store reports the failure instead of loading forever", async () => { responses["/api/diagnostics?state=active"] = fail(503, "store unavailable"); renderRoute("/diagnostics", { retry: false }); await screen.findByRole("heading", { name: "Diagnostics" }); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("The server is starting or degraded."); expect(screen.queryByText("Loading diagnostics…")).toBeNull(); // The resolved section answered, so it still renders its own history. expect(screen.getByText("Disk space low")).toBeTruthy(); }); test("a failed history query reports the failure and retries on demand", async () => { responses["/api/diagnostics?state=resolved"] = fail(500, "diagnostics store read failed"); renderRoute("/diagnostics", { retry: false }); await screen.findByRole("heading", { name: "Diagnostics" }); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("diagnostics store read failed"); expect(screen.queryByText("Loading history…")).toBeNull(); responses["/api/diagnostics?state=resolved"] = RESOLVED; fireEvent.click(screen.getByRole("button", { name: "Retry" })); await screen.findByText("Disk space low"); expect(screen.queryByRole("alert")).toBeNull(); }); test("only the resolved history offers a purge", async () => { renderRoute(); await screen.findByRole("heading", { name: "Diagnostics" }); // An episode still failing is the state of the box, not history: no purge // affordance anywhere on its card. const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!; expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull(); const row = screen.getByText("Disk space low").closest("tr")!; expect(within(row).getByRole("button", { name: "Purge" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Purge all resolved" })).toBeTruthy(); }); test("with no resolved history there is nothing to purge in bulk", async () => { responses["/api/diagnostics?state=resolved"] = page([]); renderRoute(); await screen.findByRole("heading", { name: "Diagnostics" }); await screen.findByText("Nothing has failed and recovered in the retained window."); expect(screen.queryByRole("button", { name: "Purge all resolved" })).toBeNull(); }); test("purging one row asks first, then sends the DELETE and refetches the lists", async () => { responses["DELETE /api/diagnostics/30"] = NO_CONTENT; renderRoute(); await screen.findByText("Disk space low"); fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).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/30"); fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" })); fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/30")); // The invalidation covers both sections: the page the row left and the // active list, whose `active` counts come from the same table. await waitFor(() => expect(requested.filter((url) => url === "/api/diagnostics?state=resolved").length).toBeGreaterThan(1), ); await waitFor(() => expect(requested.filter((url) => url === "/api/diagnostics?state=active").length).toBeGreaterThan(1), ); }); test("purging the whole history asks first and sends one DELETE", async () => { responses["DELETE /api/diagnostics"] = { purged: 1 }; renderRoute(); await screen.findByText("Disk space low"); fireEvent.click(screen.getByRole("button", { name: "Purge all resolved" })); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain("Purge all resolved events? Active events are kept."); // What the server will answer once the purge has landed; the refetch the // mutation triggers is what has to pick it up. responses["/api/diagnostics?state=resolved"] = page([]); fireEvent.click(within(dialog).getByRole("button", { name: "Purge all" })); await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics")); await waitFor(() => expect(screen.queryByText("Disk space low")).toBeNull()); expect(screen.getByText("Blocklist source failed to update")).toBeTruthy(); }); test("a refused purge reports the server's reason and keeps the row", async () => { responses["DELETE /api/diagnostics/30"] = fail(409, "the event is still active; it can be purged once it resolves"); renderRoute("/diagnostics", { retry: false }); await screen.findByText("Disk space low"); fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).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(screen.getByText("Disk space low")).toBeTruthy(); }); test("an episode links to its own detail page", async () => { responses["/api/diagnostics/42"] = event(42); renderRoute(); const link = await screen.findByRole("link", { name: "Blocklist source failed to update" }); expect(link.getAttribute("href")).toBe("/diagnostics/42"); }); test("an absolute window reaches both requests and is stated on the page", async () => { const bounded = "since=1699999700&until=1700000300"; responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE; responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED; renderRoute(`/diagnostics?${bounded}`); await screen.findByRole("heading", { name: "Active" }); expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`); expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`); // An empty section inside a five-minute window means something different // from an empty section over the whole history, so the page has to say so. expect(screen.getByText(/Showing events that overlap/)).toBeTruthy(); }); test("clearing the range drops both bounds from the url", async () => { const bounded = "since=1699999700&until=1700000300"; responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE; responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED; const router = renderRoute(`/diagnostics?${bounded}`); fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" })); await waitFor(() => { expect(router.state.location.search).not.toContain("since"); }); expect(router.state.location.search).not.toContain("until"); }); test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => { renderRoute("/diagnostics?since=1.5&until=Infinity"); await screen.findByRole("heading", { name: "Active" }); expect(requested).toContain("/api/diagnostics?state=active"); expect(screen.queryByText(/Showing events that overlap/)).toBeNull(); });