Files
Joseph Doherty 3f7d896a34 refactor: extract NATS.Server.LeafNodes.Tests project
Move 28 leaf node test files from NATS.Server.Tests into a dedicated
NATS.Server.LeafNodes.Tests project. Update namespaces, add
InternalsVisibleTo, register in solution file. Replace all Task.Delay
polling loops with PollHelper.WaitUntilAsync/YieldForAsync from
TestUtilities. Replace private ReadUntilAsync in LeafProtocolTests
with SocketTestHelper.ReadUntilAsync.

All 281 tests pass.
2026-03-12 15:23:33 -04:00

162 lines
5.5 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
using NATS.Server.TestUtilities;
namespace NATS.Server.LeafNodes.Tests.LeafNodes;
/// <summary>
/// Basic leaf node hub-spoke connectivity tests.
/// Reference: golang/nats-server/server/leafnode_test.go — TestLeafNodeRemoteIsHub
/// Verifies that subscriptions propagate between hub and leaf (spoke) servers
/// and that messages are forwarded in both directions.
/// </summary>
public class LeafBasicTests
{
[Fact]
public async Task Leaf_node_forwards_subscriptions_to_hub()
{
// Arrange: start hub with a leaf node listener, then start a spoke that connects to hub
await using var fixture = await LeafBasicFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
// Subscribe on the leaf (spoke) side
await using var sub = await leafConn.SubscribeCoreAsync<string>("leaf.test");
await leafConn.PingAsync();
// Wait for the subscription interest to propagate to the hub
await fixture.WaitForRemoteInterestOnHubAsync("leaf.test");
// Publish on the hub side
await hubConn.PublishAsync("leaf.test", "from-hub");
// Assert: message arrives on the leaf
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
msg.Data.ShouldBe("from-hub");
}
[Fact]
public async Task Hub_forwards_subscriptions_to_leaf()
{
// Arrange: start hub with a leaf node listener, then start a spoke that connects to hub
await using var fixture = await LeafBasicFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
// Subscribe on the hub side
await using var sub = await hubConn.SubscribeCoreAsync<string>("hub.test");
await hubConn.PingAsync();
// Wait for the subscription interest to propagate to the spoke
await fixture.WaitForRemoteInterestOnSpokeAsync("hub.test");
// Publish on the leaf (spoke) side
await leafConn.PublishAsync("hub.test", "from-leaf");
// Assert: message arrives on the hub
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
msg.Data.ShouldBe("from-leaf");
}
}
internal sealed class LeafBasicFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _hubCts;
private readonly CancellationTokenSource _spokeCts;
private LeafBasicFixture(NatsServer hub, NatsServer spoke, CancellationTokenSource hubCts, CancellationTokenSource spokeCts)
{
Hub = hub;
Spoke = spoke;
_hubCts = hubCts;
_spokeCts = spokeCts;
}
public NatsServer Hub { get; }
public NatsServer Spoke { get; }
public static async Task<LeafBasicFixture> StartAsync()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
// Wait for the leaf node connection to be established on both sides
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
return new LeafBasicFixture(hub, spoke, hubCts, spokeCts);
}
public async Task WaitForRemoteInterestOnHubAsync(string subject)
{
await PollHelper.WaitOrThrowAsync(() => Hub.HasRemoteInterest(subject), $"Timed out waiting for remote interest on hub for '{subject}'.", timeoutMs: 5000);
}
public async Task WaitForRemoteInterestOnSpokeAsync(string subject)
{
await PollHelper.WaitOrThrowAsync(() => Spoke.HasRemoteInterest(subject), $"Timed out waiting for remote interest on spoke for '{subject}'.", timeoutMs: 5000);
}
public async ValueTask DisposeAsync()
{
await _spokeCts.CancelAsync();
await _hubCts.CancelAsync();
Spoke.Dispose();
Hub.Dispose();
_spokeCts.Dispose();
_hubCts.Dispose();
}
}