refactor: extract NATS.Server.Gateways.Tests project

Move 25 gateway-related test files from NATS.Server.Tests into a
dedicated NATS.Server.Gateways.Tests project. Update namespaces,
replace private ReadUntilAsync with SocketTestHelper from TestUtilities,
inline TestServerFactory usage, add InternalsVisibleTo, and register
the project in the solution file. All 261 tests pass.
This commit is contained in:
Joseph Doherty
2026-03-12 15:10:50 -04:00
parent a6be5e11ed
commit 9972b74bc3
29 changed files with 101 additions and 58 deletions

View File

@@ -0,0 +1,19 @@
using NATS.Server.Gateways;
namespace NATS.Server.Gateways.Tests;
public class GatewayAdvancedRemapRuntimeTests
{
[Fact]
public void Transport_internal_reply_and_loop_markers_never_leak_to_client_visible_subjects()
{
const string clientReply = "_INBOX.123";
var nested = ReplyMapper.ToGatewayReply(
ReplyMapper.ToGatewayReply(clientReply, "CLUSTER-A"),
"CLUSTER-B");
ReplyMapper.TryRestoreGatewayReply(nested, out var restored).ShouldBeTrue();
restored.ShouldBe(clientReply);
restored.ShouldNotStartWith("_GR_.");
}
}

View File

@@ -0,0 +1,20 @@
using NATS.Server.Gateways;
namespace NATS.Server.Gateways.Tests;
public class GatewayAdvancedSemanticsTests
{
[Fact]
public void Gateway_forwarding_remaps_reply_subject_with_gr_prefix_and_restores_on_return()
{
const string originalReply = "_INBOX.123";
const string clusterId = "CLUSTER-A";
var mapped = ReplyMapper.ToGatewayReply(originalReply, clusterId);
mapped.ShouldStartWith("_GR_.");
mapped.ShouldContain(clusterId);
ReplyMapper.TryRestoreGatewayReply(mapped, out var restored).ShouldBeTrue();
restored.ShouldBe(originalReply);
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
namespace NATS.Server.Gateways.Tests;
public class GatewayLeafBootstrapTests
{
[Fact]
public async Task Server_bootstraps_gateway_and_leaf_managers_when_configured()
{
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Gateway = new GatewayOptions
{
Name = "G1",
Host = "127.0.0.1",
Port = 0,
},
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
try
{
server.Stats.Gateways.ShouldBeGreaterThanOrEqualTo(0);
server.Stats.Leafs.ShouldBeGreaterThanOrEqualTo(0);
}
finally
{
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
}
}

View File

@@ -0,0 +1,138 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.TestUtilities;
namespace NATS.Server.Gateways.Tests;
public class GatewayProtocolTests
{
[Fact]
public async Task Gateway_link_establishes_and_forwards_interested_message()
{
await using var fx = await GatewayFixture.StartTwoClustersAsync();
await fx.SubscribeRemoteClusterAsync("g.>");
await fx.PublishLocalClusterAsync("g.test", "hello");
(await fx.ReadRemoteClusterMessageAsync()).ShouldContain("hello");
}
}
internal sealed class GatewayFixture : IAsyncDisposable
{
private readonly NatsServer _local;
private readonly NatsServer _remote;
private readonly CancellationTokenSource _localCts;
private readonly CancellationTokenSource _remoteCts;
private Socket? _remoteSubscriber;
private Socket? _localPublisher;
private GatewayFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts)
{
_local = local;
_remote = remote;
_localCts = localCts;
_remoteCts = remoteCts;
}
public static async Task<GatewayFixture> StartTwoClustersAsync()
{
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 GatewayFixture(local, remote, localCts, remoteCts);
}
public async Task SubscribeRemoteClusterAsync(string subject)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _remote.Port);
_remoteSubscriber = sock;
_ = await ReadLineAsync(sock); // INFO
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {{}}\r\nSUB {subject} 1\r\nPING\r\n"));
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
}
public async Task PublishLocalClusterAsync(string subject, string payload)
{
var sock = _localPublisher;
if (sock == null)
{
sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _local.Port);
_localPublisher = sock;
_ = await ReadLineAsync(sock); // INFO
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
}
await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subject} {payload.Length}\r\n{payload}\r\nPING\r\n"));
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
}
public Task<string> ReadRemoteClusterMessageAsync()
{
if (_remoteSubscriber == null)
throw new InvalidOperationException("Remote subscriber was not initialized.");
return SocketTestHelper.ReadUntilAsync(_remoteSubscriber, "MSG ");
}
public async ValueTask DisposeAsync()
{
_remoteSubscriber?.Dispose();
_localPublisher?.Dispose();
await _localCts.CancelAsync();
await _remoteCts.CancelAsync();
_local.Dispose();
_remote.Dispose();
_localCts.Dispose();
_remoteCts.Dispose();
}
private static async Task<string> ReadLineAsync(Socket sock)
{
var buf = new byte[4096];
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
return Encoding.ASCII.GetString(buf, 0, n);
}
}

View File

@@ -0,0 +1,157 @@
// Go: gateway.go — per-account subscription routing state on outbound gateway connections.
// Gap 11.3: Account-specific gateway routes.
using System.Net;
using System.Net.Sockets;
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Unit tests for account-specific subscription tracking on GatewayConnection.
/// Each test constructs a GatewayConnection using a connected socket pair so the
/// constructor succeeds; the subscription tracking methods are pure in-memory operations
/// that do not require the network handshake to have completed.
/// Go reference: gateway.go — account-scoped subscription propagation on outbound routes.
/// </summary>
public class AccountGatewayRoutesTests : IAsyncDisposable
{
private readonly TcpListener _listener;
private readonly Socket _clientSocket;
private readonly Socket _serverSocket;
private readonly GatewayConnection _conn;
public AccountGatewayRoutesTests()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
var port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_clientSocket.Connect(IPAddress.Loopback, port);
_serverSocket = _listener.AcceptSocket();
_conn = new GatewayConnection(_serverSocket);
}
public async ValueTask DisposeAsync()
{
await _conn.DisposeAsync();
_clientSocket.Dispose();
_listener.Stop();
}
// Go: gateway.go — AddAccountSubscription records the subject under the account key.
[Fact]
public void AddAccountSubscription_adds_subject()
{
_conn.AddAccountSubscription("ACCT_A", "orders.created");
_conn.AccountSubscriptionCount("ACCT_A").ShouldBe(1);
}
// Go: gateway.go — GetAccountSubscriptions returns a snapshot of tracked subjects.
[Fact]
public void GetAccountSubscriptions_returns_subjects()
{
_conn.AddAccountSubscription("ACCT_B", "payments.processed");
_conn.AddAccountSubscription("ACCT_B", "payments.failed");
var subs = _conn.GetAccountSubscriptions("ACCT_B");
subs.ShouldContain("payments.processed");
subs.ShouldContain("payments.failed");
subs.Count.ShouldBe(2);
}
// Go: gateway.go — RemoveAccountSubscription removes a specific subject.
[Fact]
public void RemoveAccountSubscription_removes_subject()
{
_conn.AddAccountSubscription("ACCT_C", "foo.bar");
_conn.AddAccountSubscription("ACCT_C", "foo.baz");
_conn.RemoveAccountSubscription("ACCT_C", "foo.bar");
_conn.GetAccountSubscriptions("ACCT_C").ShouldNotContain("foo.bar");
_conn.GetAccountSubscriptions("ACCT_C").ShouldContain("foo.baz");
}
// Go: gateway.go — AccountSubscriptionCount reflects current tracked count.
[Fact]
public void AccountSubscriptionCount_tracks_count()
{
_conn.AddAccountSubscription("ACCT_D", "s1");
_conn.AddAccountSubscription("ACCT_D", "s2");
_conn.AddAccountSubscription("ACCT_D", "s3");
_conn.RemoveAccountSubscription("ACCT_D", "s2");
_conn.AccountSubscriptionCount("ACCT_D").ShouldBe(2);
}
// Go: gateway.go — each account maintains its own isolated subscription set.
[Fact]
public void Different_accounts_tracked_independently()
{
_conn.AddAccountSubscription("ACC_X", "shared.subject");
_conn.AddAccountSubscription("ACC_Y", "other.subject");
_conn.GetAccountSubscriptions("ACC_X").ShouldContain("shared.subject");
_conn.GetAccountSubscriptions("ACC_X").ShouldNotContain("other.subject");
_conn.GetAccountSubscriptions("ACC_Y").ShouldContain("other.subject");
_conn.GetAccountSubscriptions("ACC_Y").ShouldNotContain("shared.subject");
}
// Go: gateway.go — GetAccountSubscriptions returns empty set for a never-seen account.
[Fact]
public void GetAccountSubscriptions_returns_empty_for_unknown()
{
var subs = _conn.GetAccountSubscriptions("UNKNOWN_ACCOUNT");
subs.ShouldBeEmpty();
}
// Go: gateway.go — duplicate AddAccountSubscription calls are idempotent (set semantics).
[Fact]
public void AddAccountSubscription_deduplicates()
{
_conn.AddAccountSubscription("ACCT_E", "orders.>");
_conn.AddAccountSubscription("ACCT_E", "orders.>");
_conn.AddAccountSubscription("ACCT_E", "orders.>");
_conn.AccountSubscriptionCount("ACCT_E").ShouldBe(1);
}
// Go: gateway.go — removing a subject that was never added is a no-op (no exception).
[Fact]
public void RemoveAccountSubscription_no_error_for_unknown()
{
// Neither the account nor the subject has ever been added.
var act = () => _conn.RemoveAccountSubscription("NEVER_ADDED", "some.subject");
act.ShouldNotThrow();
}
// Go: gateway.go — an account can track many subjects simultaneously.
[Fact]
public void Multiple_subjects_per_account()
{
var subjects = new[] { "a.b", "c.d", "e.f.>", "g.*", "h" };
foreach (var s in subjects)
_conn.AddAccountSubscription("ACCT_F", s);
var result = _conn.GetAccountSubscriptions("ACCT_F");
result.Count.ShouldBe(subjects.Length);
foreach (var s in subjects)
result.ShouldContain(s);
}
// Go: gateway.go — AccountSubscriptionCount returns 0 for an account with no entries.
[Fact]
public void AccountSubscriptionCount_zero_for_unknown()
{
_conn.AccountSubscriptionCount("COMPLETELY_NEW_ACCOUNT").ShouldBe(0);
}
}

View File

@@ -0,0 +1,140 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
using NATS.Server.Configuration;
namespace NATS.Server.Gateways.Tests.Gateways;
public class GatewayAccountScopedDeliveryTests
{
[Fact]
public async Task Remote_message_delivery_uses_target_account_sublist_not_global_sublist()
{
const string subject = "orders.created";
await using var fixture = await GatewayAccountDeliveryFixture.StartAsync();
await using var remoteAccountA = await fixture.ConnectAsync(fixture.Remote, "a_sub");
await using var remoteAccountB = await fixture.ConnectAsync(fixture.Remote, "b_sub");
await using var publisher = await fixture.ConnectAsync(fixture.Local, "a_pub");
await using var subA = await remoteAccountA.SubscribeCoreAsync<string>(subject);
await using var subB = await remoteAccountB.SubscribeCoreAsync<string>(subject);
await remoteAccountA.PingAsync();
await remoteAccountB.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("A", subject);
await publisher.PublishAsync(subject, "from-gateway-a");
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msgA = await subA.Msgs.ReadAsync(receiveTimeout.Token);
msgA.Data.ShouldBe("from-gateway-a");
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await subB.Msgs.ReadAsync(leakTimeout.Token));
}
}
internal sealed class GatewayAccountDeliveryFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _localCts;
private readonly CancellationTokenSource _remoteCts;
private GatewayAccountDeliveryFixture(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<GatewayAccountDeliveryFixture> StartAsync()
{
var users = new User[]
{
new() { Username = "a_pub", Password = "pass", Account = "A" },
new() { Username = "a_sub", Password = "pass", Account = "A" },
new() { Username = "b_sub", Password = "pass", Account = "B" },
};
var localOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
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,
Users = users,
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 GatewayAccountDeliveryFixture(local, remote, localCts, remoteCts);
}
public async Task<NatsConnection> ConnectAsync(NatsServer server, string username)
{
var connection = new NatsConnection(new NatsOpts
{
Url = $"nats://{username}:pass@127.0.0.1:{server.Port}",
});
await connection.ConnectAsync();
return connection;
}
public async Task WaitForRemoteInterestOnLocalAsync(string account, string subject)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested)
{
if (Local.HasRemoteInterest(account, subject))
return;
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
}
public async ValueTask DisposeAsync()
{
await _localCts.CancelAsync();
await _remoteCts.CancelAsync();
Local.Dispose();
Remote.Dispose();
_localCts.Dispose();
_remoteCts.Dispose();
}
}

View File

@@ -0,0 +1,185 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Ports TestGatewayBasic and TestGatewayDoesntSendBackToItself from
/// golang/nats-server/server/gateway_test.go.
/// </summary>
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<string>("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<string>("foo");
await remoteConn.PingAsync();
await using var localSub = await localConn.SubscribeCoreAsync<string>("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<OperationCanceledException>(async () =>
await localSub.Msgs.ReadAsync(noMoreTimeout.Token));
using var noMoreTimeout2 = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Should.ThrowAsync<OperationCanceledException>(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<TwoClusterFixture> 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();
}
}

View File

