Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
T

409 lines
17 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The claim/release state machine itself needs a broker and is covered by
/// <c>MqttPrimaryHostLiveTests</c> 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.
/// </remarks>
[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<ArgumentException>(() =>
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();
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>The ladder is exponential from 2 s and capped, so a long outage settles at one attempt per cap.</summary>
[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;
}
/// <summary>A broker that refuses instantly, so a connect attempt is observable but not slow.</summary>
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());
}
/// <summary>
/// <b>Driver.Mqtt-006.</b> 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 <c>ObjectDisposedException</c>
/// naming <c>SemaphoreSlim</c>, logged as a reconcile failure: it tells an operator nothing about
/// what happened, and it hides whatever the reconcile was actually failing on.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<bool> WaitUntilAsync(Func<bool> 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;
}
/// <summary>Records the type name of every exception logged, which is the observable here.</summary>
private sealed class ExceptionCapturingLogger : ILogger
{
private readonly List<string> _captured = [];
public IReadOnlyList<string> Captured
{
get
{
lock (_captured)
{
return [.. _captured];
}
}
}
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
=> null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (exception is null)
{
return;
}
lock (_captured)
{
_captured.Add(exception.GetType().Name);
}
}
}
}