import { act, fireEvent, render, screen, waitFor } 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 { clearRefreshStatus } from "@/features/blocklists/refreshStore"; const BLOCKLISTS = { blocklists: [ { id: 1, url: "https://example.com/hosts.txt", name: "StevenBlack", enabled: true, is_suggested: true, last_updated: 1700000000, domain_count: 1000, wildcard_count: 10, exception_count: 7, skipped_regex_count: 3, skipped_unsupported_count: 21, checksum: "abc", }, { id: 2, url: "https://example.org/list.txt", name: "Custom", enabled: false, is_suggested: false, last_updated: null, domain_count: 0, wildcard_count: 0, exception_count: 0, skipped_regex_count: 0, skipped_unsupported_count: 0, checksum: null, }, ], }; const RESPONSES: Record = { "/api/blocklists": BLOCKLISTS, "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, }; let resolveUpdate: ((response: Response) => void) | null; let deleted: string[]; beforeEach(() => { clearRefreshStatus(); resolveUpdate = null; deleted = []; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (init?.method === "DELETE") { deleted.push(url); return new Response(null, { status: 204 }); } if (url === "/api/blocklists/update" && init?.method === "POST") { return new Promise((resolve) => { resolveUpdate = resolve; }); } 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 renderBlocklistsRoute(queryClient = createQueryClient()) { const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient); const view = render( , ); return { queryClient, unmount: view.unmount }; } const SNAPSHOT = { sources: [ { id: 1, state: "loaded", loaded: true, last_attempt: 1700000100, last_success: 1700000100, url: "https://example.com/hosts.txt", last_error: "", domains: 1200, wildcards: 12, exceptions: 9, skipped_regex: 4, skipped_unsupported: 17, }, ], }; test("renders the source table and the status empty state", async () => { renderBlocklistsRoute(); await screen.findByRole("heading", { name: "Blocklists" }); expect(screen.getByText("StevenBlack")).toBeTruthy(); expect(screen.getByText("https://example.com/hosts.txt")).toBeTruthy(); expect(screen.getByText("Suggested")).toBeTruthy(); expect(screen.getByText("1000")).toBeTruthy(); expect(screen.getByText("10")).toBeTruthy(); expect(screen.getByText("7")).toBeTruthy(); expect(screen.getByText("3")).toBeTruthy(); expect(screen.getByText("21")).toBeTruthy(); expect(screen.getByText("never")).toBeTruthy(); expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy(); expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy(); expect( screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/), ).toBeTruthy(); const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement; expect(enabledToggle.checked).toBe(true); const disabledToggle = screen.getByLabelText("Custom enabled") as HTMLInputElement; expect(disabledToggle.checked).toBe(false); expect(screen.getByText(/run .Update now. to fetch status/)).toBeTruthy(); expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy(); }); test("update now disables the button, then replaces the status section from the 202 snapshot", async () => { renderBlocklistsRoute(); await screen.findByRole("heading", { name: "Blocklists" }); const button = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement; fireEvent.click(button); const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement; expect(pending.disabled).toBe(true); expect(resolveUpdate).not.toBeNull(); const snapshot = { sources: [ { id: 1, state: "loaded", loaded: true, last_attempt: 1700000100, last_success: 1700000100, url: "https://example.com/hosts.txt", last_error: "", domains: 1200, wildcards: 12, exceptions: 9, skipped_regex: 4, skipped_unsupported: 17, }, { id: 2, state: "fetch_failed", loaded: false, last_attempt: 1700000100, last_success: 0, url: "https://example.org/list.txt", last_error: "connect timed out", domains: 0, wildcards: 0, exceptions: 0, skipped_regex: 0, skipped_unsupported: 0, }, ], }; resolveUpdate!( new Response(JSON.stringify(snapshot), { status: 202, headers: { "content-type": "application/json" } }), ); await screen.findByText("loaded"); expect(screen.getByText("fetch_failed")).toBeTruthy(); expect(screen.getByText("connect timed out")).toBeTruthy(); expect(screen.getByText("1200")).toBeTruthy(); expect(screen.getByText("12")).toBeTruthy(); expect(screen.getByText("9")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("17")).toBeTruthy(); expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2); expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull(); // The store notifies one flush before the mutation's success state lands. await screen.findByText(/Update completed/); await waitFor(() => { const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement; expect(idle.disabled).toBe(false); }); }); test("update now shows a countdown when rate limited with Retry-After", async () => { renderBlocklistsRoute(); await screen.findByRole("heading", { name: "Blocklists" }); fireEvent.click(screen.getByRole("button", { name: "Update now" })); await screen.findByRole("button", { name: "Updating…" }); expect(resolveUpdate).not.toBeNull(); resolveUpdate!( new Response(JSON.stringify({ error: "rate limited" }), { status: 429, headers: { "content-type": "application/json", "Retry-After": "7" }, }), ); const alert = await screen.findByRole("alert"); expect(alert.textContent).toBe("Rate limited. Try again in 7s."); }); test("the refresh snapshot outlives the query cache's gcTime", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { const { queryClient, unmount } = renderBlocklistsRoute(); await screen.findByRole("heading", { name: "Blocklists" }); fireEvent.click(screen.getByRole("button", { name: "Update now" })); await screen.findByRole("button", { name: "Updating…" }); resolveUpdate!( new Response(JSON.stringify(SNAPSHOT), { status: 202, headers: { "content-type": "application/json" }, }), ); await screen.findByText("loaded"); unmount(); // Well past the default 5-minute gcTime: an unsubscribed cache entry is // collected by now, which is what used to erase the snapshot. await act(async () => { await vi.advanceTimersByTimeAsync(6 * 60_000); }); renderBlocklistsRoute(queryClient); await screen.findByRole("heading", { name: "Blocklists" }); expect(await screen.findByText("loaded")).toBeTruthy(); expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull(); } finally { vi.useRealTimers(); } }); test("delete asks for confirmation, and cancelling sends no request", async () => { renderBlocklistsRoute(); await screen.findByRole("heading", { name: "Blocklists" }); fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.'); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(deleted).toEqual([]); }); test("confirming the delete dialog issues the DELETE for that source", async () => { renderBlocklistsRoute(); await screen.findByRole("heading", { name: "Blocklists" }); fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!); await screen.findByRole("alertdialog"); fireEvent.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"])); });