Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s
query rows gain qclass, rcode, group, policy action and reason, the matched rule or list entry with its source, cname and safe-search targets, route kind, forward zone, and the resolver that actually answered — the pool and local markers die. servfails are logged and name the resolver that lost; post-parse protocol refusals become rows. a detail page at /queries/:id renders the ordered explanation, and coverage watermarks distinguish an empty history from a missing one. the schema fingerprint changes: existing query history is recreated with the old file kept aside and the reset filed as a resolved diagnostic. fixes an oversized udp reply being rebuilt as noerror, which handed clients a truncated nxdomain as success.
253 lines
7.6 KiB
TypeScript
253 lines
7.6 KiB
TypeScript
import { fireEvent, render, screen } 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";
|
|
|
|
/** Wall clock at import; the upstream fixtures date their failures against it. */
|
|
const NOW_S = Math.floor(Date.now() / 1000);
|
|
|
|
const RESPONSES: Record<string, unknown> = {
|
|
"/api/stats?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
queries: 1000,
|
|
blocked: 250,
|
|
cached: 100,
|
|
clients: 7,
|
|
avg_response_time_us: 2345,
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats/timeseries?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
bucket_seconds: 1800,
|
|
buckets: [
|
|
{ ts: 0, queries: 60, blocked: 20, cached: 10 },
|
|
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
|
|
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
|
|
],
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats?period=1h": {
|
|
period: "1h",
|
|
since: 0,
|
|
until: 3600,
|
|
queries: 12,
|
|
blocked: 3,
|
|
cached: 0,
|
|
clients: 2,
|
|
avg_response_time_us: null,
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/stats/timeseries?period=1h": {
|
|
period: "1h",
|
|
since: 0,
|
|
until: 3600,
|
|
bucket_seconds: 60,
|
|
buckets: [],
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/health": {
|
|
status: "degraded",
|
|
disk: {
|
|
state: "warn",
|
|
free_bytes: 400 * 1024 * 1024,
|
|
db_bytes: 12 * 1024 * 1024,
|
|
log_bytes: 2048,
|
|
sample_failures: 0,
|
|
},
|
|
upstreams: { available: 1, total: 2 },
|
|
queries_dropped: 5,
|
|
writer_failed: false,
|
|
refreshes_gated: 0,
|
|
snapshot_generation: 3,
|
|
diagnostics: { state: "recording", active_warnings: 1, active_errors: 0 },
|
|
},
|
|
"/api/upstream/health?period=24h": {
|
|
period: "24h",
|
|
since: NOW_S - 86_400,
|
|
until: NOW_S,
|
|
available: 1,
|
|
total: 2,
|
|
complete: true,
|
|
upstreams: [
|
|
{
|
|
url: "https://dns.example/dns-query",
|
|
enabled: true,
|
|
available: false,
|
|
period: {
|
|
attempts: 100,
|
|
successes: 90,
|
|
failures: 10,
|
|
success_rate: 0.9,
|
|
// 3h30m before the fixture's now, far from a unit boundary.
|
|
last_failure_at: NOW_S - 12_600,
|
|
last_failure_error: "timeout",
|
|
},
|
|
},
|
|
{
|
|
url: "udp://9.9.9.9:53",
|
|
enabled: true,
|
|
available: true,
|
|
period: {
|
|
attempts: 100,
|
|
successes: 100,
|
|
failures: 0,
|
|
success_rate: 1,
|
|
last_failure_at: null,
|
|
last_failure_error: null,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"/api/upstream/health?period=1h": {
|
|
period: "1h",
|
|
since: NOW_S - 3600,
|
|
until: NOW_S,
|
|
available: 1,
|
|
total: 1,
|
|
complete: true,
|
|
upstreams: [
|
|
{
|
|
url: "https://dns.example/dns-query",
|
|
enabled: true,
|
|
available: true,
|
|
period: {
|
|
attempts: 7,
|
|
successes: 6,
|
|
failures: 1,
|
|
success_rate: 6 / 7,
|
|
last_failure_at: NOW_S - 300,
|
|
last_failure_error: "timeout",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
|
};
|
|
|
|
// Endpoints forced to fail with a 4xx, which the query client does not retry.
|
|
let failing: Set<string>;
|
|
|
|
beforeEach(() => {
|
|
failing = new Set();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (failing.has(url)) {
|
|
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
|
|
status: 400,
|
|
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" },
|
|
});
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function renderDashboard() {
|
|
const queryClient = createQueryClient();
|
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
|
render(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
}
|
|
|
|
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
|
|
renderDashboard();
|
|
await screen.findByRole("heading", { name: "Dashboard" });
|
|
|
|
expect(screen.getByText("1,000")).toBeTruthy();
|
|
expect(screen.getByText("250")).toBeTruthy();
|
|
expect(screen.getByText("25.0%")).toBeTruthy();
|
|
expect(screen.getByText("7")).toBeTruthy();
|
|
expect(screen.getByText("2.3 ms")).toBeTruthy();
|
|
|
|
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
|
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
|
|
|
|
expect(screen.getByText("Storage now")).toBeTruthy();
|
|
expect(screen.getByText("warn")).toBeTruthy();
|
|
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
|
|
expect(screen.getByText("12.0 MiB")).toBeTruthy();
|
|
expect(screen.getByText("2.0 KiB")).toBeTruthy();
|
|
|
|
const alerts = screen.getAllByRole("alert");
|
|
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
|
|
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
|
|
|
|
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
|
expect(screen.getByText("90.0%")).toBeTruthy();
|
|
expect(screen.getByText("100.0%")).toBeTruthy();
|
|
expect(screen.getByText("1/2 available")).toBeTruthy();
|
|
});
|
|
|
|
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
|
|
renderDashboard();
|
|
await screen.findByRole("heading", { name: "Dashboard" });
|
|
|
|
expect(screen.getByText("Storage now")).toBeTruthy();
|
|
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
|
|
expect(screen.queryByText("Right now")).toBeNull();
|
|
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
|
|
});
|
|
|
|
test("the period picker rescopes the upstream table", async () => {
|
|
renderDashboard();
|
|
await screen.findByRole("heading", { name: "Dashboard" });
|
|
await screen.findByText("90.0%");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
|
|
|
await screen.findByText("85.7%");
|
|
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
|
|
expect(screen.queryByText("90.0%")).toBeNull();
|
|
});
|
|
|
|
test("period picker refetches stats and shows the empty chart state", async () => {
|
|
renderDashboard();
|
|
await screen.findByRole("heading", { name: "Dashboard" });
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
|
|
|
await screen.findByText("12");
|
|
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
|
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
|
await screen.findByText("No queries in this period.");
|
|
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
|
|
});
|
|
|
|
test("one failing endpoint degrades its own widget on cold navigation", async () => {
|
|
failing.add("/api/upstream/health?period=24h");
|
|
renderDashboard();
|
|
await screen.findByRole("heading", { name: "Dashboard" });
|
|
|
|
// The page renders; only the upstream widget carries the error.
|
|
await screen.findByText("upstream health unavailable");
|
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
|
expect(screen.queryByText("Request failed (400)")).toBeNull();
|
|
|
|
expect(screen.getByText("1,000")).toBeTruthy();
|
|
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
|
expect(screen.getByText("Storage now")).toBeTruthy();
|
|
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
|
|
});
|