feat: add account-specific gateway routes (Gap 11.3)

Adds per-account subscription tracking to GatewayConnection via
AddAccountSubscription/RemoveAccountSubscription/GetAccountSubscriptions/
AccountSubscriptionCount, with corresponding SendAccountSubscriptions and
GetAccountSubscriptions helpers on GatewayManager. Covered by 10 new unit tests.
This commit is contained in:
Joseph Doherty
2026-02-25 11:51:09 -05:00
parent 4c53159de8
commit d598276807
4 changed files with 481 additions and 0 deletions

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.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,155 @@
using System.Net;
using System.Net.Sockets;
using NATS.Server.Gateways;
using Shouldly;
namespace NATS.Server.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);
}
}