using System.Collections.Immutable;
using System.Reflection;
using Akka.Actor;
using Akka.Cluster;
using Akka.Util;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
///
/// Per-cluster mesh Phase 1 Task 4: ClusterNode.AkkaPort and the node's own
/// Cluster:Port are the same fact in two places, and nothing makes them agree. These pin
/// the shapes that drift takes.
///
public sealed class ClusterNodeAddressReconcilerTests
{
private static ClusterNodeAddress Row(string host, int akkaPort, string? nodeId = null) =>
new(nodeId ?? $"{host}:{akkaPort}", host, akkaPort);
/// A fleet whose rows match membership reports nothing.
[Fact]
public void Matching_rows_and_membership_produce_no_findings()
{
var findings = ClusterNodeAddressReconciler.Reconcile(
[("site-a-1", 4053), ("site-a-2", 4053)],
[Row("site-a-1", 4053), Row("site-a-2", 4053)],
["site-a-1:4053", "site-a-2:4053"]);
findings.ShouldBeEmpty();
}
///
/// The drift the plan names: a node binds 4054 while its row says 4053. Membership shows it
/// at an address no row claims, and the row it should have matched looks absent — both halves
/// are reported, because either alone reads as a different problem.
///
[Fact]
public void Node_bound_to_the_wrong_port_is_reported_from_both_sides()
{
var findings = ClusterNodeAddressReconciler.Reconcile(
[("site-a-1", 4054)],
[Row("site-a-1", 4053)],
["site-a-1:4053"]);
findings.Count.ShouldBe(2);
findings.ShouldContain(f =>
f.Kind == AddressMismatchKind.RunningNodeHasNoRow && f.NodeId == "site-a-1:4054");
findings.ShouldContain(f =>
f.Kind == AddressMismatchKind.EnabledRowNotInCluster && f.NodeId == "site-a-1:4053");
}
///
/// The Phase 2 shape: the row's NodeId still matches membership, but its Host /
/// AkkaPort columns — the values central will actually dial — have been edited away
/// from it. Gossip still works today, so nothing else would notice.
///
[Fact]
public void Row_whose_dial_target_disagrees_with_its_own_NodeId_is_reported()
{
var findings = ClusterNodeAddressReconciler.Reconcile(
[("site-a-1", 4053)],
[Row("site-a-1", 4054, nodeId: "site-a-1:4053")],
["site-a-1:4053"]);
var only = findings.ShouldHaveSingleItem();
only.Kind.ShouldBe(AddressMismatchKind.RowDialTargetDisagrees);
only.NodeId.ShouldBe("site-a-1:4053");
only.Detail.ShouldContain("site-a-1:4054");
}
/// A running driver node nobody registered — the coordinator discards its acks.
[Fact]
public void Running_node_with_no_row_is_reported()
{
var findings = ClusterNodeAddressReconciler.Reconcile(
[("ghost-9", 4053)], [], []);
findings.ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow);
}
///
/// A disabled row for an absent node is silence, not a finding — being absent is the entire
/// point of disabling it. Without this the maintenance hatch would trade a failed deploy for
/// a permanent warning.
///
[Fact]
public void Disabled_row_for_an_absent_node_is_not_reported()
{
var findings = ClusterNodeAddressReconciler.Reconcile(
[("site-a-1", 4053)],
[Row("site-a-1", 4053), Row("site-a-2", 4053)],
["site-a-1:4053"]);
findings.ShouldBeEmpty();
}
///
/// An admin-only member has no ClusterNode row by design, so the caller filters
/// membership to driver-role members. Passing an unfiltered list would report correct
/// configuration as broken — pinned here because the filter lives at the call site.
///
[Fact]
public void Admin_only_members_are_the_callers_responsibility_to_exclude()
{
// What the actor passes: driver members only.
ClusterNodeAddressReconciler.Reconcile(
[("central-1", 4053)], [Row("central-1", 4053)], ["central-1:4053"])
.ShouldBeEmpty();
// What an unfiltered call would produce — the false positive this guards against.
ClusterNodeAddressReconciler.Reconcile(
[("central-1", 4053), ("admin-only-1", 4053)],
[Row("central-1", 4053)],
["central-1:4053"])
.ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow);
}
///
/// Host matching is case-insensitive, matching the coordinator's NodeIdComparer — DNS
/// and SQL collation are both case-insensitive, so the same node surfaces with either casing.
///
[Fact]
public void Host_matching_is_case_insensitive()
{
ClusterNodeAddressReconciler.Reconcile(
[("SITE-A-1", 4053)], [Row("site-a-1", 4053)], ["site-a-1:4053"])
.ShouldBeEmpty();
}
private static int _memberUid;
// Akka's Member.Create factories are internal, so reflect the Up-status overload
// Create(UniqueAddress, int upNumber, MemberStatus, ImmutableHashSet roles, AppVersion).
private static readonly MethodInfo MemberCreate = typeof(Member).GetMethod(
"Create",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
binder: null,
types:
[
typeof(UniqueAddress), typeof(int), typeof(MemberStatus),
typeof(ImmutableHashSet), typeof(AppVersion),
],
modifiers: null)
?? throw new InvalidOperationException("Akka.Cluster.Member.Create(5-arg) not found — Akka API changed");
///
/// Builds an Up cluster at :
/// carrying , for exercising
/// directly.
///
private static Member UpMember(string host, int port, params string[] roles) =>
(Member)MemberCreate.Invoke(null,
[
new UniqueAddress(new Address("akka.tcp", "otopcua", host, port), Interlocked.Increment(ref _memberUid)),
1,
MemberStatus.Up,
roles.ToImmutableHashSet(),
AppVersion.Zero,
])!;
///
/// Per-cluster mesh Phase 6: scoped to cluster-MAIN, a driver member carrying a FOREIGN
/// cluster role (cluster-SITE-A) — the mixed-mesh window, where central still sees it via
/// gossip while its row is already filtered out by ClusterId — is excluded from observed. Left
/// in, it would hit the Error-level branch.
///
[Fact]
public void Foreign_cluster_role_member_is_excluded_when_scoped()
{
var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
[UpMember("site-a-1", 4053, "driver", "cluster-SITE-A")],
ownClusterRole: "cluster-MAIN");
observed.ShouldBeEmpty();
}
/// Scoped to cluster-MAIN, a member carrying that same role is kept.
[Fact]
public void Own_cluster_role_member_is_kept_when_scoped()
{
var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
[UpMember("main-1", 4053, "driver", "cluster-MAIN")],
ownClusterRole: "cluster-MAIN");
observed.ShouldHaveSingleItem().ShouldBe(("main-1", 4053));
}
///
/// Legacy: a null cluster role keeps every Up driver member regardless of cluster — an admin
/// node with no cluster role genuinely sees the whole fleet via gossip (reconcile-all).
///
[Fact]
public void Null_cluster_role_keeps_all_driver_members()
{
var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
[
UpMember("main-1", 4053, "driver", "cluster-MAIN"),
UpMember("site-a-1", 4053, "driver", "cluster-SITE-A"),
],
ownClusterRole: null);
observed.Count.ShouldBe(2);
observed.ShouldContain(("main-1", 4053));
observed.ShouldContain(("site-a-1", 4053));
}
/// Non-driver members (e.g. admin-only) are excluded whether or not a scope is set.
[Fact]
public void Non_driver_members_are_always_excluded()
{
ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
[UpMember("central-1", 4053, "admin", "cluster-MAIN")], ownClusterRole: "cluster-MAIN")
.ShouldBeEmpty();
}
}