using System.Diagnostics;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// The driver's own session lifecycle, against the fake: what
/// does to the session it is replacing, and what a
/// mis-authored duration knob does to the two paths whose whole job is to not fail
/// ( / ).
///
///
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE, and
/// answers CONNECT and PINGREQ and nothing else — so a Sparkplug driver
/// would park on its group SUBSCRIBE until the deadline and prove nothing about connections.
///
[Trait("Category", "Unit")]
public sealed class MqttDriverSessionLifecycleTests
{
///
/// Driver.Mqtt-002. InitializeCoreAsync publishes _connection — an
/// — so re-initializing a live driver without tearing down first
/// stranded a connected, supervised broker session that no later dispose could reach, and the
/// abandoned session's reconnect supervisor kept it alive against the broker indefinitely.
///
///
///
/// Reachable with no teardown of the driver's own: DriverInstanceActor re-runs
/// InitializeAsync with no ShutdownAsync when an ApplyDelta arrives while
/// the driver is Connecting/Reconnecting (AdoptConfigDuringInit). The
/// teardownFirst flag on the #527 retry path covers only the retry path.
///
///
/// The assertion is the broker's live socket count, not the driver's own view: a leak
/// is precisely the case where the driver believes it holds one session and the broker is
/// serving two. is asserted alongside it as the
/// control — without it, a second initialize that never dialled at all would satisfy
/// "exactly one live connection" just as well.
///
///
[Fact]
public async Task InitializeAsync_OnALiveDriver_TearsDownThePreviousSession()
{
using var broker = new MiniBroker();
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
await WaitForAsync(() => broker.LiveConnections == 1, "the first session to be established");
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
broker.AcceptedCount.ShouldBe(2, "the second initialize must genuinely open a new session");
await WaitForAsync(
() => broker.LiveConnections == 1,
"the replaced session to be torn down rather than left live and unreachable");
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
///
/// Driver.Mqtt-003. connectTimeoutSeconds carries a [Range] annotation that
/// nothing validates on the runtime path — the blob is deserialized straight out of the deploy
/// artifact. A negative value is outside the legal range of both
/// and , so
/// it threw out of shutdown and dispose — the two calls
/// that must always complete, and the ones a host makes while tearing a bad deployment down.
///
[Fact]
public async Task ShutdownAndDispose_WithANegativeConnectTimeout_DoNotThrow()
{
var options = BrokerOptions(1) with { ConnectTimeoutSeconds = -1, KeepAliveSeconds = -1 };
var driver = new MqttDriver(options, "d", null);
await Should.NotThrowAsync(async () =>
await driver.ShutdownAsync(TestContext.Current.CancellationToken));
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
}
///
/// The control for the test above: the normalization must not quietly rewrite a legal
/// value, or a deployment's authored connect budget would silently become the default.
///
[Trait("Category", "SlowUnit")]
[Fact]
public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt()
{
using var broker = new MiniBroker { Blackhole = true };
var options = BrokerOptions(broker.Port) with { ConnectTimeoutSeconds = 2 };
await using var driver = new MqttDriver(options, "d", null);
var sw = Stopwatch.StartNew();
await Should.ThrowAsync(async () =>
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
sw.Stop();
sw.Elapsed.ShouldBeLessThan(
TimeSpan.FromSeconds(10),
"the authored 2s budget must still bound the connect, not the 15s default");
}
///
/// End-to-end through (archreview 05/STAB-18). Deliver a value,
/// let the real broker drop the real session, and the driver publishes
/// BadNoCommunication with the value retained; feed the identical payload back —
/// the retained replay a broker sends on reconnect — and it publishes Good again.
///
///
///
/// What this adds over MqttSubscriptionManagerRecoveryTests: the wiring. Those
/// drive OnConnectionLost() directly, so they stay green even if
/// MqttSubscriptionManager.AttachTo never subscribes to
/// — an unforwarded seam is this repo's
/// best-documented way to ship an inert fix. Here the edge comes from a genuine socket drop
/// through MQTTnet's own DisconnectedAsync.
///
///
/// Vacuity rule: the recovery payload is byte-identical to the pre-outage one. A test
/// that replays a different value passes against a build with no stale at all — the
/// value change alone would defeat the suppression — and is not the guard.
///
///
/// Ingest is fed through Subscriptions.HandleMessage, the method MQTTnet's dispatcher
/// calls, because answers CONNECT and PINGREQ and nothing else: it
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
///
///
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers()
{
const string rawPath = "Plant/Mqtt/dev1/Temp";
const string topic = "factory/oven/temp";
const uint good = 0x00000000u;
const uint badNoCommunication = 0x80310000u;
using var broker = new MiniBroker();
var options = BrokerOptions(broker.Port) with
{
RawTags =
[
new RawTagEntry(
rawPath,
$$"""{"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""",
WriteIdempotent: false),
],
};
await using var driver = new MqttDriver(options, "d", null);
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
await driver.SubscribeAsync([rawPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
var raised = new List();
driver.OnDataChange += (_, e) => { lock (raised) raised.Add(e.Snapshot); };
driver.Subscriptions.HandleMessage(topic, """{"value":21.5}"""u8.ToArray(), retained: false);
lock (raised) raised.Count.ShouldBe(1, "the first value must publish");
broker.DropAll();
await WaitForAsync(
() => { lock (raised) return raised.Count == 2; },
"the broker drop to publish a BadNoCommunication snapshot");
DataValueSnapshot staled;
lock (raised) staled = raised[1];
staled.StatusCode.ShouldBe(badNoCommunication);
staled.Value.ShouldBe(21.5, "the last known value is retained through the outage");
// The reconnect's retained replay — the IDENTICAL payload.
driver.Subscriptions.HandleMessage(topic, """{"value":21.5}"""u8.ToArray(), retained: true);
DataValueSnapshot recovered;
lock (raised) { raised.Count.ShouldBe(3); recovered = raised[2]; }
recovered.StatusCode.ShouldBe(good);
recovered.Value.ShouldBe(21.5);
}
private static async Task WaitForAsync(Func condition, string what)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25, TestContext.Current.CancellationToken);
}
throw new Xunit.Sdk.XunitException($"Timed out waiting for {what}.");
}
private static MqttDriverOptions BrokerOptions(int port) => new()
{
Mode = MqttMode.Plain,
Host = "127.0.0.1",
Port = port,
UseTls = false,
ProtocolVersion = MqttProtocolVersion.V311,
KeepAliveSeconds = 300,
ConnectTimeoutSeconds = 5,
ReconnectMinBackoffSeconds = 1,
ReconnectMaxBackoffSeconds = 2,
};
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
}