using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
///
/// Guards the self-first seed-ordering rule at the only moment it can still be fixed cheaply: boot.
///
///
///
/// The invariant (decision 2026-07-22) is conditional: if a node's own address
/// appears in its own , it must be entry 0. Akka
/// runs FirstSeedNodeProcess — the only path that can form a NEW cluster when no peer
/// answers InitJoin — exclusively for seed-nodes[0]; every other node retries
/// InitJoin forever. A node listed second therefore cannot cold-start while its peer
/// is down, and it fails silently: the process is healthy, the port is open, the node
/// simply never becomes a cluster member. That is why the rule is enforced loudly here.
///
///
/// The conditional form is required, not incidental: a flat "self must be first" rule would
/// reject every driver-only site node, which is seeded solely by central-1 and is
/// legitimately not a seed of anything —
/// see .
///
///
public sealed class AkkaClusterOptionsValidatorTests
{
private static AkkaClusterOptions CentralNode(string publicHostname, params string[] seeds) =>
new()
{
// The docker-dev shape: bind to every interface, advertise the container DNS name.
Hostname = "0.0.0.0",
PublicHostname = publicHostname,
Port = 4053,
Roles = new[] { "admin", "driver" },
SeedNodes = seeds,
};
private static string Seed(string host, int port = 4053) => $"akka.tcp://otopcua@{host}:{port}";
/// The shipped central-1 / central-2 shape: own address first, partner second.
[Fact]
public void Self_first_seed_order_passes()
{
var options = CentralNode("central-2", Seed("central-2"), Seed("central-1"));
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
///
/// The pre-2026-07-22 ordering — partner first — is the boot-alone outage gap, and must not
/// start. This is the shape docker-dev's central-2 carried.
///
[Fact]
public void Peer_first_seed_order_fails()
{
var options = CentralNode("central-2", Seed("central-1"), Seed("central-2"));
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull().ShouldContain("SeedNodes");
result.FailureMessage.ShouldContain("must list this node itself first");
}
///
/// THE conditional exemption. A driver-only site node lists only central-1 — it is not a
/// seed at all, is never legitimately first, and must pass. An unconditional "self must be
/// first" rule would refuse to boot every site node in the fleet.
///
[Fact]
public void Node_absent_from_its_own_seed_list_is_exempt()
{
var options = CentralNode("site-a-1", Seed("central-1"));
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
///
/// The bind address is NOT the node's identity. In docker-dev Cluster:Hostname is
/// 0.0.0.0 and Cluster:PublicHostname is the container name — the value Akka puts
/// in SelfAddress and therefore the only one a seed entry can match. A validator that
/// compared against Hostname would find no match anywhere, silently exempt every node,
/// and pass this misordered config.
///
[Fact]
public void Identity_is_the_public_hostname_not_the_bind_address()
{
var options = CentralNode("central-2", Seed("central-1"), Seed("central-2"));
options.Hostname.ShouldBe("0.0.0.0", "the trap only exists when the bind address is a wildcard");
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue("matching on the 0.0.0.0 bind address would make this rule vacuous");
}
///
/// With PublicHostname blank Akka advertises the bind hostname, so that is the identity to
/// match — otherwise a loopback/bare-metal pair configured that way would be silently exempt.
///
[Fact]
public void Identity_falls_back_to_the_bind_hostname_when_no_public_hostname_is_set()
{
var options = CentralNode("central-2", Seed("node-a"), Seed("node-b"));
options.Hostname = "node-b";
options.PublicHostname = string.Empty;
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull().ShouldContain("must list this node itself first");
}
///
/// Host AND port: a two-node install sharing one hostname and differing only by port (a
/// loopback/dev pair) is a real topology, and matching on host alone would clear the misordered
/// node.
///
[Fact]
public void Self_match_compares_host_and_port()
{
var options = CentralNode("127.0.0.1", Seed("127.0.0.1", 4053), Seed("127.0.0.1", 4054));
options.Port = 4054;
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue("this node is 127.0.0.1:4054, which is the SECOND seed");
}
///
/// Positive control for the pair above: the same host on the same port really is a match, so the
/// port comparison cannot be passing merely by never matching anything.
///
[Fact]
public void Self_match_on_the_same_host_and_port_passes()
{
var options = CentralNode("127.0.0.1", Seed("127.0.0.1", 4054), Seed("127.0.0.1", 4053));
options.Port = 4054;
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
///
/// An empty seed list is the single-node/dev default and says nothing about ordering — the rule
/// has nothing to bind to and must not invent a failure.
///
[Fact]
public void Empty_seed_list_passes()
{
var options = CentralNode("central-1");
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
///
/// A malformed seed URI is Akka's error to report, with Akka's wording. This rule must not
/// crash on it, and must not turn a parse problem into a misleading "ordering" complaint —
/// here the ordering is correct and only a later entry is unparseable.
///
[Fact]
public void Malformed_seed_entry_does_not_throw()
{
var options = CentralNode("central-1", Seed("central-1"), "not-an-akka-address");
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
}