/** * The fixtures and the stubbed API the configuration tests share. * * All three pages read the same handful of collections plus * `/api/config/status`, so a per-file copy of the stub would be five copies of * one contract drifting apart. Test-only: nothing in `src` imports it, so it * never reaches the bundle. */ import { render, screen } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; import { health } from "@/lib/healthFixture"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import type { ConfigStatus, Settings } from "@/lib/types"; export const CONFIG_PATH = "/etc/nxdns/config.zon"; export const RECONCILED_AT = 1754899200; export const DATABASE: ConfigStatus = { authority: "database", path: null, reconciled_at: null, restart_pending: false, }; export const MANAGED_FILE: ConfigStatus = { authority: "managed_file", path: CONFIG_PATH, reconciled_at: RECONCILED_AT, restart_pending: false, }; export function baseSettings(): Settings { return { upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 }, dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 }, blocking: { response: "zero", ttl: 300 }, cache: { size: 10000, negative_ttl_max: 300 }, web: { enabled: true, bind: "127.0.0.1", port: 8080, session_ttl_hours: 24, api_rate_limit_per_min: 60, api_localhost_exempt: true, sse_max_connections_per_ip: 2, trusted_proxies: "", auth_enabled: true, }, doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" }, dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" }, edns: { ecs_mode: "strip" }, logging: { level: "info", retention_days: 30, query_log_buffer_max: 10000, query_log_flush_interval_s: 60, hide_domains: false, hide_client_ips: false, output: "stderr", file_path: "", max_size_mb: 50, max_files: 3, }, disk: { min_free_mb: 100, warn_free_mb: 500 }, blocklist_update: { enabled: true, interval_hours: 24 }, }; } export const GROUPS = [ { id: 1, name: "default", safe_search: false }, { id: 2, name: "kids", safe_search: true }, ]; export const BLOCKLISTS = [ { id: 1, url: "https://example.com/ads.txt", name: "Ads", enabled: true, is_suggested: false, last_updated: null, domain_count: 100, wildcard_count: 2, exception_count: 1, skipped_regex_count: 0, skipped_unsupported_count: 0, checksum: null, }, { id: 2, url: "https://example.com/trackers.txt", name: "Trackers", enabled: false, is_suggested: true, last_updated: 1700000000, domain_count: 50, wildcard_count: 0, exception_count: 0, skipped_regex_count: 3, skipped_unsupported_count: 4, checksum: null, }, ]; export const 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: "*.social.example", kind: "wildcard", action: "block", created_at: 1700000100, }, ]; export const CLIENTS = [ { id: 1, ip: "192.168.1.10", name: "laptop", learned_name: "", group_id: 1, group: "default", hand_edited: true, first_seen: 1700000000, last_seen: 1700003600, }, { id: 2, ip: "192.168.1.11", name: "", learned_name: "tablet.lan", group_id: 2, group: "kids", hand_edited: false, first_seen: 1700000000, last_seen: 1700007200, }, ]; export const UPSTREAMS = [ { id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" }, { id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" }, ]; export const LOCAL_RECORDS = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.5", ttl: 300 }]; export const FORWARD_ZONES = [{ id: 1, zone: "lan.home", resolver: "udp://192.168.1.1:53" }]; export interface Call { url: string; method: string; body: unknown; } export interface StubOptions { /** * Overrides and additions, keyed `"GET /api/x"`. A `Response` is used * verbatim; a function is called per request, so a test can change what the * server says between two reads of the same endpoint. */ responses?: Record; /** * First refusal on every non-GET; return null to fall through to the default * echo. An unsettled promise holds the mutation in flight, which is how a * test observes a pending control. */ onWrite?: (call: Call) => Response | Promise | null; } function defaultResponses(status: ConfigStatus): Record { return { "GET /api/config/status": status, "GET /api/health": health(), "GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "GET /api/groups": { groups: GROUPS }, "GET /api/groups/1/sources": { source_ids: [1] }, "GET /api/groups/2/sources": { source_ids: [] }, "GET /api/blocklists": { blocklists: BLOCKLISTS }, "GET /api/rules": { rules: RULES }, "GET /api/clients": { clients: CLIENTS }, "GET /api/client-prefixes": { client_prefixes: [] }, "GET /api/upstreams": { upstreams: UPSTREAMS }, "GET /api/local-records": { local_records: LOCAL_RECORDS }, "GET /api/forward-zones": { forward_zones: FORWARD_ZONES }, "GET /api/settings": { settings: baseSettings(), restart_required: ["dns.port", "web.port"] }, }; } function json(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } }); } /** Installs the stub and returns the list every non-GET request is appended to. */ export function stubApi(status: ConfigStatus = DATABASE, options: StubOptions = {}): Call[] { const map = { ...defaultResponses(status), ...options.responses }; const calls: Call[] = []; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = init?.method ?? "GET"; const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; const key = `${method} ${url}`; const configured = map[key]; // A promise is handed back unsettled on purpose: it is how a test holds a // query in its pending state for as long as it needs to. if (configured instanceof Promise) return configured; if (configured instanceof Response) return configured.clone(); if (typeof configured === "function") return json((configured as () => unknown)()); if (method !== "GET") { calls.push({ url, method, body }); const override = options.onWrite?.({ url, method, body }); if (override !== null && override !== undefined) return override; if (configured !== undefined) return json(configured); if (method === "DELETE") return new Response(null, { status: 204 }); return json(body ?? {}); } if (configured === undefined) return json({ error: `not stubbed: ${key}` }, 404); return json(configured); }), ); return calls; } export function renderRoute(route: string) { const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient); render( , ); return router; } export async function renderPage(route: string, heading: string) { const router = renderRoute(route); await screen.findByRole("heading", { name: heading, level: 1 }); return router; } /** The configuration page's own content, excluding the shell chrome around it. */ export function contentArea(): HTMLElement { const main = document.querySelector("main"); if (main === null) throw new Error("no main element"); return main; }