@@ -0,0 +1,99 @@
using System.Text;
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.Gateways.Tests.Gateways;
// Go reference: gateway.go:90-120 — gateway protocol constants and command formatting.
public class GatewayCommandTests
{
[Fact]
public void FormatSub_produces_correct_wire_format()
{
// Go reference: gateway.go — RS+ propagation, sendGatewaySubsToGateway
var bytes = GatewayCommands.FormatSub("$G", "orders.>");
var line = Encoding.UTF8.GetString(bytes);
line.ShouldBe("GS+ $G orders.>\r\n");
}
[Fact]
public void FormatUnsub_produces_correct_wire_format()
{
// Go reference: gateway.go — RS- propagation, sendGatewayUnsubToGateway
var bytes = GatewayCommands.FormatUnsub("$G", "orders.>");
var line = Encoding.UTF8.GetString(bytes);
line.ShouldBe("GS- $G orders.>\r\n");
}
[Fact]
public void FormatMode_optimistic_produces_O()
{
// Go reference: gateway.go — GMODE command, "O" = Optimistic mode
var bytes = GatewayCommands.FormatMode("$G", GatewayInterestMode.Optimistic);
var line = Encoding.UTF8.GetString(bytes);
line.ShouldBe("GMODE $G O\r\n");
}
[Fact]
public void FormatMode_interest_only_produces_I()
{
// Go reference: gateway.go — GMODE command, "I" = InterestOnly mode
var bytes = GatewayCommands.FormatMode("$G", GatewayInterestMode.InterestOnly);
var line = Encoding.UTF8.GetString(bytes);
line.ShouldBe("GMODE $G I\r\n");
}
[Fact]
public void ParseCommandType_identifies_sub()
{
// Go reference: gateway.go — processGatewayMsg dispatch on GS+
var line = Encoding.UTF8.GetBytes("GS+ ACC foo.bar\r\n");
var result = GatewayCommands.ParseCommandType(line);
result.ShouldBe(GatewayCommandType.Sub);
}
[Fact]
public void ParseCommandType_identifies_unsub()
{
// Go reference: gateway.go — processGatewayMsg dispatch on GS-
var line = Encoding.UTF8.GetBytes("GS- ACC foo.bar\r\n");
var result = GatewayCommands.ParseCommandType(line);
result.ShouldBe(GatewayCommandType.Unsub);
}
[Fact]
public void ParseCommandType_identifies_ping()
{
// Go reference: gateway.go — keepalive GPING command
var result = GatewayCommands.ParseCommandType(GatewayCommands.Ping);
result.ShouldBe(GatewayCommandType.Ping);
}
[Fact]
public void ParseCommandType_identifies_pong()
{
// Go reference: gateway.go — keepalive GPONG response
var result = GatewayCommands.ParseCommandType(GatewayCommands.Pong);
result.ShouldBe(GatewayCommandType.Pong);
}
[Fact]
public void ParseCommandType_returns_null_for_unknown()
{
// Unrecognized commands should return null for graceful handling
var line = Encoding.UTF8.GetBytes("UNKNOWN something\r\n");
var result = GatewayCommands.ParseCommandType(line);
result.ShouldBeNull();
}
[Fact]
public void Wire_format_constants_end_correctly()
{
// Go reference: gateway.go — all gateway commands use CRLF line endings
var crlf = new byte[] { (byte)'\r', (byte)'\n' };
GatewayCommands.Ping.TakeLast(2).ShouldBe(crlf);
GatewayCommands.Pong.TakeLast(2).ShouldBe(crlf);
GatewayCommands.Crlf.ShouldBe(crlf);
}
}

View File

