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.
This commit is contained in:
Joseph Doherty
2026-03-12 15:23:33 -04:00
parent 9972b74bc3
commit 3f7d896a34
31 changed files with 132 additions and 243 deletions

View File

@@ -0,0 +1,330 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
using NATS.Server.TestUtilities;
namespace NATS.Server.LeafNodes.Tests.LeafNodes;
/// <summary>
/// Tests for JetStream behavior over leaf node connections.
/// Reference: golang/nats-server/server/leafnode_test.go — TestLeafNodeJetStreamDomainMapCrossTalk, etc.
/// </summary>
public class LeafNodeJetStreamTests
{
// Go: TestLeafNodeJetStreamDomainMapCrossTalk server/leafnode_test.go:5948
[Fact]
public async Task JetStream_API_requests_reach_hub_with_JS_enabled()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-hub-{Guid.NewGuid():N}") },
};
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();
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
hub.Stats.JetStreamEnabled.ShouldBeTrue();
// Verify hub counts leaf
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
// Clean up store dir
if (Directory.Exists(hubOptions.JetStream.StoreDir))
Directory.Delete(hubOptions.JetStream.StoreDir, true);
}
// Go: TestLeafNodeJetStreamDomainMapCrossTalk server/leafnode_test.go:5948
[Fact]
public async Task JetStream_on_hub_receives_messages_published_from_leaf()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-leaf-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = storeDir },
};
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();
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
// Subscribe on hub for a subject
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{hub.Port}",
});
await hubConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("js.leaf.test");
await hubConn.PingAsync();
// Wait for interest propagation
await PollHelper.WaitUntilAsync(() => !(!spoke.HasRemoteInterest("js.leaf.test")), timeoutMs: 5000);
// Publish from spoke
await using var spokeConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{spoke.Port}",
});
await spokeConn.ConnectAsync();
await spokeConn.PublishAsync("js.leaf.test", "from-leaf-to-js");
using var msgTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(msgTimeout.Token);
msg.Data.ShouldBe("from-leaf-to-js");
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(storeDir))
Directory.Delete(storeDir, true);
}
// Go: TestLeafNodeStreamImport server/leafnode_test.go:3441
[Fact]
public async Task Leaf_node_with_JetStream_disabled_spoke_still_forwards_messages()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-fwd-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = storeDir },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
// Spoke without JetStream
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();
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
hub.Stats.JetStreamEnabled.ShouldBeTrue();
spoke.Stats.JetStreamEnabled.ShouldBeFalse();
// Subscribe on hub
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("njs.forward");
await hubConn.PingAsync();
await PollHelper.WaitUntilAsync(() => !(!spoke.HasRemoteInterest("njs.forward")), timeoutMs: 5000);
// Publish from spoke
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await spokeConn.ConnectAsync();
await spokeConn.PublishAsync("njs.forward", "no-js-spoke");
using var msgTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(msgTimeout.Token);
msg.Data.ShouldBe("no-js-spoke");
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(storeDir))
Directory.Delete(storeDir, true);
}
// Go: TestLeafNodeJetStreamDomainMapCrossTalk server/leafnode_test.go:5948
[Fact]
public async Task Both_hub_and_spoke_with_JetStream_enabled_connect_successfully()
{
var hubStoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-hub2-{Guid.NewGuid():N}");
var spokeStoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-spoke2-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = hubStoreDir },
};
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!],
},
JetStream = new JetStreamOptions { StoreDir = spokeStoreDir },
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
hub.Stats.JetStreamEnabled.ShouldBeTrue();
spoke.Stats.JetStreamEnabled.ShouldBeTrue();
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(hubStoreDir))
Directory.Delete(hubStoreDir, true);
if (Directory.Exists(spokeStoreDir))
Directory.Delete(spokeStoreDir, true);
}
// Go: TestLeafNodeStreamAndShadowSubs server/leafnode_test.go:6176
[Fact]
public async Task Leaf_node_message_forwarding_works_alongside_JetStream()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-combo-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = storeDir },
};
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();
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
// Regular pub/sub should still work alongside JS
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("combo.test");
await leafConn.PingAsync();
await PollHelper.WaitUntilAsync(() => !(!hub.HasRemoteInterest("combo.test")), timeoutMs: 5000);
await hubConn.PublishAsync("combo.test", "js-combo");
using var msgTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(msgTimeout.Token);
msg.Data.ShouldBe("js-combo");
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(storeDir))
Directory.Delete(storeDir, true);
}
}