Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
224 lines
8.5 KiB
TypeScript
224 lines
8.5 KiB
TypeScript
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> = {}): 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<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 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(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
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();
|
|
});
|