@@ -0,0 +1,580 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
using NATS.Server.Monitoring;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Gateway configuration validation, options parsing, monitoring endpoint,
/// and server lifecycle tests.
/// Ported from golang/nats-server/server/gateway_test.go.
/// </summary>
public class GatewayConfigTests
{
// ── GatewayOptions Defaults ─────────────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void Default_gateway_options_have_correct_defaults()
{
var options = new GatewayOptions();
options.Name.ShouldBeNull();
options.Host.ShouldBe("0.0.0.0");
options.Port.ShouldBe(0);
options.Remotes.ShouldNotBeNull();
options.Remotes.Count.ShouldBe(0);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void Gateway_options_name_can_be_set()
{
var options = new GatewayOptions { Name = "CLUSTER-A" };
options.Name.ShouldBe("CLUSTER-A");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void Gateway_options_host_can_be_set()
{
var options = new GatewayOptions { Host = "192.168.1.1" };
options.Host.ShouldBe("192.168.1.1");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void Gateway_options_port_can_be_set()
{
var options = new GatewayOptions { Port = 7222 };
options.Port.ShouldBe(7222);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void Gateway_options_remotes_can_be_set()
{
var options = new GatewayOptions
{
Remotes = ["127.0.0.1:7222", "127.0.0.1:7223"],
};
options.Remotes.Count.ShouldBe(2);
}
// ── NatsOptions Gateway Configuration ───────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void NatsOptions_gateway_is_null_by_default()
{
var opts = new NatsOptions();
opts.Gateway.ShouldBeNull();
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void NatsOptions_gateway_can_be_assigned()
{
var opts = new NatsOptions
{
Gateway = new GatewayOptions
{
Name = "TestGW",
Host = "127.0.0.1",
Port = 7222,
},
};
opts.Gateway.ShouldNotBeNull();
opts.Gateway.Name.ShouldBe("TestGW");
}
// ── Config File Parsing ─────────────────────────────────────────────
// Go: TestGatewayWithListenToAny server/gateway_test.go:834
[Fact]
public void Config_processor_parses_gateway_name()
{
var config = """
gateway {
name: "MY-GATEWAY"
}
""";
var opts = ConfigProcessor.ProcessConfig(config);
opts.Gateway.ShouldNotBeNull();
opts.Gateway!.Name.ShouldBe("MY-GATEWAY");
}
// Go: TestGatewayWithListenToAny server/gateway_test.go:834
[Fact]
public void Config_processor_parses_gateway_listen()
{
var config = """
gateway {
name: "GW"
listen: "127.0.0.1:7222"
}
""";
var opts = ConfigProcessor.ProcessConfig(config);
opts.Gateway.ShouldNotBeNull();
opts.Gateway!.Host.ShouldBe("127.0.0.1");
opts.Gateway!.Port.ShouldBe(7222);
}
// Go: TestGatewayWithListenToAny server/gateway_test.go:834
[Fact]
public void Config_processor_parses_gateway_listen_any()
{
var config = """
gateway {
name: "GW"
listen: "0.0.0.0:7333"
}
""";
var opts = ConfigProcessor.ProcessConfig(config);
opts.Gateway.ShouldNotBeNull();
opts.Gateway!.Host.ShouldBe("0.0.0.0");
opts.Gateway!.Port.ShouldBe(7333);
}
// Go: TestGatewayWithListenToAny server/gateway_test.go:834
[Fact]
public void Config_processor_gateway_without_name_leaves_null()
{
var config = """
gateway {
listen: "127.0.0.1:7222"
}
""";
var opts = ConfigProcessor.ProcessConfig(config);
opts.Gateway.ShouldNotBeNull();
opts.Gateway!.Name.ShouldBeNull();
}
// Go: TestGatewayWithListenToAny server/gateway_test.go:834
[Fact]
public void Config_processor_no_gateway_section_leaves_null()
{
var config = """
port: 4222
""";
var opts = ConfigProcessor.ProcessConfig(config);
opts.Gateway.ShouldBeNull();
}
// ── Server Lifecycle with Gateway ───────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Server_starts_with_gateway_configured()
{
var opts = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Gateway = new GatewayOptions
{
Name = "LIFECYCLE",
Host = "127.0.0.1",
Port = 0,
},
};
var server = new NatsServer(opts, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
server.GatewayListen.ShouldNotBeNull();
server.GatewayListen.ShouldContain("127.0.0.1:");
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Server_gateway_listen_uses_ephemeral_port()
{
var opts = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Gateway = new GatewayOptions
{
Name = "EPHEMERAL",
Host = "127.0.0.1",
Port = 0,
},
};
var server = new NatsServer(opts, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
// The gateway listen should have a non-zero port
var parts = server.GatewayListen!.Split(':');
int.Parse(parts[1]).ShouldBeGreaterThan(0);
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Server_without_gateway_has_null_gateway_listen()
{
var opts = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
};
var server = new NatsServer(opts, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
server.GatewayListen.ShouldBeNull();
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
// Go: TestGatewayNoPanicOnStartupWithMonitoring server/gateway_test.go:6903
[Fact]
public async Task Server_starts_with_both_gateway_and_monitoring()
{
var opts = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
MonitorPort = 0,
Gateway = new GatewayOptions
{
Name = "MON-GW",
Host = "127.0.0.1",
Port = 0,
},
};
var server = new NatsServer(opts, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
server.GatewayListen.ShouldNotBeNull();
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
// ── GatewayManager Unit Tests ───────────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_starts_and_listens()
{
var options = new GatewayOptions
{
Name = "UNIT",
Host = "127.0.0.1",
Port = 0,
};
var stats = new ServerStats();
var manager = new GatewayManager(
options,
stats,
"SERVER-1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.StartAsync(CancellationToken.None);
manager.ListenEndpoint.ShouldContain("127.0.0.1:");
await manager.DisposeAsync();
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_ephemeral_port_resolves()
{
var options = new GatewayOptions
{
Name = "UNIT",
Host = "127.0.0.1",
Port = 0,
};
var manager = new GatewayManager(
options,
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.StartAsync(CancellationToken.None);
// Port should have been resolved
options.Port.ShouldBeGreaterThan(0);
await manager.DisposeAsync();
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_dispose_decrements_stats()
{
var options = new GatewayOptions
{
Name = "STATS",
Host = "127.0.0.1",
Port = 0,
};
var stats = new ServerStats();
var manager = new GatewayManager(
options,
stats,
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.StartAsync(CancellationToken.None);
await manager.DisposeAsync();
stats.Gateways.ShouldBe(0);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_forward_without_connections_does_not_throw()
{
var options = new GatewayOptions
{
Name = "EMPTY",
Host = "127.0.0.1",
Port = 0,
};
var manager = new GatewayManager(
options,
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
// ForwardMessageAsync without any connections should not throw
await manager.ForwardMessageAsync("$G", "test", null, new byte[] { 1 }, CancellationToken.None);
manager.ForwardedJetStreamClusterMessages.ShouldBe(0);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_propagate_without_connections_does_not_throw()
{
var options = new GatewayOptions
{
Name = "EMPTY",
Host = "127.0.0.1",
Port = 0,
};
var manager = new GatewayManager(
options,
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
// These should not throw even without connections
manager.PropagateLocalSubscription("$G", "test.>", null);
manager.PropagateLocalUnsubscription("$G", "test.>", null);
}
// ── GatewayzHandler ─────────────────────────────────────────────────
// Go: TestGatewayNoPanicOnStartupWithMonitoring server/gateway_test.go:6903
[Fact]
public async Task Gatewayz_handler_returns_gateway_count()
{
await using var fixture = await GatewayConfigFixture.StartAsync();
var handler = new GatewayzHandler(fixture.Local);
var result = handler.Build();
result.ShouldNotBeNull();
}
// Go: TestGatewayNoPanicOnStartupWithMonitoring server/gateway_test.go:6903
[Fact]
public async Task Gatewayz_handler_reflects_active_connections()
{
await using var fixture = await GatewayConfigFixture.StartAsync();
fixture.Local.Stats.Gateways.ShouldBeGreaterThan(0);
}
// ── Duplicate Remote Deduplication ───────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Duplicate_remotes_are_deduplicated()
{
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();
// Create remote with duplicate entries
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!, 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);
// Should have exactly 1 gateway connection, not 2
// (remote deduplicates identical endpoints)
local.Stats.Gateways.ShouldBeGreaterThan(0);
remote.Stats.Gateways.ShouldBeGreaterThan(0);
await localCts.CancelAsync();
await remoteCts.CancelAsync();
local.Dispose();
remote.Dispose();
localCts.Dispose();
remoteCts.Dispose();
}
// ── ServerStats Gateway Fields ──────────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void ServerStats_gateway_fields_initialized_to_zero()
{
var stats = new ServerStats();
stats.Gateways.ShouldBe(0);
stats.SlowConsumerGateways.ShouldBe(0);
stats.StaleConnectionGateways.ShouldBe(0);
}
// Go: TestGatewaySlowConsumer server/gateway_test.go:7003
[Fact]
public void ServerStats_gateway_counter_atomic()
{
var stats = new ServerStats();
Interlocked.Increment(ref stats.Gateways);
Interlocked.Increment(ref stats.Gateways);
stats.Gateways.ShouldBe(2);
Interlocked.Decrement(ref stats.Gateways);
stats.Gateways.ShouldBe(1);
}
}
/// <summary>
/// Shared fixture for config tests.
/// </summary>
internal sealed class GatewayConfigFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _localCts;
private readonly CancellationTokenSource _remoteCts;
private GatewayConfigFixture(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<GatewayConfigFixture> 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 GatewayConfigFixture(local, remote, localCts, remoteCts);
}
public async ValueTask DisposeAsync()
{
await _localCts.CancelAsync();
await _remoteCts.CancelAsync();
Local.Dispose();
Remote.Dispose();
_localCts.Dispose();
_remoteCts.Dispose();
}
}

View File

@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
namespace NATS.Server.Gateways.Tests.Gateways;
public class GatewayConnectionDirectionParityBatch2Tests
{
[Fact]
public async Task Gateway_manager_tracks_inbound_and_outbound_connection_sets()
{
var a = await StartServerAsync(MakeGatewayOptions("GW-A"));
var b = await StartServerAsync(MakeGatewayOptions("GW-B", a.Server.GatewayListen));
try
{
await WaitForCondition(() =>
a.Server.NumInboundGateways() == 1 &&
b.Server.NumOutboundGateways() == 1,
10000);
a.Server.NumInboundGateways().ShouldBe(1);
a.Server.NumOutboundGateways().ShouldBe(0);
b.Server.NumOutboundGateways().ShouldBe(1);
b.Server.NumInboundGateways().ShouldBe(0);
var aManager = a.Server.GatewayManager;
var bManager = b.Server.GatewayManager;
aManager.ShouldNotBeNull();
bManager.ShouldNotBeNull();
aManager!.HasInbound(b.Server.ServerId).ShouldBeTrue();
bManager!.HasInbound(a.Server.ServerId).ShouldBeFalse();
bManager.GetOutboundGatewayConnection(a.Server.ServerId).ShouldNotBeNull();
bManager.GetOutboundGatewayConnection("does-not-exist").ShouldBeNull();
aManager.GetInboundGatewayConnections().Count.ShouldBe(1);
aManager.GetOutboundGatewayConnections().Count.ShouldBe(0);
bManager.GetOutboundGatewayConnections().Count.ShouldBe(1);
bManager.GetInboundGatewayConnections().Count.ShouldBe(0);
}
finally
{
await DisposeServers(a, b);
}
}
private static NatsOptions MakeGatewayOptions(string gatewayName, string? remote = null)
{
return new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Gateway = new GatewayOptions
{
Name = gatewayName,
Host = "127.0.0.1",
Port = 0,
Remotes = remote is null ? [] : [remote],
},
};
}
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(NatsOptions opts)
{
var server = new NatsServer(opts, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return (server, cts);
}
private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
{
foreach (var (server, cts) in servers)
{
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
}
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs)
{
using var cts = new CancellationTokenSource(timeoutMs);
while (!cts.IsCancellationRequested)
{
if (predicate())
return;
await Task.Yield();
}
throw new TimeoutException("Condition not met.");
}
}

View File

@@ -0,0 +1,898 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
using NATS.Server.Subscriptions;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Gateway connection establishment, handshake, lifecycle, and reconnection tests.
/// Ported from golang/nats-server/server/gateway_test.go.
/// </summary>
public class GatewayConnectionTests
{
// ── Handshake and Connection Establishment ──────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_outbound_handshake_sets_remote_id()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL-SERVER", cts.Token);
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldBe("GATEWAY LOCAL-SERVER");
await WriteLineAsync(clientSocket, "GATEWAY REMOTE-SERVER", cts.Token);
await handshake;
gw.RemoteId.ShouldBe("REMOTE-SERVER");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_inbound_handshake_sets_remote_id()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformInboundHandshakeAsync("LOCAL-SERVER", cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE-CLIENT", cts.Token);
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldBe("GATEWAY LOCAL-SERVER");
await handshake;
gw.RemoteId.ShouldBe("REMOTE-CLIENT");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_handshake_rejects_invalid_protocol()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformInboundHandshakeAsync("LOCAL", cts.Token);
await WriteLineAsync(clientSocket, "INVALID protocol", cts.Token);
await Should.ThrowAsync<InvalidOperationException>(async () => await handshake);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_handshake_rejects_empty_id()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformInboundHandshakeAsync("LOCAL", cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY ", cts.Token);
await Should.ThrowAsync<InvalidOperationException>(async () => await handshake);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Two_clusters_establish_gateway_connections()
{
await using var fixture = await GatewayConnectionFixture.StartAsync();
fixture.Local.Stats.Gateways.ShouldBeGreaterThan(0);
fixture.Remote.Stats.Gateways.ShouldBeGreaterThan(0);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_connection_count_tracked_in_stats()
{
await using var fixture = await GatewayConnectionFixture.StartAsync();
fixture.Local.Stats.Gateways.ShouldBeGreaterThanOrEqualTo(1);
fixture.Remote.Stats.Gateways.ShouldBeGreaterThanOrEqualTo(1);
}
// Go: TestGatewayDoesntSendBackToItself server/gateway_test.go:2150
[Fact]
public async Task Gateway_does_not_create_echo_cycle()
{
await using var fixture = await GatewayConnectionFixture.StartAsync();
await using var remoteSub = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
});
await remoteSub.ConnectAsync();
await using var localConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
});
await localConn.ConnectAsync();
await using var sub = await remoteSub.SubscribeCoreAsync<string>("cycle.test");
await remoteSub.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("cycle.test");
await localConn.PublishAsync("cycle.test", "ping");
await localConn.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("ping");
// Verify no additional cycle messages arrive
await Task.Delay(200);
using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await sub.Msgs.ReadAsync(noMoreTimeout.Token));
}
// Go: TestGatewaySolicitShutdown server/gateway_test.go:784
[Fact]
public async Task Gateway_manager_shutdown_does_not_hang()
{
var options = new GatewayOptions
{
Name = "TEST",
Host = "127.0.0.1",
Port = 0,
Remotes = ["127.0.0.1:19999"], // Non-existent host
};
var manager = new GatewayManager(
options,
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.StartAsync(CancellationToken.None);
// Dispose should complete promptly even with pending reconnect attempts
var disposeTask = manager.DisposeAsync().AsTask();
var completed = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.ShouldBe(disposeTask, "DisposeAsync should complete within timeout");
}
// Go: TestGatewayBasic server/gateway_test.go:399 (reconnection part)
[Fact]
public async Task Gateway_reconnects_after_remote_shutdown()
{
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();
// Start remote
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);
local.Stats.Gateways.ShouldBeGreaterThan(0);
remote.Stats.Gateways.ShouldBeGreaterThan(0);
// Shutdown remote
await remoteCts.CancelAsync();
remote.Dispose();
// Wait for gateway count to drop
using var dropTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!dropTimeout.IsCancellationRequested && local.Stats.Gateways > 0)
await Task.Delay(50, dropTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Restart remote connecting to local
var remote2Options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Gateway = new GatewayOptions
{
Name = "REMOTE2",
Host = "127.0.0.1",
Port = 0,
Remotes = [local.GatewayListen!],
},
};
var remote2 = new NatsServer(remote2Options, NullLoggerFactory.Instance);
var remote2Cts = new CancellationTokenSource();
_ = remote2.StartAsync(remote2Cts.Token);
await remote2.WaitForReadyAsync();
// Wait for new gateway link
using var reconTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!reconTimeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote2.Stats.Gateways == 0))
await Task.Delay(50, reconTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
local.Stats.Gateways.ShouldBeGreaterThan(0);
remote2.Stats.Gateways.ShouldBeGreaterThan(0);
await localCts.CancelAsync();
await remote2Cts.CancelAsync();
local.Dispose();
remote2.Dispose();
localCts.Dispose();
remote2Cts.Dispose();
remoteCts.Dispose();
}
// Go: TestGatewayNoReconnectOnClose server/gateway_test.go:1735
[Fact]
public async Task Connection_read_loop_starts_and_processes_messages()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Perform handshake
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var receivedMessage = new TaskCompletionSource<GatewayMessage>();
gw.MessageReceived = msg =>
{
receivedMessage.TrySetResult(msg);
return Task.CompletedTask;
};
gw.StartLoop(cts.Token);
// Send a GMSG message
var payload = "hello-gateway"u8.ToArray();
var line = $"GMSG $G test.subject - {payload.Length}\r\n";
await clientSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, cts.Token);
await clientSocket.SendAsync(payload, SocketFlags.None, cts.Token);
await clientSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, cts.Token);
var msg = await receivedMessage.Task.WaitAsync(cts.Token);
msg.Subject.ShouldBe("test.subject");
msg.ReplyTo.ShouldBeNull();
Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("hello-gateway");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Connection_read_loop_processes_gmsg_with_reply()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var receivedMessage = new TaskCompletionSource<GatewayMessage>();
gw.MessageReceived = msg =>
{
receivedMessage.TrySetResult(msg);
return Task.CompletedTask;
};
gw.StartLoop(cts.Token);
var payload = "data"u8.ToArray();
var line = $"GMSG $G test.subject _INBOX.abc {payload.Length}\r\n";
await clientSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, cts.Token);
await clientSocket.SendAsync(payload, SocketFlags.None, cts.Token);
await clientSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, cts.Token);
var msg = await receivedMessage.Task.WaitAsync(cts.Token);
msg.Subject.ShouldBe("test.subject");
msg.ReplyTo.ShouldBe("_INBOX.abc");
msg.Account.ShouldBe("$G");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Connection_read_loop_processes_account_scoped_gmsg()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var receivedMessage = new TaskCompletionSource<GatewayMessage>();
gw.MessageReceived = msg =>
{
receivedMessage.TrySetResult(msg);
return Task.CompletedTask;
};
gw.StartLoop(cts.Token);
var payload = "msg"u8.ToArray();
var line = $"GMSG ACCT test.subject - {payload.Length}\r\n";
await clientSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, cts.Token);
await clientSocket.SendAsync(payload, SocketFlags.None, cts.Token);
await clientSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, cts.Token);
var msg = await receivedMessage.Task.WaitAsync(cts.Token);
msg.Account.ShouldBe("ACCT");
msg.Subject.ShouldBe("test.subject");
}
// Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755
[Fact]
public async Task Connection_read_loop_processes_aplus_interest()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var receivedSub = new TaskCompletionSource<RemoteSubscription>();
gw.RemoteSubscriptionReceived = sub =>
{
receivedSub.TrySetResult(sub);
return Task.CompletedTask;
};
gw.StartLoop(cts.Token);
await WriteLineAsync(clientSocket, "A+ MYACC orders.>", cts.Token);
var sub = await receivedSub.Task.WaitAsync(cts.Token);
sub.Subject.ShouldBe("orders.>");
sub.Account.ShouldBe("MYACC");
sub.IsRemoval.ShouldBeFalse();
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public async Task Connection_read_loop_processes_aminus_interest()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var receivedSubs = new List<RemoteSubscription>();
var tcs = new TaskCompletionSource();
gw.RemoteSubscriptionReceived = sub =>
{
receivedSubs.Add(sub);
if (receivedSubs.Count >= 2)
tcs.TrySetResult();
return Task.CompletedTask;
};
gw.StartLoop(cts.Token);
await WriteLineAsync(clientSocket, "A+ ACC foo.*", cts.Token);
await WriteLineAsync(clientSocket, "A- ACC foo.*", cts.Token);
await tcs.Task.WaitAsync(cts.Token);
receivedSubs[0].IsRemoval.ShouldBeFalse();
receivedSubs[1].IsRemoval.ShouldBeTrue();
}
// Go: TestGatewayQueueSub server/gateway_test.go:2265
[Fact]
public async Task Connection_read_loop_processes_aplus_with_queue()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var receivedSub = new TaskCompletionSource<RemoteSubscription>();
gw.RemoteSubscriptionReceived = sub =>
{
receivedSub.TrySetResult(sub);
return Task.CompletedTask;
};
gw.StartLoop(cts.Token);
await WriteLineAsync(clientSocket, "A+ $G foo.bar workers", cts.Token);
var sub = await receivedSub.Task.WaitAsync(cts.Token);
sub.Subject.ShouldBe("foo.bar");
sub.Queue.ShouldBe("workers");
sub.Account.ShouldBe("$G");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Send_message_writes_gmsg_protocol()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
var payload = Encoding.UTF8.GetBytes("payload-data");
await gw.SendMessageAsync("$G", "test.subject", "_INBOX.reply", payload, cts.Token);
var buf = new byte[4096];
var total = new StringBuilder();
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (true)
{
var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
if (n == 0) break;
total.Append(Encoding.ASCII.GetString(buf, 0, n));
if (total.ToString().Contains("payload-data", StringComparison.Ordinal))
break;
}
var received = total.ToString();
received.ShouldContain("GMSG $G test.subject _INBOX.reply");
received.ShouldContain("payload-data");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Send_aplus_writes_interest_protocol()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
await gw.SendAPlusAsync("$G", "orders.>", null, cts.Token);
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldBe("A+ $G orders.>");
}
// Go: TestGatewayQueueSub server/gateway_test.go:2265
[Fact]
public async Task Send_aplus_with_queue_writes_interest_protocol()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
await gw.SendAPlusAsync("$G", "foo", "workers", cts.Token);
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldBe("A+ $G foo workers");
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public async Task Send_aminus_writes_unsubscribe_interest_protocol()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
await gw.SendAMinusAsync("$G", "orders.>", null, cts.Token);
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldBe("A- $G orders.>");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Send_message_with_no_reply_uses_dash()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
await gw.SendMessageAsync("$G", "test.subject", null, new byte[] { 0x41 }, cts.Token);
var buf = new byte[4096];
var total = new StringBuilder();
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (true)
{
var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
if (n == 0) break;
total.Append(Encoding.ASCII.GetString(buf, 0, n));
if (total.ToString().Contains("\r\n", StringComparison.Ordinal) && total.Length > 20)
break;
}
total.ToString().ShouldContain("GMSG $G test.subject - 1");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Send_message_with_empty_payload()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
await gw.SendMessageAsync("$G", "test.empty", null, ReadOnlyMemory<byte>.Empty, cts.Token);
var buf = new byte[4096];
var total = new StringBuilder();
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (true)
{
var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
if (n == 0) break;
total.Append(Encoding.ASCII.GetString(buf, 0, n));
if (total.ToString().Contains("GMSG", StringComparison.Ordinal))
break;
}
total.ToString().ShouldContain("GMSG $G test.empty - 0");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Connection_dispose_cleans_up_gracefully()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
gw.StartLoop(cts.Token);
await gw.DisposeAsync(); // Should not throw
// Verify the connection is no longer usable after dispose
gw.RemoteId.ShouldBe("REMOTE");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Multiple_concurrent_sends_are_serialized()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var serverSocket = await listener.AcceptSocketAsync();
await using var gw = new GatewayConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
await ReadLineAsync(clientSocket, cts.Token);
await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token);
await handshake;
// Fire off concurrent sends
var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
var idx = i;
tasks.Add(gw.SendMessageAsync("$G", $"sub.{idx}", null, Encoding.UTF8.GetBytes($"msg-{idx}"), cts.Token));
}
await Task.WhenAll(tasks);
// Drain all data from socket
var buf = new byte[8192];
var total = new StringBuilder();
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (true)
{
try
{
var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
if (n == 0) break;
total.Append(Encoding.ASCII.GetString(buf, 0, n));
}
catch (OperationCanceledException)
{
break;
}
}
// All 10 messages should be present
var received = total.ToString();
for (int i = 0; i < 10; i++)
{
received.ShouldContain($"sub.{i}");
}
}
// ── Helpers ─────────────────────────────────────────────────────────
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}
/// <summary>
/// Shared fixture for gateway connection tests that need two running server clusters.
/// </summary>
internal sealed class GatewayConnectionFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _localCts;
private readonly CancellationTokenSource _remoteCts;
private GatewayConnectionFixture(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<GatewayConnectionFixture> 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 GatewayConnectionFixture(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}'.");
}
public async ValueTask DisposeAsync()
{
await _localCts.CancelAsync();
await _remoteCts.CancelAsync();
Local.Dispose();
Remote.Dispose();
_localCts.Dispose();
_remoteCts.Dispose();
}
}

