import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings } from "@/lib/types"; function baseSettings(): Settings { return { runtime: { io_backend: "threaded" }, upstream: { connect_timeout_ms: 2000, 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, 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, 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 }, }; } function edit(mutate: (s: Settings) => void): Settings { const edited = structuredClone(baseSettings()); mutate(edited); return edited; } test("no changes and no password produces null", () => { expect(buildSettingsPatch(baseSettings(), baseSettings())).toBeNull(); }); test("an empty password is not a change", () => { expect(buildSettingsPatch(baseSettings(), baseSettings(), "")).toBeNull(); }); test("a single scalar change patches only its section field", () => { const edited = edit((s) => { s.dns.port = 5353; }); expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ dns: { port: 5353 } }); }); test("changes across sections stay grouped and minimal", () => { const edited = edit((s) => { s.logging.level = "debug"; s.logging.retention_days = 7; s.cache.size = 20000; }); expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ cache: { size: 20000 }, logging: { level: "debug", retention_days: 7 }, }); }); test("tls listener sections diff like any other", () => { const edited = edit((s) => { s.dot_server.enabled = true; s.dot_server.cert_path = "/etc/nxdns/dot.pem"; }); expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ dot_server: { enabled: true, cert_path: "/etc/nxdns/dot.pem" }, }); }); test("web.auth_enabled is never emitted even when it differs", () => { const edited = edit((s) => { s.web.auth_enabled = false; s.web.port = 9090; }); expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ web: { port: 9090 } }); }); test("a password alone produces a web-only patch", () => { expect(buildSettingsPatch(baseSettings(), baseSettings(), "hunter2")).toEqual({ web: { password: "hunter2" }, }); }); test("a password merges into an existing web section diff", () => { const edited = edit((s) => { s.web.session_ttl_hours = 48; }); expect(buildSettingsPatch(baseSettings(), edited, "hunter2")).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" }, }); });