using Akka.Actor; using Akka.Cluster.Hosting; using Akka.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// Guards Phase 6's re-homing of RedundancyStateActor off the fleet-wide admin singleton onto /// a per-node cluster-{ClusterId} singleton (see /// ). After the mesh /// split a driver-only site pair has NO admin node to elect its Primary, so the singleton must be /// scoped to the node's own cluster role and spawned on every driver node — one per mesh. /// /// The role scope + the missing-role guard are asserted against the extracted /// helper directly — /// the same pattern SplitBrainResolverActivationTests uses for BuildDowningHocon / /// BuildClusterOptions: it pins the exact without /// booting a cluster, which no builder-introspection API can otherwise reach. The admin-removal claim /// is behavioral (a booted admin node's must no longer carry the /// redundancy key) so it needs a real host. /// public sealed class RedundancyStateSingletonRehomeTests { /// The singleton options scope to the node's OWN cluster role, not the fixed admin role. [Fact] public void Options_scope_the_singleton_to_the_nodes_cluster_role() { var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions( new FakeClusterRoleInfo(clusterRole: "cluster-SITE-A", clusterId: "SITE-A")); options.Role.ShouldBe("cluster-SITE-A"); } /// /// A node with no cluster-{ClusterId} role (a legacy single-mesh node, or the /// TwoNodeClusterHarness with roles admin,driver) falls back to the fixed /// driver role rather than throwing — so legacy/not-yet-migrated deployments keep booting. /// On a split 2-node mesh the driver role is already pair-local (the mesh IS the pair). /// [Fact] public void Missing_cluster_role_falls_back_to_the_driver_role() { var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions( new FakeClusterRoleInfo(clusterRole: null, clusterId: null)); options.Role.ShouldBe(RoleParser.Driver); } /// /// The admin singleton set no longer registers redundancy-state: a booted admin node's /// registry carries the OTHER admin singletons (proxy) but NOT the redundancy key. /// [Fact] public async Task Admin_singletons_no_longer_register_the_redundancy_singleton() { var registry = await BootAndGetRegistryAsync( roles: new[] { "admin" }, configure: ab => ab.WithOtOpcUaControlPlaneSingletons()); registry.TryGet(out _).ShouldBeFalse( "the redundancy singleton was re-homed off the admin node onto every driver node (Phase 6)"); registry.TryGet(out _).ShouldBeTrue( "the remaining admin singletons must still register"); } /// /// The re-home target: a driver node carrying a cluster-{ClusterId} role registers the /// redundancy singleton (proxy) via the new extension. /// [Fact] public async Task Cluster_redundancy_extension_registers_the_singleton_on_a_driver_node() { var registry = await BootAndGetRegistryAsync( roles: new[] { "driver", "cluster-SITE-A" }, configure: ab => ab.WithOtOpcUaClusterRedundancySingleton( new FakeClusterRoleInfo(clusterRole: "cluster-SITE-A", clusterId: "SITE-A"))); registry.TryGet(out _).ShouldBeTrue( "every driver node hosts its own mesh's redundancy singleton (Phase 6)"); } /// /// The driver-role FALLBACK end-to-end: a node carrying only driver (no cluster role — a /// legacy single-mesh node or the TwoNodeClusterHarness) still boots and registers the /// redundancy singleton via the new extension. Proves the fallback works at the host/registry /// level, not just in the pure helper — the boot that the earlier throw-based version would have /// aborted. /// [Fact] public async Task Driver_role_fallback_registers_the_singleton_end_to_end() { var registry = await BootAndGetRegistryAsync( roles: new[] { "driver" }, configure: ab => ab.WithOtOpcUaClusterRedundancySingleton( new FakeClusterRoleInfo(clusterRole: null, clusterId: null))); registry.TryGet(out _).ShouldBeTrue( "a driver node with no cluster role falls back to the driver role and still boots + registers"); } /// /// Boots a real host through the production cluster bootstrap (Port=0, no seeds, so it never forms /// a cluster and no singleton props factory runs — only the proxies register), applies the /// supplied singleton registration, and returns the after teardown. /// Start/stop live here rather than the test bodies to keep the CancellationToken-less calls out /// of the way (mirrors ClusterRoleInfoTests.ResolveAsync). /// private static async Task BootAndGetRegistryAsync( string[] roles, Action configure) { var options = new AkkaClusterOptions { Port = 0, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1", SeedNodes = Array.Empty(), Roles = roles, }; var builder = Host.CreateDefaultBuilder(); builder.ConfigureServices(services => { services.AddSingleton>(Options.Create(options)); // The admin WithActors block reads IOptions.Value at spawn time. services.AddSingleton>(Options.Create(new MeshTransportOptions())); // Akka.Hosting invokes every singleton's props factory eagerly at StartAsync; the admin // singletons (coordinator/audit/reconciler) read IDbContextFactory, // so supply an in-memory one or those factories NRE. The driver-only boots don't touch it. services.AddDbContextFactory( o => o.UseInMemoryDatabase($"rehome-test-{Guid.NewGuid():N}")); // The admin ClusterNodeAddressReconciler props factory resolves IClusterRoleInfo (for its // per-cluster reconcile scope). A null cluster role ⇒ full-fleet reconcile, which is all // this test needs — it never asserts on the reconciler, only on the singleton registry. services.AddSingleton(new FakeClusterRoleInfo(clusterRole: null, clusterId: null)); services.AddAkka("otopcua-rehome-test", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); configure(ab); }); }); using var host = builder.Build(); await host.StartAsync(); try { return host.Services.GetRequiredService(); } finally { await host.StopAsync(); } } /// Minimal stub exposing only the cluster-role identity. private sealed class FakeClusterRoleInfo : IClusterRoleInfo { public FakeClusterRoleInfo(string? clusterRole, string? clusterId) { ClusterRole = clusterRole; ClusterId = clusterId; } public string? ClusterRole { get; } public string? ClusterId { get; } public NodeId LocalNode => NodeId.Parse("127.0.0.1:0"); public IReadOnlySet LocalRoles => new HashSet(); public bool HasRole(string role) => false; public IReadOnlyList MembersWithRole(string role) => Array.Empty(); public NodeId? RoleLeader(string role) => null; public event EventHandler? RoleLeaderChanged { add { } remove { } } } }