View File

@@ -0,0 +1,775 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
using NATS.Server.Subscriptions;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Gateway message forwarding, reply mapping, queue subscription delivery,
/// and cross-cluster pub/sub tests.
/// Ported from golang/nats-server/server/gateway_test.go.
/// </summary>
public class GatewayForwardingTests
{
// ── Basic Message Forwarding ────────────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Message_published_on_local_arrives_at_remote_subscriber()
{
await using var fixture = await ForwardingFixture.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<string>("fwd.test");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("fwd.test");
await publisher.PublishAsync("fwd.test", "hello-world");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("hello-world");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Message_published_on_remote_arrives_at_local_subscriber()
{
await using var fixture = await ForwardingFixture.StartAsync();
await using var subscriber = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
});
await subscriber.ConnectAsync();
await using var publisher = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
});
await publisher.ConnectAsync();
await using var sub = await subscriber.SubscribeCoreAsync<string>("fwd.reverse");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnRemoteAsync("fwd.reverse");
await publisher.PublishAsync("fwd.reverse", "reverse-msg");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("reverse-msg");
}
// Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993
[Fact]
public async Task Message_forwarded_only_once_to_remote_subscriber()
{
await using var fixture = await ForwardingFixture.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<string>("once.test");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("once.test");
await publisher.PublishAsync("once.test", "exactly-once");
await publisher.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("exactly-once");
// Wait and verify no duplicates
await Task.Delay(300);
using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await sub.Msgs.ReadAsync(noMoreTimeout.Token));
}
// Go: TestGatewaySendsToNonLocalSubs server/gateway_test.go:3140
[Fact]
public async Task Message_without_local_subscriber_forwarded_to_remote()
{
await using var fixture = await ForwardingFixture.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();
// Subscribe only on remote, no local subscriber
await using var sub = await subscriber.SubscribeCoreAsync<string>("only.remote");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("only.remote");
await publisher.PublishAsync("only.remote", "no-local");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("no-local");
}
// Go: TestGatewayDoesntSendBackToItself server/gateway_test.go:2150
[Fact]
public async Task Both_local_and_remote_subscribers_receive_message_published_locally()
{
await using var fixture = await ForwardingFixture.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<string>("both.test");
await remoteConn.PingAsync();
await using var localSub = await localConn.SubscribeCoreAsync<string>("both.test");
await localConn.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("both.test");
await localConn.PublishAsync("both.test", "shared");
await localConn.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var localMsg = await localSub.Msgs.ReadAsync(timeout.Token);
localMsg.Data.ShouldBe("shared");
var remoteMsg = await remoteSub.Msgs.ReadAsync(timeout.Token);
remoteMsg.Data.ShouldBe("shared");
}
// ── Wildcard Subject Forwarding ─────────────────────────────────────
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
[Fact]
public async Task Wildcard_subscription_receives_matching_gateway_messages()
{
await using var fixture = await ForwardingFixture.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<string>("wc.>");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("wc.test.one");
await publisher.PublishAsync("wc.test.one", "wildcard-msg");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Subject.ShouldBe("wc.test.one");
msg.Data.ShouldBe("wildcard-msg");
}
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
[Fact]
public async Task Partial_wildcard_subscription_receives_gateway_messages()
{
await using var fixture = await ForwardingFixture.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<string>("orders.*");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("orders.created");
await publisher.PublishAsync("orders.created", "order-1");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Subject.ShouldBe("orders.created");
msg.Data.ShouldBe("order-1");
}
// ── Reply Subject Mapping (_GR_. Prefix) ────────────────────────────
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
[Fact]
public void Reply_mapper_adds_gr_prefix_with_cluster_id()
{
var mapped = ReplyMapper.ToGatewayReply("_INBOX.abc", "CLUSTER-A");
mapped.ShouldNotBeNull();
mapped.ShouldStartWith("_GR_.");
mapped.ShouldContain("CLUSTER-A");
}
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
[Fact]
public void Reply_mapper_restores_original_reply()
{
var original = "_INBOX.abc123";
var mapped = ReplyMapper.ToGatewayReply(original, "C1");
mapped.ShouldNotBeNull();
ReplyMapper.TryRestoreGatewayReply(mapped, out var restored).ShouldBeTrue();
restored.ShouldBe(original);
}
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
[Fact]
public void Reply_mapper_handles_nested_gr_prefixes()
{
var original = "_INBOX.reply1";
var once = ReplyMapper.ToGatewayReply(original, "CLUSTER-A");
var twice = ReplyMapper.ToGatewayReply(once, "CLUSTER-B");
ReplyMapper.TryRestoreGatewayReply(twice!, out var restored).ShouldBeTrue();
restored.ShouldBe(original);
}
// Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586
[Fact]
public void Reply_mapper_returns_null_for_null_input()
{
var result = ReplyMapper.ToGatewayReply(null, "CLUSTER");
result.ShouldBeNull();
}
// Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586
[Fact]
public void Reply_mapper_returns_empty_for_empty_input()
{
var result = ReplyMapper.ToGatewayReply("", "CLUSTER");
result.ShouldBe("");
}
// Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586
[Fact]
public void Has_gateway_reply_prefix_detects_gr_prefix()
{
ReplyMapper.HasGatewayReplyPrefix("_GR_.CLUSTER.inbox").ShouldBeTrue();
ReplyMapper.HasGatewayReplyPrefix("_INBOX.abc").ShouldBeFalse();
ReplyMapper.HasGatewayReplyPrefix(null).ShouldBeFalse();
ReplyMapper.HasGatewayReplyPrefix("").ShouldBeFalse();
}
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
[Fact]
public void Restore_returns_false_for_non_gr_subject()
{
ReplyMapper.TryRestoreGatewayReply("_INBOX.abc", out _).ShouldBeFalse();
}
// Go: TestGatewayReplyMapTracking server/gateway_test.go:6017
[Fact]
public void Restore_returns_false_for_malformed_gr_subject()
{
// _GR_. with no cluster separator
ReplyMapper.TryRestoreGatewayReply("_GR_.nodot", out _).ShouldBeFalse();
}
// Go: TestGatewayReplyMapTracking server/gateway_test.go:6017
[Fact]
public void Restore_returns_false_for_gr_prefix_with_nothing_after_separator()
{
ReplyMapper.TryRestoreGatewayReply("_GR_.CLUSTER.", out _).ShouldBeFalse();
}
// ── Queue Subscription Forwarding ───────────────────────────────────
// Go: TestGatewayQueueSub server/gateway_test.go:2265
[Fact]
public async Task Queue_subscription_interest_tracked_on_remote()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
subList.HasRemoteInterest("$G", "foo").ShouldBeTrue();
subList.MatchRemote("$G", "foo").Count.ShouldBe(1);
}
// Go: TestGatewayQueueSub server/gateway_test.go:2265
[Fact]
public async Task Queue_subscription_with_multiple_groups_all_tracked()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
subList.ApplyRemoteSub(new RemoteSubscription("foo", "baz", "gw1", "$G"));
subList.MatchRemote("$G", "foo").Count.ShouldBe(2);
}
// Go: TestGatewayQueueSub server/gateway_test.go:2265
[Fact]
public async Task Queue_sub_removal_clears_remote_interest()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
subList.HasRemoteInterest("$G", "foo").ShouldBeTrue();
subList.ApplyRemoteSub(RemoteSubscription.Removal("foo", "bar", "gw1", "$G"));
subList.HasRemoteInterest("$G", "foo").ShouldBeFalse();
}
// ── GatewayManager Forwarding ───────────────────────────────────────
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_forward_message_increments_js_counter()
{
var manager = new GatewayManager(
new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 },
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.ForwardJetStreamClusterMessageAsync(
new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()),
default);
manager.ForwardedJetStreamClusterMessages.ShouldBe(1);
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public async Task Gateway_manager_forward_js_message_multiple_times()
{
var manager = new GatewayManager(
new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 },
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
for (int i = 0; i < 5; i++)
{
await manager.ForwardJetStreamClusterMessageAsync(
new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()),
default);
}
manager.ForwardedJetStreamClusterMessages.ShouldBe(5);
}
// ── Multiple Messages ───────────────────────────────────────────────
// Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993
[Fact]
public async Task Multiple_messages_forwarded_across_gateway()
{
await using var fixture = await ForwardingFixture.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<string>("multi.test");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("multi.test");
const int count = 10;
for (int i = 0; i < count; i++)
{
await publisher.PublishAsync("multi.test", $"msg-{i}");
}
await publisher.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var received = new List<string>();
for (int i = 0; i < count; i++)
{
var msg = await sub.Msgs.ReadAsync(timeout.Token);
received.Add(msg.Data!);
}
received.Count.ShouldBe(count);
for (int i = 0; i < count; i++)
{
received.ShouldContain($"msg-{i}");
}
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
// Verifies that a message published on local with a reply-to subject is forwarded
// to the remote with the reply-to intact, allowing manual request-reply across gateway.
[Fact]
public async Task Message_with_reply_to_forwarded_across_gateway()
{
await using var fixture = await ForwardingFixture.StartAsync();
await using var remoteConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
});
await remoteConn.ConnectAsync();
// Subscribe on remote for requests
await using var sub = await remoteConn.SubscribeCoreAsync<string>("svc.request");
await remoteConn.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("svc.request");
// Publish from local with a reply-to subject via raw socket
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, fixture.Local.Port);
var infoBuf = new byte[4096];
_ = await sock.ReceiveAsync(infoBuf); // read INFO
await sock.SendAsync(Encoding.ASCII.GetBytes(
"CONNECT {}\r\nPUB svc.request _INBOX.reply123 12\r\nrequest-data\r\nPING\r\n"));
// Wait for PONG to confirm the message was processed
var pongBuf = new byte[4096];
var pongTotal = new StringBuilder();
using var pongCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!pongTotal.ToString().Contains("PONG"))
{
var n = await sock.ReceiveAsync(pongBuf, SocketFlags.None, pongCts.Token);
if (n == 0) break;
pongTotal.Append(Encoding.ASCII.GetString(pongBuf, 0, n));
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("request-data");
msg.ReplyTo.ShouldNotBeNullOrEmpty();
}
// ── Account Scoped Forwarding ───────────────────────────────────────
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public async Task Messages_forwarded_within_same_account_only()
{
var users = new User[]
{
new() { Username = "user_a", Password = "pass", Account = "ACCT_A" },
new() { Username = "user_b", Password = "pass", Account = "ACCT_B" },
};
await using var fixture = await ForwardingFixture.StartWithUsersAsync(users);
await using var remoteA = new NatsConnection(new NatsOpts
{
Url = $"nats://user_a:pass@127.0.0.1:{fixture.Remote.Port}",
});
await remoteA.ConnectAsync();
await using var remoteB = new NatsConnection(new NatsOpts
{
Url = $"nats://user_b:pass@127.0.0.1:{fixture.Remote.Port}",
});
await remoteB.ConnectAsync();
await using var publisherA = new NatsConnection(new NatsOpts
{
Url = $"nats://user_a:pass@127.0.0.1:{fixture.Local.Port}",
});
await publisherA.ConnectAsync();
await using var subA = await remoteA.SubscribeCoreAsync<string>("acct.test");
await using var subB = await remoteB.SubscribeCoreAsync<string>("acct.test");
await remoteA.PingAsync();
await remoteB.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("ACCT_A", "acct.test");
await publisherA.PublishAsync("acct.test", "for-account-a");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msgA = await subA.Msgs.ReadAsync(timeout.Token);
msgA.Data.ShouldBe("for-account-a");
// Account B should not receive
using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await subB.Msgs.ReadAsync(noMsgTimeout.Token));
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public async Task Non_matching_subject_not_forwarded_after_interest_established()
{
await using var fixture = await ForwardingFixture.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();
// Subscribe to a specific subject
await using var sub = await subscriber.SubscribeCoreAsync<string>("specific.topic");
await subscriber.PingAsync();
await fixture.WaitForRemoteInterestOnLocalAsync("specific.topic");
// Publish to a different subject
await publisher.PublishAsync("other.topic", "should-not-arrive");
await publisher.PingAsync();
await Task.Delay(300);
using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await sub.Msgs.ReadAsync(noMsgTimeout.Token));
}
// Go: TestGatewayNoCrashOnInvalidSubject server/gateway_test.go:6279
[Fact]
public void GatewayMessage_record_stores_all_fields()
{
var payload = new byte[] { 1, 2, 3 };
var msg = new GatewayMessage("test.subject", "_INBOX.reply", payload, "MYACCT");
msg.Subject.ShouldBe("test.subject");
msg.ReplyTo.ShouldBe("_INBOX.reply");
msg.Payload.Length.ShouldBe(3);
msg.Account.ShouldBe("MYACCT");
}
// Go: TestGatewayBasic server/gateway_test.go:399
[Fact]
public void GatewayMessage_defaults_account_to_global()
{
var msg = new GatewayMessage("test.subject", null, new byte[] { });
msg.Account.ShouldBe("$G");
}
// ── Interest-Only Mode and ShouldForwardInterestOnly ────────────────
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void Should_forward_interest_only_returns_true_when_interest_exists()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A"));
GatewayManager.ShouldForwardInterestOnly(subList, "A", "orders.created").ShouldBeTrue();
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void Should_forward_interest_only_returns_false_without_interest()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A"));
GatewayManager.ShouldForwardInterestOnly(subList, "A", "payments.created").ShouldBeFalse();
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void Should_forward_interest_only_for_different_account_returns_false()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A"));
GatewayManager.ShouldForwardInterestOnly(subList, "B", "orders.created").ShouldBeFalse();
}
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
[Fact]
public void Should_forward_with_wildcard_interest()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("test.*", null, "gw1", "$G"));
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.one").ShouldBeTrue();
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.two").ShouldBeTrue();
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.one").ShouldBeFalse();
}
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
[Fact]
public void Should_forward_with_fwc_interest()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G"));
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "events.a.b.c").ShouldBeTrue();
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.x").ShouldBeFalse();
}
}
/// <summary>
/// Shared fixture for forwarding tests that need two running server clusters.
/// </summary>
internal sealed class ForwardingFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _localCts;
private readonly CancellationTokenSource _remoteCts;
private ForwardingFixture(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 Task<ForwardingFixture> StartAsync()
=> StartWithUsersAsync(null);
public static async Task<ForwardingFixture> StartWithUsersAsync(IReadOnlyList<User>? users)
{
var localOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
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,
Users = users,
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 ForwardingFixture(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}'.");
}
public async Task WaitForRemoteInterestOnLocalAsync(string account, string subject)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested)
{
if (Local.HasRemoteInterest(account, subject))
return;
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
}
public async Task WaitForRemoteInterestOnRemoteAsync(string subject)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested)
{
if (Remote.HasRemoteInterest(subject))
return;
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'.");
}
public async ValueTask DisposeAsync()
{
await _localCts.CancelAsync();
await _remoteCts.CancelAsync();
Local.Dispose();
Remote.Dispose();
_localCts.Dispose();
_remoteCts.Dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using NATS.Server.Gateways;
using NATS.Server.Subscriptions;
namespace NATS.Server.Gateways.Tests.Gateways;
public class GatewayInterestIdempotencyTests
{
[Fact]
public async Task Duplicate_RSplus_or_reconnect_replay_does_not_double_count_remote_interest()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var gatewaySocket = await listener.AcceptSocketAsync();
await using var gateway = new GatewayConnection(gatewaySocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = gateway.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("GATEWAY LOCAL");
await WriteLineAsync(remoteSocket, "GATEWAY REMOTE", timeout.Token);
await handshakeTask;
using var subList = new SubList();
var remoteAdded = 0;
subList.InterestChanged += change =>
{
if (change.Kind == InterestChangeKind.RemoteAdded)
remoteAdded++;
};
gateway.RemoteSubscriptionReceived = sub =>
{
subList.ApplyRemoteSub(sub);
return Task.CompletedTask;
};
gateway.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "A+ A orders.*", timeout.Token);
await WaitForAsync(() => subList.HasRemoteInterest("A", "orders.created"), timeout.Token);
await WriteLineAsync(remoteSocket, "A+ A orders.*", timeout.Token);
await Task.Delay(100, timeout.Token);
subList.MatchRemote("A", "orders.created").Count.ShouldBe(1);
remoteAdded.ShouldBe(1);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (predicate())
return;
await Task.Delay(20, ct);
}
throw new TimeoutException("Timed out waiting for condition.");
}
}

