using System.Text; using Microsoft.Extensions.Logging; using MQTTnet; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Unit tests for the Sparkplug primary-host claim (Gitea #511) — the parts that are decidable /// without a broker: the STATE bodies, the Will registration, and the gate that decides whether a /// claim is attempted at all. /// /// /// The claim/release state machine itself needs a broker and is covered by /// MqttPrimaryHostLiveTests in the integration suite. What is pinned here is everything a /// live test could not tell apart from a working implementation by accident — above all that the /// birth and the Will carry the SAME timestamp, which a live test would see as two well-formed /// messages. /// [Trait("Category", "Unit")] public sealed class SparkplugPrimaryHostTests { private static MqttDriverOptions Options( bool actAsPrimaryHost = true, string hostId = "otopcua-primary", MqttMode mode = MqttMode.SparkplugB) => new() { Host = "broker.example", Port = 1883, ClientId = "otopcua", Mode = mode, Sparkplug = new MqttSparkplugOptions { GroupId = "OtOpcUaSim", HostId = hostId, ActAsPrimaryHost = actAsPrimaryHost, }, }; // ---- The matched-timestamp invariant -------------------------------------------------------- [Fact] public void The_birth_and_the_will_carry_the_same_timestamp() { // Sparkplug 3.0 pairs a host's birth and death certificates by their timestamp, so an edge node // may ignore an OFFLINE older than the ONLINE it holds. The two bodies are necessarily built at // different moments — the Will goes in the CONNECT packet, the birth is published after CONNACK // — so reading the clock twice is the natural mistake, and it produces two individually // well-formed messages that no live test would flag. var session = SparkplugStatePayload.ForSession("otopcua-primary", 1_770_000_000_123); SparkplugStatePayload.TryDecode(session.OnlineBody, out var online, out var birthTs).ShouldBeTrue(); SparkplugStatePayload.TryDecode(session.OfflineBody, out var offline, out var deathTs).ShouldBeTrue(); online.ShouldBeTrue(); offline.ShouldBeFalse(); birthTs.ShouldBe(1_770_000_000_123); deathTs.ShouldBe(birthTs); } [Fact] public void The_state_topic_is_the_v3_form() => SparkplugStatePayload.ForSession("scada-primary", 1).Topic.ShouldBe("spBv1.0/STATE/scada-primary"); [Fact] public void The_timestamp_is_a_bare_json_number() { // A quoted timestamp still parses as a valid JSON object, so a serializer choosing string // formatting for a long would be silently wrong to every conformant host. var body = Encoding.UTF8.GetString(SparkplugStatePayload.Encode(online: true, 1_770_000_000_123)); body.ShouldBe("{\"online\":true,\"timestamp\":1770000000123}"); } [Fact] public void The_body_survives_a_non_invariant_culture() { var previous = Thread.CurrentThread.CurrentCulture; Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); try { Encoding.UTF8.GetString(SparkplugStatePayload.Encode(online: false, 1_770_000_000_123)) .ShouldBe("{\"online\":false,\"timestamp\":1770000000123}"); } finally { Thread.CurrentThread.CurrentCulture = previous; } } // ---- The Will actually reaches the CONNECT packet -------------------------------------------- [Fact] public void The_will_is_registered_on_the_client_options_retained_at_qos1() { // The single most important assertion in this file. A retained ONLINE with no Will behind it is // strictly worse than publishing nothing: a crashed node then asserts forever that a dead host // is alive. This is the check that the death certificate is armed at CONNECT rather than merely // constructed. var session = SparkplugStatePayload.ForSession("otopcua-primary", 42); var will = new MqttLastWill(session.Topic, session.OfflineBody, SparkplugStatePayload.StateQos, Retain: true); var built = MqttClientOptionsFactory.BuildClientOptions( Options(), SparkplugPrimaryHost.ClientIdSuffix, logger: null, lastWill: will, cleanSessionOverride: true); built.WillTopic.ShouldBe("spBv1.0/STATE/otopcua-primary"); built.WillRetain.ShouldBeTrue(); built.WillQualityOfServiceLevel.ShouldBe(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce); SparkplugStatePayload.TryDecode(built.WillPayload, out var online, out _).ShouldBeTrue(); online.ShouldBeFalse(); } [Fact] public void No_will_is_registered_when_none_is_supplied() // Every other connection this repo opens — ingest, browse, probe — must stay Will-free. A Will // leaking onto the ingest session would publish a host death when ingest merely reconnects. => MqttClientOptionsFactory.BuildClientOptions(Options(), clientIdSuffix: null).WillTopic.ShouldBeNull(); [Fact] public void The_claim_connection_forces_clean_session_over_the_configured_value() { // A broker resuming a previous session for this client id may keep the OLD session's Will and // discard the one this CONNECT carries — a claim with no death certificate behind it. var persistent = Options() with { CleanSession = false }; MqttClientOptionsFactory.BuildClientOptions(persistent, "-spbhost", cleanSessionOverride: true) .CleanSession.ShouldBeTrue(); // ...and the override is genuinely an override, not a constant. MqttClientOptionsFactory.BuildClientOptions(persistent, clientIdSuffix: null) .CleanSession.ShouldBeFalse(); } [Fact] public void The_claim_connection_uses_a_distinct_client_id() { // Two MQTT clients presenting one client id is not an error the broker reports: it evicts the // older one. Ingest and the claim would take turns killing each other, forever, looking like a // flapping broker. var ingest = MqttClientOptionsFactory.BuildClientOptions(Options(), clientIdSuffix: null).ClientId; var claim = MqttClientOptionsFactory .BuildClientOptions(Options(), SparkplugPrimaryHost.ClientIdSuffix).ClientId; claim.ShouldNotBe(ingest); claim.ShouldBe("otopcua-spbhost"); } // ---- Whether a claim is attempted at all ----------------------------------------------------- [Fact] public void A_configured_claim_is_claimable() => SparkplugPrimaryHost.CanClaim(Options()).ShouldBeTrue(); [Theory] [InlineData("")] [InlineData(" ")] public void A_blank_host_id_is_refused_rather_than_publishing_to_a_malformed_topic(string hostId) { // "spBv1.0/STATE/" is a legal MQTT topic, so a publish would succeed and reach nobody. SparkplugPrimaryHost.CanClaim(Options(hostId: hostId)).ShouldBeFalse(); SparkplugPrimaryHost.DescribeUnusable(Options(hostId: hostId)).ShouldNotBeNullOrWhiteSpace(); } [Fact] public void The_flag_is_ignored_outside_sparkplug_mode_and_says_so() { var plain = Options(mode: MqttMode.Plain); SparkplugPrimaryHost.CanClaim(plain).ShouldBeFalse(); SparkplugPrimaryHost.DescribeUnusable(plain).ShouldNotBeNullOrWhiteSpace(); } [Fact] public void An_unset_flag_produces_no_complaint() { // DescribeUnusable must stay silent about a configuration that never asked for anything, or the // driver warns on every ordinary Sparkplug deployment. SparkplugPrimaryHost.CanClaim(Options(actAsPrimaryHost: false)).ShouldBeFalse(); SparkplugPrimaryHost.DescribeUnusable(Options(actAsPrimaryHost: false)).ShouldBeNull(); SparkplugPrimaryHost.DescribeUnusable(Options(actAsPrimaryHost: false, hostId: "")).ShouldBeNull(); } [Fact] public void Constructing_a_claim_without_a_host_id_throws_rather_than_half_working() => Should.Throw(() => new SparkplugPrimaryHost(Options(hostId: " "), "drv-1", UnmanagedDriverPrimaryRoleSource.Instance)); // ---- The gate --------------------------------------------------------------------------------- [Fact] public void A_driver_with_no_role_source_claims() { // The documented fallback for a standalone host or a test: with nothing publishing a role there // is no peer to collide with, and defaulting closed would make the feature silently inert on the // single-node deployment that is its commonest use. UnmanagedDriverPrimaryRoleSource.Instance.ShouldActAsPrimary.ShouldBeTrue(); // Publishing into it is a no-op rather than a throw, so a caller need not special-case it. UnmanagedDriverPrimaryRoleSource.Instance.PublishPrimaryRole(false); UnmanagedDriverPrimaryRoleSource.Instance.ShouldActAsPrimary.ShouldBeTrue(); } [Fact] public async Task A_secondary_never_even_attempts_a_claim_connection() { // The whole point of gating. Asserted on ATTEMPTS, not on IsClaimed: both nodes point at the // same unreachable broker here, so IsClaimed is false either way and an IsClaimed-only // assertion would pass just as happily with the gate deleted. See ClaimAttemptsForTests. var host = new SparkplugPrimaryHost( Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: false)); await host.StartAsync(TestContext.Current.CancellationToken); host.ClaimAttemptsForTests.ShouldBe(0); host.IsClaimed.ShouldBeFalse(); await host.DisposeAsync(); } [Fact] public async Task A_primary_attempts_the_claim_and_survives_a_broker_it_cannot_reach() { // The falsifying control for the test above — same options, same broker, opposite verdict — so // "0 attempts" there is a fact about the gate rather than about the fixture. // // It also pins the swallow: the claim is advisory and ingest is the data plane, so a broker that // refuses it must cost a warning, not the driver. And IsClaimed must stay honest — a connection // whose ONLINE never landed is not a claim. var host = new SparkplugPrimaryHost(Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: true)); await Should.NotThrowAsync(() => host.StartAsync(TestContext.Current.CancellationToken)); host.ClaimAttemptsForTests.ShouldBe(1); host.IsClaimed.ShouldBeFalse(); await host.DisposeAsync(); } /// /// STAB-27 — a claim the broker keeps refusing used to be redialled (and Warning-logged) on every /// one-second role sample, forever. It now backs off. The clock is frozen, so the real one-second /// loop ticks several times without the backoff window ever opening; advancing the clock opens it. /// [Trait("Category", "SlowUnit")] [Fact] public async Task A_failing_claim_backs_off_instead_of_redialling_every_tick() { var clock = new FrozenClock(); var host = new SparkplugPrimaryHost( Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: true), timeProvider: clock); await host.StartAsync(TestContext.Current.CancellationToken); host.ClaimAttemptsForTests.ShouldBe(1); // Several real loop ticks elapse while the (frozen) backoff window stays shut. await Task.Delay(TimeSpan.FromMilliseconds(3200), TestContext.Current.CancellationToken); host.ClaimAttemptsForTests.ShouldBe(1, "no further claim while inside the backoff window"); // Positive control: once the window passes, the very next tick does try again. clock.Advance(TimeSpan.FromSeconds(5)); (await WaitUntilAsync(() => host.ClaimAttemptsForTests >= 2, TimeSpan.FromSeconds(10))) .ShouldBeTrue("the claim must resume after the backoff window"); await host.DisposeAsync(); } /// The ladder is exponential from 2 s and capped, so a long outage settles at one attempt per cap. [Theory] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(3, 8)] [InlineData(4, 16)] [InlineData(5, 30)] [InlineData(40, 30)] public void Claim_backoff_is_exponential_and_capped(int failures, int expectedSeconds) => SparkplugPrimaryHost.ClaimBackoffFor(failures).ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); private sealed class FrozenClock : TimeProvider { private DateTimeOffset _now = new(2026, 10, 8, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now += by; } /// A broker that refuses instantly, so a connect attempt is observable but not slow. private static MqttDriverOptions Unreachable() => Options() with { Host = "127.0.0.1", Port = 1, ConnectTimeoutSeconds = 1 }; [Fact] public async Task Stopping_an_unclaimed_host_is_a_no_op() { var host = new SparkplugPrimaryHost(Options(), "drv-1", new FixedRole(shouldActAsPrimary: false)); await Should.NotThrowAsync(() => host.StopAsync(TestContext.Current.CancellationToken)); // Twice: teardown runs from both ShutdownAsync and DisposeAsync, so a second release that threw // — or that published a second OFFLINE — would surface on every ordinary driver shutdown. await Should.NotThrowAsync(async () => await host.DisposeAsync()); await Should.NotThrowAsync(async () => await host.DisposeAsync()); } /// /// Driver.Mqtt-006. Dispose runs while a reconcile is in flight — the claim is inside the /// gate, blocked on a broker that accepts TCP and never answers CONNECT — and must not leave that /// reconcile releasing a disposed semaphore. The symptom is an ObjectDisposedException /// naming SemaphoreSlim, logged as a reconcile failure: it tells an operator nothing about /// what happened, and it hides whatever the reconcile was actually failing on. /// /// /// Deterministic rather than timing-dependent: the gate is taken before the connect starts, so /// the broker having accepted the socket proves the claim is inside it when dispose runs. /// [Trait("Category", "SlowUnit")] [Fact] public async Task Disposing_while_a_reconcile_is_in_flight_does_not_fault_on_a_disposed_gate() { using var broker = new MiniBroker { Blackhole = true }; var log = new ExceptionCapturingLogger(); var options = Options() with { Host = "127.0.0.1", Port = broker.Port, UseTls = false, ProtocolVersion = MqttProtocolVersion.V311, ConnectTimeoutSeconds = 2, }; var host = new SparkplugPrimaryHost(options, "drv-1", new FixedRole(shouldActAsPrimary: true), log); var starting = host.StartAsync(CancellationToken.None); (await WaitUntilAsync(() => broker.AcceptedCount > 0, TimeSpan.FromSeconds(10))) .ShouldBeTrue("the claim must be inside the gate before dispose runs"); await host.DisposeAsync(); await starting; log.Captured.ShouldNotContain( name => name == nameof(ObjectDisposedException), "a teardown racing an in-flight reconcile must not surface a disposed-semaphore failure"); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (condition()) { return true; } await Task.Delay(25); } return condition(); } private sealed class FixedRole(bool shouldActAsPrimary) : IDriverPrimaryRoleSource { public bool ShouldActAsPrimary { get; private set; } = shouldActAsPrimary; public void PublishPrimaryRole(bool value) => ShouldActAsPrimary = value; } /// Records the type name of every exception logged, which is the observable here. private sealed class ExceptionCapturingLogger : ILogger { private readonly List _captured = []; public IReadOnlyList Captured { get { lock (_captured) { return [.. _captured]; } } } public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (exception is null) { return; } lock (_captured) { _captured.Add(exception.GetType().Name); } } } }