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 { 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 = { "/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( , ); 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((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((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 = { "/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 = { "/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(); });