View File

@@ -0,0 +1,576 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
using NATS.Server.Subscriptions;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Gateway interest-only mode, account interest, subject interest propagation,
/// and subscription lifecycle tests.
/// Ported from golang/nats-server/server/gateway_test.go.
/// </summary>
public class GatewayInterestModeTests
{
// ── Remote Interest Tracking via SubList ─────────────────────────────
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void Remote_interest_tracked_for_literal_subject()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.created", null, "gw1", "$G"));
subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue();
subList.HasRemoteInterest("$G", "orders.updated").ShouldBeFalse();
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void Remote_interest_tracked_for_wildcard_subject()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G"));
subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue();
subList.HasRemoteInterest("$G", "orders.updated").ShouldBeTrue();
subList.HasRemoteInterest("$G", "orders.deep.nested").ShouldBeFalse();
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void Remote_interest_tracked_for_fwc_subject()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G"));
subList.HasRemoteInterest("$G", "events.one").ShouldBeTrue();
subList.HasRemoteInterest("$G", "events.one.two.three").ShouldBeTrue();
subList.HasRemoteInterest("$G", "other").ShouldBeFalse();
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void Remote_interest_scoped_to_account()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "ACCT_A"));
subList.HasRemoteInterest("ACCT_A", "orders.created").ShouldBeTrue();
subList.HasRemoteInterest("ACCT_B", "orders.created").ShouldBeFalse();
subList.HasRemoteInterest("$G", "orders.created").ShouldBeFalse();
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public void Remote_interest_removed_on_aminus()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "$G"));
subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue();
subList.ApplyRemoteSub(RemoteSubscription.Removal("orders.>", null, "gw1", "$G"));
subList.HasRemoteInterest("$G", "orders.created").ShouldBeFalse();
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void Multiple_remote_interests_from_different_routes()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G"));
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw2", "$G"));
subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue();
subList.MatchRemote("$G", "orders.created").Count.ShouldBe(2);
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public void Removing_one_route_interest_keeps_other()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G"));
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw2", "$G"));
subList.ApplyRemoteSub(RemoteSubscription.Removal("orders.*", null, "gw1", "$G"));
subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue();
subList.MatchRemote("$G", "orders.created").Count.ShouldBe(1);
}
// ── Interest Change Events ──────────────────────────────────────────
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void Interest_change_event_fired_on_remote_add()
{
using var subList = new SubList();
var changes = new List<InterestChange>();
subList.InterestChanged += change => changes.Add(change);
subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G"));
changes.Count.ShouldBe(1);
changes[0].Kind.ShouldBe(InterestChangeKind.RemoteAdded);
changes[0].Subject.ShouldBe("test.>");
changes[0].Account.ShouldBe("$G");
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public void Interest_change_event_fired_on_remote_remove()
{
using var subList = new SubList();
var changes = new List<InterestChange>();
subList.InterestChanged += change => changes.Add(change);
subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G"));
subList.ApplyRemoteSub(RemoteSubscription.Removal("test.>", null, "gw1", "$G"));
changes.Count.ShouldBe(2);
changes[1].Kind.ShouldBe(InterestChangeKind.RemoteRemoved);
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void Duplicate_remote_add_does_not_fire_extra_event()
{
using var subList = new SubList();
var addCount = 0;
subList.InterestChanged += change =>
{
if (change.Kind == InterestChangeKind.RemoteAdded)
addCount++;
};
subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G"));
subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G"));
addCount.ShouldBe(1);
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public void Remove_nonexistent_subscription_does_not_fire_event()
{
using var subList = new SubList();
var removeCount = 0;
subList.InterestChanged += change =>
{
if (change.Kind == InterestChangeKind.RemoteRemoved)
removeCount++;
};
subList.ApplyRemoteSub(RemoteSubscription.Removal("nonexistent", null, "gw1", "$G"));
removeCount.ShouldBe(0);
}
// ── Queue Weight in MatchRemote ─────────────────────────────────────
// Go: TestGatewayTotalQSubs server/gateway_test.go:2484
[Fact]
public void Match_remote_expands_queue_weight()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G", QueueWeight: 3));
var matches = subList.MatchRemote("$G", "foo");
matches.Count.ShouldBe(3);
}
// Go: TestGatewayTotalQSubs server/gateway_test.go:2484
[Fact]
public void Match_remote_default_weight_is_one()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
var matches = subList.MatchRemote("$G", "foo");
matches.Count.ShouldBe(1);
}
// ── End-to-End Interest Propagation via Gateway ─────────────────────
// Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755
[Fact]
public async Task Local_subscription_propagated_to_remote_via_gateway()
{
await using var fixture = await InterestModeFixture.StartAsync();
await using var localConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
});
await localConn.ConnectAsync();
await using var sub = await localConn.SubscribeCoreAsync<string>("prop.test");
await localConn.PingAsync();
// The remote server should see the interest
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && !fixture.Remote.HasRemoteInterest("prop.test"))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Remote.HasRemoteInterest("prop.test").ShouldBeTrue();
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public async Task Unsubscribe_propagated_to_remote_via_gateway()
{
await using var fixture = await InterestModeFixture.StartAsync();
await using var localConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
});
await localConn.ConnectAsync();
var sub = await localConn.SubscribeCoreAsync<string>("unsub.test");
await localConn.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && !fixture.Remote.HasRemoteInterest("unsub.test"))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Remote.HasRemoteInterest("unsub.test").ShouldBeTrue();
// Unsubscribe
await sub.DisposeAsync();
await localConn.PingAsync();
// Wait for interest to be removed
using var unsubTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!unsubTimeout.IsCancellationRequested && fixture.Remote.HasRemoteInterest("unsub.test"))
await Task.Delay(50, unsubTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Remote.HasRemoteInterest("unsub.test").ShouldBeFalse();
}
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
[Fact]
public async Task Remote_wildcard_subscription_establishes_interest()
{
await using var fixture = await InterestModeFixture.StartAsync();
await using var remoteConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
});
await remoteConn.ConnectAsync();
await using var sub = await remoteConn.SubscribeCoreAsync<string>("interest.>");
await remoteConn.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("interest.test"))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Local.HasRemoteInterest("interest.test").ShouldBeTrue();
fixture.Local.HasRemoteInterest("interest.deep.nested").ShouldBeTrue();
}
// Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755
[Fact]
public async Task Multiple_subscribers_same_subject_produces_single_interest()
{
await using var fixture = await InterestModeFixture.StartAsync();
await using var conn1 = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
});
await conn1.ConnectAsync();
await using var conn2 = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
});
await conn2.ConnectAsync();
await using var sub1 = await conn1.SubscribeCoreAsync<string>("multi.interest");
await using var sub2 = await conn2.SubscribeCoreAsync<string>("multi.interest");
await conn1.PingAsync();
await conn2.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("multi.interest"))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Local.HasRemoteInterest("multi.interest").ShouldBeTrue();
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public async Task Account_scoped_interest_propagated_via_gateway()
{
var users = new User[]
{
new() { Username = "acct_user", Password = "pass", Account = "MYACCT" },
};
await using var fixture = await InterestModeFixture.StartWithUsersAsync(users);
await using var conn = new NatsConnection(new NatsOpts
{
Url = $"nats://acct_user:pass@127.0.0.1:{fixture.Remote.Port}",
});
await conn.ConnectAsync();
await using var sub = await conn.SubscribeCoreAsync<string>("acct.interest");
await conn.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("MYACCT", "acct.interest"))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Local.HasRemoteInterest("MYACCT", "acct.interest").ShouldBeTrue();
}
// ── RemoteSubscription Record Tests ─────────────────────────────────
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void RemoteSubscription_record_equality()
{
var a = new RemoteSubscription("foo", null, "gw1", "$G");
var b = new RemoteSubscription("foo", null, "gw1", "$G");
a.ShouldBe(b);
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void RemoteSubscription_removal_factory()
{
var removal = RemoteSubscription.Removal("foo", "bar", "gw1", "$G");
removal.IsRemoval.ShouldBeTrue();
removal.Subject.ShouldBe("foo");
removal.Queue.ShouldBe("bar");
removal.RouteId.ShouldBe("gw1");
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
[Fact]
public void RemoteSubscription_default_account_is_global()
{
var sub = new RemoteSubscription("foo", null, "gw1");
sub.Account.ShouldBe("$G");
}
// Go: TestGatewayTotalQSubs server/gateway_test.go:2484
[Fact]
public void RemoteSubscription_default_queue_weight_is_one()
{
var sub = new RemoteSubscription("foo", "bar", "gw1");
sub.QueueWeight.ShouldBe(1);
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public void RemoteSubscription_default_is_not_removal()
{
var sub = new RemoteSubscription("foo", null, "gw1");
sub.IsRemoval.ShouldBeFalse();
}
// ── Subscription Propagation by GatewayManager ──────────────────────
// Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755
[Fact]
public async Task Gateway_manager_propagate_subscription_sends_aplus()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var options = new GatewayOptions
{
Name = "LOCAL",
Host = "127.0.0.1",
Port = 0,
Remotes = [$"127.0.0.1:{port}"],
};
var manager = new GatewayManager(
options,
new ServerStats(),
"SERVER1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.StartAsync(CancellationToken.None);
// Accept the connection from gateway manager
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var gwSocket = await listener.AcceptSocketAsync(cts.Token);
// Exchange handshakes
var line = await ReadLineAsync(gwSocket, cts.Token);
line.ShouldStartWith("GATEWAY ");
await WriteLineAsync(gwSocket, "GATEWAY REMOTE1", cts.Token);
// Wait for connection to be registered
await Task.Delay(200);
// Propagate a subscription
manager.PropagateLocalSubscription("$G", "orders.>", null);
// Read the A+ message
await Task.Delay(100);
var aplusLine = await ReadLineAsync(gwSocket, cts.Token);
aplusLine.ShouldBe("A+ $G orders.>");
await manager.DisposeAsync();
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
[Fact]
public async Task Gateway_manager_propagate_unsubscription_sends_aminus()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var options = new GatewayOptions
{
Name = "LOCAL",
Host = "127.0.0.1",
Port = 0,
Remotes = [$"127.0.0.1:{port}"],
};
var manager = new GatewayManager(
options,
new ServerStats(),
"SERVER1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.StartAsync(CancellationToken.None);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var gwSocket = await listener.AcceptSocketAsync(cts.Token);
var line = await ReadLineAsync(gwSocket, cts.Token);
line.ShouldStartWith("GATEWAY ");
await WriteLineAsync(gwSocket, "GATEWAY REMOTE1", cts.Token);
await Task.Delay(200);
manager.PropagateLocalUnsubscription("$G", "orders.>", null);
await Task.Delay(100);
var aminusLine = await ReadLineAsync(gwSocket, cts.Token);
aminusLine.ShouldBe("A- $G orders.>");
await manager.DisposeAsync();
}
// ── Helpers ─────────────────────────────────────────────────────────
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}
/// <summary>
/// Shared fixture for interest mode tests.
/// </summary>
internal sealed class InterestModeFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _localCts;
private readonly CancellationTokenSource _remoteCts;
private InterestModeFixture(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 Task<InterestModeFixture> StartAsync()
=> StartWithUsersAsync(null);
public static async Task<InterestModeFixture> StartWithUsersAsync(IReadOnlyList<User>? users)
{
var localOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
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,
Users = users,
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 InterestModeFixture(local, remote, localCts, remoteCts);
}
public async ValueTask DisposeAsync()
{
await _localCts.CancelAsync();
await _remoteCts.CancelAsync();
Local.Dispose();
Remote.Dispose();
_localCts.Dispose();
_remoteCts.Dispose();
}
}

View File

@@ -0,0 +1,17 @@
using NATS.Server.Gateways;
using NATS.Server.Subscriptions;
namespace NATS.Server.Gateways.Tests;
public class GatewayInterestOnlyParityTests
{
[Fact]
public void Gateway_interest_only_mode_forwards_only_subjects_with_remote_interest()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "A"));
GatewayManager.ShouldForwardInterestOnly(subList, "A", "orders.created").ShouldBeTrue();
GatewayManager.ShouldForwardInterestOnly(subList, "A", "payments.created").ShouldBeFalse();
}
}

