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(); } /// /// An out-of-range driver-level defaultQos must never reach the wire. MQTT defines exactly /// three QoS levels, and this manager's constructor is the single enforcement point for the /// driver options it captures — the same place MaxPayloadBytes is bounded. The /// [Range(0, 2)] attribute on MqttPlainOptions.DefaultQos never ran: nothing in the /// four MQTT projects calls Validator.TryValidateObject, and the options arrive by /// JsonSerializer.Deserialize, so a hand-edited or API-supplied driver config put an /// illegal QoS straight into the SUBSCRIBE. Clamping (rather than falling back to 1) matches what /// the AdminUI driver form already persists, so the two cannot disagree. /// /// The authored driver-level default QoS. /// The QoS the SUBSCRIBE must carry. [Theory] [InlineData(5, 2)] [InlineData(-1, 0)] [InlineData(0, 0)] [InlineData(2, 2)] public async Task DefaultQos_OutsideTheLegalRange_NeverReachesTheSubscribe(int authored, int expected) { var transport = new FakeTransport(); var mgr = new MqttSubscriptionManager( new MqttDriverOptions { Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = authored } }, "mqtt-1", transport); // The tag authors no qos of its own, so the driver-level default is what rides out. mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.Calls.ShouldHaveSingleItem().ShouldHaveSingleItem().Qos.ShouldBe(expected); } // --------------------------------------------------------------------------------- // 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); } /// /// Driver.Mqtt-007. Live subscribed topics that resolve to ZERO filters re-establish /// nothing, which is the same outcome as a total SUBACK failure and must get the same answer: /// throw, so tears the fresh session down and retries rather than /// serving a connected-but-deaf driver. A warning was not enough — the whole point of the type's /// split contract is that "no subscription was re-established" is fatal to the session. /// /// /// The state is reached the only way production can reach it: /// prunes _subscribedTopics of the topics its new table no longer carries, but a SUBSCRIBE /// already in flight records its topic after that prune has run — so a redeploy landing /// inside the subscribe round-trip leaves a live topic no authored tag resolves to. /// [Fact] public async Task OnReconnectedAsync_LiveTopicResolvingNoFilter_Throws_RatherThanServingADeafSession() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); transport.DuringSubscribe = () => mgr.Register([]); // the redeploy lands mid-round-trip await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.DuringSubscribe = null; await Should.ThrowAsync( () => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); } /// /// The falsifying control: a reconnect with no live subscriptions at all re-establishes nothing /// because there is nothing to establish, and must stay a no-op. Throwing here would tear down /// the session of every driver whose tags have not been subscribed yet. /// [Fact] public async Task OnReconnectedAsync_NoLiveSubscriptions_IsANoOp() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]); await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); transport.Calls.Count.ShouldBe(0); } // --------------------------------------------------------------------------------- // C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string. // Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed // filter, which for a wildcard tag is its PATTERN. Reading the concrete-only index there // silently drops the tag: no exception, no log, last Good value frozen forever. // --------------------------------------------------------------------------------- [Fact] public async Task OnReconnectedAsync_WildcardAuthoredTopic_IsReSubscribed() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(1); await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(2); transport.Calls[1].Select(f => f.Topic).ShouldBe(["f/+/temp"]); } /// /// The end-to-end shape of the defect: after a reconnect the wildcard tag must still receive /// data. A dropped re-subscribe is invisible in every other assertion — the cache keeps its last /// Good value, so nothing turns Bad. /// [Fact] public async Task OnReconnectedAsync_WildcardAuthoredTopic_KeepsDeliveringAfterwards() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); // The broker only forwards what it was re-subscribed to; model that by asserting the filter is // live, then that a matching publish still lands. transport.Calls[^1].Select(f => f.Topic).ShouldContain("f/+/temp"); object? got = null; mgr.OnDataChange += (_, e) => got = e.Snapshot.Value; mgr.HandleMessage("f/oven/temp", "9.5"u8.ToArray(), retained: false); got.ShouldBe(9.5); } /// /// A total re-subscribe failure must throw for a wildcard-only driver too. Before the fix the /// wildcard tag never reached the filter set, so filters.Count == 0 took a silent early /// return and the caller was told everything was fine. /// [Fact] public async Task OnReconnectedAsync_WildcardOnly_TotalFailure_StillThrows() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","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)); } [Fact] public async Task SubscribeAsync_WildcardAuthoredTopic_SubackRejection_DegradesItsRefs() { var transport = new FakeTransport { Reject = _ => true }; var mgr = NewManager(transport); mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); // Before the fix this stayed at BadWaitingForInitialData — the broker refused the subscription // and the tag never learned it was unreachable. mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); } [Fact] public async Task OnReconnectedAsync_WildcardAuthoredTopic_PartialRejection_DegradesTheWildcardRefs() { var transport = new FakeTransport(); var mgr = NewManager(transport); mgr.Register( [ Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""), Tag(OvenPressPath, """{"topic":"f/+/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/+/press"; await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken)); mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good); } // --------------------------------------------------------------------------------- // I2 — bounded decode on the dispatcher thread // --------------------------------------------------------------------------------- [Fact] public async Task HandleMessage_PayloadOverTheCeiling_IsRefusedBeforeDecode() { var mgr = new MqttSubscriptionManager( new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", maxPayloadBytes: 16); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"String"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, new byte[17], retained: false); snap!.StatusCode.ShouldBe(BadDecodingError); // …and a body at the ceiling still flows. mgr.HandleMessage(OvenTopic, "0123456789ABCDEF"u8.ToArray(), retained: false); snap.StatusCode.ShouldBe(Good); } [Fact] public void MaxPayloadBytes_NonPositive_FallsBackToTheDefault() => new MqttSubscriptionManager(new MqttDriverOptions(), "d", maxPayloadBytes: 0) .MaxPayloadBytes.ShouldBe(MqttSubscriptionManager.DefaultMaxPayloadBytes); // --------------------------------------------------------------------------------- // I3 — integral-valued reals against integer tags // --------------------------------------------------------------------------------- [Theory] [InlineData("Int32", """{"value":5.0}""", 5)] [InlineData("Int32", """{"value":5}""", 5)] [InlineData("Int64", """{"value":5.0}""", 5L)] [InlineData("Int16", """{"value":5.0}""", (short)5)] [InlineData("UInt32", """{"value":5.0}""", 5u)] public async Task HandleMessage_Json_IntegralValuedReal_CoercesToIntegerTag( string dataType, string payload, object expected) { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","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); } /// A genuinely fractional value is still refused — never rounded into a wrong value. [Fact] public async Task HandleMessage_Json_FractionalReal_StillRefusedByIntegerTag() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int32"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, """{"value":5.5}"""u8.ToArray(), retained: false); snap!.StatusCode.ShouldBe(BadTypeMismatch); snap.Value.ShouldBeNull(); } [Fact] public async Task HandleMessage_Json_OutOfRangeIntegralReal_IsRefused() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int16"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, """{"value":99999.0}"""u8.ToArray(), retained: false); snap!.StatusCode.ShouldBe(BadTypeMismatch); } [Fact] public async Task HandleMessage_Scalar_IntegralValuedReal_CoercesToIntegerTag() { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Int32"}""")]); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); DataValueSnapshot? snap = null; mgr.OnDataChange += (_, e) => snap = e.Snapshot; mgr.HandleMessage(OvenTopic, "5.0"u8.ToArray(), retained: false); snap!.StatusCode.ShouldBe(Good); snap.Value.ShouldBe(5); } // --------------------------------------------------------------------------------- // I5 — a JSON string holding a number, against a numeric tag (documented as supported) // --------------------------------------------------------------------------------- [Theory] [InlineData("Float64", """{"value":"21.5"}""", 21.5)] [InlineData("Int32", """{"value":"5"}""", 5)] [InlineData("Boolean", """{"value":"true"}""", true)] public async Task HandleMessage_Json_StringHoldingANumber_CoercesToTheNumericTag( string dataType, string payload, object expected) { var mgr = NewManager(); mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","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); } // --------------------------------------------------------------------------------- // Stale-state pruning on redeploy // --------------------------------------------------------------------------------- [Fact] public async Task Register_DroppingATag_StopsItsTopicBeingReSubscribedForever() { 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); mgr.Register([]); // redeploy drops the tag await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe } // --------------------------------------------------------------------------------- // Session rebind — this manager OUTLIVES the broker session it established filters on // --------------------------------------------------------------------------------- /// /// Driver.Mqtt-001. A driver-host reconnect (ShutdownAsync + InitializeAsync, the #527 /// watchdog's path) builds a brand-new but keeps this manager /// — MqttDriver.AdoptOptions only replaces it when the ingest identity changed. The new /// session holds none of the old one's subscriptions, so the host's post-reconnect re-subscribe /// must actually reach the broker. While _subscribedTopics survived the rebind it resolved /// an empty pending set, sent no SUBSCRIBE at all, and left the driver Connected/Healthy and /// permanently deaf. /// /// /// The rebound connection is deliberately a real, unconnected one: its /// SubscribeAsync refuses ("no established session"), which EstablishAsync catches /// and turns into BadCommunicationError on the affected references. That Bad IS the proof /// the subscribe was attempted — with the bug the tag keeps the Good value it was last delivered, /// because nothing is attempted at all. /// [Fact] public async Task AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession() { 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); mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good); // The session underneath is rebuilt and this manager is re-attached to it. await using var rebound = new MqttConnection( new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1"); mgr.AttachTo(rebound); await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe( BadCommunicationError, "the re-subscribe must be issued against the NEW session, not skipped as already-established"); } /// /// The falsifiability control for the test above: within ONE session the dedupe must still hold, /// or the fix would simply be "re-SUBSCRIBE everything on every subscribe" — which re-triggers /// every topic's retained seed and is the behaviour _subscribedTopics exists to prevent. /// [Fact] public async Task SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe() { 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.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); transport.Calls.Count.ShouldBe(1); } // --------------------------------------------------------------------------------- // Unchanged-value suppression — MQTT is push-based, so the edge dedupe every poll driver // gets from PollGroupEngine has to live here instead. // --------------------------------------------------------------------------------- /// /// A broker that timer-republishes an identical payload must not re-trigger the whole /// downstream fan-out (actor hop → mux → OPC UA sink write). The value is still cached /// — the republish freshens the snapshot so IReadable.ReadAsync stays current — but no /// OnDataChange is raised. "Cached but not raised" is an established state here; see /// . /// [Fact] public async Task An_identical_republish_raises_nothing() { 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); var raised = new List(); mgr.OnDataChange += (_, e) => raised.Add(e.Snapshot); mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); raised.Count.ShouldBe(1); raised[0].Value.ShouldBe(21.5); // The suppressed republish still WROTE the cache: the cached snapshot is the second message's // instance, not the one that was raised. Asserting on the timestamp alone would be flaky — // two dispatcher-thread calls can share a clock tick — while the instance identity is exact. var cached = mgr.Values.Read(OvenTempPath); cached.Value.ShouldBe(21.5); cached.StatusCode.ShouldBe(Good); cached.ShouldNotBeSameAs(raised[0]); cached.ServerTimestampUtc.ShouldBeGreaterThanOrEqualTo(raised[0].ServerTimestampUtc); } /// /// The falsification control for : /// suppression must be value-based, not once-only. An implementation that simply /// raised the first message per reference and went quiet passes that test and takes the /// driver permanently dark. /// [Fact] public async Task A_changed_value_after_a_suppressed_republish_raises() { 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); var raised = new List(); mgr.OnDataChange += (_, e) => raised.Add(e.Snapshot.Value); mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); // suppressed mgr.HandleMessage(OvenTopic, """{"value":22.5}"""u8.ToArray(), retained: false); mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false); // back again — a real change raised.ShouldBe([21.5, 22.5, 21.5]); } /// /// Suppression compares status as well as value, so a quality transition on an otherwise /// unchanged reference still publishes. The case that matters is /// DegradeTopic: a Good value followed by a refused subscription must reach the /// subscriber as Bad, or the client keeps a stale Good forever. /// /// /// This pins the DegradeTopic path specifically — DegradeTopic's own /// Raise call was already unconditional before the suppression change and is untouched /// by it. It does NOT exercise the prior.StatusCode == snapshot.StatusCode comparison /// added inside ; see /// for that. /// [Fact] public async Task A_status_transition_still_raises() { 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, "21.5"u8.ToArray(), retained: false); var raised = new List<(string Reference, uint Status)>(); mgr.OnDataChange += (_, e) => raised.Add((e.FullReference, e.Snapshot.StatusCode)); // Only the oven-temp topic is refused, so the re-subscribe is a PARTIAL rejection and does // not throw — one topic is still granted. transport.Reject = t => t == OvenTopic; await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken); raised.ShouldContain((OvenTempPath, BadCommunicationError)); mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); } /// /// Pins the status-code half of Deliver's suppression comparison /// (prior.StatusCode == snapshot.StatusCode) through the LIVE /// path, which /// does not reach (that test goes through the /// unconditional DegradeTopic path instead). Two different malformed payloads on the /// SAME topic each decode to a null value but a DIFFERENT Bad status code — a decode /// failure (BadDecodingError) followed by a value that decodes but fails type coercion /// (BadTypeMismatch). A value-only comparison (ignoring status) would see /// null == null on both transitions and wrongly suppress the second. /// [Fact] public async Task Different_malformed_payloads_with_the_same_null_value_both_raise() { 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); var raised = new List(); mgr.OnDataChange += (_, e) => raised.Add(e.Snapshot); // Decode failure: not valid JSON at all. mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false); // Decodes fine, but the selected value does not fit the declared Float64 type. mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false); raised.Count.ShouldBe(2); raised[0].StatusCode.ShouldBe(BadDecodingError); raised[0].Value.ShouldBeNull(); raised[1].StatusCode.ShouldBe(BadTypeMismatch); raised[1].Value.ShouldBeNull(); } /// 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; } /// /// Runs inside the subscribe round-trip, so a test can land a concurrent /// in the window between its prune and this /// call recording its topic as live. /// public Action? DuringSubscribe { get; set; } public Task> SubscribeAsync( IReadOnlyList filters, CancellationToken cancellationToken) { Calls.Add(filters); DuringSubscribe?.Invoke(); 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); } } }