280 lines
8.8 KiB
TypeScript
280 lines
8.8 KiB
TypeScript
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<string, unknown> = {
|
|
"/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<Response>((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(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
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"]));
|
|
});
|