Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteBatchProtoParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendRouteSubProtosAsync_writes_batched_rs_plus_frames()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteSubProtosAsync(
|
||||
[
|
||||
new RemoteSubscription("orders.*", null, "r1", Account: "A"),
|
||||
new RemoteSubscription("orders.q", "workers", "r1", Account: "A", QueueWeight: 2),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS+ A orders.*");
|
||||
data.ShouldContain("RS+ A orders.q workers 2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRouteUnSubProtosAsync_writes_batched_rs_minus_frames()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteUnSubProtosAsync(
|
||||
[
|
||||
new RemoteSubscription("orders.*", null, "r1", Account: "A"),
|
||||
new RemoteSubscription("orders.q", "workers", "r1", Account: "A"),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS- A orders.*");
|
||||
data.ShouldContain("RS- A orders.q workers");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRouteSubOrUnSubProtosAsync_skips_empty_lines_and_flushes_once()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteSubOrUnSubProtosAsync(
|
||||
["RS+ A foo.bar", "", " ", "RS- A foo.bar"],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS+ A foo.bar");
|
||||
data.ShouldContain("RS- A foo.bar");
|
||||
data.ShouldNotContain("\r\n\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (RouteConnection Route, Socket Peer) CreateRoutePair()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
listener.Stop();
|
||||
|
||||
return (new RouteConnection(client), server);
|
||||
}
|
||||
|
||||
private static string ReadFromPeer(Socket peer)
|
||||
{
|
||||
peer.ReceiveTimeout = 2_000;
|
||||
var buffer = new byte[4096];
|
||||
var read = peer.Receive(buffer);
|
||||
return Encoding.ASCII.GetString(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteInfoBroadcastParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateServerINFOAndSendINFOToClients_broadcasts_INFO_to_connected_clients()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
_ = await ReadLineAsync(socket, CancellationToken.None); // initial INFO
|
||||
await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None);
|
||||
_ = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None);
|
||||
|
||||
server.UpdateServerINFOAndSendINFOToClients();
|
||||
|
||||
var info = await ReadLineAsync(socket, CancellationToken.None);
|
||||
info.ShouldStartWith("INFO ");
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct)
|
||||
{
|
||||
var end = DateTime.UtcNow.AddSeconds(3);
|
||||
var builder = new StringBuilder();
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
var line = await ReadLineAsync(socket, ct);
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
builder.AppendLine(line);
|
||||
if (builder.ToString().Contains(token, StringComparison.Ordinal))
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new List<byte>(256);
|
||||
var one = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var n = await socket.ReceiveAsync(one.AsMemory(0, 1), SocketFlags.None, ct);
|
||||
if (n == 0)
|
||||
break;
|
||||
if (one[0] == '\n')
|
||||
break;
|
||||
if (one[0] != '\r')
|
||||
buffer.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. buffer]);
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
170
tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs
Normal file
170
tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteParityHelpersBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_includes_connectinfo_compat_fields()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", ["A"], "topo-v1");
|
||||
|
||||
json.ShouldContain("\"verbose\":false");
|
||||
json.ShouldContain("\"pedantic\":false");
|
||||
json.ShouldContain("\"echo\":false");
|
||||
json.ShouldContain("\"tls_required\":false");
|
||||
json.ShouldContain("\"headers\":true");
|
||||
json.ShouldContain("\"name\":\"S1\"");
|
||||
json.ShouldContain("\"cluster\":\"\"");
|
||||
json.ShouldContain("\"dynamic\":false");
|
||||
json.ShouldContain("\"lnoc\":false");
|
||||
json.ShouldContain("\"lnocu\":false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasThisRouteConfigured_matches_explicit_routes_with_scheme_normalization()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
manager.HasThisRouteConfigured("127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("nats-route://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("nats://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("127.0.0.1:7999").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_skips_configured_routes_and_tracks_new_routes()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "S2",
|
||||
ServerName = "S2",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = "127.0.0.1",
|
||||
Port = 7222,
|
||||
ConnectUrls = ["127.0.0.1:7222", "nats-route://127.0.0.1:7444"],
|
||||
};
|
||||
|
||||
manager.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
manager.DiscoveredRoutes.ShouldNotContain("127.0.0.1:7222");
|
||||
manager.DiscoveredRoutes.ShouldContain("nats-route://127.0.0.1:7444");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteStillValid_checks_configured_and_discovered_routes()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
manager.RouteStillValid("nats://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.RouteStillValid("127.0.0.1:7555").ShouldBeFalse();
|
||||
|
||||
manager.ProcessImplicitRoute(new ServerInfo
|
||||
{
|
||||
ServerId = "S2",
|
||||
ServerName = "S2",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = "127.0.0.1",
|
||||
Port = 7444,
|
||||
ConnectUrls = ["127.0.0.1:7444"],
|
||||
});
|
||||
|
||||
manager.RouteStillValid("nats-route://127.0.0.1:7444").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Solicited_route_helpers_upgrade_and_query_status()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
await using var connection = MakeRouteConnection();
|
||||
|
||||
manager.RegisterRoute("S2", connection);
|
||||
manager.HasSolicitedRoute("S2").ShouldBeFalse();
|
||||
|
||||
manager.UpgradeRouteToSolicited("S2").ShouldBeTrue();
|
||||
connection.IsSolicitedRoute().ShouldBeTrue();
|
||||
manager.HasSolicitedRoute("S2").ShouldBeTrue();
|
||||
manager.IsDuplicateServerName("S2").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveRoute_cleans_hash_and_account_route_indexes()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeRouteConnection();
|
||||
|
||||
manager.RegisterRoute("S2", connection);
|
||||
manager.RegisterRouteByHash("S2", connection);
|
||||
manager.RegisterAccountRoute("A", connection);
|
||||
|
||||
manager.HashedRouteCount.ShouldBe(1);
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
|
||||
manager.RemoveRoute("S2").ShouldBeTrue();
|
||||
manager.HashedRouteCount.ShouldBe(0);
|
||||
manager.DedicatedRouteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryParseRemoteUnsub_parses_rs_minus_and_ls_minus()
|
||||
{
|
||||
RouteConnection.TryParseRemoteUnsub("RS- ACCT_A foo.bar q1", out var account1, out var subject1, out var queue1).ShouldBeTrue();
|
||||
account1.ShouldBe("ACCT_A");
|
||||
subject1.ShouldBe("foo.bar");
|
||||
queue1.ShouldBe("q1");
|
||||
|
||||
RouteConnection.TryParseRemoteUnsub("LS- ACCT_B foo.>", out var account2, out var subject2, out var queue2).ShouldBeTrue();
|
||||
account2.ShouldBe("ACCT_B");
|
||||
subject2.ShouldBe("foo.>");
|
||||
queue2.ShouldBeNull();
|
||||
|
||||
RouteConnection.TryParseRemoteUnsub("RS+ ACCT_A foo.bar", out _, out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static RouteManager CreateManager(ClusterOptions? options = null)
|
||||
=> new(
|
||||
options ?? new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
private static RouteConnection MakeRouteConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
server.Dispose();
|
||||
listener.Stop();
|
||||
|
||||
return new RouteConnection(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteRemoteSubCleanupParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Routed_sub_key_helpers_parse_account_and_queue_fields()
|
||||
{
|
||||
var key = SubList.BuildRoutedSubKey("R1", "A", "orders.*", "q1");
|
||||
|
||||
SubList.GetAccNameFromRoutedSubKey(key).ShouldBe("A");
|
||||
|
||||
var info = SubList.GetRoutedSubKeyInfo(key);
|
||||
info.ShouldNotBeNull();
|
||||
info.Value.RouteId.ShouldBe("R1");
|
||||
info.Value.Account.ShouldBe("A");
|
||||
info.Value.Subject.ShouldBe("orders.*");
|
||||
info.Value.Queue.ShouldBe("q1");
|
||||
|
||||
SubList.GetRoutedSubKeyInfo("invalid").ShouldBeNull();
|
||||
SubList.GetAccNameFromRoutedSubKey("invalid").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_remote_subs_methods_only_remove_matching_route_or_account()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "B"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r2", "A"));
|
||||
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue();
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
|
||||
sl.RemoveRemoteSubsForAccount("r1", "A").ShouldBe(1);
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue(); // r2 still present
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
|
||||
sl.RemoveRemoteSubs("r2").ShouldBe(1);
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeFalse();
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_disconnect_cleans_remote_interest_without_explicit_rs_minus()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var serverCts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(serverCts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var cluster = server.ClusterListen!;
|
||||
var sep = cluster.LastIndexOf(':');
|
||||
var host = cluster[..sep];
|
||||
var port = int.Parse(cluster[(sep + 1)..]);
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
await remote.ConnectAsync(IPAddress.Parse(host), port, timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE1", timeout.Token);
|
||||
var response = await ReadLineAsync(remote, timeout.Token);
|
||||
response.ShouldStartWith("ROUTE ");
|
||||
|
||||
await WriteLineAsync(remote, "RS+ $G route.cleanup.test", timeout.Token);
|
||||
await WaitForCondition(() => server.HasRemoteInterest("route.cleanup.test"), 5000);
|
||||
|
||||
remote.Dispose();
|
||||
|
||||
await WaitForCondition(() => !server.HasRemoteInterest("route.cleanup.test"), 10000);
|
||||
server.HasRemoteInterest("route.cleanup.test").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await serverCts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
{
|
||||
var data = Encoding.ASCII.GetBytes($"{line}\r\n");
|
||||
await socket.SendAsync(data, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var one = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(one, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
throw new IOException("Socket closed while reading line");
|
||||
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user