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); }