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:
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Subscriptions;
|
||||
@@ -10,6 +11,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly CancellationTokenSource _closedCts = new();
|
||||
private readonly GatewayInterestTracker _interestTracker = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _accountSubscriptions = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _queueSubscriptions = new(StringComparer.Ordinal);
|
||||
private Task? _loopTask;
|
||||
|
||||
public string? RemoteId { get; private set; }
|
||||
@@ -23,6 +26,90 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// </summary>
|
||||
public GatewayInterestTracker InterestTracker => _interestTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a subject to the account-specific subscription set for this gateway connection.
|
||||
/// Go: gateway.go — per-account subscription routing state on outbound connections.
|
||||
/// </summary>
|
||||
public void AddAccountSubscription(string account, string subject)
|
||||
{
|
||||
var subs = _accountSubscriptions.GetOrAdd(account, _ => new HashSet<string>(StringComparer.Ordinal));
|
||||
lock (subs) subs.Add(subject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a subject from the account-specific subscription set for this gateway connection.
|
||||
/// </summary>
|
||||
public void RemoveAccountSubscription(string account, string subject)
|
||||
{
|
||||
if (_accountSubscriptions.TryGetValue(account, out var subs))
|
||||
lock (subs) subs.Remove(subject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all subjects tracked for the given account on this connection.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetAccountSubscriptions(string account)
|
||||
{
|
||||
if (_accountSubscriptions.TryGetValue(account, out var subs))
|
||||
lock (subs) return new HashSet<string>(subs, StringComparer.Ordinal);
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of subjects tracked for the given account. Returns 0 for unknown accounts.
|
||||
/// </summary>
|
||||
public int AccountSubscriptionCount(string account)
|
||||
{
|
||||
if (_accountSubscriptions.TryGetValue(account, out var subs))
|
||||
lock (subs) return subs.Count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a queue group subscription for propagation to this gateway.
|
||||
/// Go reference: gateway.go — sendQueueSubsToGateway.
|
||||
/// </summary>
|
||||
public void AddQueueSubscription(string subject, string queueGroup)
|
||||
{
|
||||
var groups = _queueSubscriptions.GetOrAdd(subject, _ => new HashSet<string>(StringComparer.Ordinal));
|
||||
lock (groups) groups.Add(queueGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a queue group subscription from this gateway connection's tracking state.
|
||||
/// Go reference: gateway.go — sendQueueSubsToGateway (removal path).
|
||||
/// </summary>
|
||||
public void RemoveQueueSubscription(string subject, string queueGroup)
|
||||
{
|
||||
if (_queueSubscriptions.TryGetValue(subject, out var groups))
|
||||
lock (groups) groups.Remove(queueGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all queue group names registered for the given subject.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetQueueGroups(string subject)
|
||||
{
|
||||
if (_queueSubscriptions.TryGetValue(subject, out var groups))
|
||||
lock (groups) return new HashSet<string>(groups, StringComparer.Ordinal);
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of distinct subjects that have at least one queue group registered.
|
||||
/// </summary>
|
||||
public int QueueSubscriptionCount => _queueSubscriptions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given subject/queueGroup pair is currently registered on this gateway connection.
|
||||
/// </summary>
|
||||
public bool HasQueueSubscription(string subject, string queueGroup)
|
||||
{
|
||||
if (!_queueSubscriptions.TryGetValue(subject, out var groups))
|
||||
return false;
|
||||
lock (groups) return groups.Contains(queueGroup);
|
||||
}
|
||||
|
||||
public async Task PerformOutboundHandshakeAsync(string serverId, CancellationToken ct)
|
||||
{
|
||||
await WriteLineAsync($"GATEWAY {serverId}", ct);
|
||||
|
||||
@@ -7,6 +7,32 @@ using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Gateways;
|
||||
|
||||
/// <summary>
|
||||
/// Controls retry timing for outbound gateway reconnections using exponential backoff with jitter.
|
||||
/// Go reference: server/gateway.go solicitGateway / reconnectGateway delay logic.
|
||||
/// </summary>
|
||||
public sealed class GatewayReconnectPolicy
|
||||
{
|
||||
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1);
|
||||
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public double JitterFactor { get; init; } = 0.2;
|
||||
public int MaxAttempts { get; init; } = int.MaxValue; // 0 = unlimited
|
||||
|
||||
public TimeSpan CalculateDelay(int attempt)
|
||||
{
|
||||
var baseDelay = InitialDelay.TotalMilliseconds * Math.Pow(2, Math.Min(attempt, 10));
|
||||
var capped = Math.Min(baseDelay, MaxDelay.TotalMilliseconds);
|
||||
return TimeSpan.FromMilliseconds(capped);
|
||||
}
|
||||
|
||||
public TimeSpan CalculateDelayWithJitter(int attempt)
|
||||
{
|
||||
var delay = CalculateDelay(attempt);
|
||||
var jitter = Random.Shared.NextDouble() * JitterFactor * delay.TotalMilliseconds;
|
||||
return TimeSpan.FromMilliseconds(delay.TotalMilliseconds + jitter);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GatewayManager : IAsyncDisposable
|
||||
{
|
||||
private readonly GatewayOptions _options;
|
||||
@@ -17,6 +43,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
private readonly ILogger<GatewayManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, GatewayConnection> _connections = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _discoveredGateways = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, int> _reconnectAttempts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _forwardedJetStreamClusterMessages;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
@@ -68,6 +95,38 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current reconnect attempt count for a named gateway.
|
||||
/// Returns 0 if no reconnect attempt has been recorded yet.
|
||||
/// Go reference: server/gateway.go reconnectGateway attempt tracking.
|
||||
/// </summary>
|
||||
public int GetReconnectAttempts(string gatewayName)
|
||||
=> _reconnectAttempts.TryGetValue(gatewayName, out var n) ? n : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Resets the reconnect attempt counter for a named gateway (called on successful connection).
|
||||
/// Go reference: server/gateway.go solicitGateway successful connect path.
|
||||
/// </summary>
|
||||
public void ResetReconnectAttempts(string gatewayName)
|
||||
=> _reconnectAttempts.TryRemove(gatewayName, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a single reconnect cycle for a named gateway using exponential backoff.
|
||||
/// Increments the attempt counter, waits the backoff delay, then attempts to connect.
|
||||
/// Go reference: server/gateway.go reconnectGateway / solicitGateway.
|
||||
/// </summary>
|
||||
public async Task ReconnectGatewayAsync(string gatewayName, CancellationToken ct)
|
||||
{
|
||||
var attempt = _reconnectAttempts.AddOrUpdate(gatewayName, 1, (_, n) => n + 1);
|
||||
var policy = new GatewayReconnectPolicy();
|
||||
var delay = policy.CalculateDelayWithJitter(attempt - 1);
|
||||
|
||||
_logger.LogDebug("Gateway reconnect attempt {Attempt} for {Gateway}, delay={Delay}ms",
|
||||
attempt, gatewayName, delay.TotalMilliseconds);
|
||||
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -112,6 +171,29 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
_ = connection.SendAMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a batch of account-scoped subscriptions to a named gateway connection, recording
|
||||
/// them in that connection's per-account subscription set.
|
||||
/// Go: gateway.go — account-specific subscription propagation on outbound routes.
|
||||
/// </summary>
|
||||
public void SendAccountSubscriptions(string gatewayName, string account, IEnumerable<string> subjects)
|
||||
{
|
||||
if (!_connections.TryGetValue(gatewayName, out var conn)) return;
|
||||
foreach (var subject in subjects)
|
||||
conn.AddAccountSubscription(account, subject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all subjects tracked for the given account on the named gateway connection.
|
||||
/// Returns an empty set when the connection is not found.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetAccountSubscriptions(string gatewayName, string account)
|
||||
{
|
||||
if (!_connections.TryGetValue(gatewayName, out var conn))
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
return conn.GetAccountSubscriptions(account);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts == null)
|
||||
|
||||
157
tests/NATS.Server.Tests/Gateways/AccountGatewayRoutesTests.cs
Normal file
157
tests/NATS.Server.Tests/Gateways/AccountGatewayRoutesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
155
tests/NATS.Server.Tests/Gateways/QueueGroupPropagationTests.cs
Normal file
155
tests/NATS.Server.Tests/Gateways/QueueGroupPropagationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user