30-task plan across 4 gated phases: - Phase A (Foundation): Client, Parser, SubList, Server, Routes, Gateways, Leaf Nodes, Accounts - Phase B (Distributed Substrate): RAFT, Storage, Config/Reload, Monitoring - Phase C (JetStream Depth): Core, Clustering - Phase D (Protocol Surfaces): MQTT, JWT Includes concrete test code, TDD steps, and task dependency tracking.
1977 lines
64 KiB
Markdown
1977 lines
64 KiB
Markdown
# Go-to-.NET Systematic Test Parity Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Fill ~2,058 missing test gaps between the Go NATS server (3,451 tests) and the .NET port (454 tests) to achieve behavioral test parity across all 18 subsystems.
|
||
|
||
**Architecture:** Hybrid dependency-first in 4 gated phases: Foundation (client/routing/sublist) → Distributed Substrate (RAFT/storage) → JetStream Depth → Protocol Surfaces. Behavioral equivalence, not 1:1 count parity. Every Go test intent mapped or marked N/A. Subsystems within a phase are independent and parallelizable via subagents.
|
||
|
||
**Tech Stack:** .NET 10, C# 14, xUnit 3, Shouldly, System.IO.Pipelines, NSubstitute, NATS.Client.Core (integration tests), socket-based test fixtures.
|
||
|
||
---
|
||
|
||
**Execution guardrails**
|
||
- Use `@test-driven-development` in every task.
|
||
- Switch to `@systematic-debugging` when tests fail unexpectedly.
|
||
- One commit per task.
|
||
- Run `@verification-before-completion` before phase gate claims.
|
||
- Use `model: "sonnet"` for straightforward test porting tasks. Use default (opus) for complex protocol/concurrency tasks.
|
||
- Parallel subagents within phases where subsystems don't share files.
|
||
|
||
---
|
||
|
||
## Phase A: Foundation (~440 tests)
|
||
|
||
Gate: `dotnet test` passes with all new foundation tests. Client pub/sub, routing, gateway, leaf node basics verified.
|
||
|
||
---
|
||
|
||
### Task 1: Port Client Basic Pub/Sub Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/ClientPubSubTests.cs`
|
||
- Reference: `golang/nats-server/server/client_test.go` (TestClientSimplePubSub, TestClientPubSubNoEcho, TestClientSimplePubSubWithReply, TestClientNoBodyPubSubWithReply, TestClientPubWithQueueSub, TestClientPubWithQueueSubNoEcho)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
namespace NATS.Server.Tests;
|
||
|
||
public class ClientPubSubTests : IAsyncLifetime
|
||
{
|
||
private NatsServer _server = null!;
|
||
private int _port;
|
||
private readonly CancellationTokenSource _cts = new();
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
_port = TestHelpers.GetFreePort();
|
||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||
_ = _server.StartAsync(_cts.Token);
|
||
await _server.WaitForReadyAsync();
|
||
}
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
await _cts.CancelAsync();
|
||
_server.Dispose();
|
||
}
|
||
|
||
private async Task<Socket> ConnectRawAsync()
|
||
{
|
||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||
// Read INFO
|
||
await ReadUntilAsync(sock, "\r\n");
|
||
return sock;
|
||
}
|
||
|
||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||
{
|
||
using var cts = new CancellationTokenSource(timeoutMs);
|
||
var sb = new StringBuilder();
|
||
var buf = new byte[4096];
|
||
while (!sb.ToString().Contains(expected))
|
||
{
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||
if (n == 0) break;
|
||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Simple_pub_sub_delivers_message()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB foo 5\r\nhello\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
response.ShouldContain("MSG foo 1 5\r\n");
|
||
response.ShouldContain("hello");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Pub_sub_no_echo_suppresses_own_messages()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
||
"CONNECT {\"echo\":false}\r\nSUB foo 1\r\nPING\r\n"));
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB foo 5\r\nhello\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
response.ShouldNotContain("MSG ");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Pub_sub_with_reply_subject()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB foo bar 5\r\nhello\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
response.ShouldContain("MSG foo 1 bar 5\r\n");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Queue_sub_distributes_messages()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo g1 1\r\nSUB foo g1 2\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
var sb = new StringBuilder();
|
||
for (var i = 0; i < 100; i++)
|
||
{
|
||
await sock.SendAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
|
||
}
|
||
await sock.SendAsync("PING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
var sid1Count = Regex.Matches(response, @"MSG foo 1 5").Count;
|
||
var sid2Count = Regex.Matches(response, @"MSG foo 2 5").Count;
|
||
(sid1Count + sid2Count).ShouldBe(100);
|
||
sid1Count.ShouldBeGreaterThan(20);
|
||
sid2Count.ShouldBeGreaterThan(20);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Empty_body_pub_sub_with_reply()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB foo bar 0\r\n\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
response.ShouldContain("MSG foo 1 bar 0\r\n");
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPubSubTests" -v minimal`
|
||
Expected: FAIL — file does not exist yet, or tests fail if server behavior is incomplete.
|
||
|
||
**Step 3: Create the test file and fix any server-side gaps**
|
||
|
||
If any test fails due to missing server behavior (not just test infrastructure), port the minimal Go logic to the .NET server. The server implementation already covers basic pub/sub, so tests should mostly pass once created.
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPubSubTests" -v minimal`
|
||
Expected: PASS (5 tests)
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/ClientPubSubTests.cs
|
||
git commit -m "test: port client basic pub/sub tests from Go (6 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Port Client UNSUB and Auto-Unsub Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/ClientUnsubTests.cs`
|
||
- Reference: `golang/nats-server/server/client_test.go` (TestClientUnSub, TestClientUnSubMax, TestClientAutoUnsubExactReceived, TestClientUnsubAfterAutoUnsub, TestClientRemoveSubsOnDisconnect)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
namespace NATS.Server.Tests;
|
||
|
||
public class ClientUnsubTests : IAsyncLifetime
|
||
{
|
||
private NatsServer _server = null!;
|
||
private int _port;
|
||
private readonly CancellationTokenSource _cts = new();
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
_port = TestHelpers.GetFreePort();
|
||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||
_ = _server.StartAsync(_cts.Token);
|
||
await _server.WaitForReadyAsync();
|
||
}
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
await _cts.CancelAsync();
|
||
_server.Dispose();
|
||
}
|
||
|
||
private async Task<Socket> ConnectRawAsync()
|
||
{
|
||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||
await ReadUntilAsync(sock, "\r\n");
|
||
return sock;
|
||
}
|
||
|
||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||
{
|
||
using var cts = new CancellationTokenSource(timeoutMs);
|
||
var sb = new StringBuilder();
|
||
var buf = new byte[4096];
|
||
while (!sb.ToString().Contains(expected))
|
||
{
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||
if (n == 0) break;
|
||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Unsub_removes_subscription()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nSUB foo 2\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("UNSUB 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB foo 5\r\nhello\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
var matches = Regex.Matches(response, @"MSG foo (\d+) 5");
|
||
matches.Count.ShouldBe(1);
|
||
matches[0].Groups[1].Value.ShouldBe("2");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Unsub_max_auto_removes_after_n_messages()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nUNSUB 1 5\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
for (var i = 0; i < 10; i++)
|
||
await sock.SendAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
|
||
await sock.SendAsync("PING\r\n"u8.ToArray());
|
||
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
Regex.Matches(response, @"MSG foo 1 5").Count.ShouldBe(5);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Unsub_after_auto_unsub_removes_immediately()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nUNSUB 1 100\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("UNSUB 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB foo 5\r\nhello\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
response.ShouldNotContain("MSG ");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Disconnect_removes_all_subscriptions()
|
||
{
|
||
using var sub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sub.ConnectAsync(IPAddress.Loopback, _port);
|
||
await ReadUntilAsync(sub, "\r\n");
|
||
await sub.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sub, "PONG\r\n");
|
||
|
||
sub.Shutdown(SocketShutdown.Both);
|
||
sub.Close();
|
||
await Task.Delay(200);
|
||
|
||
_server.Stats.CurrentSubscriptions.ShouldBe(0);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientUnsubTests" -v minimal`
|
||
Expected: FAIL
|
||
|
||
**Step 3: Create the test file and fix any server-side gaps**
|
||
|
||
The server already implements UNSUB and auto-unsub. If `Stats.CurrentSubscriptions` doesn't exist, add it.
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientUnsubTests" -v minimal`
|
||
Expected: PASS (4 tests)
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/ClientUnsubTests.cs
|
||
git commit -m "test: port client unsub and auto-unsub tests from Go (4 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Port Client Header Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/ClientHeaderTests.cs`
|
||
- Reference: `golang/nats-server/server/client_test.go` (TestClientHeaderDeliverMsg, TestClientHeaderDeliverStrippedMsg, TestClientHeaderDeliverQueueSubStrippedMsg, TestServerHeaderSupport, TestClientHeaderSupport)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
namespace NATS.Server.Tests;
|
||
|
||
public class ClientHeaderTests : IAsyncLifetime
|
||
{
|
||
private NatsServer _server = null!;
|
||
private int _port;
|
||
private readonly CancellationTokenSource _cts = new();
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
_port = TestHelpers.GetFreePort();
|
||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||
_ = _server.StartAsync(_cts.Token);
|
||
await _server.WaitForReadyAsync();
|
||
}
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
await _cts.CancelAsync();
|
||
_server.Dispose();
|
||
}
|
||
|
||
private async Task<Socket> ConnectWithHeadersAsync()
|
||
{
|
||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||
await ReadUntilAsync(sock, "\r\n");
|
||
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
||
"CONNECT {\"headers\":true,\"no_responders\":true}\r\n"));
|
||
return sock;
|
||
}
|
||
|
||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||
{
|
||
using var cts = new CancellationTokenSource(timeoutMs);
|
||
var sb = new StringBuilder();
|
||
var buf = new byte[4096];
|
||
while (!sb.ToString().Contains(expected))
|
||
{
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||
if (n == 0) break;
|
||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Hpub_delivers_hmsg_with_headers()
|
||
{
|
||
using var sock = await ConnectWithHeadersAsync();
|
||
await sock.SendAsync("SUB foo 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
var hdr = "NATS/1.0\r\nX-Test: value\r\n\r\n";
|
||
var payload = "hello";
|
||
var hdrLen = hdr.Length;
|
||
var totalLen = hdrLen + payload.Length;
|
||
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
||
$"HPUB foo {hdrLen} {totalLen}\r\n{hdr}{payload}\r\nPING\r\n"));
|
||
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
response.ShouldContain($"HMSG foo 1 {hdrLen} {totalLen}\r\n");
|
||
response.ShouldContain("X-Test: value");
|
||
response.ShouldContain("hello");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Server_info_advertises_headers_true()
|
||
{
|
||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||
var info = await ReadUntilAsync(sock, "\r\n");
|
||
info.ShouldContain("\"headers\":true");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task No_responders_sends_503_hmsg_when_no_subscribers()
|
||
{
|
||
using var sock = await ConnectWithHeadersAsync();
|
||
await sock.SendAsync("SUB reply.inbox 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
await sock.SendAsync("PUB no.listeners reply.inbox 0\r\n\r\nPING\r\n"u8.ToArray());
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
response.ShouldContain("HMSG reply.inbox 1");
|
||
response.ShouldContain("503");
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientHeaderTests" -v minimal`
|
||
Expected: FAIL
|
||
|
||
**Step 3: Create the test file and fix any server-side gaps**
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientHeaderTests" -v minimal`
|
||
Expected: PASS (3 tests)
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/ClientHeaderTests.cs
|
||
git commit -m "test: port client header and no-responders tests from Go (3 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Port Client Lifecycle and Slow Consumer Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/ClientLifecycleTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/ClientSlowConsumerTests.cs`
|
||
- Reference: `golang/nats-server/server/client_test.go` (TestClientConnect, TestClientConnectProto, TestAuthorizationTimeout, TestClientConnectionName, TestClientLimits, TestClientMaxPending, TestNoClientLeakOnSlowConsumer, TestClientSlowConsumerWithoutConnect)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// ClientLifecycleTests.cs
|
||
namespace NATS.Server.Tests;
|
||
|
||
public class ClientLifecycleTests : IAsyncLifetime
|
||
{
|
||
private NatsServer _server = null!;
|
||
private int _port;
|
||
private readonly CancellationTokenSource _cts = new();
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
_port = TestHelpers.GetFreePort();
|
||
_server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = _port,
|
||
MaxSubscriptions = 10,
|
||
PingInterval = TimeSpan.FromSeconds(1),
|
||
MaxPingsOut = 2,
|
||
}, NullLoggerFactory.Instance);
|
||
_ = _server.StartAsync(_cts.Token);
|
||
await _server.WaitForReadyAsync();
|
||
}
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
await _cts.CancelAsync();
|
||
_server.Dispose();
|
||
}
|
||
|
||
private async Task<Socket> ConnectRawAsync()
|
||
{
|
||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||
await ReadUntilAsync(sock, "\r\n");
|
||
return sock;
|
||
}
|
||
|
||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||
{
|
||
using var cts = new CancellationTokenSource(timeoutMs);
|
||
var sb = new StringBuilder();
|
||
var buf = new byte[4096];
|
||
while (!sb.ToString().Contains(expected))
|
||
{
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||
if (n == 0) break;
|
||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Connect_proto_accepted()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
||
"CONNECT {\"verbose\":false,\"pedantic\":false,\"name\":\"test-client\"}\r\nPING\r\n"));
|
||
var response = await ReadUntilAsync(sock, "PONG\r\n");
|
||
response.ShouldContain("PONG");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Max_subscriptions_enforced()
|
||
{
|
||
using var sock = await ConnectRawAsync();
|
||
await sock.SendAsync("CONNECT {}\r\n"u8.ToArray());
|
||
|
||
for (var i = 1; i <= 11; i++)
|
||
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB foo.{i} {i}\r\n"));
|
||
await sock.SendAsync("PING\r\n"u8.ToArray());
|
||
|
||
var response = await ReadUntilAsync(sock, "\r\n", 3000);
|
||
response.ShouldContain("-ERR");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Auth_timeout_closes_connection_if_no_connect()
|
||
{
|
||
var authPort = TestHelpers.GetFreePort();
|
||
using var authCts = new CancellationTokenSource();
|
||
var authServer = new NatsServer(new NatsOptions
|
||
{
|
||
Port = authPort,
|
||
Username = "user",
|
||
Password = "pass",
|
||
AuthTimeout = TimeSpan.FromMilliseconds(500),
|
||
}, NullLoggerFactory.Instance);
|
||
_ = authServer.StartAsync(authCts.Token);
|
||
await authServer.WaitForReadyAsync();
|
||
|
||
try
|
||
{
|
||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, authPort);
|
||
await ReadUntilAsync(sock, "\r\n");
|
||
|
||
// Don't send CONNECT — wait for timeout
|
||
await Task.Delay(1500);
|
||
var buf = new byte[1];
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
|
||
n.ShouldBe(0); // Connection closed by server
|
||
}
|
||
finally
|
||
{
|
||
await authCts.CancelAsync();
|
||
authServer.Dispose();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// ClientSlowConsumerTests.cs
|
||
namespace NATS.Server.Tests;
|
||
|
||
public class ClientSlowConsumerTests : IAsyncLifetime
|
||
{
|
||
private NatsServer _server = null!;
|
||
private int _port;
|
||
private readonly CancellationTokenSource _cts = new();
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
_port = TestHelpers.GetFreePort();
|
||
_server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = _port,
|
||
MaxPendingBytes = 1024, // Very small for testing
|
||
}, NullLoggerFactory.Instance);
|
||
_ = _server.StartAsync(_cts.Token);
|
||
await _server.WaitForReadyAsync();
|
||
}
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
await _cts.CancelAsync();
|
||
_server.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Slow_consumer_detected_when_pending_exceeds_limit()
|
||
{
|
||
using var sub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sub.ConnectAsync(IPAddress.Loopback, _port);
|
||
await ReadUntilAsync(sub, "\r\n");
|
||
await sub.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sub, "PONG\r\n");
|
||
|
||
// Stop reading from subscriber to simulate slow consumer
|
||
using var pub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await pub.ConnectAsync(IPAddress.Loopback, _port);
|
||
await ReadUntilAsync(pub, "\r\n");
|
||
await pub.SendAsync("CONNECT {}\r\n"u8.ToArray());
|
||
|
||
var bigPayload = new string('X', 512);
|
||
for (var i = 0; i < 100; i++)
|
||
await pub.SendAsync(Encoding.ASCII.GetBytes($"PUB foo {bigPayload.Length}\r\n{bigPayload}\r\n"));
|
||
await pub.SendAsync("PING\r\n"u8.ToArray());
|
||
|
||
await Task.Delay(1000);
|
||
_server.Stats.SlowConsumers.ShouldBeGreaterThan(0);
|
||
}
|
||
|
||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||
{
|
||
using var cts = new CancellationTokenSource(timeoutMs);
|
||
var sb = new StringBuilder();
|
||
var buf = new byte[4096];
|
||
while (!sb.ToString().Contains(expected))
|
||
{
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||
if (n == 0) break;
|
||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientLifecycleTests|FullyQualifiedName~ClientSlowConsumerTests" -v minimal`
|
||
Expected: FAIL
|
||
|
||
**Step 3: Create the test files and fix any server-side gaps**
|
||
|
||
May need to add `Stats.CurrentSubscriptions` and `Stats.SlowConsumers` if not already exposed.
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientLifecycleTests|FullyQualifiedName~ClientSlowConsumerTests" -v minimal`
|
||
Expected: PASS (4 tests)
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/ClientLifecycleTests.cs tests/NATS.Server.Tests/ClientSlowConsumerTests.cs
|
||
git commit -m "test: port client lifecycle and slow consumer tests from Go (4 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Port Parser Edge Case Tests
|
||
|
||
**Files:**
|
||
- Modify: `tests/NATS.Server.Tests/ParserTests.cs`
|
||
- Reference: `golang/nats-server/server/parser_test.go` (TestParsePubArg, TestParsePubBadSize, TestParsePubSizeOverflow, TestParseHeaderPubArg, TestShouldFail, TestMaxControlLine)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
Add to existing `ParserTests.cs`:
|
||
|
||
```csharp
|
||
[Theory]
|
||
[InlineData("PUB a 2\r\nok\r\n", "a", "", 2)]
|
||
[InlineData("PUB foo bar 22\r\n", "foo", "bar", 22)]
|
||
[InlineData("PUB foo 22\r\n", "foo", "", 22)]
|
||
[InlineData("PUB\tfoo\tbar\t22\r\n", "foo", "bar", 22)]
|
||
public async Task Parse_PUB_argument_variations(string input, string expectedSubject, string expectedReply, int expectedSize)
|
||
{
|
||
var cmds = await ParseAsync(input.EndsWith("\r\n") && !input.Contains("\r\nok\r\n")
|
||
? input + new string('X', expectedSize) + "\r\n"
|
||
: input);
|
||
cmds.ShouldNotBeEmpty();
|
||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||
cmds[0].Subject.ShouldBe(expectedSubject);
|
||
if (!string.IsNullOrEmpty(expectedReply))
|
||
cmds[0].ReplyTo.ShouldBe(expectedReply);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("Px\r\n")]
|
||
[InlineData("PIx\r\n")]
|
||
[InlineData("PINx\r\n")]
|
||
[InlineData(" PING\r\n")]
|
||
[InlineData("SUB\r\n")]
|
||
[InlineData("PUB\r\n")]
|
||
[InlineData("PUB \r\n")]
|
||
[InlineData("UNSUB_2\r\n")]
|
||
public async Task Parse_malformed_protocol_fails(string input)
|
||
{
|
||
var ex = await Should.ThrowAsync<Exception>(async () => await ParseAsync(input));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Parse_exceeding_max_control_line_fails()
|
||
{
|
||
var longSubject = new string('a', 4097);
|
||
var input = $"PUB {longSubject} 0\r\n\r\n";
|
||
var parser = new NatsParser(maxPayload: NatsProtocol.MaxPayloadSize);
|
||
var pipe = new Pipe();
|
||
await pipe.Writer.WriteAsync(Encoding.ASCII.GetBytes(input));
|
||
pipe.Writer.Complete();
|
||
|
||
var result = await pipe.Reader.ReadAsync();
|
||
var buffer = result.Buffer;
|
||
var threw = false;
|
||
try { parser.TryParse(ref buffer, out _); }
|
||
catch { threw = true; }
|
||
threw.ShouldBeTrue();
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ParserTests" -v minimal`
|
||
Expected: FAIL for new tests
|
||
|
||
**Step 3: Fix any parser behavior gaps**
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ParserTests" -v minimal`
|
||
Expected: PASS
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/ParserTests.cs
|
||
git commit -m "test: port parser edge case and negative tests from Go (12 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Port SubList Concurrency and Edge Case Tests
|
||
|
||
**Files:**
|
||
- Modify: `tests/NATS.Server.Tests/SubListTests.cs`
|
||
- Modify: `tests/NATS.Server.Tests/SubjectMatchTests.cs`
|
||
- Reference: `golang/nats-server/server/sublist_test.go` (TestSublistRaceOnRemove, TestSublistRaceOnInsert, TestSublistRaceOnMatch, TestSublistRemoveWithLargeSubs, TestSublistInvalidSubjectsInsert, TestSublistInsertWithWildcardsAsLiterals, TestSublistMatchWithEmptyTokens)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
Add to `SubListTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Race_on_remove_does_not_corrupt_cache()
|
||
{
|
||
var sl = new SubList();
|
||
var subs = Enumerable.Range(0, 100)
|
||
.Select(i => new Subscription { Subject = "foo", Sid = i.ToString() })
|
||
.ToArray();
|
||
|
||
foreach (var s in subs) sl.Insert(s);
|
||
var r = sl.Match("foo");
|
||
r.PlainSubs.Length.ShouldBe(100);
|
||
|
||
var removeTask = Task.Run(() =>
|
||
{
|
||
foreach (var s in subs) sl.Remove(s);
|
||
});
|
||
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
var result = sl.Match("foo");
|
||
foreach (var ps in result.PlainSubs)
|
||
ps.Subject.ShouldBe("foo");
|
||
}
|
||
|
||
await removeTask;
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Race_on_insert_does_not_corrupt_cache()
|
||
{
|
||
var sl = new SubList();
|
||
var subs = Enumerable.Range(0, 100)
|
||
.Select(i => new Subscription { Subject = "foo", Sid = i.ToString() })
|
||
.ToArray();
|
||
|
||
var insertTask = Task.Run(() =>
|
||
{
|
||
foreach (var s in subs) sl.Insert(s);
|
||
});
|
||
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
var result = sl.Match("foo");
|
||
foreach (var ps in result.PlainSubs)
|
||
ps.Subject.ShouldBe("foo");
|
||
}
|
||
|
||
await insertTask;
|
||
}
|
||
|
||
[Fact]
|
||
public void Remove_from_large_subscription_list()
|
||
{
|
||
var sl = new SubList();
|
||
var subs = Enumerable.Range(0, 200)
|
||
.Select(i => new Subscription { Subject = "foo", Sid = i.ToString() })
|
||
.ToArray();
|
||
|
||
foreach (var s in subs) sl.Insert(s);
|
||
var r = sl.Match("foo");
|
||
r.PlainSubs.Length.ShouldBe(200);
|
||
|
||
sl.Remove(subs[100]);
|
||
sl.Remove(subs[0]);
|
||
sl.Remove(subs[199]);
|
||
|
||
r = sl.Match("foo");
|
||
r.PlainSubs.Length.ShouldBe(197);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(".foo")]
|
||
[InlineData("foo.")]
|
||
[InlineData("foo..bar")]
|
||
[InlineData("foo.bar..baz")]
|
||
[InlineData("foo.>.bar")]
|
||
public void Insert_invalid_subject_is_rejected(string subject)
|
||
{
|
||
var sl = new SubList();
|
||
var sub = new Subscription { Subject = subject, Sid = "1" };
|
||
Should.Throw<Exception>(() => sl.Insert(sub));
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListTests" -v minimal`
|
||
Expected: FAIL for new tests
|
||
|
||
**Step 3: Fix any SubList behavior gaps**
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListTests" -v minimal`
|
||
Expected: PASS
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/SubListTests.cs tests/NATS.Server.Tests/SubjectMatchTests.cs
|
||
git commit -m "test: port sublist concurrency and edge case tests from Go (7 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Port Server Configuration and Lifecycle Edge Case Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/ServerConfigTests.cs`
|
||
- Reference: `golang/nats-server/server/server_test.go` (TestMaxSubscriptions, TestRandomPorts, TestLameDuckModeInfo, TestAcceptError, TestServerShutdownDuringStart, TestGetConnectURLs, TestInfoServerNameDefaultsToPK, TestInfoServerNameIsSettable)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
namespace NATS.Server.Tests;
|
||
|
||
public class ServerConfigTests
|
||
{
|
||
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||
{
|
||
using var cts = new CancellationTokenSource(timeoutMs);
|
||
var sb = new StringBuilder();
|
||
var buf = new byte[4096];
|
||
while (!sb.ToString().Contains(expected))
|
||
{
|
||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||
if (n == 0) break;
|
||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Server_resolves_ephemeral_port_when_zero()
|
||
{
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
server.Port.ShouldBeGreaterThan(0);
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Server_info_contains_server_name()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = port,
|
||
ServerName = "my-test-server",
|
||
}, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||
var info = await ReadUntilAsync(sock, "\r\n");
|
||
info.ShouldContain("\"server_name\":\"my-test-server\"");
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Server_info_defaults_name_to_server_id()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||
var info = await ReadUntilAsync(sock, "\r\n");
|
||
info.ShouldContain("\"server_name\":");
|
||
info.ShouldContain("\"server_id\":");
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Lame_duck_mode_sends_updated_info()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = port,
|
||
LameDuckDuration = TimeSpan.FromSeconds(5),
|
||
}, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||
var info = await ReadUntilAsync(sock, "\r\n");
|
||
await sock.SendAsync("CONNECT {}\r\nPING\r\n"u8.ToArray());
|
||
await ReadUntilAsync(sock, "PONG\r\n");
|
||
|
||
_ = server.LameDuckShutdownAsync(cts.Token);
|
||
await Task.Delay(500);
|
||
|
||
var ldInfo = await ReadUntilAsync(sock, "\r\n", 3000);
|
||
ldInfo.ShouldContain("\"ldm\":true");
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ServerConfigTests" -v minimal`
|
||
Expected: FAIL
|
||
|
||
**Step 3: Create the test file and fix any server-side gaps**
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ServerConfigTests" -v minimal`
|
||
Expected: PASS (4 tests)
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/ServerConfigTests.cs
|
||
git commit -m "test: port server config and lifecycle edge case tests from Go (4 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Port Route Tests — Config, Auth, Compression, Per-Account
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Routes/RouteConfigTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Routes/RouteAuthTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Routes/RouteCompressionParityTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Routes/RoutePerAccountTests.cs`
|
||
- Reference: `golang/nats-server/server/routes_test.go` (TestRouteConfig, TestRouteCompression*, TestRoutePerAccount, TestRoutePool*, TestServerRoutesWithAuthAndBCrypt, TestSeedSolicitWorks, TestTLSSeedSolicitWorks)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// Routes/RouteConfigTests.cs
|
||
namespace NATS.Server.Tests.Routes;
|
||
|
||
public class RouteConfigTests
|
||
{
|
||
[Fact]
|
||
public async Task Two_servers_form_full_mesh_cluster()
|
||
{
|
||
using var cts1 = new CancellationTokenSource();
|
||
var s1 = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Cluster = new ClusterOptions { Name = "test", Host = "127.0.0.1", Port = 0 },
|
||
}, NullLoggerFactory.Instance);
|
||
_ = s1.StartAsync(cts1.Token);
|
||
await s1.WaitForReadyAsync();
|
||
|
||
using var cts2 = new CancellationTokenSource();
|
||
var s2 = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Cluster = new ClusterOptions
|
||
{
|
||
Name = "test",
|
||
Host = "127.0.0.1",
|
||
Port = 0,
|
||
Routes = [s1.ClusterListen!],
|
||
},
|
||
}, NullLoggerFactory.Instance);
|
||
_ = s2.StartAsync(cts2.Token);
|
||
await s2.WaitForReadyAsync();
|
||
|
||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
while (!timeout.IsCancellationRequested && s1.Stats.Routes == 0)
|
||
await Task.Delay(50);
|
||
|
||
s1.Stats.Routes.ShouldBeGreaterThan(0);
|
||
s2.Stats.Routes.ShouldBeGreaterThan(0);
|
||
|
||
await cts1.CancelAsync(); await cts2.CancelAsync();
|
||
s1.Dispose(); s2.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Route_forwards_messages_between_clusters()
|
||
{
|
||
using var cts1 = new CancellationTokenSource();
|
||
var s1 = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Cluster = new ClusterOptions { Name = "test", Host = "127.0.0.1", Port = 0 },
|
||
}, NullLoggerFactory.Instance);
|
||
_ = s1.StartAsync(cts1.Token);
|
||
await s1.WaitForReadyAsync();
|
||
|
||
using var cts2 = new CancellationTokenSource();
|
||
var s2 = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Cluster = new ClusterOptions
|
||
{
|
||
Name = "test", Host = "127.0.0.1", Port = 0,
|
||
Routes = [s1.ClusterListen!],
|
||
},
|
||
}, NullLoggerFactory.Instance);
|
||
_ = s2.StartAsync(cts2.Token);
|
||
await s2.WaitForReadyAsync();
|
||
|
||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
while (!timeout.IsCancellationRequested && s1.Stats.Routes == 0)
|
||
await Task.Delay(50);
|
||
|
||
// Subscribe on s1
|
||
await using var sub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{s1.Port}" });
|
||
await sub.ConnectAsync();
|
||
await using var subscription = await sub.SubscribeCoreAsync<string>("cross.cluster");
|
||
await sub.PingAsync();
|
||
await Task.Delay(200); // Let subscription propagate
|
||
|
||
// Publish on s2
|
||
await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{s2.Port}" });
|
||
await pub.ConnectAsync();
|
||
await pub.PublishAsync("cross.cluster", "hello-from-s2");
|
||
|
||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
var msg = await subscription.Msgs.ReadAsync(readCts.Token);
|
||
msg.Data.ShouldBe("hello-from-s2");
|
||
|
||
await cts1.CancelAsync(); await cts2.CancelAsync();
|
||
s1.Dispose(); s2.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~RouteConfigTests" -v minimal`
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/Routes/RouteConfigTests.cs
|
||
git commit -m "test: port route config and cross-cluster messaging tests from Go (2+ scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Port Gateway Tests — Basic, Interest Tracking, Service Import/Export
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Gateways/GatewayBasicTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Gateways/GatewayInterestTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Gateways/GatewayServiceImportTests.cs`
|
||
- Reference: `golang/nats-server/server/gateway_test.go` (TestGatewayBasic, TestGatewaySubjectInterest, TestGatewayAccountInterest, TestGatewayServiceImport, TestGatewayQueueSub, TestGatewayDoesntSendBackToItself)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// Gateways/GatewayBasicTests.cs
|
||
namespace NATS.Server.Tests.Gateways;
|
||
|
||
public class GatewayBasicTests
|
||
{
|
||
[Fact]
|
||
public async Task Gateway_forwards_messages_between_clusters()
|
||
{
|
||
using var localCts = new CancellationTokenSource();
|
||
var local = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Gateway = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0 },
|
||
}, NullLoggerFactory.Instance);
|
||
_ = local.StartAsync(localCts.Token);
|
||
await local.WaitForReadyAsync();
|
||
|
||
using var remoteCts = new CancellationTokenSource();
|
||
var remote = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Gateway = new GatewayOptions
|
||
{
|
||
Name = "REMOTE", Host = "127.0.0.1", Port = 0,
|
||
Remotes = [local.GatewayListen!],
|
||
},
|
||
}, NullLoggerFactory.Instance);
|
||
_ = 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);
|
||
|
||
await using var sub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{local.Port}" });
|
||
await sub.ConnectAsync();
|
||
await using var subscription = await sub.SubscribeCoreAsync<string>("gw.test");
|
||
await sub.PingAsync();
|
||
await Task.Delay(500);
|
||
|
||
await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{remote.Port}" });
|
||
await pub.ConnectAsync();
|
||
await pub.PublishAsync("gw.test", "hello-from-remote");
|
||
|
||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
var msg = await subscription.Msgs.ReadAsync(readCts.Token);
|
||
msg.Data.ShouldBe("hello-from-remote");
|
||
|
||
await localCts.CancelAsync(); await remoteCts.CancelAsync();
|
||
local.Dispose(); remote.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Gateway_does_not_echo_back_to_origin()
|
||
{
|
||
// Setup same as above, but verify message from local doesn't loop back
|
||
// via remote gateway
|
||
using var localCts = new CancellationTokenSource();
|
||
var local = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Gateway = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0 },
|
||
}, NullLoggerFactory.Instance);
|
||
_ = local.StartAsync(localCts.Token);
|
||
await local.WaitForReadyAsync();
|
||
|
||
using var remoteCts = new CancellationTokenSource();
|
||
var remote = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
Gateway = new GatewayOptions
|
||
{
|
||
Name = "REMOTE", Host = "127.0.0.1", Port = 0,
|
||
Remotes = [local.GatewayListen!],
|
||
},
|
||
}, NullLoggerFactory.Instance);
|
||
_ = 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);
|
||
|
||
await using var sub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{local.Port}" });
|
||
await sub.ConnectAsync();
|
||
await using var subscription = await sub.SubscribeCoreAsync<string>("echo.test");
|
||
await sub.PingAsync();
|
||
await Task.Delay(500);
|
||
|
||
await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{local.Port}" });
|
||
await pub.ConnectAsync();
|
||
await pub.PublishAsync("echo.test", "local-msg");
|
||
|
||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||
var msg = await subscription.Msgs.ReadAsync(readCts.Token);
|
||
msg.Data.ShouldBe("local-msg");
|
||
|
||
// Verify only 1 message (no echo from gateway)
|
||
var gotSecond = false;
|
||
try
|
||
{
|
||
using var cts2 = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||
await subscription.Msgs.ReadAsync(cts2.Token);
|
||
gotSecond = true;
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
gotSecond.ShouldBeFalse();
|
||
|
||
await localCts.CancelAsync(); await remoteCts.CancelAsync();
|
||
local.Dispose(); remote.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~GatewayBasicTests" -v minimal`
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/Gateways/GatewayBasicTests.cs
|
||
git commit -m "test: port gateway basic and echo-prevention tests from Go (2+ scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Port Leaf Node Tests — Auth, Loop Detection, Queue Distribution
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/LeafNodes/LeafBasicTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/LeafNodes/LeafLoopDetectionTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/LeafNodes/LeafQueueDistributionTests.cs`
|
||
- Reference: `golang/nats-server/server/leafnode_test.go` (TestLeafNodeBasicAuth*, TestLeafNodeLoop*, TestLeafNodeQueueGroupDistribution*, TestLeafNodePermissions)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// LeafNodes/LeafBasicTests.cs
|
||
namespace NATS.Server.Tests.LeafNodes;
|
||
|
||
public class LeafBasicTests
|
||
{
|
||
[Fact]
|
||
public async Task Leaf_node_forwards_subscriptions_to_hub()
|
||
{
|
||
using var hubCts = new CancellationTokenSource();
|
||
var hub = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
|
||
}, NullLoggerFactory.Instance);
|
||
_ = hub.StartAsync(hubCts.Token);
|
||
await hub.WaitForReadyAsync();
|
||
|
||
using var leafCts = new CancellationTokenSource();
|
||
var leaf = new NatsServer(new NatsOptions
|
||
{
|
||
Port = 0,
|
||
LeafNode = new LeafNodeOptions
|
||
{
|
||
Remotes = [$"nats://127.0.0.1:{hub.LeafNodePort}"],
|
||
},
|
||
}, NullLoggerFactory.Instance);
|
||
_ = leaf.StartAsync(leafCts.Token);
|
||
await leaf.WaitForReadyAsync();
|
||
|
||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
while (!timeout.IsCancellationRequested && hub.Stats.LeafNodes == 0)
|
||
await Task.Delay(50);
|
||
|
||
// Subscribe on leaf
|
||
await using var sub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{leaf.Port}" });
|
||
await sub.ConnectAsync();
|
||
await using var subscription = await sub.SubscribeCoreAsync<string>("leaf.test");
|
||
await sub.PingAsync();
|
||
await Task.Delay(500);
|
||
|
||
// Publish on hub
|
||
await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||
await pub.ConnectAsync();
|
||
await pub.PublishAsync("leaf.test", "from-hub");
|
||
|
||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
var msg = await subscription.Msgs.ReadAsync(readCts.Token);
|
||
msg.Data.ShouldBe("from-hub");
|
||
|
||
await hubCts.CancelAsync(); await leafCts.CancelAsync();
|
||
hub.Dispose(); leaf.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/LeafNodes/LeafBasicTests.cs
|
||
git commit -m "test: port leaf node basic forwarding tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Port Account Isolation and Import/Export Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Accounts/AccountConfigTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs`
|
||
- Modify: `tests/NATS.Server.Tests/AccountIsolationTests.cs`
|
||
- Reference: `golang/nats-server/server/accounts_test.go` (TestAccountIsolation, TestAccountIsolationExportImport, TestMultiAccountsIsolation, TestAccountSimpleConfig, TestAddServiceExport, TestAddStreamExport, TestCrossAccountRequestReply, TestAccountMaxConnectionsDisconnectsNewestFirst)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// Accounts/AccountImportExportTests.cs
|
||
namespace NATS.Server.Tests.Accounts;
|
||
|
||
public class AccountImportExportTests
|
||
{
|
||
[Fact]
|
||
public async Task Stream_export_import_delivers_cross_account()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = port,
|
||
Accounts =
|
||
[
|
||
new AccountConfig
|
||
{
|
||
Name = "A",
|
||
Users = [new User { Username = "userA", Password = "passA" }],
|
||
Exports = [new StreamExport { Subject = "events.>" }],
|
||
},
|
||
new AccountConfig
|
||
{
|
||
Name = "B",
|
||
Users = [new User { Username = "userB", Password = "passB" }],
|
||
Imports = [new StreamImport { Account = "A", Subject = "events.>" }],
|
||
},
|
||
],
|
||
}, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
// Subscribe as account B
|
||
await using var sub = new NatsConnection(new NatsOpts
|
||
{
|
||
Url = $"nats://userB:passB@127.0.0.1:{port}",
|
||
});
|
||
await sub.ConnectAsync();
|
||
await using var subscription = await sub.SubscribeCoreAsync<string>("events.test");
|
||
await sub.PingAsync();
|
||
|
||
// Publish as account A
|
||
await using var pub = new NatsConnection(new NatsOpts
|
||
{
|
||
Url = $"nats://userA:passA@127.0.0.1:{port}",
|
||
});
|
||
await pub.ConnectAsync();
|
||
await pub.PublishAsync("events.test", "cross-account");
|
||
|
||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
var msg = await subscription.Msgs.ReadAsync(readCts.Token);
|
||
msg.Data.ShouldBe("cross-account");
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Account_isolation_prevents_cross_account_delivery()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = port,
|
||
Accounts =
|
||
[
|
||
new AccountConfig
|
||
{
|
||
Name = "A",
|
||
Users = [new User { Username = "userA", Password = "passA" }],
|
||
},
|
||
new AccountConfig
|
||
{
|
||
Name = "B",
|
||
Users = [new User { Username = "userB", Password = "passB" }],
|
||
},
|
||
],
|
||
}, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
// Subscribe as account B
|
||
await using var sub = new NatsConnection(new NatsOpts
|
||
{
|
||
Url = $"nats://userB:passB@127.0.0.1:{port}",
|
||
});
|
||
await sub.ConnectAsync();
|
||
await using var subscription = await sub.SubscribeCoreAsync<string>("secret.data");
|
||
await sub.PingAsync();
|
||
|
||
// Publish as account A (no export)
|
||
await using var pub = new NatsConnection(new NatsOpts
|
||
{
|
||
Url = $"nats://userA:passA@127.0.0.1:{port}",
|
||
});
|
||
await pub.ConnectAsync();
|
||
await pub.PublishAsync("secret.data", "should-not-arrive");
|
||
await pub.PingAsync();
|
||
|
||
var received = false;
|
||
try
|
||
{
|
||
using var readCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||
await subscription.Msgs.ReadAsync(readCts.Token);
|
||
received = true;
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
received.ShouldBeFalse();
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs
|
||
git commit -m "test: port account isolation and import/export tests from Go (2+ scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Phase A Gate — Full Suite Verification
|
||
|
||
**Files:**
|
||
- None created — verification only
|
||
|
||
**Step 1: Run full test suite**
|
||
|
||
Run: `dotnet test -v minimal`
|
||
Expected: All tests pass (existing + new Phase A tests).
|
||
|
||
**Step 2: Count new tests**
|
||
|
||
Run: `dotnet test --list-tests 2>/dev/null | wc -l`
|
||
Verify: Test count has increased by ~40+ from Phase A work.
|
||
|
||
**Step 3: Commit verification marker**
|
||
|
||
```bash
|
||
git commit --allow-empty -m "gate: phase A foundation tests complete — full suite passes"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase B: Distributed Substrate (~594 tests)
|
||
|
||
Gate: RAFT election/append/snapshot pass. FileStore append/read/recovery pass. Monitor endpoints return valid JSON. Config reload works.
|
||
|
||
---
|
||
|
||
### Task 13: Port Storage Contract Tests — FileStore Basics
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Storage/FileStoreBasicTests.cs`
|
||
- Reference: `golang/nats-server/server/filestore_test.go` (TestFileStoreBasics, TestFileStoreMsgHeaders, TestFileStoreBasicWriteMsgsAndRestore)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
namespace NATS.Server.Tests.JetStream.Storage;
|
||
|
||
public class FileStoreBasicTests : IDisposable
|
||
{
|
||
private readonly string _storeDir;
|
||
|
||
public FileStoreBasicTests()
|
||
{
|
||
_storeDir = Path.Combine(Path.GetTempPath(), $"nats-test-{Guid.NewGuid():N}");
|
||
Directory.CreateDirectory(_storeDir);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (Directory.Exists(_storeDir))
|
||
Directory.Delete(_storeDir, true);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Store_and_load_messages()
|
||
{
|
||
var config = new StreamConfig { Name = "test", Storage = StorageType.File };
|
||
await using var fs = FileStore.Create(_storeDir, config);
|
||
|
||
for (var i = 0; i < 5; i++)
|
||
{
|
||
var seq = await fs.StoreMsgAsync("foo", null, Encoding.UTF8.GetBytes($"msg-{i}"));
|
||
seq.ShouldBe((ulong)(i + 1));
|
||
}
|
||
|
||
var state = fs.State();
|
||
state.Msgs.ShouldBe(5UL);
|
||
|
||
var msg = await fs.LoadMsgAsync(2);
|
||
msg.ShouldNotBeNull();
|
||
Encoding.UTF8.GetString(msg.Data).ShouldBe("msg-1");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Store_message_with_headers()
|
||
{
|
||
var config = new StreamConfig { Name = "test", Storage = StorageType.File };
|
||
await using var fs = FileStore.Create(_storeDir, config);
|
||
|
||
var hdr = Encoding.UTF8.GetBytes("NATS/1.0\r\nname:derek\r\n\r\n");
|
||
var body = Encoding.UTF8.GetBytes("Hello World");
|
||
var seq = await fs.StoreMsgAsync("foo", hdr, body);
|
||
seq.ShouldBe(1UL);
|
||
|
||
var loaded = await fs.LoadMsgAsync(1);
|
||
loaded.ShouldNotBeNull();
|
||
loaded.Header.ShouldBe(hdr);
|
||
loaded.Data.ShouldBe(body);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Stop_and_restart_preserves_messages()
|
||
{
|
||
var config = new StreamConfig { Name = "test", Storage = StorageType.File };
|
||
|
||
// Write 100 messages
|
||
{
|
||
await using var fs = FileStore.Create(_storeDir, config);
|
||
for (var i = 0; i < 100; i++)
|
||
await fs.StoreMsgAsync("foo", null, Encoding.UTF8.GetBytes($"msg-{i}"));
|
||
var state = fs.State();
|
||
state.Msgs.ShouldBe(100UL);
|
||
}
|
||
|
||
// Restart and verify
|
||
{
|
||
await using var fs = FileStore.Create(_storeDir, config);
|
||
var state = fs.State();
|
||
state.Msgs.ShouldBe(100UL);
|
||
|
||
// Write 100 more
|
||
for (var i = 100; i < 200; i++)
|
||
await fs.StoreMsgAsync("foo", null, Encoding.UTF8.GetBytes($"msg-{i}"));
|
||
}
|
||
|
||
// Final verify
|
||
{
|
||
await using var fs = FileStore.Create(_storeDir, config);
|
||
var state = fs.State();
|
||
state.Msgs.ShouldBe(200UL);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Remove_messages_updates_state()
|
||
{
|
||
var config = new StreamConfig { Name = "test", Storage = StorageType.File };
|
||
await using var fs = FileStore.Create(_storeDir, config);
|
||
|
||
for (var i = 0; i < 5; i++)
|
||
await fs.StoreMsgAsync("foo", null, Encoding.UTF8.GetBytes($"msg-{i}"));
|
||
|
||
await fs.RemoveMsgAsync(1);
|
||
await fs.RemoveMsgAsync(5);
|
||
await fs.RemoveMsgAsync(3);
|
||
|
||
var state = fs.State();
|
||
state.Msgs.ShouldBe(2UL);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git add tests/NATS.Server.Tests/JetStream/Storage/FileStoreBasicTests.cs
|
||
git commit -m "test: port filestore basic contract tests from Go (4 scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Port Storage Contract Tests — MemStore and Retention
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Storage/MemStoreBasicTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Storage/StorageRetentionTests.cs`
|
||
- Reference: `golang/nats-server/server/memstore_test.go` (first 10 tests), `filestore_test.go` (retention tests)
|
||
|
||
**Step 1–5: Standard TDD cycle** — mirror FileStore patterns for MemStore.
|
||
|
||
```bash
|
||
git commit -m "test: port memstore basic and retention tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Port RAFT Consensus Tests — Election, Append, Snapshot
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Raft/RaftElectionBasicTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Raft/RaftAppendEntryTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Raft/RaftSnapshotTests.cs`
|
||
- Reference: `golang/nats-server/server/raft_test.go` (TestNRGSimple, TestNRGSnapshotAndRestart, TestNRGAppendEntryEncode)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// Raft/RaftElectionBasicTests.cs
|
||
namespace NATS.Server.Tests.Raft;
|
||
|
||
public class RaftElectionBasicTests
|
||
{
|
||
[Fact]
|
||
public async Task Three_node_group_elects_leader()
|
||
{
|
||
await using var cluster = await RaftTestCluster.CreateAsync(3);
|
||
await cluster.WaitForLeaderAsync(TimeSpan.FromSeconds(10));
|
||
|
||
cluster.Leader.ShouldNotBeNull();
|
||
cluster.Followers.Length.ShouldBe(2);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task State_converges_after_proposals()
|
||
{
|
||
await using var cluster = await RaftTestCluster.CreateAsync(3);
|
||
await cluster.WaitForLeaderAsync(TimeSpan.FromSeconds(10));
|
||
|
||
await cluster.ProposeAsync(Encoding.UTF8.GetBytes("delta:22"));
|
||
await cluster.ProposeAsync(Encoding.UTF8.GetBytes("delta:-11"));
|
||
await cluster.ProposeAsync(Encoding.UTF8.GetBytes("delta:-10"));
|
||
|
||
await cluster.WaitForConvergenceAsync(TimeSpan.FromSeconds(5));
|
||
// All nodes should have applied same entries
|
||
cluster.AllNodesConverged.ShouldBeTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port raft election and convergence tests from Go (2+ scenarios)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Port Config Reload Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Configuration/ConfigReloadParityTests.cs`
|
||
- Reference: `golang/nats-server/server/reload_test.go` (TestReloadAuth, TestReloadTLS, TestReloadMaxConnections, TestReloadLameDuck)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port config hot reload tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Port Monitoring Endpoint Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Monitoring/VarzTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Monitoring/ConnzTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Monitoring/HealthzTests.cs`
|
||
- Reference: `golang/nats-server/server/monitor_test.go` (TestHTTPHost, TestVarz, TestConnz, TestHealthz)
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
// Monitoring/VarzTests.cs
|
||
namespace NATS.Server.Tests.Monitoring;
|
||
|
||
public class VarzTests
|
||
{
|
||
[Fact]
|
||
public async Task Varz_returns_server_info()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
var monitorPort = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = port,
|
||
MonitorPort = monitorPort,
|
||
}, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
using var http = new HttpClient();
|
||
var response = await http.GetStringAsync($"http://127.0.0.1:{monitorPort}/varz");
|
||
|
||
response.ShouldContain("\"server_id\"");
|
||
response.ShouldContain("\"version\"");
|
||
response.ShouldContain("\"now\"");
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Healthz_returns_ok()
|
||
{
|
||
var port = TestHelpers.GetFreePort();
|
||
var monitorPort = TestHelpers.GetFreePort();
|
||
using var cts = new CancellationTokenSource();
|
||
var server = new NatsServer(new NatsOptions
|
||
{
|
||
Port = port,
|
||
MonitorPort = monitorPort,
|
||
}, NullLoggerFactory.Instance);
|
||
_ = server.StartAsync(cts.Token);
|
||
await server.WaitForReadyAsync();
|
||
|
||
using var http = new HttpClient();
|
||
var response = await http.GetAsync($"http://127.0.0.1:{monitorPort}/healthz");
|
||
response.IsSuccessStatusCode.ShouldBeTrue();
|
||
|
||
await cts.CancelAsync();
|
||
server.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port monitoring endpoint tests from Go (varz, healthz)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Phase B Gate — Full Suite Verification
|
||
|
||
**Step 1: Run full test suite**
|
||
|
||
Run: `dotnet test -v minimal`
|
||
Expected: All tests pass.
|
||
|
||
```bash
|
||
git commit --allow-empty -m "gate: phase B distributed substrate tests complete — full suite passes"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase C: JetStream Depth (~889 tests)
|
||
|
||
Gate: All JetStream API endpoints work. Stream/consumer lifecycle correct. Cluster failover preserves data.
|
||
|
||
---
|
||
|
||
### Task 19: Port JetStream Stream Lifecycle Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/StreamLifecycleTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_test.go` (TestJetStreamCreateStream*, TestJetStreamDeleteStream, TestJetStreamUpdateStream, TestJetStreamPurge*)
|
||
|
||
**Step 1–5: Standard TDD cycle** — test stream create/update/delete/purge/info via NATS.Client.Core JetStream API.
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream stream lifecycle tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Port JetStream Publish and Ack Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/PublishAckTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_test.go` (TestJetStreamPublish*, TestJetStreamDedup*, TestJetStreamPubAck*)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream publish and ack tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Port JetStream Consumer Delivery Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/ConsumerDeliveryTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/JetStream/ConsumerAckPolicyTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_consumer_test.go` (TestJetStreamConsumerCreate*, deliver policy, ack policy tests)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream consumer delivery and ack policy tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 22: Port JetStream Retention Policy Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/RetentionPolicyTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_test.go` (retention-related: Limits, Interest, WorkQueue)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream retention policy tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: Port JetStream API Endpoint Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Api/StreamApiTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Api/ConsumerApiTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_test.go` ($JS.API.* handler tests)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream API endpoint tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Port JetStream Cluster Formation and Replica Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Cluster/ClusterFormationTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Cluster/ReplicaStreamTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_cluster_1_test.go` (TestJetStreamClusterConfig, TestJetStreamClusterMultiReplicaStreams)
|
||
|
||
**Step 1–5: Standard TDD cycle** — use in-process 3-server JetStream cluster fixture.
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream cluster formation and replica tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 25: Port JetStream Cluster Leader Failover Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/JetStream/Cluster/LeaderFailoverTests.cs`
|
||
- Reference: `golang/nats-server/server/jetstream_cluster_1_test.go` (leader election, failover scenarios)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port jetstream cluster leader failover tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 26: Phase C Gate — Full Suite Verification
|
||
|
||
**Step 1: Run full test suite**
|
||
|
||
Run: `dotnet test -v minimal`
|
||
Expected: All tests pass.
|
||
|
||
```bash
|
||
git commit --allow-empty -m "gate: phase C jetstream depth tests complete — full suite passes"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase D: Protocol Surfaces (~135 tests)
|
||
|
||
Gate: MQTT pub/sub through .NET server works. JWT full claim validation passes.
|
||
|
||
---
|
||
|
||
### Task 27: Port MQTT Packet Parsing Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Mqtt/MqttPacketParsingParityTests.cs`
|
||
- Reference: `golang/nats-server/server/mqtt_test.go` (CONNECT, PUBLISH, SUBSCRIBE, UNSUBSCRIBE, DISCONNECT packet tests)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port mqtt packet parsing tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 28: Port MQTT QoS and Session Tests
|
||
|
||
**Files:**
|
||
- Create: `tests/NATS.Server.Tests/Mqtt/MqttQosDeliveryTests.cs`
|
||
- Create: `tests/NATS.Server.Tests/Mqtt/MqttSessionTests.cs`
|
||
- Reference: `golang/nats-server/server/mqtt_test.go` (QoS 0/1/2, session management, clean session flag)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port mqtt qos and session tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 29: Port JWT Claim Edge Case Tests
|
||
|
||
**Files:**
|
||
- Modify: `tests/NATS.Server.Tests/JwtTests.cs`
|
||
- Reference: `golang/nats-server/server/jwt_test.go` (remaining ~27 edge cases)
|
||
|
||
**Step 1–5: Standard TDD cycle**
|
||
|
||
```bash
|
||
git commit -m "test: port jwt claim edge case tests from Go"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 30: Phase D Gate and Final Verification
|
||
|
||
**Files:**
|
||
- Modify: `differences.md` — synchronize with verified behavior
|
||
|
||
**Step 1: Run full test suite**
|
||
|
||
Run: `dotnet test -v minimal`
|
||
Expected: All tests pass with zero failures.
|
||
|
||
**Step 2: Count total tests**
|
||
|
||
Run: `dotnet test --list-tests 2>/dev/null | wc -l`
|
||
Verify: Significant increase from baseline 867.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git commit -m "gate: phase D protocol surfaces complete — all phases pass"
|
||
```
|
||
|
||
---
|
||
|
||
## Plan Notes
|
||
|
||
- Each task is parallelizable within its phase. Use `@dispatching-parallel-agents` for independent subsystems.
|
||
- If a test reveals missing server functionality, port the minimal Go logic. Tag the commit `feat:` not `test:`.
|
||
- If a Go test is N/A for .NET (e.g., Windows-specific, Go-runtime-specific), mark it N/A in the mapping file with rationale.
|
||
- For very large subsystems (JetStream Clustering), break into sub-batches of ~20 tests per commit.
|
||
- Always run full suite (`dotnet test`) before phase gate commits.
|