Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
T
Joseph Doherty a13ae926a8 fix(mqtt): gate DisposeAsync against in-flight lifecycle ops (C1) + rebuild-branch tests (I1)
C1 (Critical) — DisposeAsync went straight to TeardownAsync with no lifecycle-gate
wait, reopening the orphaned-connection class. Interleaving: ReinitializeAsync's
rebuild branch holds the gate mid-InitializeCoreAsync, past its own teardown but
before `_connection = connection`; an ungated dispose sees a null connection, does
nothing, and sets `_disposed`; the rebuild then publishes a live connection plus a
host-probe loop that every later dispose short-circuits past. Not proven reachable
today (DriverInstanceActor.PostStop calls the gated ShutdownAsync), but MqttDriver
publicly implements IAsyncDisposable, which invites `await using`.

- DisposeAsync now takes the gate with the same bounded wait + fallback teardown
  ShutdownAsync uses, and sets `_disposed` BEFORE the wait.
- InitializeCoreAsync re-checks `_disposed` after the connect and disposes the
  connection it just built rather than publishing it — this closes the residual
  window on the bounded-timeout fallback path.
- The doc comment no longer claims parity with ShutdownAsync it did not have, and
  stops conflating "don't Dispose() the semaphore object" with "don't WaitAsync".

I1 (Important) — the SameSession == false rebuild branch had no test driving it.
Adds three: an endpoint-changing delta rebuilds and Faults on a refused connect;
an ingest-only delta (MaxPayloadBytes) ALSO rebuilds, pinning that IngestIdentity
is nested inside SessionIdentity; and DisposeAsync serializes behind a lifecycle
operation parked at a new internal BeforeConnectHookForTests seam (mirroring
MqttConnection's AfterConnectHookForTests, which exists for the same race class).

Minors: comments recording that IngestIdentity names no Sparkplug field and must
grow one in P2 (tasks 21/22), and why _options/_subscriptions/_authoredRawPaths
get looser memory discipline than _health/_hostState.

Falsifiability: reverting DisposeAsync to the ungated form reddens exactly the new
serialization test ("Shouldly.ShouldAssertException : raced"); forcing SameSession
to always-true reddens exactly the two new rebuild tests. 240/240 MQTT tests pass;
forced rebuild of the driver project is 0 warnings under TreatWarningsAsErrors.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:31:04 -04:00

413 lines
18 KiB
C#

using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Shell-level tests for <see cref="MqttDriver"/>: authored-only discovery, the lifecycle
/// surface (<c>Reinitialize</c> / <c>Shutdown</c> / health / footprint / cache flush), and the
/// <c>IReadable</c> batch contract. Nothing here dials a broker — every test exercises the
/// connection-free half of the driver, which is exactly the half a chatty broker must not be
/// able to influence.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverDiscoveryTests
{
/// <summary>
/// An authored TagConfig blob. <c>dataType</c> uses the real <see cref="DriverDataType"/>
/// member names — there is no <c>Double</c>; the 64-bit float is <c>Float64</c>.
/// </summary>
private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw")
=> $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}""";
private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String")
=> new(rawPath, TagJson(topic, dataType), WriteIdempotent: false);
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
/// <summary>
/// Options aimed at a definitely-closed port so a connect is <b>refused immediately</b> rather
/// than hanging — the tests that must prove a rebuild actually dials need the dial to fail
/// fast, not to burn a connect deadline.
/// </summary>
private static MqttDriverOptions ClosedPortOptions(params RawTagEntry[] tags) => new()
{
Mode = MqttMode.Plain,
Host = "127.0.0.1",
Port = 1,
UseTls = false,
ConnectTimeoutSeconds = 2,
RawTags = tags,
};
private static MqttDriver ClosedPortDriver(params RawTagEntry[] tags)
=> new(ClosedPortOptions(tags), "d", null);
/// <summary>
/// Serializes a full options record as the reinitialize blob. Round-tripping the whole record
/// (rather than hand-writing a partial JSON object) guarantees that only the field the caller
/// changed differs — a hand-written delta silently omitting a session field would take the
/// rebuild branch for the wrong reason and the test would pass vacuously.
/// </summary>
private static string DeltaJson(MqttDriverOptions options)
=> System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options);
/// <summary>
/// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a
/// single discovery pass (<see cref="DiscoveryRediscoverPolicy.Once"/>), and no online
/// discovery — the universal browser must never treat a broker's topic traffic as a
/// browsable address space.
/// </summary>
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp");
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
/// <summary>
/// The falsifiability pin for authored-only discovery: a message that arrives on a topic no
/// authored tag names must not add anything to the discovered set. A driver that
/// auto-provisioned from broker traffic would grow the address space on every deploy against
/// a chatty broker.
/// </summary>
[Fact]
public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Traffic this driver never authored — a neighbouring publisher on the same broker.
driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false);
driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered.</summary>
[Fact]
public async Task DiscoverAsync_SkipsUnmappableTagConfig()
{
var driver = PlainDriver(
Tag("Plant/Mqtt/dev1/Good", "f/t"),
new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]);
}
/// <summary>Discovered MQTT variables are read-only — this driver has no <c>IWritable</c> leg.</summary>
[Fact]
public async Task DiscoverAsync_MarksVariablesViewOnly()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64);
}
/// <summary>
/// The falsifiability pin for the batch-read contract: a reference that is not an authored
/// tag degrades to <c>BadWaitingForInitialData</c> in its own slot rather than throwing and
/// failing every other reference in the same batch.
/// </summary>
[Fact]
public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
var results = await driver.ReadAsync(
["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"],
CancellationToken.None);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
}
/// <summary>An empty batch is legal and returns an empty result, not an exception.</summary>
[Fact]
public async Task ReadAsync_EmptyBatch_ReturnsEmpty()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var results = await driver.ReadAsync([], CancellationToken.None);
results.ShouldBeEmpty();
}
/// <summary>
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
/// </summary>
[Fact]
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
}
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None));
driver.GetHealth().State.ShouldNotBe(DriverState.Faulted);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>
/// A delta that changes only the authored tag set is applied in place — no teardown, no
/// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered.
/// </summary>
[Fact]
public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p"));
var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q"));
var delta = $$"""
{
"mode": "Plain",
"rawTags": [
{ "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false },
{ "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false }
]
}
""";
await driver.ReinitializeAsync(delta, CancellationToken.None);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName)
.OrderBy(x => x, StringComparer.Ordinal)
.ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]);
}
/// <summary>
/// A delta that changes the broker endpoint takes the <b>rebuild</b> branch: it must actually
/// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect
/// fails must land <see cref="DriverState.Faulted"/> rather than letting the failure escape
/// with the health surface still claiming Healthy.
/// </summary>
[Fact]
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Only Host differs from the ctor options — everything else round-trips identically.
var delta = DeltaJson(ClosedPortOptions() with { Host = "127.0.0.2" });
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(delta, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// Pins that <c>IngestIdentity</c> is nested inside <c>SessionIdentity</c>: a delta touching
/// ONLY an ingest setting still rebuilds. Without the nesting it would be judged "same
/// session", applied in place, and the subscription manager that actually reads
/// <c>MaxPayloadBytes</c> would never be rebuilt — a config change that silently does nothing.
/// </summary>
[Fact]
public async Task ReinitializeAsync_IngestOnlyDelta_AlsoRebuilds()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var delta = DeltaJson(ClosedPortOptions() with { MaxPayloadBytes = 4096 });
// Reaching the (refused) connect at all is the proof the rebuild branch was taken; the
// tag-only test above is the control that shows an in-place delta never dials.
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(delta, CancellationToken.None));
}
/// <summary>
/// The falsifiability pin for the orphaned-connection class: <see cref="MqttDriver.DisposeAsync"/>
/// must serialize behind an in-flight lifecycle operation, not race past it.
/// </summary>
/// <remarks>
/// An ungated dispose running while a rebuild holds the gate observes <c>_connection == null</c>
/// (the rebuild has torn the old session down but not yet assigned the new one), does nothing,
/// and sets the disposed flag — after which the rebuild assigns a live connection plus a
/// host-probe loop that no later dispose can reach. Here the driver is parked inside
/// <c>InitializeCoreAsync</c> at the pre-connect hook; the assertion is that dispose does not
/// complete while it is parked, and does complete once it is released.
/// </remarks>
[Fact]
public async Task DisposeAsync_SerializesBehindAnInFlightLifecycleOperation()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.BeforeConnectHookForTests = async _ =>
{
entered.TrySetResult();
await release.Task;
};
var initialize = Task.Run(() => driver.InitializeAsync("", CancellationToken.None));
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var dispose = driver.DisposeAsync().AsTask();
// The gate is held by the parked initialize. Dispose must still be waiting. (The gate wait is
// bounded by ConnectTimeoutSeconds = 2 s, so this 300 ms window is comfortably inside it —
// an ungated dispose returns essentially instantly.)
var raced = await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromMilliseconds(300)));
raced.ShouldNotBe(dispose, "DisposeAsync returned while a lifecycle operation held the gate");
release.SetResult();
await Should.ThrowAsync<Exception>(() => initialize); // connect refused on the closed port
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
dispose.IsCompletedSuccessfully.ShouldBeTrue();
}
/// <summary>Identity is fixed at construction and must match the persisted <c>DriverInstance.DriverType</c>.</summary>
[Fact]
public void Identity_IsMqtt()
{
var driver = PlainDriver();
driver.DriverType.ShouldBe("Mqtt");
driver.DriverInstanceId.ShouldBe("d");
}
/// <summary>A driver that has never been initialized reports Unknown, not Healthy.</summary>
[Fact]
public void GetHealth_BeforeInitialize_IsUnknown()
{
PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Plain mode never signals rediscovery — the authored set only changes by redeploy.</summary>
[Fact]
public async Task Plain_NeverRaisesRediscoveryNeeded()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None);
fired.ShouldBe(0);
}
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
[Fact]
public void GetMemoryFootprint_TracksAuthoredTable()
{
PlainDriver().GetMemoryFootprint().ShouldBe(0);
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
}
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>The single-broker connectivity probe names the configured endpoint.</summary>
[Fact]
public void GetHostStatuses_ReportsTheConfiguredBroker()
{
var driver = new MqttDriver(
new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 },
"d",
null);
var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses();
statuses.Count.ShouldBe(1);
statuses[0].HostName.ShouldBe("broker.example:1883");
statuses[0].State.ShouldBe(HostState.Unknown);
}
// ---- test doubles ----
/// <summary>
/// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this
/// suite: the driver test project does not reference <c>Commons</c>, where the runtime's own
/// capturing builder lives. Mirrors the per-driver <c>RecordingBuilder</c> the AB CIP / FOCAS
/// suites use.
/// </summary>
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
/// <summary>Every folder streamed, in order, across the whole tree.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>Every variable streamed, in order, across the whole tree.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}