379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
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> = {}): 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<string, unknown>;
|
|
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(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
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();
|
|
});
|