View File

@@ -0,0 +1,241 @@
// Go: gateway.go:100-150 (InterestMode enum), gateway.go:1500-1600 (switchToInterestOnlyMode)
using NATS.Server.Gateways;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Unit tests for GatewayInterestTracker — the per-connection interest mode state machine.
/// Covers Optimistic/InterestOnly modes, threshold-based switching, and per-account isolation.
/// Go reference: gateway_test.go, TestGatewaySwitchToInterestOnlyModeImmediately (line 6934),
/// TestGatewayAccountInterest (line 1794), TestGatewayAccountUnsub (line 1912).
/// </summary>
public class GatewayInterestTrackerTests
{
// Go: TestGatewayBasic server/gateway_test.go:399 — initial state is Optimistic
[Fact]
public void StartsInOptimisticMode()
{
var tracker = new GatewayInterestTracker();
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.Optimistic);
tracker.GetMode("ACCT_A").ShouldBe(GatewayInterestMode.Optimistic);
tracker.GetMode("ANY_ACCOUNT").ShouldBe(GatewayInterestMode.Optimistic);
}
// Go: TestGatewayBasic server/gateway_test.go:399 — optimistic mode forwards everything
[Fact]
public void OptimisticForwardsEverything()
{
var tracker = new GatewayInterestTracker();
tracker.ShouldForward("$G", "any.subject").ShouldBeTrue();
tracker.ShouldForward("$G", "orders.created").ShouldBeTrue();
tracker.ShouldForward("$G", "deeply.nested.subject.path").ShouldBeTrue();
tracker.ShouldForward("ACCT", "foo").ShouldBeTrue();
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912 — RS- adds to no-interest
[Fact]
public void TrackNoInterest_AddsToNoInterestSet()
{
var tracker = new GatewayInterestTracker();
tracker.TrackNoInterest("$G", "orders.created");
// Should not forward that specific subject in Optimistic mode
tracker.ShouldForward("$G", "orders.created").ShouldBeFalse();
// Other subjects still forwarded
tracker.ShouldForward("$G", "orders.updated").ShouldBeTrue();
tracker.ShouldForward("$G", "payments.created").ShouldBeTrue();
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 — threshold switch
[Fact]
public void SwitchesToInterestOnlyAfterThreshold()
{
const int threshold = 10;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.Optimistic);
// Add subjects up to (but not reaching) the threshold
for (int i = 0; i < threshold - 1; i++)
tracker.TrackNoInterest("$G", $"subject.{i}");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.Optimistic);
// One more crosses the threshold
tracker.TrackNoInterest("$G", $"subject.{threshold - 1}");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void InterestOnlyMode_OnlyForwardsTrackedSubjects()
{
const int threshold = 5;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
// Trigger mode switch
for (int i = 0; i < threshold; i++)
tracker.TrackNoInterest("$G", $"noise.{i}");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
// Nothing forwarded until interest is explicitly tracked
tracker.ShouldForward("$G", "orders.created").ShouldBeFalse();
// Track a positive interest
tracker.TrackInterest("$G", "orders.created");
// Now only that subject is forwarded
tracker.ShouldForward("$G", "orders.created").ShouldBeTrue();
tracker.ShouldForward("$G", "orders.updated").ShouldBeFalse();
tracker.ShouldForward("$G", "payments.done").ShouldBeFalse();
}
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972 — wildcard interest in InterestOnly
[Fact]
public void InterestOnlyMode_SupportsWildcards()
{
const int threshold = 3;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
// Trigger InterestOnly mode
for (int i = 0; i < threshold; i++)
tracker.TrackNoInterest("$G", $"x.{i}");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
// Register a wildcard interest
tracker.TrackInterest("$G", "foo.>");
// Matching subjects are forwarded
tracker.ShouldForward("$G", "foo.bar").ShouldBeTrue();
tracker.ShouldForward("$G", "foo.bar.baz").ShouldBeTrue();
tracker.ShouldForward("$G", "foo.anything.deep.nested").ShouldBeTrue();
// Non-matching subjects are not forwarded
tracker.ShouldForward("$G", "other.subject").ShouldBeFalse();
tracker.ShouldForward("$G", "foo").ShouldBeFalse(); // "foo.>" requires at least one token after "foo"
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794 — per-account mode isolation
[Fact]
public void ModePerAccount()
{
const int threshold = 5;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
// Switch ACCT_A to InterestOnly
for (int i = 0; i < threshold; i++)
tracker.TrackNoInterest("ACCT_A", $"noise.{i}");
tracker.GetMode("ACCT_A").ShouldBe(GatewayInterestMode.InterestOnly);
// ACCT_B remains Optimistic
tracker.GetMode("ACCT_B").ShouldBe(GatewayInterestMode.Optimistic);
// ACCT_A blocks unknown subjects, ACCT_B forwards
tracker.ShouldForward("ACCT_A", "orders.created").ShouldBeFalse();
tracker.ShouldForward("ACCT_B", "orders.created").ShouldBeTrue();
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void ModePersistsAfterSwitch()
{
const int threshold = 3;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
// Trigger switch
for (int i = 0; i < threshold; i++)
tracker.TrackNoInterest("$G", $"y.{i}");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
// TrackInterest in InterestOnly mode — mode stays InterestOnly
tracker.TrackInterest("$G", "orders.created");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
// TrackNoInterest in InterestOnly mode — mode stays InterestOnly
tracker.TrackNoInterest("$G", "something.else");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794 — explicit SwitchToInterestOnly
[Fact]
public void ExplicitSwitchToInterestOnly_SetsMode()
{
var tracker = new GatewayInterestTracker();
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.Optimistic);
tracker.SwitchToInterestOnly("$G");
tracker.GetMode("$G").ShouldBe(GatewayInterestMode.InterestOnly);
}
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912 — RS+ restores interest after RS-
[Fact]
public void TrackInterest_InOptimisticMode_RemovesFromNoInterestSet()
{
var tracker = new GatewayInterestTracker();
// Mark no interest
tracker.TrackNoInterest("$G", "orders.created");
tracker.ShouldForward("$G", "orders.created").ShouldBeFalse();
// Remote re-subscribes — track interest again
tracker.TrackInterest("$G", "orders.created");
tracker.ShouldForward("$G", "orders.created").ShouldBeTrue();
}
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
[Fact]
public void InterestOnlyMode_TrackNoInterest_RemovesFromInterestSet()
{
const int threshold = 3;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
// Trigger InterestOnly
for (int i = 0; i < threshold; i++)
tracker.TrackNoInterest("$G", $"z.{i}");
tracker.TrackInterest("$G", "orders.created");
tracker.ShouldForward("$G", "orders.created").ShouldBeTrue();
// Remote unsubscribes — subject removed from interest set
tracker.TrackNoInterest("$G", "orders.created");
tracker.ShouldForward("$G", "orders.created").ShouldBeFalse();
}
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972 — pwc wildcard in InterestOnly
[Fact]
public void InterestOnlyMode_SupportsPwcWildcard()
{
const int threshold = 3;
var tracker = new GatewayInterestTracker(noInterestThreshold: threshold);
for (int i = 0; i < threshold; i++)
tracker.TrackNoInterest("$G", $"n.{i}");
tracker.TrackInterest("$G", "orders.*");
tracker.ShouldForward("$G", "orders.created").ShouldBeTrue();
tracker.ShouldForward("$G", "orders.deleted").ShouldBeTrue();
tracker.ShouldForward("$G", "orders.deep.nested").ShouldBeFalse(); // * is single token
tracker.ShouldForward("$G", "payments.created").ShouldBeFalse();
}
// Go: TestGatewayAccountInterest server/gateway_test.go:1794 — unknown account defaults optimistic
[Fact]
public void UnknownAccount_DefaultsToOptimisticForwarding()
{
var tracker = new GatewayInterestTracker();
// Account never seen — should forward everything
tracker.ShouldForward("BRAND_NEW_ACCOUNT", "any.subject").ShouldBeTrue();
}
}

View File

@@ -0,0 +1,213 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Tests for GatewayReconnectPolicy and GatewayManager reconnection tracking.
/// Go reference: server/gateway.go reconnectGateway / solicitGateway (gateway.go:1700+).
/// </summary>
public class GatewayReconnectionTests
{
// ── GatewayReconnectPolicy delay calculation ──────────────────────────
// Go: server/gateway.go solicitGateway delay=0 on first attempt
[Fact]
public void CalculateDelay_first_attempt_is_initial_delay()
{
var policy = new GatewayReconnectPolicy
{
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30),
};
var delay = policy.CalculateDelay(0);
delay.ShouldBe(TimeSpan.FromSeconds(1));
}
// Go: server/gateway.go reconnectGateway exponential back-off doubling
[Fact]
public void CalculateDelay_doubles_each_attempt()
{
var policy = new GatewayReconnectPolicy
{
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(1000),
};
var delay0 = policy.CalculateDelay(0);
var delay1 = policy.CalculateDelay(1);
var delay2 = policy.CalculateDelay(2);
var delay3 = policy.CalculateDelay(3);
delay1.ShouldBe(delay0 * 2);
delay2.ShouldBe(delay0 * 4);
delay3.ShouldBe(delay0 * 8);
}
// Go: server/gateway.go reconnectGateway maxDelay cap
[Fact]
public void CalculateDelay_caps_at_max_delay()
{
var policy = new GatewayReconnectPolicy
{
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(5),
};
// At attempt=10 (2^10 = 1024 seconds * 1s initial), should be capped
var delay = policy.CalculateDelay(10);
delay.ShouldBe(TimeSpan.FromSeconds(5));
}
// Go: server/gateway.go reconnectGateway jitter added to avoid thundering herd
[Fact]
public void CalculateDelayWithJitter_adds_jitter()
{
var policy = new GatewayReconnectPolicy
{
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30),
JitterFactor = 0.2,
};
var baseDelay = policy.CalculateDelay(3);
// Run several times to increase the chance of observing jitter
var observed = false;
for (var i = 0; i < 20; i++)
{
var jittered = policy.CalculateDelayWithJitter(3);
jittered.ShouldBeGreaterThanOrEqualTo(baseDelay);
jittered.ShouldBeLessThanOrEqualTo(TimeSpan.FromMilliseconds(
baseDelay.TotalMilliseconds * (1 + policy.JitterFactor) + 1));
if (jittered > baseDelay)
observed = true;
}
observed.ShouldBeTrue("at least one jittered delay should exceed base delay");
}
// ── GatewayManager reconnect attempt tracking ──────────────────────────
// Go: server/gateway.go initial state has no reconnect history
[Fact]
public void GetReconnectAttempts_starts_at_zero()
{
var manager = BuildManager();
manager.GetReconnectAttempts("gw-east").ShouldBe(0);
manager.GetReconnectAttempts("gw-west").ShouldBe(0);
}
// Go: server/gateway.go reconnectGateway increments attempt counter each cycle
[Fact]
public async Task ReconnectAttempts_incremented_on_reconnect()
{
var manager = BuildManager();
using var cts = new CancellationTokenSource();
cts.Cancel(); // Cancel immediately so Task.Delay throws before any real wait
// Counter is incremented before the delay, so it reaches 1 even when cancelled.
await Should.ThrowAsync<OperationCanceledException>(
() => manager.ReconnectGatewayAsync("gw-east", cts.Token));
manager.GetReconnectAttempts("gw-east").ShouldBe(1);
}
// Go: server/gateway.go solicitGateway resets counter after successful connect
[Fact]
public async Task ResetReconnectAttempts_clears_count()
{
var manager = BuildManager();
// Seed the counter with one cancelled attempt
await ReconnectAsync(manager, "gw-east");
manager.GetReconnectAttempts("gw-east").ShouldBe(1);
manager.ResetReconnectAttempts("gw-east");
manager.GetReconnectAttempts("gw-east").ShouldBe(0);
}
// Go: server/gateway.go configurable initial delay via options
[Fact]
public void Custom_initial_delay_respected()
{
var policy = new GatewayReconnectPolicy
{
InitialDelay = TimeSpan.FromMilliseconds(500),
MaxDelay = TimeSpan.FromSeconds(30),
};
policy.CalculateDelay(0).ShouldBe(TimeSpan.FromMilliseconds(500));
policy.CalculateDelay(1).ShouldBe(TimeSpan.FromMilliseconds(1000));
policy.CalculateDelay(2).ShouldBe(TimeSpan.FromMilliseconds(2000));
}
// Go: server/gateway.go configurable max delay cap
[Fact]
public void Custom_max_delay_caps_correctly()
{
var policy = new GatewayReconnectPolicy
{
InitialDelay = TimeSpan.FromSeconds(2),
MaxDelay = TimeSpan.FromSeconds(10),
};
// 2s * 2^10 = 2048s >> 10s cap
policy.CalculateDelay(10).ShouldBe(TimeSpan.FromSeconds(10));
// 2s * 2^2 = 8s < 10s cap
policy.CalculateDelay(2).ShouldBe(TimeSpan.FromSeconds(8));
}
// Go: server/gateway.go independent reconnect state per remote gateway
[Fact]
public async Task Multiple_gateways_tracked_independently()
{
var manager = BuildManager();
// Increment east twice, west once
await ReconnectAsync(manager, "gw-east");
await ReconnectAsync(manager, "gw-east");
await ReconnectAsync(manager, "gw-west");
manager.GetReconnectAttempts("gw-east").ShouldBe(2);
manager.GetReconnectAttempts("gw-west").ShouldBe(1);
// Reset east should not affect west
manager.ResetReconnectAttempts("gw-east");
manager.GetReconnectAttempts("gw-east").ShouldBe(0);
manager.GetReconnectAttempts("gw-west").ShouldBe(1);
}
// ── Helpers ─────────────────────────────────────────────────────────────
private static GatewayManager BuildManager() =>
new GatewayManager(
new GatewayOptions { Name = "TEST", Host = "127.0.0.1", Port = 0 },
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
/// <summary>
/// Triggers a single ReconnectGatewayAsync cycle with an immediately-cancelled token so
/// the attempt counter is incremented without waiting for any real delay.
/// The expected OperationCanceledException is asserted via Shouldly.
/// </summary>
private static async Task ReconnectAsync(GatewayManager manager, string gatewayName)
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Should.ThrowAsync<OperationCanceledException>(
() => manager.ReconnectGatewayAsync(gatewayName, cts.Token));
}
}

