feat: add account-specific dedicated routes (Gap 13.2)
Add _accountRoutes ConcurrentDictionary to RouteManager with full CRUD API: RegisterAccountRoute, UnregisterAccountRoute, GetDedicatedAccountRoute, HasDedicatedRoute, GetAccountsWithDedicatedRoutes, and DedicatedRouteCount property. Update GetRouteForAccount to check dedicated routes before falling back to pool-based selection. Add 10 unit tests in AccountRouteTests.
This commit is contained in:
197
tests/NATS.Server.Tests/Routes/AccountRouteTests.cs
Normal file
197
tests/NATS.Server.Tests/Routes/AccountRouteTests.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
// Reference: golang/nats-server/server/route.go — per-account dedicated route registration (Gap 13.2).
|
||||
// Tests for account-specific dedicated route connections in RouteManager.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for per-account dedicated route connections (Gap 13.2).
|
||||
/// Verifies that RouteManager correctly stores, retrieves, and prioritises
|
||||
/// dedicated routes over pool-based routes for specific accounts.
|
||||
/// Go reference: server/route.go — per-account dedicated route handling.
|
||||
/// </summary>
|
||||
public class AccountRouteTests : IDisposable
|
||||
{
|
||||
// -- Helpers --
|
||||
|
||||
// Track listeners and sockets for cleanup after each test.
|
||||
private readonly List<TcpListener> _listeners = [];
|
||||
private readonly List<Socket> _sockets = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var s in _sockets) s.Dispose();
|
||||
foreach (var l in _listeners) l.Stop();
|
||||
}
|
||||
|
||||
private static RouteManager CreateManager() =>
|
||||
new(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0, Routes = [] },
|
||||
new ServerStats(),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a RouteConnection backed by a connected loopback socket pair so
|
||||
/// that RouteConnection can construct its internal NetworkStream without
|
||||
/// throwing. Both sockets and the listener are tracked for disposal.
|
||||
/// </summary>
|
||||
private RouteConnection MakeConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
_listeners.Add(listener);
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_sockets.Add(client);
|
||||
client.Connect((IPEndPoint)listener.LocalEndpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
_sockets.Add(server);
|
||||
|
||||
return new RouteConnection(server);
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
// Go: server/route.go — per-account dedicated route registration.
|
||||
[Fact]
|
||||
public void RegisterAccountRoute_AddsRoute()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-A", connection);
|
||||
|
||||
manager.GetDedicatedAccountRoute("ACCT-A").ShouldBeSameAs(connection);
|
||||
}
|
||||
|
||||
// Go: server/route.go — overwrite existing dedicated route for same account.
|
||||
[Fact]
|
||||
public void RegisterAccountRoute_OverwritesPrevious()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var first = MakeConnection();
|
||||
var second = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-A", first);
|
||||
manager.RegisterAccountRoute("ACCT-A", second);
|
||||
|
||||
manager.GetDedicatedAccountRoute("ACCT-A").ShouldBeSameAs(second);
|
||||
}
|
||||
|
||||
// Go: server/route.go — removing a dedicated route cleans up the entry.
|
||||
[Fact]
|
||||
public void UnregisterAccountRoute_RemovesRoute()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-A", connection);
|
||||
manager.UnregisterAccountRoute("ACCT-A");
|
||||
|
||||
manager.GetDedicatedAccountRoute("ACCT-A").ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: server/route.go — unregistering a never-registered account is safe.
|
||||
[Fact]
|
||||
public void UnregisterAccountRoute_NonExistent_NoOp()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
// Must not throw.
|
||||
var ex = Record.Exception(() => manager.UnregisterAccountRoute("NONEXISTENT"));
|
||||
ex.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: server/route.go — lookup on unregistered account returns null.
|
||||
[Fact]
|
||||
public void GetDedicatedAccountRoute_NotRegistered_ReturnsNull()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.GetDedicatedAccountRoute("UNKNOWN").ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: server/route.go — HasDedicatedRoute returns true for registered account.
|
||||
[Fact]
|
||||
public void HasDedicatedRoute_RegisteredReturnsTrue()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-B", connection);
|
||||
|
||||
manager.HasDedicatedRoute("ACCT-B").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: server/route.go — HasDedicatedRoute returns false for unknown account.
|
||||
[Fact]
|
||||
public void HasDedicatedRoute_NotRegisteredReturnsFalse()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.HasDedicatedRoute("ACCT-B").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: server/route.go — listing accounts with dedicated routes.
|
||||
[Fact]
|
||||
public void GetAccountsWithDedicatedRoutes_ReturnsAllRegistered()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-1", MakeConnection());
|
||||
manager.RegisterAccountRoute("ACCT-2", MakeConnection());
|
||||
manager.RegisterAccountRoute("ACCT-3", MakeConnection());
|
||||
|
||||
var accounts = manager.GetAccountsWithDedicatedRoutes();
|
||||
|
||||
accounts.Count.ShouldBe(3);
|
||||
accounts.ShouldContain("ACCT-1");
|
||||
accounts.ShouldContain("ACCT-2");
|
||||
accounts.ShouldContain("ACCT-3");
|
||||
}
|
||||
|
||||
// Go: server/route.go — DedicatedRouteCount tracks registered entries.
|
||||
[Fact]
|
||||
public void DedicatedRouteCount_MatchesRegistrations()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.DedicatedRouteCount.ShouldBe(0);
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-X", MakeConnection());
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-Y", MakeConnection());
|
||||
manager.DedicatedRouteCount.ShouldBe(2);
|
||||
|
||||
manager.UnregisterAccountRoute("ACCT-X");
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: server/route.go — dedicated route takes priority over pool-based route
|
||||
// for the account it is registered against.
|
||||
[Fact]
|
||||
public void GetRouteForAccount_PrefersDedicatedRoute()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
// Register a dedicated route for "ACCT-PREF".
|
||||
var dedicated = MakeConnection();
|
||||
manager.RegisterAccountRoute("ACCT-PREF", dedicated);
|
||||
|
||||
// GetRouteForAccount must return the dedicated connection even though
|
||||
// no pool routes exist (the dedicated path short-circuits pool lookup).
|
||||
var result = manager.GetRouteForAccount("ACCT-PREF");
|
||||
|
||||
result.ShouldBeSameAs(dedicated);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user