using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
namespace NATS.Server.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();
}
}