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 AkkaClusterOptions { Roles = new[] { "driver", "cluster-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 AkkaClusterOptions { Roles = new[] { "admin", "driver" } });
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 AkkaClusterOptions { Roles = new[] { "driver", "cluster-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 AkkaClusterOptions { Roles = new[] { "driver" } }));
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 { } }
}
}