// Reference: golang/nats-server/server/client_test.go — TestClientHeaderDeliverMsg,
// TestServerHeaderSupport, TestClientHeaderSupport
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.TestUtilities;
namespace NATS.Server.Core.Tests;
///
/// Tests for HPUB/HMSG header support, mirroring the Go reference tests:
/// TestClientHeaderDeliverMsg, TestServerHeaderSupport, TestClientHeaderSupport.
///
/// Go reference: golang/nats-server/server/client_test.go:259–368
///
public class ClientHeaderTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _port;
private readonly CancellationTokenSource _cts = new();
public ClientHeaderTests()
{
_port = TestPortAllocator.GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
///
/// Reads from the socket accumulating data until the accumulated string contains
/// , or the timeout elapses.
///
///
/// Connect a raw TCP socket, read the INFO line, and send a CONNECT with
/// headers:true and no_responders:true.
///
private async Task ConnectWithHeadersAsync()
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _port);
await SocketTestHelper.ReadUntilAsync(sock, "\r\n"); // discard INFO
await sock.SendAsync(Encoding.ASCII.GetBytes(
"CONNECT {\"headers\":true,\"no_responders\":true}\r\n"));
return sock;
}
///
/// Port of TestClientHeaderDeliverMsg (client_test.go:330).
///
/// A client that advertises headers:true sends an HPUB message with a custom
/// header block. A subscriber should receive the message as HMSG with the
/// header block and payload intact.
///
/// HPUB format: HPUB subject hdr_len total_len\r\n{headers}{payload}\r\n
/// HMSG format: HMSG subject sid hdr_len total_len\r\n{headers}{payload}\r\n
///
/// Matches Go reference: HPUB foo 12 14\r\nName:Derek\r\nOK\r\n
/// hdrLen=12 ("Name:Derek\r\n"), totalLen=14 (headers + "OK")
///
[Fact]
public async Task Hpub_delivers_hmsg_with_headers()
{
// Use two separate connections: subscriber and publisher.
// The Go reference uses a single connection for both, but two connections
// make the test clearer and avoid echo-suppression edge cases.
using var sub = await ConnectWithHeadersAsync();
using var pub = await ConnectWithHeadersAsync();
// Subscribe on 'foo' with SID 1
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\n"));
// Flush via PING/PONG to ensure the subscription is registered before publishing
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
// Match Go reference test exactly:
// Header block: "Name:Derek\r\n" = 12 bytes
// Payload: "OK" = 2 bytes → total = 14 bytes
const string headerBlock = "Name:Derek\r\n";
const string payload = "OK";
const int hdrLen = 12; // "Name:Derek\r\n"
const int totalLen = 14; // hdrLen + "OK"
var hpub = $"HPUB foo {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n";
await pub.SendAsync(Encoding.ASCII.GetBytes(hpub));
// Read the full HMSG on the subscriber socket (control line + header + payload + trailing CRLF)
// The complete wire message ends with the payload followed by \r\n
var received = await SocketTestHelper.ReadUntilAsync(sub, payload + "\r\n", timeoutMs: 5000);
// Verify HMSG control line: HMSG foo 1
received.ShouldContain($"HMSG foo 1 {hdrLen} {totalLen}\r\n");
// Verify the header block is delivered verbatim
received.ShouldContain("Name:Derek");
// Verify the payload is delivered
received.ShouldContain(payload);
}
///
/// Port of TestServerHeaderSupport (client_test.go:259).
///
/// By default the server advertises "headers":true in the INFO response.
///
[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);
// Read the INFO line
var infoLine = await SocketTestHelper.ReadUntilAsync(sock, "\r\n");
// INFO must start with "INFO "
infoLine.ShouldStartWith("INFO ");
// Extract the JSON blob after "INFO "
var jsonStart = infoLine.IndexOf('{');
var jsonEnd = infoLine.LastIndexOf('}');
jsonStart.ShouldBeGreaterThanOrEqualTo(0);
jsonEnd.ShouldBeGreaterThan(jsonStart);
var json = infoLine[jsonStart..(jsonEnd + 1)];
// The JSON must contain "headers":true
json.ShouldContain("\"headers\":true");
}
///
/// Port of TestClientNoResponderSupport (client_test.go:230) — specifically
/// the branch that sends a PUB to a subject with no subscribers when the
/// client has opted in with headers:true + no_responders:true.
///
/// The server must send an HMSG on the reply subject with the 503 status
/// header "NATS/1.0 503\r\n\r\n".
///
/// Wire sequence:
/// Client → CONNECT {headers:true, no_responders:true}
/// Client → SUB reply.inbox 1
/// Client → PUB no.listeners reply.inbox 0 (0-byte payload, no subscribers)
/// Server → HMSG reply.inbox 1 {hdrLen} {hdrLen}\r\nNATS/1.0 503\r\n\r\n\r\n
///
[Fact]
public async Task No_responders_sends_503_hmsg_when_no_subscribers()
{
using var sock = await ConnectWithHeadersAsync();
// Subscribe to the reply inbox
await sock.SendAsync(Encoding.ASCII.GetBytes("SUB reply.inbox 1\r\n"));
// Flush via PING/PONG to ensure SUB is registered
await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
// Publish to a subject with no subscribers, using reply.inbox as reply-to
await sock.SendAsync(Encoding.ASCII.GetBytes("PUB no.listeners reply.inbox 0\r\n\r\n"));
// The server should send back an HMSG on reply.inbox with status 503
var received = await SocketTestHelper.ReadUntilAsync(sock, "NATS/1.0 503", timeoutMs: 5000);
// Must be an HMSG (header message) on the reply subject
received.ShouldContain("HMSG reply.inbox");
// Must carry the 503 status header
received.ShouldContain("NATS/1.0 503");
}
}