using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Shell-level tests for : authored-only discovery, the lifecycle /// surface (Reinitialize / Shutdown / health / footprint / cache flush), and the /// IReadable 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. /// [Trait("Category", "Unit")] public sealed class MqttDriverDiscoveryTests { /// /// An authored TagConfig blob. dataType uses the real /// member names — there is no Double; the 64-bit float is Float64. /// 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); /// /// Options aimed at a definitely-closed port so a connect is refused immediately rather /// than hanging — the tests that must prove a rebuild actually dials need the dial to fail /// fast, not to burn a connect deadline. /// 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); /// /// 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. /// private static string DeltaJson(MqttDriverOptions options) => System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options); /// /// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a /// single discovery pass (), and no online /// discovery — the universal browser must never treat a broker's topic traffic as a /// browsable address space. /// [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(); } /// /// 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. /// [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"]); } /// A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered. [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"]); } /// Discovered MQTT variables are read-only — this driver has no IWritable leg. [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); } /// /// The falsifiability pin for the batch-read contract: a reference that is not an authored /// tag degrades to BadWaitingForInitialData in its own slot rather than throwing and /// failing every other reference in the same batch. /// [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 } /// An empty batch is legal and returns an empty result, not an exception. [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(); } /// /// The falsifiability pin for the cache-flush contract: the last-value cache backs /// IReadable, so flushing it would silently turn every subscribed node Bad under /// memory pressure. FlushOptionalCachesAsync must leave it untouched. /// [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"); } /// A malformed reinitialize delta must never fault the driver, and must not lose the running config. [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"]); } /// /// 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. /// [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"]); } /// /// A delta that changes the broker endpoint takes the rebuild branch: it must actually /// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect /// fails must land rather than letting the failure escape /// with the health surface still claiming Healthy. /// [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(() => driver.ReinitializeAsync(delta, CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } /// /// Pins that IngestIdentity is nested inside SessionIdentity: 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 /// MaxPayloadBytes would never be rebuilt — a config change that silently does nothing. /// [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(() => driver.ReinitializeAsync(delta, CancellationToken.None)); } /// /// The falsifiability pin for the orphaned-connection class: /// must serialize behind an in-flight lifecycle operation, not race past it. /// /// /// An ungated dispose running while a rebuild holds the gate observes _connection == null /// (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 /// InitializeCoreAsync at the pre-connect hook; the assertion is that dispose does not /// complete while it is parked, and does complete once it is released. /// [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(() => initialize); // connect refused on the closed port await dispose.WaitAsync(TimeSpan.FromSeconds(10)); dispose.IsCompletedSuccessfully.ShouldBeTrue(); } /// Identity is fixed at construction and must match the persisted DriverInstance.DriverType. [Fact] public void Identity_IsMqtt() { var driver = PlainDriver(); driver.DriverType.ShouldBe("Mqtt"); driver.DriverInstanceId.ShouldBe("d"); } /// A driver that has never been initialized reports Unknown, not Healthy. [Fact] public void GetHealth_BeforeInitialize_IsUnknown() { PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown); } /// Plain mode never signals rediscovery — the authored set only changes by redeploy. [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); } /// The footprint tracks the authored table; an empty driver reports no driver-attributable bytes. [Fact] public void GetMemoryFootprint_TracksAuthoredTable() { PlainDriver().GetMemoryFootprint().ShouldBe(0); PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0); } /// Shutdown on a never-initialized driver is a no-op, not a null-reference. [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); } /// The single-broker connectivity probe names the configured endpoint. [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 ---- /// /// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this /// suite: the driver test project does not reference Commons, where the runtime's own /// capturing builder lives. Mirrors the per-driver RecordingBuilder the AB CIP / FOCAS /// suites use. /// private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder { /// Every folder streamed, in order, across the whole tree. public List<(string BrowseName, string DisplayName)> Folders { get; } = []; /// Every variable streamed, in order, across the whole tree. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; /// public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add((browseName, attributeInfo)); return new Handle(attributeInfo.FullName); } /// 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) { } } } }