import { fireEvent, render, screen, 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 GROUPS = { groups: [ { id: 1, name: "default", safe_search: false }, { id: 2, name: "kids", safe_search: true }, ], }; const CLIENTS = { clients: [ { id: 1, ip: "192.168.1.10", name: "laptop", learned_name: "laptop-1.lan", group_id: 1, group: "default", hand_edited: true, first_seen: 1700000000, last_seen: 1700003600, }, { id: 2, ip: "192.168.1.11", name: "", learned_name: "kids-tablet.lan", group_id: 2, group: "kids", hand_edited: false, first_seen: 1700000000, last_seen: 1700007200, }, ], }; const PREFIXES = { client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }], }; const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; function stubFetch(map: Record) { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const key = `${init?.method ?? "GET"} ${String(input)}`; const payload = map[key]; if (payload === undefined) { return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 }); } return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, }); }), ); } async function renderClientsPage(map: Record) { stubFetch(map); const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient); render( , ); await screen.findByRole("heading", { name: "Clients" }); } const BASE = { "GET /api/clients": CLIENTS, "GET /api/client-prefixes": PREFIXES, "GET /api/groups": GROUPS, "GET /api/version": VERSION, }; afterEach(() => { vi.unstubAllGlobals(); }); test("renders the client table with group names and one hand-edited badge", async () => { await renderClientsPage(BASE); expect(screen.getByText("192.168.1.10")).toBeTruthy(); expect(screen.getByText("192.168.1.11")).toBeTruthy(); expect(screen.getByText("laptop")).toBeTruthy(); expect(screen.getAllByText("edited")).toHaveLength(1); expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1); }); test("a named row shows the typed name and hides the learned one", async () => { await renderClientsPage(BASE); expect(screen.getByText("laptop")).toBeTruthy(); expect(screen.queryByText("laptop-1.lan")).toBeNull(); }); test("an unnamed row shows the learned name with the learned affordance", async () => { await renderClientsPage(BASE); // The cell holds the learned name followed by the tag, so the match is on // the containing span rather than on a bare text node. const learned = screen.getByText( (content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"), ); // The affordance is text, not colour, so a screen reader announces it too. expect(within(learned).getByText("learned")).toBeTruthy(); }); test("shows the DNS-activity empty state when there are no clients", async () => { await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } }); expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy(); expect(screen.queryByRole("table")).toBeNull(); }); test("edit opens a dialog seeded with the client's name and group", async () => { await renderClientsPage(BASE); fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!); // The dialog portals out of the table, so every field query is scoped to it. const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })); expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop"); // The RAC Select names its trigger with the value and then the label. expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default"); }); test("the group picker offers every group and reports the choice", async () => { await renderClientsPage(BASE); fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!); const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })); fireEvent.click(dialog.getByRole("button", { name: /Group$/ })); const options = await screen.findAllByRole("option"); expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]); fireEvent.click(screen.getByRole("option", { name: "kids" })); expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids"); }); test("prefix editor starts clean and dirties on add", async () => { await renderClientsPage(BASE); expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24"); const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement; expect(save.disabled).toBe(true); fireEvent.click(screen.getByRole("button", { name: "Add prefix" })); expect(save.disabled).toBe(false); expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe(""); });