View File

@@ -0,0 +1,163 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Tests for gateway connection registration and state tracking (Gap 11.7).
/// Go reference: server/gateway.go srvGateway / outboundGateway struct and gwConnState transitions.
/// </summary>
public class GatewayRegistrationTests
{
// Go: server/gateway.go solicitGateway creates an outbound entry before dialling.
[Fact]
public void RegisterGateway_creates_registration()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
manager.GetRegistration("gw-east").ShouldNotBeNull();
}
// Go: server/gateway.go initial gwConnState is gwConnecting before the dial completes.
[Fact]
public void RegisterGateway_starts_in_connecting_state()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
var reg = manager.GetRegistration("gw-east")!;
reg.State.ShouldBe(GatewayConnectionState.Connecting);
}
// Go: server/gateway.go gwConnState transitions (Connecting → Connected, Connected → Disconnected, etc.).
[Fact]
public void UpdateState_changes_state()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
manager.UpdateState("gw-east", GatewayConnectionState.Connected);
manager.GetRegistration("gw-east")!.State.ShouldBe(GatewayConnectionState.Connected);
}
// Go: server/gateway.go getOutboundGatewayConnection returns nil for unknown names.
[Fact]
public void GetRegistration_returns_null_for_unknown()
{
var manager = BuildManager();
manager.GetRegistration("does-not-exist").ShouldBeNull();
}
// Go: server/gateway.go server.gateways map stores all configured outbound gateways.
[Fact]
public void GetAllRegistrations_returns_all()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
manager.RegisterGateway("gw-west");
var all = manager.GetAllRegistrations();
all.Count.ShouldBe(2);
all.Select(r => r.Name).ShouldContain("gw-east");
all.Select(r => r.Name).ShouldContain("gw-west");
}
// Go: server/gateway.go outboundGateway teardown removes entry from server.gateways.
[Fact]
public void UnregisterGateway_removes_registration()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
manager.UnregisterGateway("gw-east");
manager.GetRegistration("gw-east").ShouldBeNull();
}
// Go: server/gateway.go numOutboundGatewayConnections counts only fully-connected entries.
[Fact]
public void GetConnectedGatewayCount_counts_connected_only()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east"); // Connecting
manager.RegisterGateway("gw-west"); // Connecting
manager.RegisterGateway("gw-south"); // → Connected
manager.UpdateState("gw-south", GatewayConnectionState.Connected);
manager.GetConnectedGatewayCount().ShouldBe(1);
}
// Go: server/gateway.go outboundGateway.msgs.outMsgs incremented per forwarded message.
[Fact]
public void IncrementMessagesSent_increments()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
manager.IncrementMessagesSent("gw-east");
manager.IncrementMessagesSent("gw-east");
manager.IncrementMessagesSent("gw-east");
manager.GetRegistration("gw-east")!.MessagesSent.ShouldBe(3L);
}
// Go: server/gateway.go inboundGateway.msgs.inMsgs incremented per received message.
[Fact]
public void IncrementMessagesReceived_increments()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
manager.IncrementMessagesReceived("gw-east");
manager.IncrementMessagesReceived("gw-east");
manager.GetRegistration("gw-east")!.MessagesReceived.ShouldBe(2L);
}
// Go: server/gateway.go outboundGateway.remoteName / remoteIP stored for monitoring.
[Fact]
public void Registration_stores_remote_address()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east", remoteAddress: "10.0.0.1:7222");
manager.GetRegistration("gw-east")!.RemoteAddress.ShouldBe("10.0.0.1:7222");
}
// Go: server/gateway.go gwConnState Connected transition stamps connected-at time.
[Fact]
public void UpdateState_to_connected_stamps_ConnectedAtUtc()
{
var manager = BuildManager();
manager.RegisterGateway("gw-east");
var before = DateTime.UtcNow;
manager.UpdateState("gw-east", GatewayConnectionState.Connected);
var after = DateTime.UtcNow;
var stamp = manager.GetRegistration("gw-east")!.ConnectedAtUtc;
stamp.ShouldBeGreaterThanOrEqualTo(before);
stamp.ShouldBeLessThanOrEqualTo(after);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static GatewayManager BuildManager() =>
new GatewayManager(
new GatewayOptions { Name = "TEST", Host = "127.0.0.1", Port = 0 },
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
}

View File

@@ -0,0 +1,61 @@
using NATS.Server.Configuration;
namespace NATS.Server.Gateways.Tests.Gateways;
public class GatewayRemoteConfigParityBatch3Tests
{
[Fact]
public void RemoteGatewayOptions_tracks_connection_attempts_and_implicit_flag()
{
var cfg = new RemoteGatewayOptions { Name = "GW-B", Implicit = true };
cfg.IsImplicit().ShouldBeTrue();
cfg.GetConnAttempts().ShouldBe(0);
cfg.BumpConnAttempts().ShouldBe(1);
cfg.BumpConnAttempts().ShouldBe(2);
cfg.GetConnAttempts().ShouldBe(2);
cfg.ResetConnAttempts();
cfg.GetConnAttempts().ShouldBe(0);
}
[Fact]
public void RemoteGatewayOptions_add_and_update_urls_normalize_and_deduplicate()
{
var cfg = new RemoteGatewayOptions();
cfg.AddUrls(["127.0.0.1:7222", "nats://127.0.0.1:7222", "nats://127.0.0.1:7223"]);
cfg.Urls.Count.ShouldBe(2);
cfg.Urls.ShouldContain("nats://127.0.0.1:7222");
cfg.Urls.ShouldContain("nats://127.0.0.1:7223");
cfg.UpdateUrls(
configuredUrls: ["127.0.0.1:7333"],
discoveredUrls: ["nats://127.0.0.1:7334", "127.0.0.1:7333"]);
cfg.Urls.Count.ShouldBe(2);
cfg.Urls.ShouldContain("nats://127.0.0.1:7333");
cfg.Urls.ShouldContain("nats://127.0.0.1:7334");
}
[Fact]
public void RemoteGatewayOptions_save_tls_hostname_and_get_urls_helpers()
{
var cfg = new RemoteGatewayOptions
{
Urls = ["127.0.0.1:7444", "nats://localhost:7445"],
};
cfg.SaveTlsHostname("nats://gw.example.net:7522");
cfg.TlsName.ShouldBe("gw.example.net");
var urlStrings = cfg.GetUrlsAsStrings();
urlStrings.Count.ShouldBe(2);
urlStrings.ShouldContain("nats://127.0.0.1:7444");
urlStrings.ShouldContain("nats://localhost:7445");
var urls = cfg.GetUrls();
urls.Count.ShouldBe(2);
urls.ShouldContain(u => u.Authority == "127.0.0.1:7444");
urls.ShouldContain(u => u.Authority == "localhost:7445");
}
}

View File

@@ -0,0 +1,104 @@
using NATS.Server.Configuration;
using NATS.Server.Gateways;
namespace NATS.Server.Gateways.Tests.Gateways;
public class GatewayReplyAndConfigParityBatch1Tests
{
[Fact]
public void HasGatewayReplyPrefix_accepts_new_and_old_prefixes()
{
ReplyMapper.HasGatewayReplyPrefix("_GR_.clusterA.reply").ShouldBeTrue();
ReplyMapper.HasGatewayReplyPrefix("$GR.clusterA.reply").ShouldBeTrue();
ReplyMapper.HasGatewayReplyPrefix("_INBOX.reply").ShouldBeFalse();
}
[Fact]
public void IsGatewayRoutedSubject_reports_old_prefix_flag()
{
ReplyMapper.IsGatewayRoutedSubject("_GR_.C1.r", out var newPrefixOldFlag).ShouldBeTrue();
newPrefixOldFlag.ShouldBeFalse();
ReplyMapper.IsGatewayRoutedSubject("$GR.C1.r", out var oldPrefixOldFlag).ShouldBeTrue();
oldPrefixOldFlag.ShouldBeTrue();
}
[Fact]
public void TryRestoreGatewayReply_handles_old_prefix_format()
{
ReplyMapper.TryRestoreGatewayReply("$GR.clusterA.reply.one", out var restored).ShouldBeTrue();
restored.ShouldBe("reply.one");
}
[Fact]
public void GatewayHash_helpers_are_deterministic_and_expected_length()
{
var hash1 = ReplyMapper.ComputeGatewayHash("east");
var hash2 = ReplyMapper.ComputeGatewayHash("east");
var oldHash1 = ReplyMapper.ComputeOldGatewayHash("east");
var oldHash2 = ReplyMapper.ComputeOldGatewayHash("east");
hash1.ShouldBe(hash2);
oldHash1.ShouldBe(oldHash2);
hash1.Length.ShouldBe(ReplyMapper.GatewayHashLen);
oldHash1.Length.ShouldBe(ReplyMapper.OldGatewayHashLen);
}
[Fact]
public void Legacy_prefixed_reply_extracts_cluster_and_not_hash()
{
ReplyMapper.TryExtractClusterId("$GR.clusterB.inbox.reply", out var cluster).ShouldBeTrue();
cluster.ShouldBe("clusterB");
ReplyMapper.TryExtractHash("$GR.clusterB.inbox.reply", out _).ShouldBeFalse();
}
[Fact]
public void RemoteGatewayOptions_clone_deep_copies_url_list()
{
var original = new RemoteGatewayOptions
{
Name = "gw-west",
Urls = ["nats://127.0.0.1:7522", "nats://127.0.0.1:7523"],
};
var clone = original.Clone();
clone.ShouldNotBeSameAs(original);
clone.Name.ShouldBe(original.Name);
clone.Urls.ShouldBe(original.Urls);
clone.Urls.Add("nats://127.0.0.1:7524");
original.Urls.Count.ShouldBe(2);
}
[Fact]
public void ValidateGatewayOptions_checks_required_fields()
{
GatewayManager.ValidateGatewayOptions(new GatewayOptions
{
Name = "gw",
Host = "127.0.0.1",
Port = 7222,
Remotes = ["127.0.0.1:8222"],
}, out var error).ShouldBeTrue();
error.ShouldBeNull();
GatewayManager.ValidateGatewayOptions(new GatewayOptions { Port = 7222 }, out error).ShouldBeFalse();
error.ShouldNotBeNull();
error.ShouldContain("name");
GatewayManager.ValidateGatewayOptions(new GatewayOptions { Name = "gw", Port = -1 }, out error).ShouldBeFalse();
error.ShouldNotBeNull();
error.ShouldContain("0-65535");
GatewayManager.ValidateGatewayOptions(new GatewayOptions { Name = "gw", Port = 7222, Remotes = [""] }, out error).ShouldBeFalse();
error.ShouldNotBeNull();
error.ShouldContain("cannot be empty");
}
[Fact]
public void Gateway_tls_warning_constant_is_present()
{
GatewayManager.GatewayTlsInsecureWarning.ShouldNotBeNullOrWhiteSpace();
GatewayManager.GatewayTlsInsecureWarning.ShouldContain("TLS");
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
namespace NATS.Server.Gateways.Tests.Gateways;
public class GatewayServerAccessorParityBatch4Tests
{
[Fact]
public void Gateway_address_url_and_name_accessors_reflect_gateway_options()
{
using var server = new NatsServer(
new NatsOptions
{
Gateway = new GatewayOptions
{
Name = "gw-a",
Host = "127.0.0.1",
Port = 7222,
},
},
NullLoggerFactory.Instance);
server.GatewayAddr().ShouldBe("127.0.0.1:7222");
server.GetGatewayURL().ShouldBe("127.0.0.1:7222");
server.GetGatewayName().ShouldBe("gw-a");
}
[Fact]
public void Gateway_accessors_return_null_when_gateway_is_not_configured()
{
using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance);
server.GatewayAddr().ShouldBeNull();
server.GetGatewayURL().ShouldBeNull();
server.GetGatewayName().ShouldBeNull();
}
}

View File

@@ -0,0 +1,155 @@
using System.Net;
using System.Net.Sockets;
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Tests for queue group subscription tracking on GatewayConnection.
/// Go reference: gateway.go — sendQueueSubsToGateway, queueSubscriptions state.
/// </summary>
public class QueueGroupPropagationTests : IAsyncDisposable
{
private readonly TcpListener _listener;
private readonly Socket _clientSocket;
private readonly Socket _serverSocket;
private readonly GatewayConnection _gw;
public QueueGroupPropagationTests()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
var port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_clientSocket.Connect(IPAddress.Loopback, port);
_serverSocket = _listener.AcceptSocket();
_gw = new GatewayConnection(_serverSocket);
}
public async ValueTask DisposeAsync()
{
await _gw.DisposeAsync();
_clientSocket.Dispose();
_listener.Stop();
}
// Go: gateway.go — sendQueueSubsToGateway registers queue group subscriptions per subject
[Fact]
public void AddQueueSubscription_registers_group()
{
_gw.AddQueueSubscription("orders.new", "workers");
_gw.HasQueueSubscription("orders.new", "workers").ShouldBeTrue();
}
// Go: gateway.go — getQueueGroups returns all groups for a subject
[Fact]
public void GetQueueGroups_returns_groups_for_subject()
{
_gw.AddQueueSubscription("payments.>", "billing");
_gw.AddQueueSubscription("payments.>", "audit");
var groups = _gw.GetQueueGroups("payments.>");
groups.ShouldContain("billing");
groups.ShouldContain("audit");
groups.Count.ShouldBe(2);
}
// Go: gateway.go — removeQueueSubscription removes a specific queue group
[Fact]
public void RemoveQueueSubscription_removes_group()
{
_gw.AddQueueSubscription("events.click", "analytics");
_gw.AddQueueSubscription("events.click", "logging");
_gw.RemoveQueueSubscription("events.click", "analytics");
_gw.HasQueueSubscription("events.click", "analytics").ShouldBeFalse();
_gw.HasQueueSubscription("events.click", "logging").ShouldBeTrue();
}
// Go: gateway.go — hasQueueSubscription returns true for registered subject/group pair
[Fact]
public void HasQueueSubscription_true_when_registered()
{
_gw.AddQueueSubscription("tasks.process", "pool");
_gw.HasQueueSubscription("tasks.process", "pool").ShouldBeTrue();
}
// Go: gateway.go — hasQueueSubscription returns false for unknown pair
[Fact]
public void HasQueueSubscription_false_when_not_registered()
{
_gw.HasQueueSubscription("unknown.subject", "nonexistent-group").ShouldBeFalse();
}
// Go: gateway.go — multiple queue groups can be registered for the same subject
[Fact]
public void Multiple_groups_per_subject()
{
_gw.AddQueueSubscription("jobs.run", "fast");
_gw.AddQueueSubscription("jobs.run", "slow");
_gw.AddQueueSubscription("jobs.run", "batch");
var groups = _gw.GetQueueGroups("jobs.run");
groups.Count.ShouldBe(3);
groups.ShouldContain("fast");
groups.ShouldContain("slow");
groups.ShouldContain("batch");
}
// Go: gateway.go — subscriptions for different subjects are tracked independently
[Fact]
public void Different_subjects_tracked_independently()
{
_gw.AddQueueSubscription("subject.a", "group1");
_gw.AddQueueSubscription("subject.b", "group2");
_gw.HasQueueSubscription("subject.a", "group2").ShouldBeFalse();
_gw.HasQueueSubscription("subject.b", "group1").ShouldBeFalse();
_gw.HasQueueSubscription("subject.a", "group1").ShouldBeTrue();
_gw.HasQueueSubscription("subject.b", "group2").ShouldBeTrue();
}
// Go: gateway.go — QueueSubscriptionCount reflects number of distinct subjects with queue groups
[Fact]
public void QueueSubscriptionCount_tracks_subjects()
{
_gw.QueueSubscriptionCount.ShouldBe(0);
_gw.AddQueueSubscription("foo", "g1");
_gw.QueueSubscriptionCount.ShouldBe(1);
_gw.AddQueueSubscription("bar", "g2");
_gw.QueueSubscriptionCount.ShouldBe(2);
// Adding a second group to an existing subject does not increase count
_gw.AddQueueSubscription("foo", "g3");
_gw.QueueSubscriptionCount.ShouldBe(2);
}
// Go: gateway.go — removing a queue group that was never added is a no-op
[Fact]
public void RemoveQueueSubscription_no_error_for_unknown()
{
// Should not throw even though neither subject nor group was registered
var act = () => _gw.RemoveQueueSubscription("never.registered", "ghost");
act.ShouldNotThrow();
}
// Go: gateway.go — GetQueueGroups returns empty set for unknown subject
[Fact]
public void GetQueueGroups_empty_for_unknown_subject()
{
var groups = _gw.GetQueueGroups("nonexistent.subject");
groups.ShouldNotBeNull();
groups.Count.ShouldBe(0);
}
}

View File

@@ -0,0 +1,164 @@
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Tests for the ReplyMapCache LRU cache with TTL expiration.
/// Go reference: gateway.go — reply mapping cache.
/// </summary>
public class ReplyMapCacheTests
{
// Go: gateway.go — cache is initially empty, lookups return false
[Fact]
public void TryGet_returns_false_on_empty()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
var found = cache.TryGet("_INBOX.abc", out var value);
found.ShouldBeFalse();
value.ShouldBeNull();
}
// Go: gateway.go — cached mapping is retrievable after Set
[Fact]
public void Set_and_TryGet_round_trips()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
cache.Set("_INBOX.abc", "_GR_.cluster1.42._INBOX.abc");
var found = cache.TryGet("_INBOX.abc", out var value);
found.ShouldBeTrue();
value.ShouldBe("_GR_.cluster1.42._INBOX.abc");
}
// Go: gateway.go — LRU eviction removes the least-recently-used entry when at capacity
[Fact]
public void LRU_eviction_at_capacity()
{
var cache = new ReplyMapCache(capacity: 2, ttlMs: 60_000);
cache.Set("key1", "val1");
cache.Set("key2", "val2");
// Adding a third entry should evict the LRU entry (key1, since key2 was added last)
cache.Set("key3", "val3");
cache.TryGet("key1", out _).ShouldBeFalse();
cache.TryGet("key2", out var v2).ShouldBeTrue();
v2.ShouldBe("val2");
cache.TryGet("key3", out var v3).ShouldBeTrue();
v3.ShouldBe("val3");
}
// Go: gateway.go — entries expire after the configured TTL window
[Fact]
[SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing a time-based cache eviction")]
public void TTL_expiration()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 1);
cache.Set("_INBOX.ttl", "_GR_.c1.1._INBOX.ttl");
Thread.Sleep(5); // Wait longer than the 1ms TTL
var found = cache.TryGet("_INBOX.ttl", out var value);
found.ShouldBeFalse();
value.ShouldBeNull();
}
// Go: gateway.go — hit counter tracks successful cache lookups
[Fact]
public void Hits_incremented_on_hit()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
cache.Set("key", "value");
cache.TryGet("key", out _);
cache.TryGet("key", out _);
cache.Hits.ShouldBe(2);
cache.Misses.ShouldBe(0);
}
// Go: gateway.go — miss counter tracks failed lookups
[Fact]
public void Misses_incremented_on_miss()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
cache.TryGet("nope", out _);
cache.TryGet("also-nope", out _);
cache.Misses.ShouldBe(2);
cache.Hits.ShouldBe(0);
}
// Go: gateway.go — Clear removes all entries from the cache
[Fact]
public void Clear_removes_all()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
cache.Set("a", "1");
cache.Set("b", "2");
cache.Set("c", "3");
cache.Clear();
cache.Count.ShouldBe(0);
cache.TryGet("a", out _).ShouldBeFalse();
cache.TryGet("b", out _).ShouldBeFalse();
cache.TryGet("c", out _).ShouldBeFalse();
}
// Go: gateway.go — Set on existing key updates the value and promotes to MRU
[Fact]
public void Set_updates_existing()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
cache.Set("key", "original");
cache.Set("key", "updated");
cache.TryGet("key", out var value).ShouldBeTrue();
value.ShouldBe("updated");
cache.Count.ShouldBe(1);
}
// Go: gateway.go — PurgeExpired removes only expired entries
[Fact]
[SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing a time-based cache eviction")]
public void PurgeExpired_removes_old_entries()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 1);
cache.Set("old1", "v1");
cache.Set("old2", "v2");
Thread.Sleep(5); // Ensure both entries are past the 1ms TTL
var purged = cache.PurgeExpired();
purged.ShouldBe(2);
cache.Count.ShouldBe(0);
}
// Go: gateway.go — Count reflects the current number of cached entries
[Fact]
public void Count_reflects_entries()
{
var cache = new ReplyMapCache(capacity: 16, ttlMs: 60_000);
cache.Count.ShouldBe(0);
cache.Set("a", "1");
cache.Count.ShouldBe(1);
cache.Set("b", "2");
cache.Count.ShouldBe(2);
cache.Set("c", "3");
cache.Count.ShouldBe(3);
cache.Clear();
cache.Count.ShouldBe(0);
}
}

