234 lines
8.4 KiB
TypeScript
234 lines
8.4 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } 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";
|
|
|
|
const RESPONSES: Record<string, unknown> = {
|
|
"/api/rules": {
|
|
rules: [
|
|
{
|
|
id: 1,
|
|
group_id: 1,
|
|
group: "Default",
|
|
pattern: "ads.example.com",
|
|
kind: "exact",
|
|
action: "block",
|
|
created_at: 1700000000,
|
|
},
|
|
{
|
|
id: 2,
|
|
group_id: 2,
|
|
group: "Kids",
|
|
pattern: "*.cdn.example.com",
|
|
kind: "wildcard",
|
|
action: "allow",
|
|
created_at: 1700000100,
|
|
},
|
|
],
|
|
},
|
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
|
};
|
|
|
|
// The API orders groups by name, so the id-1 default is not always first.
|
|
let groups: { id: number; name: string; safe_search: boolean }[];
|
|
let deleted: string[];
|
|
let posted: { pattern: string; kind: string }[];
|
|
|
|
function deleteCalls(): string[] {
|
|
return deleted;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
groups = [
|
|
{ id: 1, name: "Default", safe_search: false },
|
|
{ id: 2, name: "Kids", safe_search: true },
|
|
];
|
|
deleted = [];
|
|
posted = [];
|
|
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/rules" && init?.method === "POST") {
|
|
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
|
|
return new Response(JSON.stringify({ error: "rate limited" }), {
|
|
status: 429,
|
|
headers: { "content-type": "application/json", "Retry-After": "5" },
|
|
});
|
|
}
|
|
const payload = url === "/api/groups" ? { groups } : 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 renderRulesRoute() {
|
|
const queryClient = createQueryClient();
|
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
|
|
render(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A RAC Select names its trigger with the current value and then the label, so
|
|
* the label alone is a suffix match. Opening it is the only way to read the
|
|
* options: there is no `<select>` carrying them any more.
|
|
*/
|
|
function trigger(label: string): HTMLElement {
|
|
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
|
|
}
|
|
|
|
async function optionsOf(label: string): Promise<(string | null)[]> {
|
|
fireEvent.click(trigger(label));
|
|
const options = await screen.findAllByRole("option");
|
|
const labels = options.map((option) => option.textContent);
|
|
// Re-picking the current value closes the listbox and changes nothing.
|
|
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
|
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
|
return labels;
|
|
}
|
|
|
|
test("renders the rule table and the create form with contract enums", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
const table = within(screen.getByRole("table"));
|
|
expect(table.getByText("ads.example.com")).toBeTruthy();
|
|
expect(table.getByText("*.cdn.example.com")).toBeTruthy();
|
|
expect(table.getByText("block")).toBeTruthy();
|
|
expect(table.getByText("allow")).toBeTruthy();
|
|
expect(table.getByText("Kids")).toBeTruthy();
|
|
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
|
|
|
|
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
|
|
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
|
|
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
|
|
});
|
|
|
|
test("the kind selector can select the regex option, not only list it", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
fireEvent.click(trigger("Kind"));
|
|
const options = await screen.findAllByRole("option");
|
|
const regex = options.find((option) => option.textContent === "regex");
|
|
expect(regex).toBeTruthy();
|
|
fireEvent.click(regex!);
|
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
|
|
|
expect(trigger("Kind").textContent).toContain("regex");
|
|
});
|
|
|
|
async function selectKind(label: string): Promise<void> {
|
|
fireEvent.click(trigger("Kind"));
|
|
const options = await screen.findAllByRole("option");
|
|
fireEvent.click(options.find((option) => option.textContent === label)!);
|
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
|
}
|
|
|
|
// A regex is stored and matched byte for byte, so whitespace inside it is data,
|
|
// not slop the UI may drop. Exact and wildcard are normalized server-side.
|
|
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
await selectKind("regex");
|
|
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
|
await waitFor(() => expect(posted).toHaveLength(1));
|
|
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
|
|
|
|
await selectKind("exact");
|
|
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
|
await waitFor(() => expect(posted).toHaveLength(2));
|
|
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
|
|
});
|
|
|
|
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
const input = screen.getByLabelText("Pattern");
|
|
expect(input.getAttribute("autocapitalize")).toBe("none");
|
|
expect(input.getAttribute("autocorrect")).toBe("off");
|
|
expect(input.getAttribute("spellcheck")).toBe("false");
|
|
});
|
|
|
|
test("rule create shows a countdown when rate limited with Retry-After", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
|
|
});
|
|
|
|
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
|
|
groups = [
|
|
{ id: 5, name: "Attic", safe_search: false },
|
|
{ id: 1, name: "Default", safe_search: false },
|
|
];
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
|
|
expect(trigger("Group").textContent).toContain("Default");
|
|
});
|
|
|
|
test("the group select falls back to the first group when the default is absent", async () => {
|
|
groups = [
|
|
{ id: 5, name: "Attic", safe_search: false },
|
|
{ id: 7, name: "Basement", safe_search: false },
|
|
];
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
expect(trigger("Group").textContent).toContain("Attic");
|
|
});
|
|
|
|
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
|
const dialog = await screen.findByRole("alertdialog");
|
|
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
|
expect(deleteCalls()).toEqual([]);
|
|
});
|
|
|
|
test("confirming the delete dialog issues the DELETE for that rule", async () => {
|
|
renderRulesRoute();
|
|
await screen.findByRole("heading", { name: "Rules" });
|
|
|
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
|
await screen.findByRole("alertdialog");
|
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
|
|
|
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
|
|
});
|