import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { BASE, MANAGED_FILE, NEVER, renderAt } from "./testFixtures"; afterEach(() => { vi.unstubAllGlobals(); }); test("a cold deep link fetches the list once and renders the client (D9)", async () => { const { fetchMock } = renderAt("/clients/1", BASE); await screen.findByRole("heading", { name: "laptop" }); expect(screen.getByText("192.168.1.10")).toBeTruthy(); const listCalls = fetchMock.mock.calls.filter(([input]) => String(input) === "/api/clients"); expect(listCalls).toHaveLength(1); }); test("identity carries the name, its provenance and both timestamps", async () => { renderAt("/clients/2", BASE); // The unnamed row is the learned one, and Clients is where the marker shows. const heading = await screen.findByRole("heading", { name: /kids-tablet\.lan/ }); expect(within(heading).getByText("learned")).toBeTruthy(); expect(screen.getByText(/This client appeared from DNS traffic/)).toBeTruthy(); expect(screen.getByText("First seen")).toBeTruthy(); expect(screen.getByText("Last seen")).toBeTruthy(); }); test("a hand-set row reads as edited under database authority", async () => { renderAt("/clients/1", BASE); await waitFor(() => expect(screen.getByText(/The name and group were set here/)).toBeTruthy()); }); test("the same row reads as declared under file authority", async () => { renderAt("/clients/1", { ...BASE, "GET /api/config/status": MANAGED_FILE }); await screen.findByText(/Declared in \/etc\/nxdns\/config\.zon/); }); test("an unresolved authority names the doubt instead of picking a provenance", async () => { renderAt("/clients/1", { ...BASE, "GET /api/config/status": NEVER }); await screen.findByText(/Either declared in the configuration file or edited here/); }); test("an id the list does not contain renders the missing-client state (D9)", async () => { renderAt("/clients/99", BASE); await screen.findByRole("heading", { name: "No such client" }); expect(screen.getByText(/no client with id 99/i)).toBeTruthy(); expect(screen.getByRole("link", { name: "← All clients" })).toBeTruthy(); }); test("policy links to the group that filters this client", async () => { renderAt("/clients/2", BASE); const link = await screen.findByRole("link", { name: "Group settings" }); expect(link.getAttribute("href")).toBe("/configuration/protection?tab=groups&group=2"); }); test("the Activity affordance is a real link, with bounds minted at interaction time", async () => { const { router } = renderAt("/clients/1", BASE); const link = await screen.findByRole("link", { name: /last 24 hours/i }); // A real is what makes middle-click, copy-link and open-in-new-tab // work; the bounds must be in that href before the click, not minted by a // handler the browser never runs on those gestures. expect(link.tagName).toBe("A"); expect(link.getAttribute("href")).toMatch(/^\/activity\?/); const before = Math.floor(Date.now() / 1000); fireEvent.pointerDown(link); fireEvent.click(link); const after = Math.floor(Date.now() / 1000); await waitFor(() => expect(router.state.location.pathname).toBe("/activity")); const search = router.state.location.search as { mode: string; client: string; since: number; until: number }; expect(search.mode).toBe("history"); expect(search.client).toBe("192.168.1.10"); expect(search.until).toBeGreaterThanOrEqual(before - 1); expect(search.until).toBeLessThanOrEqual(after + 1); expect(search.until - search.since).toBe(24 * 60 * 60); }); test("keyboard activation re-mints the bounds, not the focus that preceded it by hours", async () => { const { router } = renderAt("/clients/1", BASE); const link = await screen.findByRole("link", { name: /last 24 hours/i }); // Focus can be hours old by the time Enter lands, so the window it minted is // stale; the keypress itself is what must be bracketed. fireEvent.focus(link); const pressedAt = Date.now() + 3 * 60 * 60 * 1000; const clock = vi.spyOn(Date, "now").mockReturnValue(pressedAt); try { fireEvent.keyDown(link, { key: "Enter" }); // jsdom does not synthesize the click Enter produces on a real anchor. fireEvent.click(link); } finally { clock.mockRestore(); } await waitFor(() => expect(router.state.location.pathname).toBe("/activity")); const search = router.state.location.search as { client: string; since: number; until: number }; expect(search.client).toBe("192.168.1.10"); expect(search.until).toBe(Math.floor(pressedAt / 1000)); expect(search.until - search.since).toBe(24 * 60 * 60); });