using Akka.Actor; using Microsoft.EntityFrameworkCore; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// Proves the reconciler is actually wired to something — subscribes to membership, reads the DB, /// and emits. covers the comparison logic, which a /// dormant actor would leave perfectly correct and completely inert. /// public sealed class ClusterNodeAddressReconcilerActorTests : ControlPlaneActorTestBase { /// /// An enabled row with no matching driver member is warned about. The harness ActorSystem /// joins as admin with no driver members, so the seeded row is unmatched by /// construction. /// [Fact] public void Enabled_row_with_no_matching_member_is_logged() { var dbFactory = NewInMemoryDbFactory(); SeedNode(dbFactory, "site-a-1:4053", enabled: true); EventFilter.Warning(contains: "site-a-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () => Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory))); } /// /// The finding set is logged once, not once per sweep. A check that reprints the same warning /// every five minutes trains operators to filter it out, which costs more than it catches — /// so this runs a deliberately fast sweep and asserts the count stays at one. /// [Fact] public void Unchanged_findings_are_not_repeated_on_every_sweep() { var dbFactory = NewInMemoryDbFactory(); SeedNode(dbFactory, "site-a-1:4053", enabled: true); // ~6 sweeps inside the window; without the change-detection guard this logs 6 times. EventFilter.Warning(contains: "site-a-1:4053").Expect(1, TimeSpan.FromSeconds(3), () => { Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props( dbFactory, sweepInterval: TimeSpan.FromMilliseconds(400))); // Hold the filter open past several sweeps rather than returning immediately. ExpectNoMsg(TimeSpan.FromSeconds(2.5)); }); } /// /// A disabled row is silence — the maintenance hatch must not trade a failed deployment for a /// permanent warning. Positive control for the test above: same setup, one flag flipped. /// [Fact] public void Disabled_row_produces_no_warning() { var dbFactory = NewInMemoryDbFactory(); SeedNode(dbFactory, "site-a-1:4053", enabled: false); EventFilter.Warning(contains: "site-a-1:4053").Expect(0, TimeSpan.FromSeconds(2), () => { Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props( dbFactory, sweepInterval: TimeSpan.FromMilliseconds(400))); ExpectNoMsg(TimeSpan.FromSeconds(1.5)); }); } /// /// Per-cluster mesh Phase 6: an admin node scoped to its OWN cluster ignores rows in a cluster /// its mesh can no longer see. Scoped to MAIN, a SITE-A enabled row is filtered /// out before ever sees it, so it produces /// no warning. This is the false /// positive the split would otherwise print forever. /// [Fact] public void Row_in_a_foreign_cluster_is_ignored_when_scoped() { var dbFactory = NewInMemoryDbFactory(); SeedNode(dbFactory, "site-a-1:4053", enabled: true, clusterId: "SITE-A"); EventFilter.Warning(contains: "site-a-1:4053").Expect(0, TimeSpan.FromSeconds(2), () => { Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props( dbFactory, ownClusterId: "MAIN", sweepInterval: TimeSpan.FromMilliseconds(400))); ExpectNoMsg(TimeSpan.FromSeconds(1.5)); }); } /// /// Scoping does not silence the admin node's own cluster. Scoped to MAIN, an enabled /// MAIN row with no matching driver member is still warned about — proving the filter /// keeps in-cluster rows (and with them, within-cluster drift detection). /// [Fact] public void Enabled_row_in_own_cluster_is_still_logged_when_scoped() { var dbFactory = NewInMemoryDbFactory(); SeedNode(dbFactory, "main-1:4053", enabled: true, clusterId: "MAIN"); EventFilter.Warning(contains: "main-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () => Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory, ownClusterId: "MAIN"))); } /// /// Legacy backward-compat: an admin node with no cluster role (null ownClusterId) still /// reconciles the WHOLE fleet — such a node genuinely sees every member via gossip. A /// SITE-A enabled row with no member is warned about, exactly as before the Phase 6 scope. /// [Fact] public void Foreign_cluster_row_is_still_logged_when_unscoped() { var dbFactory = NewInMemoryDbFactory(); SeedNode(dbFactory, "site-a-1:4053", enabled: true, clusterId: "SITE-A"); EventFilter.Warning(contains: "site-a-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () => Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory, ownClusterId: null))); } private static void SeedNode( IDbContextFactory dbFactory, string nodeId, bool enabled, string clusterId = "SITE-A") { using var db = dbFactory.CreateDbContext(); if (!db.ServerClusters.Any(c => c.ClusterId == clusterId)) { db.ServerClusters.Add(new ServerCluster { ClusterId = clusterId, Name = clusterId, Enterprise = "zb", Site = clusterId.ToLowerInvariant(), NodeCount = 2, RedundancyMode = RedundancyMode.Warm, CreatedBy = "test", }); } db.ClusterNodes.Add(new ClusterNode { NodeId = nodeId, ClusterId = clusterId, Host = nodeId.Split(':')[0], ApplicationUri = $"urn:OtOpcUa:{nodeId}", Enabled = enabled, CreatedBy = "test", }); db.SaveChanges(); } }