305 lines
10 KiB
TypeScript
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();
|
|
});
|