feat: implement full MQTT Go parity across 5 phases — binary protocol, auth/TLS, cross-protocol bridging, monitoring, and JetStream persistence
Phase 1: Binary MQTT 3.1.1 wire protocol with PipeReader-based parsing, full packet type dispatch, and MQTT 3.1.1 compliance checks. Phase 2: Auth pipeline routing MQTT CONNECT through AuthService, TLS transport with SslStream wrapping, pinned cert validation. Phase 3: IMessageRouter refactor (NatsClient → INatsClient), MqttNatsClientAdapter for cross-protocol bridging, MqttTopicMapper with full Go-parity topic/subject translation. Phase 4: /connz mqtt_client field population, /varz actual MQTT port. Phase 5: JetStream persistence — MqttStreamInitializer creates 5 internal streams, MqttConsumerManager for QoS 1/2 consumers, subject-keyed session/retained lookups replacing linear scans. All 503 MQTT tests and 1589 Core tests pass.
This commit is contained in:
@@ -28,6 +28,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Subscribe_exact_topic_receives_matching_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -55,6 +56,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Subscribe_exact_topic_does_not_receive_non_matching_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -82,6 +84,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Subscribe_two_level_topic_receives_matching_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -109,6 +112,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Unsubscribe_stops_message_delivery()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -156,6 +160,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Publish_qos0_and_qos1_both_work()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -396,6 +401,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Subscription_matching_is_case_sensitive()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -430,6 +436,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Clean_session_reconnect_produces_no_pending_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -461,6 +468,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Duplicate_client_id_second_connection_accepted()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -485,6 +493,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Server_accepts_tcp_connections()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -503,6 +512,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Connack_is_first_response_to_connect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -523,6 +533,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Multiple_subscriptions_to_same_topic_do_not_cause_duplicates()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -557,6 +568,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Rapid_connect_disconnect_cycles_do_not_crash_server()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -578,6 +590,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Unacked_qos1_messages_are_redelivered_on_reconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -688,6 +701,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Listener_allocates_dynamic_port_when_zero_specified()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -704,6 +718,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Multiple_subscribers_on_different_topics_receive_correct_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -747,6 +762,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Client_connect_and_disconnect_lifecycle()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -836,6 +852,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Persistent_session_redelivers_unacked_on_reconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -888,6 +905,7 @@ public class MqttAdvancedParityTests
|
||||
public async Task Concurrent_publishers_deliver_to_single_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public class MqttAuthIntegrationTests
|
||||
public async Task Invalid_mqtt_credentials_or_keepalive_timeout_close_session_with_protocol_error()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0, requiredUsername: "mqtt", requiredPassword: "secret");
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ public class MqttAuthParityTests
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "mqtt",
|
||||
requiredPassword: "client");
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -43,6 +44,7 @@ public class MqttAuthParityTests
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "mqtt",
|
||||
requiredPassword: "client");
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -64,6 +66,7 @@ public class MqttAuthParityTests
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "mqtt",
|
||||
requiredPassword: "secret");
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -82,6 +85,7 @@ public class MqttAuthParityTests
|
||||
public async Task No_auth_configured_connects_without_credentials()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -97,6 +101,7 @@ public class MqttAuthParityTests
|
||||
public async Task No_auth_configured_accepts_any_credentials()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -164,6 +169,7 @@ public class MqttAuthParityTests
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "admin",
|
||||
requiredPassword: "password");
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -193,6 +199,7 @@ public class MqttAuthParityTests
|
||||
public async Task Keepalive_timeout_disconnects_idle_client()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -279,6 +286,7 @@ public class MqttAuthParityTests
|
||||
public async Task Non_connect_as_first_packet_is_handled()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -300,6 +308,7 @@ public class MqttAuthParityTests
|
||||
public async Task Second_connect_from_same_tcp_connection_is_handled()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
865
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttBinaryProtocolTests.cs
Normal file
865
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttBinaryProtocolTests.cs
Normal file
@@ -0,0 +1,865 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the binary MQTT 3.1.1 wire protocol implementation.
|
||||
/// Covers: TryRead, ParseUnsubscribe, new WriteXxx methods, PipeReader-based
|
||||
/// connection handling, and MQTT 3.1.1 compliance rules.
|
||||
/// </summary>
|
||||
public class MqttBinaryProtocolTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttPacketReader.TryRead tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryRead_complete_connect_packet_succeeds()
|
||||
{
|
||||
// Build a CONNECT packet
|
||||
var connectPayload = BuildConnectPayload("test-client");
|
||||
var raw = MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload);
|
||||
var seq = new ReadOnlySequence<byte>(raw);
|
||||
|
||||
MqttPacketReader.TryRead(seq, out var packet, out var consumed).ShouldBeTrue();
|
||||
packet.ShouldNotBeNull();
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
seq.GetOffset(consumed).ShouldBe(raw.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_returns_false_on_partial_fixed_header()
|
||||
{
|
||||
var seq = new ReadOnlySequence<byte>([0x10]); // just first byte, no remaining length
|
||||
MqttPacketReader.TryRead(seq, out var packet, out _).ShouldBeFalse();
|
||||
packet.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_returns_false_on_partial_payload()
|
||||
{
|
||||
// CONNECT with remaining length indicating 10 bytes but only 3 present
|
||||
var raw = new byte[] { 0x10, 10, 0x00, 0x04, 0x4D }; // truncated
|
||||
var seq = new ReadOnlySequence<byte>(raw);
|
||||
MqttPacketReader.TryRead(seq, out var packet, out _).ShouldBeFalse();
|
||||
packet.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_handles_multi_byte_remaining_length()
|
||||
{
|
||||
// Create a packet with remaining length = 200 (requires 2 bytes to encode)
|
||||
var payload = new byte[200];
|
||||
var raw = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload, flags: 0x00);
|
||||
var seq = new ReadOnlySequence<byte>(raw);
|
||||
|
||||
MqttPacketReader.TryRead(seq, out var packet, out var consumed).ShouldBeTrue();
|
||||
packet.ShouldNotBeNull();
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
packet.RemainingLength.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_handles_segmented_sequence()
|
||||
{
|
||||
// Simulate a split packet across two segments
|
||||
var connectPayload = BuildConnectPayload("seg-client");
|
||||
var raw = MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload);
|
||||
var mid = raw.Length / 2;
|
||||
|
||||
var first = new ReadOnlyMemory<byte>(raw, 0, mid);
|
||||
var second = new ReadOnlyMemory<byte>(raw, mid, raw.Length - mid);
|
||||
|
||||
var firstSegment = new MemorySegment<byte>(first);
|
||||
var lastSegment = firstSegment.Append(second);
|
||||
var seq = new ReadOnlySequence<byte>(firstSegment, 0, lastSegment, second.Length);
|
||||
|
||||
MqttPacketReader.TryRead(seq, out var packet, out _).ShouldBeTrue();
|
||||
packet.ShouldNotBeNull();
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_reads_multiple_packets_from_buffer()
|
||||
{
|
||||
var ping = MqttPacketWriter.Write(MqttControlPacketType.PingReq, []);
|
||||
var combined = new byte[ping.Length * 3];
|
||||
ping.CopyTo(combined, 0);
|
||||
ping.CopyTo(combined, ping.Length);
|
||||
ping.CopyTo(combined, ping.Length * 2);
|
||||
|
||||
var seq = new ReadOnlySequence<byte>(combined);
|
||||
var count = 0;
|
||||
|
||||
while (MqttPacketReader.TryRead(seq, out var packet, out var consumed))
|
||||
{
|
||||
packet!.Type.ShouldBe(MqttControlPacketType.PingReq);
|
||||
seq = seq.Slice(consumed);
|
||||
count++;
|
||||
}
|
||||
|
||||
count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_zero_remaining_length_packet()
|
||||
{
|
||||
// PINGREQ has 0 remaining length
|
||||
var raw = MqttPacketWriter.Write(MqttControlPacketType.PingReq, []);
|
||||
raw.Length.ShouldBe(2); // 1 byte header + 1 byte remaining length (0)
|
||||
|
||||
var seq = new ReadOnlySequence<byte>(raw);
|
||||
MqttPacketReader.TryRead(seq, out var packet, out _).ShouldBeTrue();
|
||||
packet!.Type.ShouldBe(MqttControlPacketType.PingReq);
|
||||
packet.RemainingLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttBinaryDecoder.ParseUnsubscribe tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ParseUnsubscribe_single_filter()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
// Packet ID
|
||||
payload.Add(0x00);
|
||||
payload.Add(0x0A); // 10
|
||||
// Topic filter
|
||||
var filter = Encoding.UTF8.GetBytes("sensor/temp");
|
||||
payload.Add((byte)(filter.Length >> 8));
|
||||
payload.Add((byte)(filter.Length & 0xFF));
|
||||
payload.AddRange(filter);
|
||||
|
||||
var result = MqttBinaryDecoder.ParseUnsubscribe([.. payload]);
|
||||
result.PacketId.ShouldBe((ushort)10);
|
||||
result.Filters.Count.ShouldBe(1);
|
||||
result.Filters[0].ShouldBe("sensor/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUnsubscribe_multiple_filters()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
payload.Add(0x00);
|
||||
payload.Add(0x01); // Packet ID = 1
|
||||
foreach (var topic in new[] { "a/b", "c/d", "e/f" })
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(topic);
|
||||
payload.Add((byte)(bytes.Length >> 8));
|
||||
payload.Add((byte)(bytes.Length & 0xFF));
|
||||
payload.AddRange(bytes);
|
||||
}
|
||||
|
||||
var result = MqttBinaryDecoder.ParseUnsubscribe([.. payload]);
|
||||
result.PacketId.ShouldBe((ushort)1);
|
||||
result.Filters.Count.ShouldBe(3);
|
||||
result.Filters[0].ShouldBe("a/b");
|
||||
result.Filters[1].ShouldBe("c/d");
|
||||
result.Filters[2].ShouldBe("e/f");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUnsubscribe_rejects_invalid_flags()
|
||||
{
|
||||
var payload = new byte[] { 0x00, 0x01, 0x00, 0x01, (byte)'a' };
|
||||
Should.Throw<FormatException>(() => MqttBinaryDecoder.ParseUnsubscribe(payload, flags: 0x00));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUnsubscribe_rejects_empty_filter_list()
|
||||
{
|
||||
// Just packet ID, no filters
|
||||
var payload = new byte[] { 0x00, 0x01 };
|
||||
Should.Throw<FormatException>(() => MqttBinaryDecoder.ParseUnsubscribe(payload));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttPacketWriter response helper tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void WriteConnAck_encodes_correctly()
|
||||
{
|
||||
var data = MqttPacketWriter.WriteConnAck(0x01, 0x00);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.ConnAck);
|
||||
packet.RemainingLength.ShouldBe(2);
|
||||
packet.Payload.Span[0].ShouldBe((byte)0x01); // session present
|
||||
packet.Payload.Span[1].ShouldBe((byte)0x00); // accepted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePubAck_round_trips_packet_id()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePubAck(42);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PubAck);
|
||||
var id = (ushort)((packet.Payload.Span[0] << 8) | packet.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteSubAck_encodes_granted_qos()
|
||||
{
|
||||
byte[] grantedQoS = [0, 1, 2, 0x80]; // 0x80 = failure
|
||||
var data = MqttPacketWriter.WriteSubAck(99, grantedQoS);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.SubAck);
|
||||
// Packet ID
|
||||
var id = (ushort)((packet.Payload.Span[0] << 8) | packet.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)99);
|
||||
// QoS values
|
||||
packet.Payload.Span[2].ShouldBe((byte)0);
|
||||
packet.Payload.Span[3].ShouldBe((byte)1);
|
||||
packet.Payload.Span[4].ShouldBe((byte)2);
|
||||
packet.Payload.Span[5].ShouldBe((byte)0x80);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteUnsubAck_round_trips_packet_id()
|
||||
{
|
||||
var data = MqttPacketWriter.WriteUnsubAck(7);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.UnsubAck);
|
||||
var id = (ushort)((packet.Payload.Span[0] << 8) | packet.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePingResp_is_correct()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePingResp();
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PingResp);
|
||||
packet.RemainingLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePubRec_round_trips_packet_id()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePubRec(100);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PubRec);
|
||||
var id = (ushort)((packet.Payload.Span[0] << 8) | packet.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePubRel_has_correct_flags()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePubRel(50);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PubRel);
|
||||
packet.Flags.ShouldBe((byte)0x02); // PUBREL must have flags 0x02
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePubComp_round_trips_packet_id()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePubComp(200);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PubComp);
|
||||
var id = (ushort)((packet.Payload.Span[0] << 8) | packet.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePublish_qos0_no_packet_id()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePublish("test/topic", "hello"u8, qos: 0);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
var pub = MqttBinaryDecoder.ParsePublish(packet.Payload.Span, packet.Flags);
|
||||
pub.Topic.ShouldBe("test/topic");
|
||||
pub.QoS.ShouldBe((byte)0);
|
||||
pub.PacketId.ShouldBe((ushort)0);
|
||||
Encoding.UTF8.GetString(pub.Payload.Span).ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritePublish_qos1_with_flags()
|
||||
{
|
||||
var data = MqttPacketWriter.WritePublish("a/b", "data"u8, qos: 1, retain: true, dup: true, packetId: 5);
|
||||
var packet = MqttPacketReader.Read(data);
|
||||
var pub = MqttBinaryDecoder.ParsePublish(packet.Payload.Span, packet.Flags);
|
||||
pub.QoS.ShouldBe((byte)1);
|
||||
pub.Retain.ShouldBeTrue();
|
||||
pub.Dup.ShouldBeTrue();
|
||||
pub.PacketId.ShouldBe((ushort)5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Enum completeness
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(MqttControlPacketType.PubRec, 5)]
|
||||
[InlineData(MqttControlPacketType.PubRel, 6)]
|
||||
[InlineData(MqttControlPacketType.PubComp, 7)]
|
||||
[InlineData(MqttControlPacketType.Unsubscribe, 10)]
|
||||
[InlineData(MqttControlPacketType.UnsubAck, 11)]
|
||||
public void Enum_has_all_mqtt_packet_types(MqttControlPacketType type, byte expectedValue)
|
||||
{
|
||||
((byte)type).ShouldBe(expectedValue);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Binary connection integration tests (MQTT 3.1.1 compliance)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_connect_and_ping_pong()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// Send CONNECT
|
||||
await SendMqttPacketAsync(stream, BuildConnectPacket("ping-client"));
|
||||
|
||||
// Read CONNACK
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Type.ShouldBe(MqttControlPacketType.ConnAck);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckAccepted);
|
||||
|
||||
// Send PINGREQ
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.PingReq, []));
|
||||
|
||||
// Read PINGRESP
|
||||
var pingResp = await ReadMqttPacketAsync(stream);
|
||||
pingResp.Type.ShouldBe(MqttControlPacketType.PingResp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_first_packet_must_be_connect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// Send PINGREQ as first packet (not CONNECT) — should be disconnected
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.PingReq, []));
|
||||
|
||||
// Connection should be closed
|
||||
var response = await ReadWithTimeoutAsync(stream, 500);
|
||||
response.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_reject_bad_protocol_level()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// CONNECT with protocol level 5 (not 4)
|
||||
var connectPayload = BuildConnectPayload("bad-level", protocolLevel: 5);
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload));
|
||||
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Type.ShouldBe(MqttControlPacketType.ConnAck);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckUnacceptableProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_empty_clientid_clean_session_generates_id()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// CONNECT with empty client ID + clean session
|
||||
var connectPayload = BuildConnectPayload("", cleanSession: true);
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload));
|
||||
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckAccepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_empty_clientid_persistent_session_rejected()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// CONNECT with empty client ID + persistent session
|
||||
var connectPayload = BuildConnectPayload("", cleanSession: false);
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload));
|
||||
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckIdentifierRejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_auth_failure_returns_not_authorized()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0,
|
||||
requiredUsername: "admin", requiredPassword: "pass");
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// CONNECT with wrong credentials
|
||||
var connectPayload = BuildConnectPayload("auth-fail", username: "wrong", password: "creds");
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload));
|
||||
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckNotAuthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_auth_success_with_credentials()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0,
|
||||
requiredUsername: "admin", requiredPassword: "secret");
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
var connectPayload = BuildConnectPayload("auth-ok", username: "admin", password: "secret");
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload));
|
||||
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckAccepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_subscribe_and_publish_qos0()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
// Subscriber
|
||||
using var subTcp = new TcpClient();
|
||||
await subTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = subTcp.GetStream();
|
||||
await ConnectAsync(subStream, "sub-client");
|
||||
|
||||
// Subscribe to "test/topic"
|
||||
await SendMqttPacketAsync(subStream, BuildSubscribePacket(1, "test/topic", 0));
|
||||
var subAck = await ReadMqttPacketAsync(subStream);
|
||||
subAck.Type.ShouldBe(MqttControlPacketType.SubAck);
|
||||
|
||||
// Publisher
|
||||
using var pubTcp = new TcpClient();
|
||||
await pubTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pubTcp.GetStream();
|
||||
await ConnectAsync(pubStream, "pub-client");
|
||||
|
||||
// Publish to "test/topic"
|
||||
await SendMqttPacketAsync(pubStream,
|
||||
MqttPacketWriter.WritePublish("test/topic", "hello binary"u8));
|
||||
|
||||
// Subscriber should receive PUBLISH
|
||||
var received = await ReadMqttPacketAsync(subStream);
|
||||
received.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
var pub = MqttBinaryDecoder.ParsePublish(received.Payload.Span, received.Flags);
|
||||
pub.Topic.ShouldBe("test/topic");
|
||||
Encoding.UTF8.GetString(pub.Payload.Span).ShouldBe("hello binary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_publish_qos1_gets_puback()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
await ConnectAsync(stream, "qos1-pub");
|
||||
|
||||
// Publish QoS 1
|
||||
await SendMqttPacketAsync(stream,
|
||||
MqttPacketWriter.WritePublish("qos1/topic", "msg"u8, qos: 1, packetId: 42));
|
||||
|
||||
var pubAck = await ReadMqttPacketAsync(stream);
|
||||
pubAck.Type.ShouldBe(MqttControlPacketType.PubAck);
|
||||
var id = (ushort)((pubAck.Payload.Span[0] << 8) | pubAck.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_publish_qos2_full_flow()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
await ConnectAsync(stream, "qos2-pub");
|
||||
|
||||
// Step 1: PUBLISH QoS 2
|
||||
await SendMqttPacketAsync(stream,
|
||||
MqttPacketWriter.WritePublish("qos2/topic", "msg"u8, qos: 2, packetId: 10));
|
||||
|
||||
// Step 2: Receive PUBREC
|
||||
var pubRec = await ReadMqttPacketAsync(stream);
|
||||
pubRec.Type.ShouldBe(MqttControlPacketType.PubRec);
|
||||
|
||||
// Step 3: Send PUBREL
|
||||
await SendMqttPacketAsync(stream, MqttPacketWriter.WritePubRel(10));
|
||||
|
||||
// Step 4: Receive PUBCOMP
|
||||
var pubComp = await ReadMqttPacketAsync(stream);
|
||||
pubComp.Type.ShouldBe(MqttControlPacketType.PubComp);
|
||||
var id = (ushort)((pubComp.Payload.Span[0] << 8) | pubComp.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_unsubscribe_returns_unsuback()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
await ConnectAsync(stream, "unsub-client");
|
||||
|
||||
// Subscribe
|
||||
await SendMqttPacketAsync(stream, BuildSubscribePacket(1, "test/unsub", 0));
|
||||
_ = await ReadMqttPacketAsync(stream); // SUBACK
|
||||
|
||||
// Unsubscribe
|
||||
await SendMqttPacketAsync(stream, BuildUnsubscribePacket(2, "test/unsub"));
|
||||
var unsubAck = await ReadMqttPacketAsync(stream);
|
||||
unsubAck.Type.ShouldBe(MqttControlPacketType.UnsubAck);
|
||||
var id = (ushort)((unsubAck.Payload.Span[0] << 8) | unsubAck.Payload.Span[1]);
|
||||
id.ShouldBe((ushort)2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_unsubscribe_stops_message_delivery()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
// Subscriber
|
||||
using var subTcp = new TcpClient();
|
||||
await subTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = subTcp.GetStream();
|
||||
await ConnectAsync(subStream, "unsub-recv");
|
||||
|
||||
await SendMqttPacketAsync(subStream, BuildSubscribePacket(1, "nosub/topic", 0));
|
||||
_ = await ReadMqttPacketAsync(subStream); // SUBACK
|
||||
|
||||
// Unsubscribe
|
||||
await SendMqttPacketAsync(subStream, BuildUnsubscribePacket(2, "nosub/topic"));
|
||||
_ = await ReadMqttPacketAsync(subStream); // UNSUBACK
|
||||
|
||||
// Publisher
|
||||
using var pubTcp = new TcpClient();
|
||||
await pubTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pubTcp.GetStream();
|
||||
await ConnectAsync(pubStream, "unsub-pub");
|
||||
|
||||
await SendMqttPacketAsync(pubStream,
|
||||
MqttPacketWriter.WritePublish("nosub/topic", "invisible"u8));
|
||||
|
||||
// Subscriber should NOT receive anything
|
||||
var result = await ReadWithTimeoutAsync(subStream, 200);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_disconnect_clears_will_message()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
// Subscriber for will topic
|
||||
using var subTcp = new TcpClient();
|
||||
await subTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = subTcp.GetStream();
|
||||
await ConnectAsync(subStream, "will-sub");
|
||||
await SendMqttPacketAsync(subStream, BuildSubscribePacket(1, "will/topic", 0));
|
||||
_ = await ReadMqttPacketAsync(subStream); // SUBACK
|
||||
|
||||
// Client with will
|
||||
using var willTcp = new TcpClient();
|
||||
await willTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var willStream = willTcp.GetStream();
|
||||
var connectPayload = BuildConnectPayload("will-client",
|
||||
willTopic: "will/topic", willMessage: "oops");
|
||||
await SendMqttPacketAsync(willStream, MqttPacketWriter.Write(MqttControlPacketType.Connect, connectPayload));
|
||||
_ = await ReadMqttPacketAsync(willStream); // CONNACK
|
||||
|
||||
// Clean DISCONNECT — should clear will
|
||||
await SendMqttPacketAsync(willStream,
|
||||
MqttPacketWriter.Write(MqttControlPacketType.Disconnect, []));
|
||||
|
||||
// Wait a bit and check that will was NOT published
|
||||
var result = await ReadWithTimeoutAsync(subStream, 300);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_duplicate_clientid_takeover()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
// First connection
|
||||
using var tcp1 = new TcpClient();
|
||||
await tcp1.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream1 = tcp1.GetStream();
|
||||
await ConnectAsync(stream1, "dup-client");
|
||||
|
||||
// Second connection with same client-id (takeover)
|
||||
using var tcp2 = new TcpClient();
|
||||
await tcp2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream2 = tcp2.GetStream();
|
||||
await ConnectAsync(stream2, "dup-client");
|
||||
|
||||
// First connection should be closed
|
||||
var result = await ReadWithTimeoutAsync(stream1, 500);
|
||||
result.ShouldBeNull();
|
||||
|
||||
// Second connection should still work (PINGREQ/PINGRESP)
|
||||
await SendMqttPacketAsync(stream2, MqttPacketWriter.Write(MqttControlPacketType.PingReq, []));
|
||||
var pingResp = await ReadMqttPacketAsync(stream2);
|
||||
pingResp.Type.ShouldBe(MqttControlPacketType.PingResp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_subscribe_flags_validation()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
await ConnectAsync(stream, "bad-sub-flags");
|
||||
|
||||
// Send SUBSCRIBE with wrong flags (0x00 instead of 0x02)
|
||||
var subPayload = BuildSubscribePayload(1, "test/topic", 0);
|
||||
var badPacket = MqttPacketWriter.Write(MqttControlPacketType.Subscribe, subPayload, flags: 0x00);
|
||||
await SendMqttPacketAsync(stream, badPacket);
|
||||
|
||||
// Connection should be closed
|
||||
var result = await ReadWithTimeoutAsync(stream, 500);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Binary_retained_message_tombstone()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
await listener.StartAsync(CancellationToken.None);
|
||||
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = tcp.GetStream();
|
||||
await ConnectAsync(stream, "retain-client");
|
||||
|
||||
// Publish retained message
|
||||
await SendMqttPacketAsync(stream,
|
||||
MqttPacketWriter.WritePublish("retain/topic", "kept"u8, retain: true));
|
||||
|
||||
// Wait for the server to process the retained publish
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
if (listener.GetRetainedMessage("retain/topic") != null)
|
||||
break;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
// Verify retained
|
||||
listener.GetRetainedMessage("retain/topic").ShouldBe("kept");
|
||||
|
||||
// Publish empty retained (tombstone)
|
||||
await SendMqttPacketAsync(stream,
|
||||
MqttPacketWriter.WritePublish("retain/topic", ReadOnlySpan<byte>.Empty, retain: true));
|
||||
|
||||
// Wait for the server to process the packet
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
if (listener.GetRetainedMessage("retain/topic") == null)
|
||||
break;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
// Verify tombstoned
|
||||
listener.GetRetainedMessage("retain/topic").ShouldBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static async Task ConnectAsync(NetworkStream stream, string clientId)
|
||||
{
|
||||
await SendMqttPacketAsync(stream, BuildConnectPacket(clientId));
|
||||
var connAck = await ReadMqttPacketAsync(stream);
|
||||
connAck.Type.ShouldBe(MqttControlPacketType.ConnAck);
|
||||
connAck.Payload.Span[1].ShouldBe(MqttProtocolConstants.ConnAckAccepted);
|
||||
}
|
||||
|
||||
private static byte[] BuildConnectPacket(string clientId, string? username = null, string? password = null,
|
||||
bool cleanSession = true, byte protocolLevel = 4, string? willTopic = null, string? willMessage = null)
|
||||
{
|
||||
var payload = BuildConnectPayload(clientId, username, password, cleanSession, protocolLevel, willTopic, willMessage);
|
||||
return MqttPacketWriter.Write(MqttControlPacketType.Connect, payload);
|
||||
}
|
||||
|
||||
private static byte[] BuildConnectPayload(string clientId, string? username = null, string? password = null,
|
||||
bool cleanSession = true, byte protocolLevel = 4, string? willTopic = null, string? willMessage = null)
|
||||
{
|
||||
var buf = new List<byte>();
|
||||
|
||||
// Protocol name "MQTT"
|
||||
buf.AddRange(MqttPacketWriter.WriteString("MQTT"));
|
||||
|
||||
// Protocol level
|
||||
buf.Add(protocolLevel);
|
||||
|
||||
// Connect flags
|
||||
byte flags = 0;
|
||||
if (cleanSession) flags |= 0x02;
|
||||
if (username != null) flags |= 0x80;
|
||||
if (password != null) flags |= 0x40;
|
||||
if (willTopic != null)
|
||||
{
|
||||
flags |= 0x04; // will flag
|
||||
// will QoS = 0, will retain = 0
|
||||
}
|
||||
buf.Add(flags);
|
||||
|
||||
// Keep-alive (60 seconds)
|
||||
buf.Add(0x00);
|
||||
buf.Add(0x3C);
|
||||
|
||||
// Client ID
|
||||
buf.AddRange(MqttPacketWriter.WriteString(clientId));
|
||||
|
||||
// Will topic + message
|
||||
if (willTopic != null)
|
||||
{
|
||||
buf.AddRange(MqttPacketWriter.WriteString(willTopic));
|
||||
buf.AddRange(MqttPacketWriter.WriteBytes(
|
||||
Encoding.UTF8.GetBytes(willMessage ?? "")));
|
||||
}
|
||||
|
||||
// Username
|
||||
if (username != null)
|
||||
buf.AddRange(MqttPacketWriter.WriteString(username));
|
||||
|
||||
// Password
|
||||
if (password != null)
|
||||
buf.AddRange(MqttPacketWriter.WriteString(password));
|
||||
|
||||
return [.. buf];
|
||||
}
|
||||
|
||||
private static byte[] BuildSubscribePacket(ushort packetId, string topic, byte qos)
|
||||
{
|
||||
var payload = BuildSubscribePayload(packetId, topic, qos);
|
||||
return MqttPacketWriter.Write(MqttControlPacketType.Subscribe, payload, flags: 0x02);
|
||||
}
|
||||
|
||||
private static byte[] BuildSubscribePayload(ushort packetId, string topic, byte qos)
|
||||
{
|
||||
var buf = new List<byte>();
|
||||
buf.Add((byte)(packetId >> 8));
|
||||
buf.Add((byte)(packetId & 0xFF));
|
||||
buf.AddRange(MqttPacketWriter.WriteString(topic));
|
||||
buf.Add(qos);
|
||||
return [.. buf];
|
||||
}
|
||||
|
||||
private static byte[] BuildUnsubscribePacket(ushort packetId, string topic)
|
||||
{
|
||||
var buf = new List<byte>();
|
||||
buf.Add((byte)(packetId >> 8));
|
||||
buf.Add((byte)(packetId & 0xFF));
|
||||
buf.AddRange(MqttPacketWriter.WriteString(topic));
|
||||
return MqttPacketWriter.Write(MqttControlPacketType.Unsubscribe, [.. buf], flags: 0x02);
|
||||
}
|
||||
|
||||
private static async Task SendMqttPacketAsync(NetworkStream stream, byte[] packet)
|
||||
{
|
||||
await stream.WriteAsync(packet);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
private static async Task<MqttControlPacket> ReadMqttPacketAsync(NetworkStream stream, int timeoutMs = 2000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var buf = new byte[4096];
|
||||
var offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(buf.AsMemory(offset), cts.Token);
|
||||
if (read == 0)
|
||||
throw new IOException("Connection closed while reading MQTT packet");
|
||||
offset += read;
|
||||
|
||||
var seq = new ReadOnlySequence<byte>(buf.AsMemory(0, offset));
|
||||
if (MqttPacketReader.TryRead(seq, out var packet, out _))
|
||||
return packet!;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<MqttControlPacket?> ReadWithTimeoutAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ReadMqttPacketAsync(stream, timeoutMs);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for creating segmented ReadOnlySequence for split-packet tests.
|
||||
/// </summary>
|
||||
private sealed class MemorySegment<T> : ReadOnlySequenceSegment<T>
|
||||
{
|
||||
public MemorySegment(ReadOnlyMemory<T> memory)
|
||||
{
|
||||
Memory = memory;
|
||||
}
|
||||
|
||||
public MemorySegment<T> Append(ReadOnlyMemory<T> memory)
|
||||
{
|
||||
var segment = new MemorySegment<T>(memory)
|
||||
{
|
||||
RunningIndex = RunningIndex + Memory.Length,
|
||||
};
|
||||
Next = segment;
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
164
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttCrossProtocolTests.cs
Normal file
164
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttCrossProtocolTests.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Mqtt;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the MqttNatsClientAdapter and cross-protocol bridging concepts.
|
||||
/// Verifies that MQTT connections can participate in the NATS SubList and
|
||||
/// that topic/subject translation works end-to-end.
|
||||
/// </summary>
|
||||
public class MqttCrossProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void Adapter_implements_INatsClient()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var listener = CreateTestListener();
|
||||
var connection = new MqttConnection(stream, listener);
|
||||
var adapter = new MqttNatsClientAdapter(connection, 42);
|
||||
|
||||
adapter.Id.ShouldBe((ulong)42);
|
||||
adapter.Kind.ShouldBe(ClientKind.Client);
|
||||
adapter.ClientOpts.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_add_and_remove_subscription()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var listener = CreateTestListener();
|
||||
var connection = new MqttConnection(stream, listener);
|
||||
var adapter = new MqttNatsClientAdapter(connection, 1);
|
||||
var account = new Account("test");
|
||||
adapter.Account = account;
|
||||
|
||||
// Add subscription
|
||||
var sub = adapter.AddSubscription("sensor.temp", "sid1");
|
||||
sub.Subject.ShouldBe("sensor.temp");
|
||||
sub.Client.ShouldBe(adapter);
|
||||
adapter.Subscriptions.Count.ShouldBe(1);
|
||||
|
||||
// Verify it's in the SubList
|
||||
var result = account.SubList.Match("sensor.temp");
|
||||
result.PlainSubs.ShouldContain(s => s.Sid == "sid1");
|
||||
|
||||
// Remove subscription
|
||||
adapter.RemoveSubscription("sid1");
|
||||
adapter.Subscriptions.Count.ShouldBe(0);
|
||||
|
||||
// Verify removed from SubList
|
||||
result = account.SubList.Match("sensor.temp");
|
||||
result.PlainSubs.ShouldNotContain(s => s.Sid == "sid1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_remove_all_subscriptions()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var listener = CreateTestListener();
|
||||
var connection = new MqttConnection(stream, listener);
|
||||
var adapter = new MqttNatsClientAdapter(connection, 1);
|
||||
var account = new Account("test");
|
||||
adapter.Account = account;
|
||||
|
||||
adapter.AddSubscription("a.b", "s1");
|
||||
adapter.AddSubscription("c.d", "s2");
|
||||
adapter.AddSubscription("e.f", "s3");
|
||||
adapter.Subscriptions.Count.ShouldBe(3);
|
||||
|
||||
adapter.RemoveAllSubscriptions();
|
||||
adapter.Subscriptions.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_queue_outbound_is_noop()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var listener = CreateTestListener();
|
||||
var connection = new MqttConnection(stream, listener);
|
||||
var adapter = new MqttNatsClientAdapter(connection, 1);
|
||||
|
||||
adapter.QueueOutbound(new byte[] { 1, 2, 3 }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_signal_flush_is_noop()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var listener = CreateTestListener();
|
||||
var connection = new MqttConnection(stream, listener);
|
||||
var adapter = new MqttNatsClientAdapter(connection, 1);
|
||||
|
||||
// Should not throw
|
||||
adapter.SignalFlush();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topic_mapper_integration_with_sublist()
|
||||
{
|
||||
var account = new Account("test");
|
||||
|
||||
// Simulate an MQTT client subscribing to "sensor/+"
|
||||
var natsSubject = MqttTopicMapper.MqttToNats("sensor/+");
|
||||
natsSubject.ShouldBe("sensor.*");
|
||||
|
||||
var sub = new Subscription
|
||||
{
|
||||
Subject = natsSubject,
|
||||
Sid = "mqtt-sub-1",
|
||||
};
|
||||
account.SubList.Insert(sub);
|
||||
|
||||
// Simulate a NATS publish to "sensor.temp" — should match
|
||||
var result = account.SubList.Match("sensor.temp");
|
||||
result.PlainSubs.ShouldContain(s => s.Sid == "mqtt-sub-1");
|
||||
|
||||
// "sensor.humidity" should also match
|
||||
result = account.SubList.Match("sensor.humidity");
|
||||
result.PlainSubs.ShouldContain(s => s.Sid == "mqtt-sub-1");
|
||||
|
||||
// "other.temp" should NOT match
|
||||
result = account.SubList.Match("other.temp");
|
||||
result.PlainSubs.ShouldNotContain(s => s.Sid == "mqtt-sub-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topic_mapper_multilevel_wildcard_with_sublist()
|
||||
{
|
||||
var account = new Account("test");
|
||||
|
||||
// MQTT subscribe to "home/#"
|
||||
var natsSubject = MqttTopicMapper.MqttToNats("home/#");
|
||||
natsSubject.ShouldBe("home.>");
|
||||
|
||||
var sub = new Subscription
|
||||
{
|
||||
Subject = natsSubject,
|
||||
Sid = "mqtt-sub-2",
|
||||
};
|
||||
account.SubList.Insert(sub);
|
||||
|
||||
// Should match multi-level subjects
|
||||
account.SubList.Match("home.living.light").PlainSubs
|
||||
.ShouldContain(s => s.Sid == "mqtt-sub-2");
|
||||
account.SubList.Match("home.kitchen").PlainSubs
|
||||
.ShouldContain(s => s.Sid == "mqtt-sub-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_mqtt_client_id_exposed()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var listener = CreateTestListener();
|
||||
var connection = new MqttConnection(stream, listener);
|
||||
var adapter = new MqttNatsClientAdapter(connection, 1);
|
||||
|
||||
// ClientId comes from the underlying connection
|
||||
adapter.MqttClientId.ShouldBe(string.Empty); // not yet connected
|
||||
}
|
||||
|
||||
private static MqttListener CreateTestListener()
|
||||
=> new("127.0.0.1", 0);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using NATS.Server.Mqtt;
|
||||
// Retained/Session store tests use MemStore + StreamConfig directly
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MQTT JetStream persistence: stream initialization, consumer management,
|
||||
/// QoS 1/2 flow with JetStream backing, session persistence, and retained message persistence.
|
||||
/// Go reference: server/mqtt.go mqttCreateAccountSessionManager, mqttStoreSession,
|
||||
/// mqttHandleRetainedMsg, trackPublish.
|
||||
/// </summary>
|
||||
public class MqttJetStreamPersistenceTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttStreamInitializer
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void StreamInitializer_creates_all_five_streams()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCreateAccountSessionManager creates 5 streams
|
||||
var (streamMgr, _, initializer) = CreateJetStreamInfra();
|
||||
|
||||
initializer.IsInitialized.ShouldBeFalse();
|
||||
initializer.EnsureStreams();
|
||||
initializer.IsInitialized.ShouldBeTrue();
|
||||
|
||||
streamMgr.Exists(MqttProtocolConstants.SessStreamName).ShouldBeTrue();
|
||||
streamMgr.Exists(MqttProtocolConstants.StreamName).ShouldBeTrue();
|
||||
streamMgr.Exists(MqttProtocolConstants.RetainedMsgsStreamName).ShouldBeTrue();
|
||||
streamMgr.Exists(MqttProtocolConstants.QoS2IncomingMsgsStreamName).ShouldBeTrue();
|
||||
streamMgr.Exists(MqttProtocolConstants.OutStreamName).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamInitializer_is_idempotent()
|
||||
{
|
||||
var (streamMgr, _, initializer) = CreateJetStreamInfra();
|
||||
|
||||
initializer.EnsureStreams();
|
||||
initializer.EnsureStreams(); // should not throw
|
||||
|
||||
streamMgr.StreamNames.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttConsumerManager — subscription consumers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ConsumerManager_creates_subscription_consumer()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttProcessSub — creates durable consumer per QoS>0 sub
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
var binding = mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.temp", qos: 1, maxAckPending: 100);
|
||||
|
||||
binding.ShouldNotBeNull();
|
||||
binding.Stream.ShouldBe(MqttProtocolConstants.StreamName);
|
||||
binding.FilterSubject.ShouldBe($"{MqttProtocolConstants.StreamSubjectPrefix}sensor.temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumerManager_removes_subscription_consumer()
|
||||
{
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.temp", qos: 1, maxAckPending: 100);
|
||||
mqttConsumerMgr.GetBinding("client1", "sensor.temp").ShouldNotBeNull();
|
||||
|
||||
mqttConsumerMgr.RemoveSubscriptionConsumer("client1", "sensor.temp");
|
||||
mqttConsumerMgr.GetBinding("client1", "sensor.temp").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumerManager_removes_all_consumers_for_client()
|
||||
{
|
||||
// Go reference: clean session disconnect removes all consumers
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.temp", qos: 1, maxAckPending: 100);
|
||||
mqttConsumerMgr.CreateSubscriptionConsumer("client1", "sensor.humidity", qos: 1, maxAckPending: 100);
|
||||
mqttConsumerMgr.GetClientBindings("client1").Count.ShouldBe(2);
|
||||
|
||||
mqttConsumerMgr.RemoveAllConsumers("client1");
|
||||
mqttConsumerMgr.GetClientBindings("client1").Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// QoS 1 with JetStream
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QoS1_publish_stores_to_stream()
|
||||
{
|
||||
// Go reference: server/mqtt.go QoS 1 publish stores message in $MQTT_msgs
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
var seq = mqttConsumerMgr.PublishToStream("sensor.temp", "72.5"u8.ToArray());
|
||||
|
||||
seq.ShouldBeGreaterThan((ulong)0);
|
||||
|
||||
// Verify message is in the stream
|
||||
streamMgr.TryGet(MqttProtocolConstants.StreamName, out var handle).ShouldBeTrue();
|
||||
var msg = await handle.Store.LoadAsync(seq, default);
|
||||
msg.ShouldNotBeNull();
|
||||
System.Text.Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("72.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QoS1_acknowledge_removes_from_stream()
|
||||
{
|
||||
// Go reference: PUBACK acks the JetStream message
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
var seq = mqttConsumerMgr.PublishToStream("sensor.temp", "72.5"u8.ToArray());
|
||||
mqttConsumerMgr.AcknowledgeMessage(seq).ShouldBeTrue();
|
||||
|
||||
// Message should be removed
|
||||
streamMgr.TryGet(MqttProtocolConstants.StreamName, out var handle).ShouldBeTrue();
|
||||
var msg = await handle.Store.LoadAsync(seq, default);
|
||||
msg.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QoS1_tracker_records_stream_sequence()
|
||||
{
|
||||
// Go reference: server/mqtt.go trackPublish — maps packet ID → stream sequence
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
|
||||
var packetId = tracker.Register("sensor/temp", "72.5"u8.ToArray(), streamSequence: 42);
|
||||
|
||||
tracker.IsPending(packetId).ShouldBeTrue();
|
||||
var acked = tracker.Acknowledge(packetId);
|
||||
acked.ShouldNotBeNull();
|
||||
acked.StreamSequence.ShouldBe((ulong)42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QoS1_tracker_redelivery_preserves_stream_sequence()
|
||||
{
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
tracker.Register("sensor/temp", "72.5"u8.ToArray(), streamSequence: 99);
|
||||
|
||||
var pending = tracker.GetPendingForRedelivery();
|
||||
pending.Count.ShouldBe(1);
|
||||
pending[0].StreamSequence.ShouldBe((ulong)99);
|
||||
pending[0].DeliveryCount.ShouldBe(2); // incremented
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// QoS 2 with JetStream
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QoS2_incoming_stores_for_dedup()
|
||||
{
|
||||
// Go reference: server/mqtt.go QoS 2 incoming stored in $MQTT_qos2in for dedup
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
var seq = mqttConsumerMgr.StoreQoS2Incoming("client1", 1, "payload"u8.ToArray());
|
||||
seq.ShouldBeGreaterThan((ulong)0);
|
||||
|
||||
var msg = await mqttConsumerMgr.LoadQoS2IncomingAsync("client1", 1);
|
||||
msg.ShouldNotBeNull();
|
||||
System.Text.Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("payload");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QoS2_incoming_removed_after_pubcomp()
|
||||
{
|
||||
// Go reference: server/mqtt.go QoS 2 state removed on PUBCOMP
|
||||
var (streamMgr, consumerMgr, initializer) = CreateJetStreamInfra();
|
||||
initializer.EnsureStreams();
|
||||
var mqttConsumerMgr = new MqttConsumerManager(streamMgr, consumerMgr);
|
||||
|
||||
mqttConsumerMgr.StoreQoS2Incoming("client1", 1, "payload"u8.ToArray());
|
||||
|
||||
var removed = await mqttConsumerMgr.RemoveQoS2IncomingAsync("client1", 1);
|
||||
removed.ShouldBeTrue();
|
||||
|
||||
var msg = await mqttConsumerMgr.LoadQoS2IncomingAsync("client1", 1);
|
||||
msg.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Session persistence with JetStream backing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Session_persists_and_recovers_from_jetstream()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttStoreSession + mqttLoadSession via JetStream
|
||||
var backingStore = new MemStore(new StreamConfig
|
||||
{
|
||||
Name = MqttProtocolConstants.SessStreamName,
|
||||
Subjects = [$"{MqttProtocolConstants.SessStreamSubjectPrefix}>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
var store1 = new MqttSessionStore(backingStore);
|
||||
await store1.ConnectAsync("client-js", cleanSession: false);
|
||||
store1.AddSubscription("client-js", "topic/a", 1);
|
||||
store1.AddSubscription("client-js", "topic/b", 0);
|
||||
await store1.SaveSessionAsync("client-js");
|
||||
|
||||
// Simulate restart with same backing store
|
||||
var store2 = new MqttSessionStore(backingStore);
|
||||
await store2.ConnectAsync("client-js", cleanSession: false);
|
||||
|
||||
var subs = store2.GetSubscriptions("client-js");
|
||||
subs.Count.ShouldBe(2);
|
||||
subs["topic/a"].ShouldBe(1);
|
||||
subs["topic/b"].ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clean_session_removes_from_jetstream()
|
||||
{
|
||||
var backingStore = new MemStore(new StreamConfig
|
||||
{
|
||||
Name = MqttProtocolConstants.SessStreamName,
|
||||
Subjects = [$"{MqttProtocolConstants.SessStreamSubjectPrefix}>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
var store = new MqttSessionStore(backingStore);
|
||||
await store.ConnectAsync("client-clean", cleanSession: false);
|
||||
store.AddSubscription("client-clean", "topic/x", 1);
|
||||
await store.SaveSessionAsync("client-clean");
|
||||
|
||||
// Clean session connect
|
||||
await store.ConnectAsync("client-clean", cleanSession: true);
|
||||
|
||||
// Simulate restart — should not find session
|
||||
var store2 = new MqttSessionStore(backingStore);
|
||||
await store2.ConnectAsync("client-clean", cleanSession: false);
|
||||
store2.GetSubscriptions("client-clean").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Retained messages with JetStream backing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Retained_persists_and_recovers_from_jetstream()
|
||||
{
|
||||
// Go reference: server/mqtt.go retained messages stored in $MQTT_rmsgs
|
||||
var backingStore = new MemStore(new StreamConfig
|
||||
{
|
||||
Name = MqttProtocolConstants.RetainedMsgsStreamName,
|
||||
Subjects = [$"{MqttProtocolConstants.RetainedMsgsStreamSubject}>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
var retained1 = new MqttRetainedStore(backingStore);
|
||||
await retained1.SetRetainedAsync("sensors/temp", "72.5"u8.ToArray());
|
||||
|
||||
// Simulate restart — new store backed by same JetStream
|
||||
var retained2 = new MqttRetainedStore(backingStore);
|
||||
var msg = await retained2.GetRetainedAsync("sensors/temp");
|
||||
|
||||
msg.ShouldNotBeNull();
|
||||
System.Text.Encoding.UTF8.GetString(msg).ShouldBe("72.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retained_tombstone_removes_from_jetstream()
|
||||
{
|
||||
// Go reference: empty payload + retain = delete retained
|
||||
var backingStore = new MemStore(new StreamConfig
|
||||
{
|
||||
Name = MqttProtocolConstants.RetainedMsgsStreamName,
|
||||
Subjects = [$"{MqttProtocolConstants.RetainedMsgsStreamSubject}>"],
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
|
||||
var retained = new MqttRetainedStore(backingStore);
|
||||
await retained.SetRetainedAsync("sensors/temp", "72.5"u8.ToArray());
|
||||
await retained.SetRetainedAsync("sensors/temp", ReadOnlyMemory<byte>.Empty); // tombstone
|
||||
|
||||
// Should be gone even from backing store
|
||||
var recovered = new MqttRetainedStore(backingStore);
|
||||
var msg = await recovered.GetRetainedAsync("sensors/temp");
|
||||
msg.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Flow controller — JetStream integration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task FlowController_IsAtCapacity_when_max_reached()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttMaxAckPending flow control
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 2);
|
||||
|
||||
// Acquire 2 slots
|
||||
(await fc.TryAcquireAsync("sub1")).ShouldBeTrue();
|
||||
(await fc.TryAcquireAsync("sub1")).ShouldBeTrue();
|
||||
|
||||
// Now at capacity
|
||||
fc.IsAtCapacity("sub1").ShouldBeTrue();
|
||||
(await fc.TryAcquireAsync("sub1")).ShouldBeFalse();
|
||||
|
||||
// Release one
|
||||
fc.Release("sub1");
|
||||
fc.IsAtCapacity("sub1").ShouldBeFalse();
|
||||
(await fc.TryAcquireAsync("sub1")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static (StreamManager StreamMgr, ConsumerManager ConsumerMgr, MqttStreamInitializer Initializer) CreateJetStreamInfra()
|
||||
{
|
||||
var consumerMgr = new ConsumerManager();
|
||||
var streamMgr = new StreamManager(consumerManager: consumerMgr);
|
||||
consumerMgr.StreamManager = streamMgr;
|
||||
var initializer = new MqttStreamInitializer(streamMgr);
|
||||
return (streamMgr, consumerMgr, initializer);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public class MqttKeepAliveTests
|
||||
public async Task Invalid_mqtt_credentials_or_keepalive_timeout_close_session_with_protocol_error()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ public class MqttListenerParityTests
|
||||
public async Task Mqtt_listener_accepts_connect_and_routes_publish_to_matching_subscription()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public class MqttPublishSubscribeParityTests
|
||||
public async Task Mqtt_publish_only_reaches_matching_topic_subscribers()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -43,19 +43,19 @@ public sealed class MqttQoSTrackingTests
|
||||
tracker.PendingCount.ShouldBe(1);
|
||||
var removed = tracker.Acknowledge(id);
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
removed.ShouldNotBeNull();
|
||||
tracker.PendingCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_returns_false_for_unknown()
|
||||
public void Acknowledge_returns_null_for_unknown()
|
||||
{
|
||||
// Go reference: server/mqtt.go — PUBACK for unknown packet ID is silently ignored
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
|
||||
var result = tracker.Acknowledge(9999);
|
||||
|
||||
result.ShouldBeFalse();
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -10,6 +10,7 @@ public class MqttQosAckRuntimeTests
|
||||
public async Task Qos1_publish_receives_puback_and_redelivery_on_session_reconnect_when_unacked()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ public class MqttQosDeliveryParityTests
|
||||
public async Task Qos0_publish_is_fire_and_forget_no_puback_returned()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -37,6 +38,7 @@ public class MqttQosDeliveryParityTests
|
||||
public async Task Qos1_publish_with_subscriber_delivers_message_to_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -72,6 +74,7 @@ public class MqttQosDeliveryParityTests
|
||||
public async Task Qos1_publish_without_subscriber_still_returns_puback_to_publisher()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -94,6 +97,7 @@ public class MqttQosDeliveryParityTests
|
||||
public async Task Multiple_qos1_publishes_use_incrementing_packet_ids()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Retained_message_not_delivered_when_subscriber_connects_after_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -44,6 +45,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Non_retained_publish_delivers_to_existing_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -71,6 +73,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Live_message_delivered_to_existing_subscriber_is_not_flagged_retained()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -98,6 +101,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Multiple_publishers_deliver_to_same_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -133,6 +137,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Message_payload_is_not_corrupted_through_broker()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -161,6 +166,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Sequential_publishes_all_deliver()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -190,6 +196,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Multiple_topics_receive_messages_independently()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -230,6 +237,7 @@ public class MqttRetainedMessageParityTests
|
||||
public async Task Subscriber_reconnect_resubscribe_receives_new_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ public class MqttSessionParityTests
|
||||
public async Task Clean_session_true_discards_previous_session_state()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -49,6 +50,7 @@ public class MqttSessionParityTests
|
||||
public async Task Clean_session_false_preserves_unacked_publishes_across_reconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -80,6 +82,7 @@ public class MqttSessionParityTests
|
||||
public async Task Session_disconnect_cleans_up_client_tracking_on_clean_session()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -109,6 +112,7 @@ public class MqttSessionParityTests
|
||||
public async Task Multiple_concurrent_sessions_on_different_client_ids_work_independently()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ public class MqttSessionRuntimeTests
|
||||
public async Task Qos1_publish_receives_puback_and_redelivery_on_session_reconnect_when_unacked()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
154
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttTopicMapperTests.cs
Normal file
154
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttTopicMapperTests.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MqttTopicMapper with full Go parity including dots in topics,
|
||||
/// empty levels, '$' prefix protection, and leading/trailing slashes.
|
||||
/// Go reference: mqtt.go mqttToNATSSubjectConversion ~line 2200.
|
||||
/// </summary>
|
||||
public class MqttTopicMapperTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttToNats — basic mapping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/b/c", "a.b.c")]
|
||||
[InlineData("sensor/temp", "sensor.temp")]
|
||||
[InlineData("home/living/light", "home.living.light")]
|
||||
public void MqttToNats_separator_mapping(string mqtt, string nats)
|
||||
{
|
||||
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("+", "*")]
|
||||
[InlineData("sensor/+", "sensor.*")]
|
||||
[InlineData("+/temp", "*.temp")]
|
||||
[InlineData("+/+/+", "*.*.*")]
|
||||
public void MqttToNats_single_level_wildcard(string mqtt, string nats)
|
||||
{
|
||||
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#", ">")]
|
||||
[InlineData("sensor/#", "sensor.>")]
|
||||
[InlineData("home/+/#", "home.*.>")]
|
||||
public void MqttToNats_multi_level_wildcard(string mqtt, string nats)
|
||||
{
|
||||
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttToNats_empty_string()
|
||||
{
|
||||
MqttTopicMapper.MqttToNats("").ShouldBe("");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttToNats — dot escaping (Go parity)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("a.b/c", "a_DOT_b.c")]
|
||||
[InlineData("host.name/metric", "host_DOT_name.metric")]
|
||||
[InlineData("a.b.c", "a_DOT_b_DOT_c")]
|
||||
public void MqttToNats_dots_in_topic_are_escaped(string mqtt, string nats)
|
||||
{
|
||||
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MqttToNats — empty levels (leading/trailing/consecutive slashes)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("/a/b", ".a.b")]
|
||||
[InlineData("a/b/", "a.b.")]
|
||||
[InlineData("a//b", "a..b")]
|
||||
[InlineData("//", "..")]
|
||||
public void MqttToNats_empty_levels(string mqtt, string nats)
|
||||
{
|
||||
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NatsToMqtt — reverse mapping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("a.b.c", "a/b/c")]
|
||||
[InlineData("sensor.temp", "sensor/temp")]
|
||||
[InlineData("*", "+")]
|
||||
[InlineData(">", "#")]
|
||||
[InlineData("sensor.*", "sensor/+")]
|
||||
[InlineData("sensor.>", "sensor/#")]
|
||||
public void NatsToMqtt_basic_reverse(string nats, string mqtt)
|
||||
{
|
||||
MqttTopicMapper.NatsToMqtt(nats).ShouldBe(mqtt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsToMqtt_empty_string()
|
||||
{
|
||||
MqttTopicMapper.NatsToMqtt("").ShouldBe("");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a_DOT_b.c", "a.b/c")]
|
||||
[InlineData("host_DOT_name.metric", "host.name/metric")]
|
||||
public void NatsToMqtt_dot_escape_reversed(string nats, string mqtt)
|
||||
{
|
||||
MqttTopicMapper.NatsToMqtt(nats).ShouldBe(mqtt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Round-trip: MqttToNats → NatsToMqtt should be identity
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/b/c")]
|
||||
[InlineData("sensor/+/data")]
|
||||
[InlineData("home/#")]
|
||||
[InlineData("a.b/c.d")]
|
||||
[InlineData("/leading")]
|
||||
[InlineData("trailing/")]
|
||||
[InlineData("a//b")]
|
||||
public void RoundTrip_mqtt_to_nats_and_back(string mqtt)
|
||||
{
|
||||
var nats = MqttTopicMapper.MqttToNats(mqtt);
|
||||
var roundTripped = MqttTopicMapper.NatsToMqtt(nats);
|
||||
roundTripped.ShouldBe(mqtt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dollar topic protection (MQTT spec [MQTT-4.7.2-1])
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void IsDollarTopic_detects_system_topics()
|
||||
{
|
||||
MqttTopicMapper.IsDollarTopic("$SYS/info").ShouldBeTrue();
|
||||
MqttTopicMapper.IsDollarTopic("$share/group/topic").ShouldBeTrue();
|
||||
MqttTopicMapper.IsDollarTopic("normal/topic").ShouldBeFalse();
|
||||
MqttTopicMapper.IsDollarTopic("").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardMatchesDollarTopic_enforces_spec()
|
||||
{
|
||||
// Wildcard filters should NOT match $ topics
|
||||
MqttTopicMapper.WildcardMatchesDollarTopic("#", "$SYS/info").ShouldBeFalse();
|
||||
MqttTopicMapper.WildcardMatchesDollarTopic("+/info", "$SYS/info").ShouldBeFalse();
|
||||
|
||||
// Explicit $ filters match $ topics
|
||||
MqttTopicMapper.WildcardMatchesDollarTopic("$SYS/#", "$SYS/info").ShouldBeTrue();
|
||||
MqttTopicMapper.WildcardMatchesDollarTopic("$SYS/+", "$SYS/info").ShouldBeTrue();
|
||||
|
||||
// Non-$ topics always matchable
|
||||
MqttTopicMapper.WildcardMatchesDollarTopic("#", "normal/topic").ShouldBeTrue();
|
||||
MqttTopicMapper.WildcardMatchesDollarTopic("+/topic", "normal/topic").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class MqttWillMessageParityTests
|
||||
public async Task Subscriber_receives_message_on_abrupt_publisher_disconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -44,6 +45,7 @@ public class MqttWillMessageParityTests
|
||||
public async Task Qos1_will_message_is_delivered_to_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -72,6 +74,7 @@ public class MqttWillMessageParityTests
|
||||
public async Task Graceful_disconnect_does_not_deliver_extra_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -104,6 +107,7 @@ public class MqttWillMessageParityTests
|
||||
public async Task Will_message_at_various_qos_levels_reaches_subscriber(int qos, string payload)
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
@@ -205,6 +209,7 @@ public class MqttWillMessageParityTests
|
||||
_ = subQos;
|
||||
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
listener.UseBinaryProtocol = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user