Files
natsdotnet/tests/NATS.Server.Tests/Routes/RouteConfigTests.cs
Joseph Doherty 7ffee8741f feat: phase A foundation test parity — 64 new tests across 11 subsystems
Port Go NATS server test behaviors to .NET:
- Client pub/sub (5 tests): simple, no-echo, reply, queue distribution, empty body
- Client UNSUB (4 tests): unsub, auto-unsub max, unsub after auto, disconnect cleanup
- Client headers (3 tests): HPUB/HMSG, server info headers, no-responders 503
- Client lifecycle (3 tests): connect proto, max subscriptions, auth timeout
- Client slow consumer (1 test): pending limit detection and disconnect
- Parser edge cases (3 tests + 2 bug fixes): PUB arg variations, malformed protocol, max control line
- SubList concurrency (13 tests): race on remove/insert/match, large lists, invalid subjects, wildcards
- Server config (4 tests): ephemeral port, server name, name defaults, lame duck
- Route config (3 tests): cluster formation, cross-cluster messaging, reconnect
- Gateway basic (2 tests): cross-cluster forwarding, no echo to origin
- Leaf node basic (2 tests): hub-to-spoke and spoke-to-hub forwarding
- Account import/export (2 tests): stream export/import delivery, isolation

Also fixes NatsParser.ParseSub/ParseUnsub to throw ProtocolViolationException
for short command lines instead of ArgumentOutOfRangeException.

Full suite: 933 passed, 0 failed (up from 869).
2026-02-23 19:26:30 -05:00

316 lines
11 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
namespace NATS.Server.Tests.Routes;
/// <summary>
/// Tests cluster route formation and message forwarding between servers.
/// Ported from Go: server/routes_test.go — TestRouteConfig, TestSeedSolicitWorks.
/// </summary>
public class RouteConfigTests
{
[Fact]
public async Task Two_servers_form_full_mesh_cluster()
{
// Reference: Go TestSeedSolicitWorks — verifies that two servers
// with one pointing Routes at the other form a connected cluster.
var clusterName = Guid.NewGuid().ToString("N");
var optsA = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
},
};
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
var ctsA = new CancellationTokenSource();
_ = serverA.StartAsync(ctsA.Token);
await serverA.WaitForReadyAsync();
var optsB = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
Routes = [serverA.ClusterListen!],
},
};
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
var ctsB = new CancellationTokenSource();
_ = serverB.StartAsync(ctsB.Token);
await serverB.WaitForReadyAsync();
try
{
// Wait for both servers to see a route connection
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
{
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
Interlocked.Read(ref serverB.Stats.Routes).ShouldBeGreaterThan(0);
}
finally
{
await ctsA.CancelAsync();
await ctsB.CancelAsync();
serverA.Dispose();
serverB.Dispose();
ctsA.Dispose();
ctsB.Dispose();
}
}
[Fact]
public async Task Route_forwards_messages_between_clusters()
{
// Reference: Go TestSeedSolicitWorks — sets up a seed + one server,
// subscribes on one, publishes on the other, verifies delivery.
var clusterName = Guid.NewGuid().ToString("N");
var optsA = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
},
};
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
var ctsA = new CancellationTokenSource();
_ = serverA.StartAsync(ctsA.Token);
await serverA.WaitForReadyAsync();
var optsB = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
Routes = [serverA.ClusterListen!],
},
};
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
var ctsB = new CancellationTokenSource();
_ = serverB.StartAsync(ctsB.Token);
await serverB.WaitForReadyAsync();
try
{
// Wait for route formation
using var routeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!routeTimeout.IsCancellationRequested
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
{
await Task.Delay(50, routeTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
// Connect subscriber to server A
await using var subscriber = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{serverA.Port}",
});
await subscriber.ConnectAsync();
await using var sub = await subscriber.SubscribeCoreAsync<string>("foo");
await subscriber.PingAsync();
// Wait for remote interest to propagate from A to B
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested
&& !serverB.HasRemoteInterest("foo"))
{
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
// Connect publisher to server B and publish
await using var publisher = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{serverB.Port}",
});
await publisher.ConnectAsync();
await publisher.PublishAsync("foo", "Hello");
// Verify message arrives on server A's subscriber
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
msg.Data.ShouldBe("Hello");
}
finally
{
await ctsA.CancelAsync();
await ctsB.CancelAsync();
serverA.Dispose();
serverB.Dispose();
ctsA.Dispose();
ctsB.Dispose();
}
}
[Fact]
public async Task Route_reconnects_after_peer_restart()
{
// Verifies that when a peer is stopped and restarted, the route
// re-forms and message forwarding resumes.
var clusterName = Guid.NewGuid().ToString("N");
var optsA = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
},
};
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
var ctsA = new CancellationTokenSource();
_ = serverA.StartAsync(ctsA.Token);
await serverA.WaitForReadyAsync();
var clusterListenA = serverA.ClusterListen!;
var optsB = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
Routes = [clusterListenA],
},
};
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
var ctsB = new CancellationTokenSource();
_ = serverB.StartAsync(ctsB.Token);
await serverB.WaitForReadyAsync();
try
{
// Wait for initial route formation
using var timeout1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout1.IsCancellationRequested
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
{
await Task.Delay(50, timeout1.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
// Stop server B
await ctsB.CancelAsync();
serverB.Dispose();
ctsB.Dispose();
// Wait for server A to notice the route drop
using var dropTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!dropTimeout.IsCancellationRequested
&& Interlocked.Read(ref serverA.Stats.Routes) != 0)
{
await Task.Delay(50, dropTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
// Restart server B with the same cluster route target
var optsB2 = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
Routes = [clusterListenA],
},
};
serverB = new NatsServer(optsB2, NullLoggerFactory.Instance);
ctsB = new CancellationTokenSource();
_ = serverB.StartAsync(ctsB.Token);
await serverB.WaitForReadyAsync();
// Wait for route to re-form
using var timeout2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout2.IsCancellationRequested
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
{
await Task.Delay(50, timeout2.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
Interlocked.Read(ref serverB.Stats.Routes).ShouldBeGreaterThan(0);
// Verify message forwarding works after reconnect
await using var subscriber = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{serverA.Port}",
});
await subscriber.ConnectAsync();
await using var sub = await subscriber.SubscribeCoreAsync<string>("bar");
await subscriber.PingAsync();
// Wait for remote interest to propagate
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested
&& !serverB.HasRemoteInterest("bar"))
{
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
await using var publisher = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{serverB.Port}",
});
await publisher.ConnectAsync();
await publisher.PublishAsync("bar", "AfterReconnect");
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
msg.Data.ShouldBe("AfterReconnect");
}
finally
{
await ctsA.CancelAsync();
await ctsB.CancelAsync();
serverA.Dispose();
serverB.Dispose();
ctsA.Dispose();
ctsB.Dispose();
}
}
}