// MQTT QoS and retained message tests. // Go reference: golang/nats-server/server/mqtt.go // Retained messages — mqttHandleRetainedMsg / mqttGetRetainedMessages (~lines 1600–1700) // QoS 2 flow — mqttProcessPubRec / mqttProcessPubRel / mqttProcessPubComp (~lines 1300–1400) using System.Text; using NATS.Server.Mqtt; namespace NATS.Server.Tests.Mqtt; public class MqttQosTests { [Fact] public void RetainedStore_SetAndGet_RoundTrips() { // Go reference: server/mqtt.go mqttHandleRetainedMsg — store and retrieve var store = new MqttRetainedStore(); var payload = Encoding.UTF8.GetBytes("temperature=72.5"); store.SetRetained("sensors/temp", payload); var result = store.GetRetained("sensors/temp"); result.ShouldNotBeNull(); Encoding.UTF8.GetString(result.Value.Span).ShouldBe("temperature=72.5"); } [Fact] public void RetainedStore_EmptyPayload_ClearsRetained() { // Go reference: server/mqtt.go mqttHandleRetainedMsg — empty payload clears var store = new MqttRetainedStore(); store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("old-value")); store.SetRetained("sensors/temp", ReadOnlyMemory.Empty); store.GetRetained("sensors/temp").ShouldBeNull(); } [Fact] public void RetainedStore_Overwrite_ReplacesOld() { // Go reference: server/mqtt.go mqttHandleRetainedMsg — overwrite replaces var store = new MqttRetainedStore(); store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("first")); store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("second")); var result = store.GetRetained("sensors/temp"); result.ShouldNotBeNull(); Encoding.UTF8.GetString(result.Value.Span).ShouldBe("second"); } [Fact] public void RetainedStore_GetMatching_WildcardPlus() { // Go reference: server/mqtt.go mqttGetRetainedMessages — '+' single-level wildcard var store = new MqttRetainedStore(); store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("72.5")); store.SetRetained("sensors/humidity", Encoding.UTF8.GetBytes("45%")); store.SetRetained("alerts/fire", Encoding.UTF8.GetBytes("!")); var matches = store.GetMatchingRetained("sensors/+"); matches.Count.ShouldBe(2); matches.Select(m => m.Topic).ShouldBe( new[] { "sensors/temp", "sensors/humidity" }, ignoreOrder: true); } [Fact] public void RetainedStore_GetMatching_WildcardHash() { // Go reference: server/mqtt.go mqttGetRetainedMessages — '#' multi-level wildcard var store = new MqttRetainedStore(); store.SetRetained("home/living/temp", Encoding.UTF8.GetBytes("22")); store.SetRetained("home/living/light", Encoding.UTF8.GetBytes("on")); store.SetRetained("home/kitchen/temp", Encoding.UTF8.GetBytes("24")); store.SetRetained("office/desk/light", Encoding.UTF8.GetBytes("off")); var matches = store.GetMatchingRetained("home/#"); matches.Count.ShouldBe(3); matches.Select(m => m.Topic).ShouldBe( new[] { "home/living/temp", "home/living/light", "home/kitchen/temp" }, ignoreOrder: true); } [Fact] public void Qos2_FullFlow_PubRecPubRelPubComp() { // Go reference: server/mqtt.go mqttProcessPubRec / mqttProcessPubRel / mqttProcessPubComp var sm = new MqttQos2StateMachine(); // Begin publish sm.BeginPublish(100).ShouldBeTrue(); sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubRec); // PUBREC sm.ProcessPubRec(100).ShouldBeTrue(); sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubRel); // PUBREL sm.ProcessPubRel(100).ShouldBeTrue(); sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubComp); // PUBCOMP — completes and removes flow sm.ProcessPubComp(100).ShouldBeTrue(); sm.GetState(100).ShouldBeNull(); } [Fact] public void Qos2_DuplicatePublish_Rejected() { // Go reference: server/mqtt.go — duplicate packet ID rejected during active flow var sm = new MqttQos2StateMachine(); sm.BeginPublish(200).ShouldBeTrue(); // Same packet ID while flow is active — should be rejected sm.BeginPublish(200).ShouldBeFalse(); } [Fact] public void Qos2_IncompleteFlow_TimesOut() { // Go reference: server/mqtt.go — incomplete QoS 2 flows time out var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero)); var sm = new MqttQos2StateMachine(timeout: TimeSpan.FromSeconds(5), timeProvider: fakeTime); sm.BeginPublish(300).ShouldBeTrue(); // Not timed out yet fakeTime.Advance(TimeSpan.FromSeconds(3)); sm.GetTimedOutFlows().ShouldBeEmpty(); // Advance past timeout fakeTime.Advance(TimeSpan.FromSeconds(3)); var timedOut = sm.GetTimedOutFlows(); timedOut.Count.ShouldBe(1); timedOut[0].ShouldBe((ushort)300); // Clean up sm.RemoveFlow(300); sm.GetState(300).ShouldBeNull(); } [Fact] public void Qos1_Puback_RemovesPending() { // Go reference: server/mqtt.go — QoS 1 PUBACK removes from pending // This tests the existing MqttListener pending publish / ack mechanism // in the context of the session store. var store = new MqttSessionStore(); var session = new MqttSessionData { ClientId = "qos1-client", PendingPublishes = [ new MqttPendingPublish(1, "topic/a", "payload-a"), new MqttPendingPublish(2, "topic/b", "payload-b"), ], }; store.SaveSession(session); // Simulate PUBACK for packet 1: remove it from pending var loaded = store.LoadSession("qos1-client"); loaded.ShouldNotBeNull(); loaded.PendingPublishes.RemoveAll(p => p.PacketId == 1); store.SaveSession(loaded); // Verify only packet 2 remains var updated = store.LoadSession("qos1-client"); updated.ShouldNotBeNull(); updated.PendingPublishes.Count.ShouldBe(1); updated.PendingPublishes[0].PacketId.ShouldBe(2); } [Fact] public void RetainedStore_GetMatching_NoMatch_ReturnsEmpty() { // Go reference: server/mqtt.go mqttGetRetainedMessages — no match returns empty var store = new MqttRetainedStore(); store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("72")); var matches = store.GetMatchingRetained("alerts/+"); matches.ShouldBeEmpty(); } }