View File

@@ -0,0 +1,151 @@
using NATS.Server.Gateways;
namespace NATS.Server.Gateways.Tests.Gateways;
/// <summary>
/// Tests for the expanded ReplyMapper with hash support.
/// Covers new format (_GR_.{clusterId}.{hash}.{reply}), legacy format (_GR_.{clusterId}.{reply}),
/// cluster/hash extraction, and FNV-1a hash determinism.
/// Go reference: gateway.go:2000-2100, gateway.go:340-380.
/// </summary>
public class ReplyMapperFullTests
{
// Go: gateway.go — replyPfx includes cluster hash + server hash segments
[Fact]
public void ToGatewayReply_WithHash_IncludesHashSegment()
{
var result = ReplyMapper.ToGatewayReply("_INBOX.abc123", "clusterA", 42);
result.ShouldNotBeNull();
result.ShouldBe("_GR_.clusterA.42._INBOX.abc123");
}
// Go: gateway.go — hash is deterministic based on reply subject
[Fact]
public void ToGatewayReply_AutoHash_IsDeterministic()
{
var result1 = ReplyMapper.ToGatewayReply("_INBOX.xyz", "cluster1");
var result2 = ReplyMapper.ToGatewayReply("_INBOX.xyz", "cluster1");
result1.ShouldNotBeNull();
result2.ShouldNotBeNull();
result1.ShouldBe(result2);
// Should contain the hash segment between cluster and reply
result1!.ShouldStartWith("_GR_.cluster1.");
result1.ShouldEndWith("._INBOX.xyz");
// Parse the hash segment
var afterPrefix = result1["_GR_.cluster1.".Length..];
var dotIdx = afterPrefix.IndexOf('.');
dotIdx.ShouldBeGreaterThan(0);
var hashStr = afterPrefix[..dotIdx];
long.TryParse(hashStr, out var hash).ShouldBeTrue();
hash.ShouldBeGreaterThan(0);
}
// Go: handleGatewayReply — strips _GR_ prefix + cluster + hash to restore original
[Fact]
public void TryRestoreGatewayReply_WithHash_RestoresOriginal()
{
var hash = ReplyMapper.ComputeReplyHash("reply.subject");
var mapped = ReplyMapper.ToGatewayReply("reply.subject", "clusterB", hash);
var success = ReplyMapper.TryRestoreGatewayReply(mapped, out var restored);
success.ShouldBeTrue();
restored.ShouldBe("reply.subject");
}
// Go: handleGatewayReply — legacy $GR. and old _GR_ formats without hash
[Fact]
public void TryRestoreGatewayReply_LegacyNoHash_StillWorks()
{
// Legacy format: _GR_.{clusterId}.{reply} (no hash segment)
// The reply itself starts with a non-numeric character, so it won't be mistaken for a hash.
var legacyReply = "_GR_.clusterX.my.reply.subject";
var success = ReplyMapper.TryRestoreGatewayReply(legacyReply, out var restored);
success.ShouldBeTrue();
restored.ShouldBe("my.reply.subject");
}
// Go: handleGatewayReply — nested _GR_ prefixes from multi-hop gateways
[Fact]
public void TryRestoreGatewayReply_NestedPrefixes_UnwrapsAll()
{
// Inner: _GR_.cluster1.{hash}.original.reply
var hash1 = ReplyMapper.ComputeReplyHash("original.reply");
var inner = ReplyMapper.ToGatewayReply("original.reply", "cluster1", hash1);
// Outer: _GR_.cluster2.{hash2}.{inner}
var hash2 = ReplyMapper.ComputeReplyHash(inner!);
var outer = ReplyMapper.ToGatewayReply(inner, "cluster2", hash2);
var success = ReplyMapper.TryRestoreGatewayReply(outer, out var restored);
success.ShouldBeTrue();
restored.ShouldBe("original.reply");
}
// Go: gateway.go — cluster hash extraction for routing decisions
[Fact]
public void TryExtractClusterId_ValidReply_ExtractsId()
{
var mapped = ReplyMapper.ToGatewayReply("test.reply", "myCluster", 999);
var success = ReplyMapper.TryExtractClusterId(mapped, out var clusterId);
success.ShouldBeTrue();
clusterId.ShouldBe("myCluster");
}
// Go: gateway.go — hash extraction for reply deduplication
[Fact]
public void TryExtractHash_ValidReply_ExtractsHash()
{
var mapped = ReplyMapper.ToGatewayReply("inbox.abc", "clusterZ", 12345);
var success = ReplyMapper.TryExtractHash(mapped, out var hash);
success.ShouldBeTrue();
hash.ShouldBe(12345);
}
// Go: getGWHash — hash must be deterministic for same input
[Fact]
public void ComputeReplyHash_Deterministic()
{
var hash1 = ReplyMapper.ComputeReplyHash("_INBOX.test123");
var hash2 = ReplyMapper.ComputeReplyHash("_INBOX.test123");
hash1.ShouldBe(hash2);
hash1.ShouldBeGreaterThan(0);
}
// Go: getGWHash — different inputs should produce different hashes
[Fact]
public void ComputeReplyHash_DifferentInputs_DifferentHashes()
{
var hash1 = ReplyMapper.ComputeReplyHash("_INBOX.aaa");
var hash2 = ReplyMapper.ComputeReplyHash("_INBOX.bbb");
var hash3 = ReplyMapper.ComputeReplyHash("reply.subject.1");
hash1.ShouldNotBe(hash2);
hash1.ShouldNotBe(hash3);
hash2.ShouldNotBe(hash3);
}
// Go: isGWRoutedReply — plain subjects should not match gateway prefix
[Fact]
public void HasGatewayReplyPrefix_PlainSubject_ReturnsFalse()
{
ReplyMapper.HasGatewayReplyPrefix("foo.bar").ShouldBeFalse();
ReplyMapper.HasGatewayReplyPrefix("_INBOX.test").ShouldBeFalse();
ReplyMapper.HasGatewayReplyPrefix(null).ShouldBeFalse();
ReplyMapper.HasGatewayReplyPrefix("").ShouldBeFalse();
ReplyMapper.HasGatewayReplyPrefix("_GR_").ShouldBeFalse(); // No trailing dot
ReplyMapper.HasGatewayReplyPrefix("_GR_.cluster.reply").ShouldBeTrue();
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>