// Reference: golang/nats-server/server/client_test.go // Functions: TestClientUnSub, TestClientUnSubMax, TestClientAutoUnsubExactReceived, // TestClientUnsubAfterAutoUnsub, TestClientRemoveSubsOnDisconnect using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; using NATS.Server.TestUtilities; namespace NATS.Server.Core.Tests; public class ClientUnsubTests : IAsyncLifetime { private readonly NatsServer _server; private readonly int _port; private readonly CancellationTokenSource _cts = new(); public ClientUnsubTests() { _port = TestPortAllocator.GetFreePort(); _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance); } public async Task InitializeAsync() { _ = _server.StartAsync(_cts.Token); await _server.WaitForReadyAsync(); } public async Task DisposeAsync() { await _cts.CancelAsync(); _server.Dispose(); } private async Task ConnectAndHandshakeAsync() { var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, _port); // Drain INFO var buf = new byte[4096]; await sock.ReceiveAsync(buf, SocketFlags.None); // Send CONNECT await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n")); return sock; } /// /// Mirrors TestClientUnSub: subscribe twice, unsubscribe one sid, publish, /// verify only the remaining sid gets the MSG. /// Reference: golang/nats-server/server/client_test.go TestClientUnSub /// [Fact] public async Task Unsub_removes_subscription() { using var pub = await ConnectAndHandshakeAsync(); using var sub = await ConnectAndHandshakeAsync(); // Subscribe to "foo" with sid 1 and sid 2 await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nSUB foo 2\r\nPING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); // Unsubscribe sid 1 await sub.SendAsync(Encoding.ASCII.GetBytes("UNSUB 1\r\nPING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); // Publish one message to "foo" await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nHello\r\n")); // Should receive exactly one MSG for sid 2; sid 1 is gone var response = await SocketTestHelper.ReadUntilAsync(sub, "MSG foo 2 5"); response.ShouldContain("MSG foo 2 5"); response.ShouldNotContain("MSG foo 1 5"); } /// /// Mirrors TestClientUnSubMax: UNSUB with a max-messages limit auto-removes /// the subscription after exactly N deliveries. /// Reference: golang/nats-server/server/client_test.go TestClientUnSubMax /// [Fact] public async Task Unsub_max_auto_removes_after_n_messages() { const int maxMessages = 5; const int totalPublishes = 10; using var pub = await ConnectAndHandshakeAsync(); using var sub = await ConnectAndHandshakeAsync(); // Subscribe to "foo" with sid 1, limit to 5 messages await sub.SendAsync(Encoding.ASCII.GetBytes($"SUB foo 1\r\nUNSUB 1 {maxMessages}\r\nPING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); // Publish 10 messages var pubData = new StringBuilder(); for (int i = 0; i < totalPublishes; i++) pubData.Append("PUB foo 1\r\nx\r\n"); await pub.SendAsync(Encoding.ASCII.GetBytes(pubData.ToString())); // Collect received messages within a short timeout, stopping when no more arrive var received = new StringBuilder(); try { using var timeout = new CancellationTokenSource(2000); var buf = new byte[4096]; while (true) { var n = await sub.ReceiveAsync(buf, SocketFlags.None, timeout.Token); if (n == 0) break; received.Append(Encoding.ASCII.GetString(buf, 0, n)); } } catch (OperationCanceledException) { // Expected — timeout means no more messages } // Count MSG occurrences var text = received.ToString(); var msgCount = CountOccurrences(text, "MSG foo 1"); msgCount.ShouldBe(maxMessages); } /// /// Mirrors TestClientUnsubAfterAutoUnsub: after setting a max-messages limit, /// an explicit UNSUB removes the subscription immediately and no messages arrive. /// Reference: golang/nats-server/server/client_test.go TestClientUnsubAfterAutoUnsub /// [Fact] public async Task Unsub_after_auto_unsub_removes_immediately() { using var pub = await ConnectAndHandshakeAsync(); using var sub = await ConnectAndHandshakeAsync(); // Subscribe with a large max-messages limit, then immediately UNSUB without limit await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nUNSUB 1 100\r\nUNSUB 1\r\nPING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); // Publish a message — subscription should already be gone await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nHello\r\n")); // Wait briefly; no MSG should arrive var received = new StringBuilder(); try { using var timeout = new CancellationTokenSource(500); var buf = new byte[4096]; while (true) { var n = await sub.ReceiveAsync(buf, SocketFlags.None, timeout.Token); if (n == 0) break; received.Append(Encoding.ASCII.GetString(buf, 0, n)); } } catch (OperationCanceledException) { // Expected } received.ToString().ShouldNotContain("MSG foo"); } /// /// Mirrors TestClientRemoveSubsOnDisconnect: when a client disconnects the server /// removes all its subscriptions from the global SubList. /// Reference: golang/nats-server/server/client_test.go TestClientRemoveSubsOnDisconnect /// [Fact] public async Task Disconnect_removes_all_subscriptions() { using var client = await ConnectAndHandshakeAsync(); // Subscribe to 3 distinct subjects await client.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nSUB bar 2\r\nSUB baz 3\r\nPING\r\n")); await SocketTestHelper.ReadUntilAsync(client, "PONG"); // Confirm subscriptions are registered in the server's SubList _server.SubList.Count.ShouldBe(3u); // Close the TCP connection abruptly client.Shutdown(SocketShutdown.Both); client.Close(); // Give the server a moment to detect the disconnect and clean up await Task.Delay(500); // All 3 subscriptions should be removed _server.SubList.Count.ShouldBe(0u); } private static int CountOccurrences(string haystack, string needle) { int count = 0; int index = 0; while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) { count++; index += needle.Length; } return count; } }