diff --git a/Infrastructure/engine.Tests/UnifiStaticDnsTests.cs b/Infrastructure/engine.Tests/UnifiStaticDnsTests.cs
new file mode 100644
index 0000000..abea689
--- /dev/null
+++ b/Infrastructure/engine.Tests/UnifiStaticDnsTests.cs
@@ -0,0 +1,174 @@
+using Homelab.Infrastructure.Unifi;
+using UnifiSharp.Legacy;
+using Xunit;
+
+namespace Homelab.Infrastructure.Tests;
+
+// Pure tests for the controller-local DNS reconcile (#314/#419). No controller —
+// everything that decides whether to write is a function of (declared, live).
+public sealed class UnifiStaticDnsTests
+{
+ private static StaticDnsSpec Spec(string name = "*.lab.chrison.dev", string value = "10.10.0.13",
+ string type = "A", int? ttl = null, bool enabled = true) =>
+ new() { Name = name, Value = value, Type = type, Ttl = ttl, Enabled = enabled };
+
+ private static UnifiStaticDnsRecord Live(string key = "*.lab.chrison.dev", string value = "10.10.0.13",
+ string type = "A", int ttl = 300, bool enabled = true) =>
+ new() { Id = "live-1", Key = key, Value = value, RecordType = type, Ttl = ttl, Enabled = enabled };
+
+ // ---- the reconcile decision ----
+
+ [Fact]
+ public void Matching_live_state_is_a_no_op()
+ {
+ var item = UnifiStaticDns.Plan(Spec(), [Live()]);
+ Assert.Equal(StaticDnsAction.NoChange, item.Action);
+ Assert.Empty(item.Changes);
+ }
+
+ [Fact]
+ public void A_missing_record_is_a_create()
+ {
+ var item = UnifiStaticDns.Plan(Spec(), [Live("*.arr.chrison.dev")]);
+ Assert.Equal(StaticDnsAction.Create, item.Action);
+ Assert.Null(item.LiveId);
+ }
+
+ [Fact]
+ public void A_record_answering_differently_is_drift_not_a_second_record()
+ {
+ // A name can only answer one way, so "already exists" is not the question —
+ // "does it answer what the shape says" is. This is the case create-if-missing
+ // would call healthy while the LAN resolved to the wrong host.
+ var item = UnifiStaticDns.Plan(Spec(value: "10.10.0.13"), [Live(value: "118.67.199.127")]);
+
+ Assert.Equal(StaticDnsAction.Update, item.Action);
+ Assert.Equal("live-1", item.LiveId);
+ Assert.Contains("value: 118.67.199.127 → 10.10.0.13", item.Changes);
+ }
+
+ [Fact]
+ public void A_disabled_record_is_drift()
+ {
+ var item = UnifiStaticDns.Plan(Spec(), [Live(enabled: false)]);
+ Assert.Contains("enabled: False → True", item.Changes);
+ }
+
+ [Fact]
+ public void The_same_name_with_a_different_type_is_a_different_record()
+ {
+ // An A and a CNAME for one name are distinct rows on the controller; matching on
+ // name alone would make converge try to turn one into the other.
+ var item = UnifiStaticDns.Plan(Spec(type: "A"), [Live(type: "CNAME", value: "elsewhere.example")]);
+ Assert.Equal(StaticDnsAction.Create, item.Action);
+ }
+
+ // ---- what the shape does and does not claim ----
+
+ [Fact]
+ public void Ttl_is_only_claimed_when_the_shape_states_one()
+ {
+ // Otherwise every converge would rewrite the controller's default forever.
+ Assert.Equal(StaticDnsAction.NoChange, UnifiStaticDns.Plan(Spec(ttl: null), [Live(ttl: 900)]).Action);
+ Assert.Contains("ttl: 900 → 60", UnifiStaticDns.Plan(Spec(ttl: 60), [Live(ttl: 900)]).Changes);
+ }
+
+ [Fact]
+ public void Cosmetic_differences_are_not_drift()
+ {
+ // A trailing dot and case are the same name; a zero-padded octet is the same address.
+ Assert.Equal(StaticDnsAction.NoChange,
+ UnifiStaticDns.Plan(Spec(name: "*.lab.chrison.dev"), [Live(key: "*.LAB.Chrison.Dev.")]).Action);
+ Assert.Equal(StaticDnsAction.NoChange,
+ UnifiStaticDns.Plan(Spec(value: "10.10.0.13"), [Live(value: "10.10.000.13")]).Action);
+ }
+
+ [Fact]
+ public void A_cname_value_compares_as_a_name_not_an_address()
+ {
+ var item = UnifiStaticDns.Plan(
+ Spec(type: "CNAME", value: "traefik.lab.chrison.dev"),
+ [Live(type: "CNAME", value: "Traefik.Lab.Chrison.Dev.")]);
+ Assert.Equal(StaticDnsAction.NoChange, item.Action);
+ }
+
+ // ---- the write body ----
+
+ [Fact]
+ public void ToRecord_sends_a_complete_record_because_v2_rejects_partials()
+ {
+ var r = UnifiStaticDns.ToRecord(Spec());
+
+ Assert.Equal("*.lab.chrison.dev", r.Key);
+ Assert.Equal("A", r.RecordType);
+ Assert.Equal("10.10.0.13", r.Value);
+ Assert.Equal(300, r.Ttl); // controller default, sent explicitly
+ Assert.True(r.Enabled);
+ Assert.Null(r.Id); // create carries no id
+ }
+
+ [Fact]
+ public void An_update_carries_the_live_records_untouched_fields_forward()
+ {
+ // A full replacement that rebuilt from defaults would silently reset fields the
+ // shape says nothing about — the exact hazard of a replace-only endpoint.
+ var live = Live(ttl: 900) with { Priority = 7, Weight = 3, Port = 8443 };
+ var r = UnifiStaticDns.ToRecord(Spec(value: "10.10.0.99"), live);
+
+ Assert.Equal("live-1", r.Id);
+ Assert.Equal("10.10.0.99", r.Value);
+ Assert.Equal(900, r.Ttl); // not reset to 300
+ Assert.Equal(7, r.Priority);
+ Assert.Equal(3, r.Weight);
+ Assert.Equal(8443, r.Port);
+ }
+
+ [Fact]
+ public void A_stated_ttl_wins_over_the_live_one()
+ {
+ Assert.Equal(60, UnifiStaticDns.ToRecord(Spec(ttl: 60), Live(ttl: 900)).Ttl);
+ }
+
+ // ---- add-only ----
+
+ [Fact]
+ public void Undeclared_records_are_listed_and_never_touched()
+ {
+ // The controller carries a dozen records owned by other things — the Azure Lab's
+ // wildcard set, the node names. Reporting them is the whole contract.
+ var live = new[]
+ {
+ Live("*.lab.chrison.dev"),
+ Live("*.topaz.local.dev", "10.50.0.10"),
+ Live("hpe-01.homelab.chrison.internal", "10.0.0.13"),
+ };
+
+ var undeclared = UnifiStaticDns.Undeclared(live, [Spec("*.lab.chrison.dev")]);
+
+ Assert.Equal(["*.topaz.local.dev", "hpe-01.homelab.chrison.internal"], undeclared.Select(u => u.Key));
+ }
+
+ [Fact]
+ public void Undeclared_matching_ignores_case_and_trailing_dots()
+ {
+ var live = new[] { Live("*.LAB.chrison.dev.") };
+ Assert.Empty(UnifiStaticDns.Undeclared(live, [Spec("*.lab.chrison.dev")]));
+ }
+
+ // ---- the actual #419 declaration ----
+
+ [Fact]
+ public void The_three_pangolin_zones_plan_as_creates_against_a_controller_that_has_none()
+ {
+ var declared = new[]
+ {
+ Spec("*.lab.chrison.dev"), Spec("*.arr.chrison.dev"), Spec("*.iot.chrison.dev"),
+ };
+ var live = new[] { Live("*.topaz.local.dev", "10.50.0.10") };
+
+ var plan = declared.Select(d => UnifiStaticDns.Plan(d, live)).ToList();
+
+ Assert.All(plan, p => Assert.Equal(StaticDnsAction.Create, p.Action));
+ Assert.All(plan, p => Assert.Equal("10.10.0.13", p.Desired.Value));
+ }
+}
diff --git a/Infrastructure/engine/Homelab.Infrastructure.csproj b/Infrastructure/engine/Homelab.Infrastructure.csproj
index a014d9e..db9c2d9 100644
--- a/Infrastructure/engine/Homelab.Infrastructure.csproj
+++ b/Infrastructure/engine/Homelab.Infrastructure.csproj
@@ -29,7 +29,7 @@
adapter requires UNIFI_USERNAME/UNIFI_PASSWORD, which secrets.env does not
carry, so converge-unifi could not authenticate at all (#416) — plus the
rest/user endpoints the DHCP-reservation reconcile is built on. -->
-
+