using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Covers — the Task-6 plain-MQTT ingest path: authored-tag /// registration keyed by RawPath, topic dedupe + fan-out, payload extraction, the retained seed, /// and the subscribe/SUBACK contract. Every test runs without a broker: messages are fed straight /// through (the method MQTTnet's dispatcher /// calls) and the subscribe leg runs against a fake . /// public sealed class MqttSubscriptionManagerTests { // OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled. private const uint Good = 0x00000000u; private const uint BadDecodingError = 0x80070000u; private const uint BadTypeMismatch = 0x80740000u; private const uint BadNodeIdUnknown = 0x80340000u; private const uint BadCommunicationError = 0x80050000u; private const string OvenTempPath = "Plant/Mqtt/broker1/OvenTemp"; private const string OvenPressPath = "Plant/Mqtt/broker1/OvenPressure"; private const string OvenTopic = "f/oven/temp"; private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false); private static MqttSubscriptionManager NewManager(IMqttSubscribeTransport? transport = null) => new(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", transport); // --------------------------------------------------------------------------------- // Routing + the RawPath identity of the published reference // --------------------------------------------------------------------------------- /// /// The plan's first snippet, corrected twice: Double is not a /// member (Float64 is), and the published reference is /// asserted to BE the RawPath rather than merely to contain the topic — a topic-keyed /// publish would satisfy the plan's assertion and be silently dead against /// DriverHostActor's RawPath-keyed dual-namespace fan-out. /// [Fact] public async Task HandleMessage_MatchingTopic_RaisesDataChangeAtJsonPath_UnderRawPath() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); string? gotRef = null; object? gotVal = null; mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; }; mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); gotVal.ShouldBe(21.5); gotRef.ShouldBe(OvenTempPath); gotRef.ShouldNotBe(OvenTopic); } /// /// A chatty broker must never auto-provision a tag the operator did not author. Deliberately /// run with a live, subscribed tag present rather than an empty table: an empty table makes /// the assertion unfalsifiable — a "deliver to every authored tag" implementation would pass it /// for want of anything to deliver to. /// [Fact] public async Task HandleMessage_UnauthoredTopic_RaisesNothing() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var fired = false; mgr.OnDataChange += (_, _) => fired = true; mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false); fired.ShouldBeFalse(); mgr.Values.Read(OvenTempPath).StatusCode.ShouldNotBe(Good); // and nothing was cached for it either } /// /// Two tags on ONE topic (different JSONPaths) — the message must reach BOTH. A /// TryGetValue → first match → return implementation passes every single-tag test and /// silently starves every tag after the first. /// [Fact] public async Task HandleMessage_TopicSharedByTwoTags_FansOutToEveryMatchingTag() { var mgr = NewManager(); mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.temp","dataType":"Float64"}"""), Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.press","dataType":"Float64"}"""), ]); await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var seen = new Dictionary(); mgr.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value; mgr.HandleMessage(OvenTopic, """{"temp":21.5,"press":1.2}"""u8.ToArray(), retained: false); seen.Count.ShouldBe(2); seen[OvenTempPath].ShouldBe(21.5); seen[OvenPressPath].ShouldBe(1.2); } /// /// An authored tag nobody subscribed to still caches its value (so a later /// IReadable.ReadAsync answers) but must not raise OnDataChange — there is no /// subscription handle to attribute it to. /// [Fact] public void HandleMessage_AuthoredButUnsubscribedTag_CachesValue_RaisesNothing() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); var fired = false; mgr.OnDataChange += (_, _) => fired = true; mgr.HandleMessage(OvenTopic, "7.5"u8.ToArray(), retained: false); fired.ShouldBeFalse(); mgr.Values.Read(OvenTempPath).Value.ShouldBe(7.5); } /// After UnsubscribeAsync the reference stops publishing. [Fact] public async Task UnsubscribeAsync_StopsRaisingForThoseReferences() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var count = 0; mgr.OnDataChange += (_, _) => count++; mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false); await mgr.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); mgr.HandleMessage(OvenTopic, "2"u8.ToArray(), retained: false); count.ShouldBe(1); } // --------------------------------------------------------------------------------- // Wildcard-authored topics (accepted by the parser, warned at deploy) // --------------------------------------------------------------------------------- [Theory] [InlineData("f/+/temp", "f/oven/temp", true)] [InlineData("f/+/temp", "f/oven/deep/temp", false)] // '+' is single-level [InlineData("f/#", "f/oven/deep/temp", true)] // '#' is multi-level [InlineData("f/#", "g/oven/temp", false)] public async Task HandleMessage_WildcardAuthoredTopic_UsesRealMqttFilterSemantics( string authoredTopic, string incomingTopic, bool expectMatch) { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{authoredTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var fired = false; mgr.OnDataChange += (_, _) => fired = true; mgr.HandleMessage(incomingTopic, "1"u8.ToArray(), retained: false); fired.ShouldBe(expectMatch); } // --------------------------------------------------------------------------------- // Retained seed // --------------------------------------------------------------------------------- [Fact] public async Task HandleMessage_RetainedMessage_SeedsWhenRetainSeedIsTrue() { var mgr = NewManager(); // retainSeed absent ⇒ true (the factory default). mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); object? gotVal = null; mgr.OnDataChange += (_, e) => gotVal = e.Snapshot.Value; mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true); gotVal.ShouldBe(3.5); } [Fact] public async Task HandleMessage_RetainedMessage_IgnoredWhenRetainSeedIsFalse() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64","retainSeed":false}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var fired = false; mgr.OnDataChange += (_, _) => fired = true; mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true); fired.ShouldBeFalse(); // A live (non-retained) publish on the same tag still lands — retainSeed gates the seed only. mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: false); fired.ShouldBeTrue(); } // --------------------------------------------------------------------------------- // Value extraction — Json / Scalar / Raw, and per-tag degradation // --------------------------------------------------------------------------------- [Theory] [InlineData("$", """21.5""", 21.5)] [InlineData("$.value", """{"value":21.5}""", 21.5)] [InlineData("$.a.b", """{"a":{"b":21.5}}""", 21.5)] [InlineData("$.arr[1]", """{"arr":[1.5,21.5]}""", 21.5)] [InlineData("$['value']", """{"value":21.5}""", 21.5)] public async Task HandleMessage_Json_ExtractsSupportedJsonPathShapes(string jsonPath, string payload, double expected) { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath.Replace("\\", "\\\\").Replace("\"", "\\\"")}}","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false); snap!.StatusCode.ShouldBe(Good); snap.Value.ShouldBe(expected); } [Fact] public async Task HandleMessage_MalformedJsonPayload_PublishesBadDecodingError_NeverThrows() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false)); snap!.StatusCode.ShouldBe(BadDecodingError); snap.Value.ShouldBeNull(); } [Theory] [InlineData("$.missing")] [InlineData("$..value")] // recursive descent — outside the supported subset [InlineData("$.arr[9]")] public async Task HandleMessage_UnresolvableOrUnsupportedJsonPath_PublishesBadDecodingError(string jsonPath) { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, """{"value":21.5,"arr":[1]}"""u8.ToArray(), retained: false); snap!.StatusCode.ShouldBe(BadDecodingError); } [Fact] public async Task HandleMessage_ValueNotCoercibleToDeclaredDataType_PublishesBadTypeMismatch() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false); snap!.StatusCode.ShouldBe(BadTypeMismatch); snap.Value.ShouldBeNull(); } [Theory] [InlineData("Boolean", "true", true)] [InlineData("Int32", "42", 42)] [InlineData("Int64", "42", 42L)] [InlineData("Float32", "1.5", 1.5f)] [InlineData("Float64", "1.5", 1.5)] [InlineData("String", "hello", "hello")] public async Task HandleMessage_Scalar_ParsesByDeclaredDataType(string dataType, string payload, object expected) { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"{{dataType}}"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false); snap!.StatusCode.ShouldBe(Good); snap.Value.ShouldBe(expected); } /// /// Raw means "bytes as-is": the payload is published as its UTF-8 text and the declared /// dataType is deliberately NOT applied (Scalar is the member that coerces). /// [Fact] public async Task HandleMessage_Raw_PublishesUtf8TextAndIgnoresDeclaredDataType() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false); snap!.StatusCode.ShouldBe(Good); snap.Value.ShouldBe("21.5"); } [Fact] public async Task HandleMessage_RawNonUtf8Payload_PublishesBadDecodingError_NeverThrows() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"String"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; Should.NotThrow(() => mgr.HandleMessage(OvenTopic, new byte[] { 0xC3, 0x28 }, retained: false)); snap!.StatusCode.ShouldBe(BadDecodingError); } /// /// HandleMessage runs on MQTTnet's dispatcher thread, so nothing it calls — including a /// misbehaving downstream subscriber — may escape and stall the client's pump. /// [Fact] public async Task HandleMessage_ThrowingSubscriber_DoesNotEscapeToTheDispatcher() { var mgr = NewManager(); mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), ]); await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var delivered = new List(); mgr.OnDataChange += (_, e) => { delivered.Add(e.FullReference); throw new InvalidOperationException("downstream blew up"); }; Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false)); // And a throwing subscriber must not starve the tags behind it in the fan-out. delivered.Count.ShouldBe(2); } // --------------------------------------------------------------------------------- // Registration // --------------------------------------------------------------------------------- [Fact] public void Register_UnmappableTagConfig_IsSkipped_NeverThrows() { var mgr = NewManager(); var mapped = 0; Should.NotThrow(() => mapped = mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), Tag(OvenPressPath, """{"payloadFormat":"Scalar"}"""), // no topic ⇒ rejected by the factory Tag("Plant/Mqtt/broker1/Junk", "not json at all"), ])); mapped.ShouldBe(1); mgr.TryResolve(OvenTempPath, out _).ShouldBeTrue(); mgr.TryResolve(OvenPressPath, out _).ShouldBeFalse(); } /// Re-registering replaces the authored table wholesale — a redeploy is not additive. [Fact] public void Register_Twice_ReplacesTheAuthoredTable() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]); mgr.TryResolve(OvenTempPath, out _).ShouldBeFalse(); mgr.TryResolve(OvenPressPath, out _).ShouldBeTrue(); } // --------------------------------------------------------------------------------- // Filter building — one SUBSCRIBE per distinct topic, strongest QoS, retain handling // --------------------------------------------------------------------------------- [Fact] public void BuildFilters_DedupesTopics_TakesStrongestQos_AndSeedsWhenAnyTagWants() { var a = new MqttTagDefinition("p/a", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 0, RetainSeed: false); var b = new MqttTagDefinition("p/b", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 2, RetainSeed: true); var c = new MqttTagDefinition("p/c", "f/oven/press", MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: null, RetainSeed: false); var filters = MqttSubscriptionManager.BuildFilters([a, b, c], defaultQos: 1); filters.Count.ShouldBe(2); var oven = filters.Single(f => f.Topic == OvenTopic); oven.Qos.ShouldBe(2); // strongest wins — nobody gets a weaker guarantee than authored oven.SeedRetained.ShouldBeTrue(); var press = filters.Single(f => f.Topic == "f/oven/press"); press.Qos.ShouldBe(1); // null ⇒ the driver-level default press.SeedRetained.ShouldBeFalse(); } // --------------------------------------------------------------------------------- // Subscribe / SUBACK contract // --------------------------------------------------------------------------------- [Fact] public async Task SubscribeAsync_IssuesTheFirstSubscribeItself_OneFilterPerDistinctTopic() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), ]); await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(1); transport.Calls[0].Select(f => f.Topic).ShouldBe([OvenTopic]); } [Fact] public async Task SubscribeAsync_HandleDiagnosticId_NamesModeAndFilters() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); handle.DiagnosticId.ShouldContain("Plain"); handle.DiagnosticId.ShouldContain(OvenTopic); } /// An already-subscribed topic is not re-SUBSCRIBEd by a second overlapping subscription. [Fact] public async Task SubscribeAsync_AlreadySubscribedTopic_IsNotResubscribed() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), ]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(1); } [Fact] public async Task SubscribeAsync_UnknownReference_MarksItBadNodeIdUnknown_AndDoesNotThrow() { var mgr = NewManager(); mgr.Register([]); await Should.NotThrowAsync(() => mgr.SubscribeAsync(["Plant/Mqtt/broker1/Nope"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown); } /// /// A SUBACK failure on the ISubscribable path degrades the affected references to Bad /// and returns a handle — it must never hang, and must never throw out of the OPC UA server's /// subscribe call. /// [Fact] public async Task SubscribeAsync_SubackFailure_DegradesRefsToBad_ReturnsHandle_DoesNotThrow() { var transport = new FakeTransport { Reject = _ => true }; var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); // Not wrapped in Should.NotThrowAsync: an escaping exception fails this test on its own, and // the handle must still come back. var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); handle.ShouldNotBeNull(); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); } [Fact] public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow() { var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") }; var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await Should.NotThrowAsync(() => mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken)); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); } // --------------------------------------------------------------------------------- // Reconnect re-subscribe // --------------------------------------------------------------------------------- [Fact] public async Task OnReconnectedAsync_ReSubscribesEveryLiveTopic_Idempotently() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(3); // initial + two reconnects transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]); transport.Calls[2].Select(f => f.Topic).ShouldBe([OvenTopic]); } /// /// A total re-subscribe failure THROWS: 's documented contract is /// that a throwing Reconnected subscriber tears the session down and retries under /// backoff, and a connected-but-deaf client is the one outcome this driver must never serve. /// [Fact] public async Task OnReconnectedAsync_TotalFailure_Throws_SoTheSessionIsTornDownAndRetried() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.Throw = new InvalidOperationException("broker gone"); await Should.ThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); } /// /// A PARTIAL rejection (e.g. one ACL-denied topic) must NOT throw — tearing the session down /// forever because one of many topics is denied would take the whole driver dark. Only the /// denied references degrade. /// [Fact] public async Task OnReconnectedAsync_PartialRejection_DoesNotThrow_OnlyDeniedRefsDegrade() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""), ]); await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false); transport.Reject = t => t == "f/oven/press"; await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good); } /// Records every subscribe call so dedupe / re-subscribe can be asserted without a broker. private sealed class FakeTransport : IMqttSubscribeTransport { public List> Calls { get; } = []; /// Topics for which the fake SUBACK reports a rejection. public Func Reject { get; set; } = _ => false; /// When set, the whole subscribe call throws instead of answering. public Exception? Throw { get; set; } public Task> SubscribeAsync( IReadOnlyList filters, CancellationToken cancellationToken) { Calls.Add(filters); if (Throw is not null) throw Throw; IReadOnlyList outcomes = [.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))]; return Task.FromResult(outcomes); } } }