ad108f6a02
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
409 lines
17 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|