Files
nxdns/admin/src/features/queries/QueryLogPage.test.tsx
T

305 lines
10 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { QueriesPage, QueryRow } from "@/lib/types";
import QueryLogPage from "./QueryLogPage";
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return {
id,
ts: 1_700_000_000 + id,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 1234,
cache_hit: false,
upstream: "udp://9.9.9.9:53",
...overrides,
};
}
const PAGES: Record<string, QueriesPage> = {
"/api/queries": {
queries: [
row(20, "first.example", { qtype: 65, cache_hit: true, upstream: "" }),
row(19, "ads.example", {
blocked: true,
block_reason: "blocklist:stevenblack",
response_time_us: null,
cache_hit: null,
}),
],
next_before: 19,
},
"/api/queries?before=19": {
queries: [row(5, "older.example")],
next_before: null,
},
"/api/queries?domain=ads": {
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
next_before: null,
},
};
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const payload = PAGES[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 renderPage() {
const client = createQueryClient();
render(
<QueryClientProvider client={client}>
<QueryLogPage />
</QueryClientProvider>,
);
return client;
}
function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
renderPage();
await screen.findByText("first.example");
expect(screen.getByText("HTTPS")).toBeTruthy();
expect(screen.getByText("A")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("1.2 ms")).toBeTruthy();
expect(screen.getByText("hit")).toBeTruthy();
expect(screen.getByText("udp://9.9.9.9:53")).toBeTruthy();
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});
test("load more appends the next page and stops at the end of the log", async () => {
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
expect(screen.getByText("first.example")).toBeTruthy();
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
});
test("applying a filter refetches and resets the accumulated list", async () => {
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await screen.findByText(/Showing 1 query /);
expect(screen.getByText("ads.example")).toBeTruthy();
expect(screen.queryByText("first.example")).toBeNull();
expect(screen.queryByText("older.example")).toBeNull();
});
test("a load-more that resolves after a filter change is discarded", async () => {
let releaseLoadMore: () => void = () => {};
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/queries?before=19") {
return new Promise<Response>((resolve) => {
releaseLoadMore = () => {
resolve(
new Response(JSON.stringify(PAGES["/api/queries?before=19"]), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
};
});
}
const payload = PAGES[url];
if (payload === undefined)
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
return Promise.resolve(
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}),
);
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await screen.findByText(/Showing 1 query /);
releaseLoadMore();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(screen.queryByText("older.example")).toBeNull();
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
let releaseFiltered: () => void = () => {};
const filteredPage: QueriesPage = {
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
next_before: 7,
};
const filteredOlderPage: QueriesPage = {
queries: [row(3, "ads.older.example")],
next_before: null,
};
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/queries?domain=ads") {
return new Promise<Response>((resolve) => {
releaseFiltered = () => {
resolve(
new Response(JSON.stringify(filteredPage), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
};
});
}
const payload = url === "/api/queries?domain=ads&before=7" ? filteredOlderPage : PAGES[url];
if (payload === undefined)
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
return Promise.resolve(
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
});
vi.stubGlobal("fetch", fetchMock);
renderPage();
await screen.findByText("first.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
const staleButton = screen.getByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
fireEvent.click(staleButton);
expect(fetchMock.mock.calls.map((call) => String(call[0]))).not.toContain("/api/queries?domain=ads&before=19");
releaseFiltered();
await waitFor(() => {
expect(screen.queryByText("first.example")).toBeNull();
});
const freshButton = screen.getByRole("button", { name: "Load more" });
expect(freshButton).toHaveProperty("disabled", false);
fireEvent.click(freshButton);
await screen.findByText("ads.older.example");
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toContain("/api/queries?domain=ads&before=7");
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
});
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
// The newest-100 window moves up while the reader has a second page open.
// Refetching only the first page would drop n20 and n19 out of the middle
// of the table; the second page must be replayed from the fresh cursor.
const before: Record<string, QueriesPage> = {
"/api/queries": { queries: [row(20, "n20.example"), row(19, "n19.example")], next_before: 19 },
"/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null },
};
const after: Record<string, QueriesPage> = {
"/api/queries": { queries: [row(22, "n22.example"), row(21, "n21.example")], next_before: 21 },
"/api/queries?before=21": {
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
next_before: null,
},
};
let live = before;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const payload = live[String(input)];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json(payload);
}),
);
const client = renderPage();
await screen.findByText("n20.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("n17.example");
live = after;
await act(async () => {
await client.invalidateQueries({ queryKey: ["queries"] });
});
await screen.findByText("n22.example");
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
});
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
const assign = vi.fn();
vi.stubGlobal("location", { pathname: "/queries", search: "", assign });
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/queries?before=19") {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const payload = PAGES[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" },
});
}),
);
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await waitFor(() => {
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/queries")}`);
});
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/Failed to load more/)).toBeNull();
});