using Microsoft.Extensions.Logging.Abstractions; using NATS.Client.Core; using NATS.Server.Configuration; namespace NATS.Server.Gateways.Tests.Gateways; /// /// Ports TestGatewayBasic and TestGatewayDoesntSendBackToItself from /// golang/nats-server/server/gateway_test.go. /// public class GatewayBasicTests { [Fact] public async Task Gateway_forwards_messages_between_clusters() { // Reference: TestGatewayBasic (gateway_test.go:399) // Start LOCAL and REMOTE gateway servers. Subscribe on REMOTE, // publish on LOCAL, verify message arrives on REMOTE via gateway. await using var fixture = await TwoClusterFixture.StartAsync(); await using var subscriber = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await subscriber.ConnectAsync(); await using var publisher = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await publisher.ConnectAsync(); await using var sub = await subscriber.SubscribeCoreAsync("gw.test"); await subscriber.PingAsync(); // Wait for remote interest to propagate through gateway await fixture.WaitForRemoteInterestOnLocalAsync("gw.test"); await publisher.PublishAsync("gw.test", "hello-from-local"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await sub.Msgs.ReadAsync(timeout.Token); msg.Data.ShouldBe("hello-from-local"); } [Fact] public async Task Gateway_does_not_echo_back_to_origin() { // Reference: TestGatewayDoesntSendBackToItself (gateway_test.go:2150) // Subscribe on REMOTE and LOCAL, publish on LOCAL. Expect exactly 2 // deliveries (one local, one via gateway to REMOTE) — no echo cycle. await using var fixture = await TwoClusterFixture.StartAsync(); await using var remoteConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await remoteConn.ConnectAsync(); await using var localConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await localConn.ConnectAsync(); await using var remoteSub = await remoteConn.SubscribeCoreAsync("foo"); await remoteConn.PingAsync(); await using var localSub = await localConn.SubscribeCoreAsync("foo"); await localConn.PingAsync(); // Wait for remote interest to propagate through gateway await fixture.WaitForRemoteInterestOnLocalAsync("foo"); await localConn.PublishAsync("foo", "cycle"); await localConn.PingAsync(); // Should receive exactly 2 messages: one on local sub, one on remote sub. // If there is a cycle, we'd see many more after a short delay. using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var localMsg = await localSub.Msgs.ReadAsync(receiveTimeout.Token); localMsg.Data.ShouldBe("cycle"); var remoteMsg = await remoteSub.Msgs.ReadAsync(receiveTimeout.Token); remoteMsg.Data.ShouldBe("cycle"); // Wait a bit to see if any echo/cycle messages arrive await Task.Delay(TimeSpan.FromMilliseconds(200)); // Try to read more — should time out because there should be no more messages using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Should.ThrowAsync(async () => await localSub.Msgs.ReadAsync(noMoreTimeout.Token)); using var noMoreTimeout2 = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Should.ThrowAsync(async () => await remoteSub.Msgs.ReadAsync(noMoreTimeout2.Token)); } } internal sealed class TwoClusterFixture : IAsyncDisposable { private readonly CancellationTokenSource _localCts; private readonly CancellationTokenSource _remoteCts; private TwoClusterFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) { Local = local; Remote = remote; _localCts = localCts; _remoteCts = remoteCts; } public NatsServer Local { get; } public NatsServer Remote { get; } public static async Task StartAsync() { var localOptions = new NatsOptions { Host = "127.0.0.1", Port = 0, Gateway = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0, }, }; var local = new NatsServer(localOptions, NullLoggerFactory.Instance); var localCts = new CancellationTokenSource(); _ = local.StartAsync(localCts.Token); await local.WaitForReadyAsync(); var remoteOptions = new NatsOptions { Host = "127.0.0.1", Port = 0, Gateway = new GatewayOptions { Name = "REMOTE", Host = "127.0.0.1", Port = 0, Remotes = [local.GatewayListen!], }, }; var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); var remoteCts = new CancellationTokenSource(); _ = remote.StartAsync(remoteCts.Token); await remote.WaitForReadyAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); return new TwoClusterFixture(local, remote, localCts, remoteCts); } public async Task WaitForRemoteInterestOnLocalAsync(string subject) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) { if (Local.HasRemoteInterest(subject)) return; await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); } throw new TimeoutException($"Timed out waiting for remote interest on subject '{subject}'."); } public async ValueTask DisposeAsync() { await _localCts.CancelAsync(); await _remoteCts.CancelAsync(); Local.Dispose(); Remote.Dispose(); _localCts.Dispose(); _remoteCts.Dispose(); } }