/** * The Clients pages under a real router: both of them read the same list, and * the detail route is reached by deep link as often as by click, so the tests * drive the router rather than the components. */ import { act, render, screen } from "@testing-library/react"; import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { queryKeys } from "@/lib/queries"; import { createAppRouter } from "@/routes"; export const GROUPS = { groups: [ { id: 1, name: "default", safe_search: false }, { id: 2, name: "kids", safe_search: true }, ], }; export 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, }, ], }; export 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 }; export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false }; export const MANAGED_FILE = { authority: "managed_file", path: "/etc/nxdns/config.zon", reconciled_at: 1754899200, restart_pending: false, }; export const BASE: Record = { "GET /api/clients": CLIENTS, "GET /api/client-prefixes": PREFIXES, "GET /api/groups": GROUPS, "GET /api/version": VERSION, "GET /api/config/status": DATABASE, }; /** A request that never settles, so its query stays pending for the whole test. */ export const NEVER = Symbol("never"); export function stubFetch(map: Record): ReturnType { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const key = `${init?.method ?? "GET"} ${String(input)}`; const payload = map[key]; if (payload === NEVER) return new Promise(() => {}); 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" }, }); }); vi.stubGlobal("fetch", fetchMock); return fetchMock; } export function renderAt(path: string, map: Record) { const fetchMock = stubFetch(map); const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient); render( , ); return { router, fetchMock, queryClient }; } export async function renderClientsPage(map: Record = BASE) { const handles = renderAt("/clients", map); await screen.findByRole("heading", { name: "Clients" }); return handles; } /** * Turns configuration authority under a mounted page, the way the status * query's own poll would: the stub reads the map on each call, so replacing the * entry and refetching is the whole transition. `undefined` stands for a failed * status — the stub answers 404 for a key it does not hold. */ export async function setConfigStatus( map: Record, queryClient: QueryClient, status: unknown, ): Promise { map["GET /api/config/status"] = status; await act(async () => { await queryClient.refetchQueries({ queryKey: queryKeys.configStatus }); // The cache lands before its observers are notified — react-query defers // that notification — so the render pass needs one more turn inside act. await new Promise((resolve) => setTimeout(resolve, 0)); }); }