refactor: extract NATS.Server.Monitoring.Tests project

Move 39 monitoring, events, and system endpoint test files from
NATS.Server.Tests into a dedicated NATS.Server.Monitoring.Tests project.
Update namespaces, replace private GetFreePort/ReadUntilAsync with
TestUtilities shared helpers, add InternalsVisibleTo, and register
in the solution file. All 439 tests pass.
This commit is contained in:
Joseph Doherty
2026-03-12 15:44:12 -04:00
parent edf9ed770e
commit 0c086522a4
42 changed files with 131 additions and 257 deletions

View File

@@ -0,0 +1,286 @@
// Port of Go server/events_test.go — auth error advisory publication tests.
// Go reference: golang/nats-server/server/events.go:2631 sendAuthErrorEvent.
//
// Tests cover: SendAuthErrorEvent counter, enqueue behaviour, record field
// preservation, SendConnectEvent, SendDisconnectEvent, and the supporting
// detail record types AuthErrorDetail, ConnectEventDetail, DisconnectEventDetail.
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Events;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for <see cref="InternalEventSystem.SendAuthErrorEvent"/>,
/// <see cref="InternalEventSystem.SendConnectEvent"/>,
/// <see cref="InternalEventSystem.SendDisconnectEvent"/>, and the three
/// companion detail record types.
/// Go reference: events_test.go TestSystemAccountDisconnectBadLogin,
/// TestSystemAccountNewConnection.
/// </summary>
public class AuthErrorEventTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
public async Task InitializeAsync()
{
_port = TestPortAllocator.GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
_ = _server.StartAsync(CancellationToken.None);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _server.ShutdownAsync();
_server.Dispose();
}
// ========================================================================
// AuthErrorEventCount
// Go reference: events.go:2631 sendAuthErrorEvent — counter per advisory
// ========================================================================
/// <summary>
/// AuthErrorEventCount starts at zero before any advisories are sent.
/// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
/// </summary>
[Fact]
public void AuthErrorEventCount_StartsAtZero()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin — no events at startup.
var es = _server.EventSystem!;
es.AuthErrorEventCount.ShouldBe(0L);
}
/// <summary>
/// Calling SendAuthErrorEvent once increments the counter to 1.
/// Go reference: events.go:2631 sendAuthErrorEvent — each call is one advisory.
/// </summary>
[Fact]
public void SendAuthErrorEvent_IncrementsCounter()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
var es = _server.EventSystem!;
var detail = new AuthErrorDetail(
ClientId: 42,
RemoteAddress: "127.0.0.1:5000",
AccountName: "$G",
UserName: "alice",
Reason: "Authorization Violation",
OccurredAt: DateTime.UtcNow);
es.SendAuthErrorEvent(_server.ServerId, detail);
es.AuthErrorEventCount.ShouldBe(1L);
}
/// <summary>
/// Each call to SendAuthErrorEvent enqueues a message (counter grows by one per call).
/// Go reference: events.go:2687 sendInternalMsg — advisory is always enqueued.
/// </summary>
[Fact]
public void SendAuthErrorEvent_EnqueuesMessage()
{
// Go reference: events.go sendAuthErrorEvent publishes via sendInternalMsg.
var es = _server.EventSystem!;
var detail = new AuthErrorDetail(
ClientId: 7,
RemoteAddress: "10.0.0.1:4222",
AccountName: null,
UserName: null,
Reason: "Authentication Timeout",
OccurredAt: DateTime.UtcNow);
var before = es.AuthErrorEventCount;
es.SendAuthErrorEvent(_server.ServerId, detail);
var after = es.AuthErrorEventCount;
// The counter increment is the observable side-effect of the enqueue path.
(after - before).ShouldBe(1L);
}
/// <summary>
/// Sending multiple auth error events increments the counter for each.
/// Go reference: events.go:2631 sendAuthErrorEvent — cumulative count.
/// </summary>
[Fact]
public void AuthErrorEventCount_MultipleSends_Incremented()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
var es = _server.EventSystem!;
var detail = new AuthErrorDetail(
ClientId: 1,
RemoteAddress: "192.168.1.1:9999",
AccountName: "myacc",
UserName: "bob",
Reason: "Bad credentials",
OccurredAt: DateTime.UtcNow);
var before = es.AuthErrorEventCount;
const int count = 5;
for (var i = 0; i < count; i++)
es.SendAuthErrorEvent(_server.ServerId, detail);
(es.AuthErrorEventCount - before).ShouldBe(count);
}
// ========================================================================
// SendConnectEvent
// Go reference: events.go postConnectEvent / sendConnect
// ========================================================================
/// <summary>
/// SendConnectEvent enqueues a message without throwing.
/// Go reference: events.go postConnectEvent — advisory fired on client connect.
/// </summary>
[Fact]
public void SendConnectEvent_EnqueuesMessage()
{
// Go reference: events_test.go TestSystemAccountNewConnection.
var es = _server.EventSystem!;
var detail = new ConnectEventDetail(
ClientId: 10,
RemoteAddress: "127.0.0.1:6000",
AccountName: "$G",
UserName: "user1",
ConnectedAt: DateTime.UtcNow);
var ex = Record.Exception(() => es.SendConnectEvent(_server.ServerId, detail));
ex.ShouldBeNull();
}
// ========================================================================
// SendDisconnectEvent
// Go reference: events.go postDisconnectEvent / sendDisconnect
// ========================================================================
/// <summary>
/// SendDisconnectEvent enqueues a message without throwing.
/// Go reference: events.go postDisconnectEvent — advisory fired on client disconnect.
/// </summary>
[Fact]
public void SendDisconnectEvent_EnqueuesMessage()
{
// Go reference: events_test.go TestSystemAccountNewConnection (disconnect part).
var es = _server.EventSystem!;
var detail = new DisconnectEventDetail(
ClientId: 20,
RemoteAddress: "127.0.0.1:7000",
AccountName: "$G",
Reason: "Client Closed",
DisconnectedAt: DateTime.UtcNow);
var ex = Record.Exception(() => es.SendDisconnectEvent(_server.ServerId, detail));
ex.ShouldBeNull();
}
// ========================================================================
// AuthErrorDetail record
// ========================================================================
/// <summary>
/// AuthErrorDetail preserves all fields passed to its constructor.
/// Go reference: events.go:2631 — all client fields captured in the advisory.
/// </summary>
[Fact]
public void AuthErrorDetail_PreservesAllFields()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
var now = DateTime.UtcNow;
var detail = new AuthErrorDetail(
ClientId: 99,
RemoteAddress: "10.0.0.2:1234",
AccountName: "test-account",
UserName: "testuser",
Reason: "Authorization Violation",
OccurredAt: now);
detail.ClientId.ShouldBe(99UL);
detail.RemoteAddress.ShouldBe("10.0.0.2:1234");
detail.AccountName.ShouldBe("test-account");
detail.UserName.ShouldBe("testuser");
detail.Reason.ShouldBe("Authorization Violation");
detail.OccurredAt.ShouldBe(now);
}
/// <summary>
/// AuthErrorDetail accepts a non-empty Reason (the key advisory field).
/// Go reference: events.go:2631 sendAuthErrorEvent — reason is always set.
/// </summary>
[Fact]
public void AuthErrorDetail_ReasonRequired()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin — reason distinguishes error types.
var detail = new AuthErrorDetail(
ClientId: 1,
RemoteAddress: "127.0.0.1:0",
AccountName: null,
UserName: null,
Reason: "Authentication Timeout",
OccurredAt: DateTime.UtcNow);
detail.Reason.ShouldNotBeNullOrEmpty();
detail.Reason.ShouldBe("Authentication Timeout");
}
// ========================================================================
// ConnectEventDetail record
// ========================================================================
/// <summary>
/// ConnectEventDetail preserves all constructor fields.
/// Go reference: events.go postConnectEvent — all fields captured on connect.
/// </summary>
[Fact]
public void ConnectEventDetail_PreservesFields()
{
// Go reference: events_test.go TestSystemAccountNewConnection.
var connectedAt = new DateTime(2026, 2, 25, 10, 0, 0, DateTimeKind.Utc);
var detail = new ConnectEventDetail(
ClientId: 55,
RemoteAddress: "192.168.0.5:8080",
AccountName: "prod-account",
UserName: "svc-user",
ConnectedAt: connectedAt);
detail.ClientId.ShouldBe(55UL);
detail.RemoteAddress.ShouldBe("192.168.0.5:8080");
detail.AccountName.ShouldBe("prod-account");
detail.UserName.ShouldBe("svc-user");
detail.ConnectedAt.ShouldBe(connectedAt);
}
// ========================================================================
// DisconnectEventDetail record
// ========================================================================
/// <summary>
/// DisconnectEventDetail preserves all constructor fields.
/// Go reference: events.go postDisconnectEvent — all fields captured on disconnect.
/// </summary>
[Fact]
public void DisconnectEventDetail_PreservesFields()
{
// Go reference: events_test.go TestSystemAccountNewConnection (disconnect part).
var disconnectedAt = new DateTime(2026, 2, 25, 11, 0, 0, DateTimeKind.Utc);
var detail = new DisconnectEventDetail(
ClientId: 77,
RemoteAddress: "172.16.0.3:3000",
AccountName: "staging-account",
Reason: "Slow Consumer",
DisconnectedAt: disconnectedAt);
detail.ClientId.ShouldBe(77UL);
detail.RemoteAddress.ShouldBe("172.16.0.3:3000");
detail.AccountName.ShouldBe("staging-account");
detail.Reason.ShouldBe("Slow Consumer");
detail.DisconnectedAt.ShouldBe(disconnectedAt);
}
}

View File

@@ -0,0 +1,152 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
public class EventApiAndSubjectsParityBatch2Tests
{
[Fact]
public void EventSubjects_DefineMissingServerRequestSubjects()
{
EventSubjects.RemoteLatency.ShouldBe("$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2");
EventSubjects.UserDirectInfo.ShouldBe("$SYS.REQ.USER.INFO");
EventSubjects.UserDirectReq.ShouldBe("$SYS.REQ.USER.{0}.INFO");
EventSubjects.AccountNumSubsReq.ShouldBe("$SYS.REQ.ACCOUNT.NSUBS");
EventSubjects.AccountSubs.ShouldBe("$SYS._INBOX_.{0}.NSUBS");
EventSubjects.ClientKickReq.ShouldBe("$SYS.REQ.SERVER.{0}.KICK");
EventSubjects.ClientLdmReq.ShouldBe("$SYS.REQ.SERVER.{0}.LDM");
EventSubjects.ServerStatsPingReq.ShouldBe("$SYS.REQ.SERVER.PING.STATSZ");
EventSubjects.ServerReloadReq.ShouldBe("$SYS.REQ.SERVER.{0}.RELOAD");
}
[Fact]
public void OcspSubjects_MatchGoPatterns()
{
EventSubjects.OcspPeerReject.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT");
EventSubjects.OcspPeerChainlinkInvalid.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID");
}
[Fact]
public void OcspPeerRejectEvent_IncludesPeerCertInfo()
{
var evt = new OcspPeerRejectEventMsg
{
Id = "id",
Kind = "client",
Reason = "revoked",
Peer = new EventCertInfo
{
Subject = "CN=client",
Issuer = "CN=issuer",
Fingerprint = "fingerprint",
Raw = "raw",
},
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"peer\":");
json.ShouldContain("\"subject\":\"CN=client\"");
}
[Fact]
public void OcspPeerChainlinkInvalidEvent_SerializesExpectedShape()
{
var evt = new OcspPeerChainlinkInvalidEventMsg
{
Id = "id",
Link = new EventCertInfo { Subject = "CN=link" },
Peer = new EventCertInfo { Subject = "CN=peer" },
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":\"io.nats.server.advisory.v1.ocsp_peer_link_invalid\"");
json.ShouldContain("\"link\":");
json.ShouldContain("\"peer\":");
}
[Fact]
public void EventFilterOptions_HasCoreGoFields()
{
var opts = new EventFilterOptions
{
Name = "srv-a",
Cluster = "cluster-a",
Host = "127.0.0.1",
Tags = ["a", "b"],
Domain = "domain-a",
};
opts.Name.ShouldBe("srv-a");
opts.Cluster.ShouldBe("cluster-a");
opts.Host.ShouldBe("127.0.0.1");
opts.Tags.ShouldBe(["a", "b"]);
opts.Domain.ShouldBe("domain-a");
}
[Fact]
public void OptionRequestTypes_IncludeBaseFilterFields()
{
new StatszEventOptions { Name = "n" }.Name.ShouldBe("n");
new ConnzEventOptions { Cluster = "c" }.Cluster.ShouldBe("c");
new RoutezEventOptions { Host = "h" }.Host.ShouldBe("h");
new HealthzEventOptions { Domain = "d" }.Domain.ShouldBe("d");
new JszEventOptions { Tags = ["t"] }.Tags.ShouldBe(["t"]);
}
[Fact]
public void ServerApiResponses_ExposeDataAndError()
{
var response = new ServerAPIResponse
{
Server = new EventServerInfo { Id = "S1" },
Data = new { ok = true },
Error = new ServerAPIError { Code = 500, Description = "err" },
};
response.Server.Id.ShouldBe("S1");
response.Error?.Code.ShouldBe(500);
response.Error?.Description.ShouldBe("err");
}
[Fact]
public void TypedServerApiWrappers_CarryResponsePayload()
{
new ServerAPIConnzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIRoutezResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIGatewayzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIJszResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIHealthzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIVarzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPISubszResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPILeafzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIAccountzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIExpvarzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIpqueueszResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIRaftzResponse { Data = new object() }.Data.ShouldNotBeNull();
}
[Fact]
public void RequestPayloadTypes_KickAndLdm()
{
var kick = new KickClientReq { ClientId = 22 };
var ldm = new LDMClientReq { ClientId = 33 };
kick.ClientId.ShouldBe(22UL);
ldm.ClientId.ShouldBe(33UL);
}
[Fact]
public void UserInfo_IncludesExpectedIdentityFields()
{
var info = new UserInfo
{
User = "alice",
Account = "A",
Permissions = "pubsub",
};
info.User.ShouldBe("alice");
info.Account.ShouldBe("A");
info.Permissions.ShouldBe("pubsub");
}
}

View File

@@ -0,0 +1,198 @@
// Go reference: server/events.go:2082-2090 — compressionType / snappyCompression,
// and events.go:578-598 — internalSendLoop optional compression branch.
using System.Text;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for <see cref="EventCompressor"/> — S2/Snappy compression for system event payloads.
/// Go reference: server/events.go — compressed system events via snappyCompression.
/// </summary>
public class EventCompressionTests : IDisposable
{
public EventCompressionTests()
{
// Ensure a clean statistics baseline for every test.
EventCompressor.ResetStats();
}
public void Dispose()
{
EventCompressor.ResetStats();
}
// ── 1 ──────────────────────────────────────────────────────────────────────
[Fact]
public void Compress_ValidPayload_ReturnsCompressed()
{
// Arrange
var json = """{"server":{"name":"s1","id":"ABCDEF"},"data":{"conns":42,"bytes":1024}}""";
var payload = Encoding.UTF8.GetBytes(json);
// Act
var compressed = EventCompressor.Compress(payload);
// Assert
compressed.ShouldNotBeNull();
compressed.Length.ShouldBeGreaterThan(0);
// Snappy output begins with a varint for the original length — not the same raw bytes.
compressed.ShouldNotBe(payload);
}
// ── 2 ──────────────────────────────────────────────────────────────────────
[Fact]
public void Decompress_RoundTrip_MatchesOriginal()
{
// Arrange
var original = Encoding.UTF8.GetBytes(
"""{"server":{"name":"test","id":"XYZ"},"stats":{"cpu":0.5,"mem":1048576}}""");
// Act
var compressed = EventCompressor.Compress(original);
var decompressed = EventCompressor.Decompress(compressed);
// Assert
decompressed.ShouldBe(original);
}
// ── 3 ──────────────────────────────────────────────────────────────────────
[Fact]
public void ShouldCompress_BelowThreshold_ReturnsFalse()
{
// 100 bytes is well below the default 256-byte threshold.
EventCompressor.ShouldCompress(100).ShouldBeFalse();
}
// ── 4 ──────────────────────────────────────────────────────────────────────
[Fact]
public void ShouldCompress_AboveThreshold_ReturnsTrue()
{
// 500 bytes exceeds the default 256-byte threshold.
EventCompressor.ShouldCompress(500).ShouldBeTrue();
}
// ── 5 ──────────────────────────────────────────────────────────────────────
[Fact]
public void CompressIfBeneficial_SmallPayload_NotCompressed()
{
// Arrange — 50 bytes is below the 256-byte threshold.
var payload = Encoding.UTF8.GetBytes("small");
// Act
var (data, compressed) = EventCompressor.CompressIfBeneficial(payload);
// Assert
compressed.ShouldBeFalse();
data.ShouldBe(payload);
}
// ── 6 ──────────────────────────────────────────────────────────────────────
[Fact]
public void CompressIfBeneficial_LargePayload_Compressed()
{
// Arrange — build a payload well above the 256-byte threshold.
var largeJson = """{"server":{"name":"s1"},"data":""" + new string('x', 500) + "}";
var payload = Encoding.UTF8.GetBytes(largeJson);
payload.Length.ShouldBeGreaterThan(256);
// Act
var (data, isCompressed) = EventCompressor.CompressIfBeneficial(payload);
// Assert
isCompressed.ShouldBeTrue();
// The returned bytes should decompress back to the original.
var restored = EventCompressor.Decompress(data);
restored.ShouldBe(payload);
}
// ── 7 ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetCompressionRatio_Calculates()
{
// 100 / 200 = 0.5
var ratio = EventCompressor.GetCompressionRatio(originalSize: 200, compressedSize: 100);
ratio.ShouldBe(0.5, tolerance: 0.001);
}
// ── 8 ──────────────────────────────────────────────────────────────────────
[Fact]
public void TotalCompressed_IncrementedOnCompress()
{
// Arrange — stats were reset in constructor.
EventCompressor.TotalCompressed.ShouldBe(0L);
var largePayload = Encoding.UTF8.GetBytes(new string('a', 512));
// Act — two calls that exceed threshold.
EventCompressor.CompressIfBeneficial(largePayload);
EventCompressor.CompressIfBeneficial(largePayload);
// Assert
EventCompressor.TotalCompressed.ShouldBe(2L);
}
// ── 9 ──────────────────────────────────────────────────────────────────────
[Fact]
public void BytesSaved_TracksCorrectly()
{
// Arrange
// Use a highly-compressible payload so savings are guaranteed.
var payload = Encoding.UTF8.GetBytes(new string('z', 1024));
// Act
EventCompressor.CompressIfBeneficial(payload);
// Assert — compressed version of 1 024 repeated bytes should be much smaller.
EventCompressor.BytesSaved.ShouldBeGreaterThan(0L);
// BytesSaved = original - compressed; should be less than original size.
EventCompressor.BytesSaved.ShouldBeLessThan(payload.Length);
}
// ── 10 ─────────────────────────────────────────────────────────────────────
[Fact]
public void ResetStats_ClearsAll()
{
// Arrange — produce some stats first.
var largePayload = Encoding.UTF8.GetBytes(new string('b', 512));
EventCompressor.CompressIfBeneficial(largePayload);
EventCompressor.TotalCompressed.ShouldBeGreaterThan(0L);
// Act
EventCompressor.ResetStats();
// Assert
EventCompressor.TotalCompressed.ShouldBe(0L);
EventCompressor.TotalUncompressed.ShouldBe(0L);
EventCompressor.BytesSaved.ShouldBe(0L);
}
[Fact]
public void GetAcceptEncoding_ParsesSnappyAndGzip()
{
EventCompressor.GetAcceptEncoding("gzip, snappy").ShouldBe(EventCompressionType.Snappy);
EventCompressor.GetAcceptEncoding("gzip").ShouldBe(EventCompressionType.Gzip);
EventCompressor.GetAcceptEncoding("br").ShouldBe(EventCompressionType.Unsupported);
EventCompressor.GetAcceptEncoding(null).ShouldBe(EventCompressionType.None);
}
[Fact]
public void CompressionHeaderConstants_MatchGo()
{
EventCompressor.AcceptEncodingHeader.ShouldBe("Accept-Encoding");
EventCompressor.ContentEncodingHeader.ShouldBe("Content-Encoding");
}
[Fact]
public void CompressAndDecompress_Gzip_RoundTrip_MatchesOriginal()
{
var payload = Encoding.UTF8.GetBytes("""{"server":"s1","data":"gzip-payload"}""");
var compressed = EventCompressor.Compress(payload, EventCompressionType.Gzip);
var restored = EventCompressor.Decompress(compressed, EventCompressionType.Gzip);
restored.ShouldBe(payload);
}
}

View File

@@ -0,0 +1,943 @@
// Port of Go server/events_test.go — system event DTO and subject parity tests.
// Reference: golang/nats-server/server/events_test.go
//
// Tests cover: ConnectEventMsg, DisconnectEventMsg, ServerStatsMsg,
// AccountNumConns, AuthErrorEventMsg, ShutdownEventMsg serialization,
// event subject pattern formatting, event filtering by tag/server ID,
// and HealthZ status code mapping.
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Parity tests ported from Go server/events_test.go exercising
/// system event DTOs, JSON serialization shapes, event subjects,
/// and event filtering logic.
/// </summary>
public class EventGoParityTests
{
// ========================================================================
// ConnectEventMsg serialization
// Go reference: events_test.go TestSystemAccountNewConnection
// ========================================================================
[Fact]
public void ConnectEventMsg_JsonShape_MatchesGo()
{
// Go: TestSystemAccountNewConnection — verifies connect event JSON shape.
var evt = new ConnectEventMsg
{
Id = "evt-001",
Time = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Cluster = "test-cluster",
Version = "2.10.0",
},
Client = new EventClientInfo
{
Id = 42,
Account = "$G",
User = "alice",
Name = "test-client",
Lang = "csharp",
Version = "1.0",
},
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(ConnectEventMsg.EventType);
json.ShouldContain("\"server\":");
json.ShouldContain("\"client\":");
json.ShouldContain("\"id\":\"evt-001\"");
}
[Fact]
public void ConnectEventMsg_EventType_Constant()
{
// Go: connect event type string.
ConnectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.client_connect");
}
[Fact]
public void ConnectEventMsg_DefaultType_MatchesConstant()
{
var evt = new ConnectEventMsg();
evt.Type.ShouldBe(ConnectEventMsg.EventType);
}
// ========================================================================
// DisconnectEventMsg serialization
// Go reference: events_test.go TestSystemAccountNewConnection (disconnect part)
// ========================================================================
[Fact]
public void DisconnectEventMsg_JsonShape_MatchesGo()
{
// Go: TestSystemAccountNewConnection — verifies disconnect event includes
// sent/received stats and reason.
var evt = new DisconnectEventMsg
{
Id = "evt-002",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Client = new EventClientInfo { Id = 42, Account = "$G" },
Sent = new DataStats { Msgs = 100, Bytes = 10240 },
Received = new DataStats { Msgs = 50, Bytes = 5120 },
Reason = "Client Closed",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(DisconnectEventMsg.EventType);
json.ShouldContain("\"sent\":");
json.ShouldContain("\"received\":");
json.ShouldContain("\"reason\":");
}
[Fact]
public void DisconnectEventMsg_EventType_Constant()
{
DisconnectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.client_disconnect");
}
[Fact]
public void DisconnectEventMsg_Reason_ClientClosed()
{
// Go: TestSystemAccountDisconnectBadLogin — reason is captured on disconnect.
var evt = new DisconnectEventMsg { Reason = "Client Closed" };
evt.Reason.ShouldBe("Client Closed");
}
[Fact]
public void DisconnectEventMsg_Reason_AuthViolation()
{
// Go: TestSystemAccountDisconnectBadLogin — bad login reason.
var evt = new DisconnectEventMsg { Reason = "Authentication Violation" };
evt.Reason.ShouldBe("Authentication Violation");
}
// ========================================================================
// DataStats
// Go reference: events_test.go TestSystemAccountingWithLeafNodes
// ========================================================================
[Fact]
public void DataStats_JsonSerialization()
{
// Go: TestSystemAccountingWithLeafNodes — verifies sent/received stats structure.
var stats = new DataStats
{
Msgs = 1000,
Bytes = 65536,
Routes = new MsgBytesStats { Msgs = 200, Bytes = 10240 },
Gateways = new MsgBytesStats { Msgs = 50, Bytes = 2048 },
Leafs = new MsgBytesStats { Msgs = 100, Bytes = 5120 },
};
var json = JsonSerializer.Serialize(stats);
json.ShouldContain("\"msgs\":");
json.ShouldContain("\"bytes\":");
json.ShouldContain("\"routes\":");
json.ShouldContain("\"gateways\":");
json.ShouldContain("\"leafs\":");
}
[Fact]
public void DataStats_NullSubStats_OmittedFromJson()
{
// Go: When no routes/gateways/leafs, those fields are omitted (omitempty).
var stats = new DataStats { Msgs = 100, Bytes = 1024 };
var json = JsonSerializer.Serialize(stats);
json.ShouldNotContain("\"routes\":");
json.ShouldNotContain("\"gateways\":");
json.ShouldNotContain("\"leafs\":");
}
// ========================================================================
// AccountNumConns
// Go reference: events_test.go TestAccountReqMonitoring
// ========================================================================
[Fact]
public void AccountNumConns_JsonShape_MatchesGo()
{
// Go: TestAccountReqMonitoring — verifies account connection count event shape.
var evt = new AccountNumConns
{
Id = "evt-003",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
AccountName = "MYACCOUNT",
Connections = 5,
LeafNodes = 2,
TotalConnections = 10,
NumSubscriptions = 42,
Sent = new DataStats { Msgs = 500, Bytes = 25600 },
Received = new DataStats { Msgs = 250, Bytes = 12800 },
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(AccountNumConns.EventType);
json.ShouldContain("\"acc\":");
json.ShouldContain("\"conns\":");
json.ShouldContain("\"leafnodes\":");
json.ShouldContain("\"total_conns\":");
json.ShouldContain("\"num_subscriptions\":");
}
[Fact]
public void AccountNumConns_EventType_Constant()
{
AccountNumConns.EventType.ShouldBe("io.nats.server.advisory.v1.account_connections");
}
[Fact]
public void AccountNumConns_SlowConsumers_IncludedWhenNonZero()
{
var evt = new AccountNumConns { SlowConsumers = 3 };
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"slow_consumers\":3");
}
[Fact]
public void AccountNumConns_SlowConsumers_OmittedWhenZero()
{
// Go: omitempty behavior — zero slow_consumers omitted.
var evt = new AccountNumConns { SlowConsumers = 0 };
var json = JsonSerializer.Serialize(evt);
json.ShouldNotContain("\"slow_consumers\":");
}
// ========================================================================
// ServerStatsMsg
// Go reference: events_test.go TestServerEventsPingStatsZDedicatedRecvQ
// ========================================================================
[Fact]
public void ServerStatsMsg_JsonShape_MatchesGo()
{
// Go: TestServerEventsPingStatsZDedicatedRecvQ — verifies server stats shape.
var msg = new ServerStatsMsg
{
Server = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Version = "2.10.0",
JetStream = true,
},
Stats = new ServerStatsData
{
Start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Mem = 134217728,
Cores = 8,
Cpu = 12.5,
Connections = 10,
TotalConnections = 100,
ActiveAccounts = 5,
Subscriptions = 42,
Sent = new DataStats { Msgs = 1000, Bytes = 65536 },
Received = new DataStats { Msgs = 500, Bytes = 32768 },
InMsgs = 500,
OutMsgs = 1000,
InBytes = 32768,
OutBytes = 65536,
},
};
var json = JsonSerializer.Serialize(msg);
json.ShouldContain("\"server\":");
json.ShouldContain("\"statsz\":");
json.ShouldContain("\"mem\":");
json.ShouldContain("\"cores\":");
json.ShouldContain("\"connections\":");
json.ShouldContain("\"total_connections\":");
json.ShouldContain("\"subscriptions\":");
json.ShouldContain("\"in_msgs\":");
json.ShouldContain("\"out_msgs\":");
}
[Fact]
public void ServerStatsData_SlowConsumerStats_JsonShape()
{
// Go: TestServerEventsPingStatsSlowConsumersStats — breakdown by type.
var data = new ServerStatsData
{
SlowConsumers = 10,
SlowConsumerStats = new NATS.Server.Events.SlowConsumersStats
{
Clients = 5,
Routes = 2,
Gateways = 1,
Leafs = 2,
},
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"slow_consumers\":10");
json.ShouldContain("\"slow_consumer_stats\":");
json.ShouldContain("\"clients\":5");
json.ShouldContain("\"routes\":2");
}
[Fact]
public void ServerStatsData_StaleConnectionStats_JsonShape()
{
// Go: TestServerEventsPingStatsStaleConnectionStats — stale conn breakdown.
var data = new ServerStatsData
{
StaleConnections = 7,
StaleConnectionStats = new NATS.Server.Events.StaleConnectionStats
{
Clients = 3,
Routes = 1,
Gateways = 2,
Leafs = 1,
},
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"stale_connections\":7");
json.ShouldContain("\"stale_connection_stats\":");
}
[Fact]
public void ServerStatsData_RouteStats_JsonShape()
{
// Go: TestServerEventsPingStatsZDedicatedRecvQ — route stats in statsz.
var data = new ServerStatsData
{
Routes =
[
new RouteStat
{
Id = 100,
Name = "route-1",
Sent = new DataStats { Msgs = 200, Bytes = 10240 },
Received = new DataStats { Msgs = 150, Bytes = 7680 },
Pending = 5,
},
],
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"routes\":");
json.ShouldContain("\"rid\":100");
json.ShouldContain("\"pending\":5");
}
[Fact]
public void ServerStatsData_GatewayStats_JsonShape()
{
// Go: TestGatewayNameClientInfo — gateway stats in statsz.
var data = new ServerStatsData
{
Gateways =
[
new GatewayStat
{
Id = 200,
Name = "gw-east",
Sent = new DataStats { Msgs = 500, Bytes = 25600 },
Received = new DataStats { Msgs = 300, Bytes = 15360 },
InboundConnections = 3,
},
],
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"gateways\":");
json.ShouldContain("\"gwid\":200");
json.ShouldContain("\"inbound_connections\":3");
}
// ========================================================================
// ShutdownEventMsg
// Go reference: events_test.go TestServerEventsLDMKick
// ========================================================================
[Fact]
public void ShutdownEventMsg_JsonShape_MatchesGo()
{
// Go: ShutdownEventMsg includes server info and reason.
var evt = new ShutdownEventMsg
{
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Reason = "process exit",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"server\":");
json.ShouldContain("\"reason\":");
json.ShouldContain("\"process exit\"");
}
// ========================================================================
// LameDuckEventMsg
// Go reference: events_test.go TestServerEventsLDMKick
// ========================================================================
[Fact]
public void LameDuckEventMsg_JsonShape_MatchesGo()
{
// Go: TestServerEventsLDMKick — lame duck event emitted before shutdown.
var evt = new LameDuckEventMsg
{
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"server\":");
json.ShouldContain("\"name\":\"test-server\"");
}
// ========================================================================
// AuthErrorEventMsg
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin
// ========================================================================
[Fact]
public void AuthErrorEventMsg_JsonShape_MatchesGo()
{
// Go: TestSystemAccountDisconnectBadLogin — auth error advisory.
var evt = new AuthErrorEventMsg
{
Id = "evt-004",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Client = new EventClientInfo { Id = 99, Host = "192.168.1.100" },
Reason = "Authorization Violation",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(AuthErrorEventMsg.EventType);
json.ShouldContain("\"reason\":");
json.ShouldContain("\"Authorization Violation\"");
}
[Fact]
public void AuthErrorEventMsg_EventType_Constant()
{
AuthErrorEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.client_auth");
}
// ========================================================================
// OcspPeerRejectEventMsg
// Go reference: events.go OCSPPeerRejectEventMsg struct
// ========================================================================
[Fact]
public void OcspPeerRejectEventMsg_JsonShape_MatchesGo()
{
var evt = new OcspPeerRejectEventMsg
{
Id = "evt-005",
Time = DateTime.UtcNow,
Kind = "client",
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Reason = "OCSP certificate revoked",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(OcspPeerRejectEventMsg.EventType);
json.ShouldContain("\"kind\":\"client\"");
json.ShouldContain("\"reason\":");
}
[Fact]
public void OcspPeerRejectEventMsg_EventType_Constant()
{
OcspPeerRejectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
}
// ========================================================================
// AccNumConnsReq
// Go reference: events.go accNumConnsReq
// ========================================================================
[Fact]
public void AccNumConnsReq_JsonShape_MatchesGo()
{
var req = new AccNumConnsReq
{
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Account = "$G",
};
var json = JsonSerializer.Serialize(req);
json.ShouldContain("\"server\":");
json.ShouldContain("\"acc\":\"$G\"");
}
// ========================================================================
// EventServerInfo
// Go reference: events_test.go TestServerEventsFilteredByTag
// ========================================================================
[Fact]
public void EventServerInfo_Tags_Serialized()
{
// Go: TestServerEventsFilteredByTag — server info includes tags for filtering.
var info = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Tags = ["region:us-east-1", "env:production"],
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"tags\":");
json.ShouldContain("\"region:us-east-1\"");
json.ShouldContain("\"env:production\"");
}
[Fact]
public void EventServerInfo_NullTags_OmittedFromJson()
{
// Go: omitempty — nil tags are not serialized.
var info = new EventServerInfo { Name = "test-server", Id = "NSVR001" };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"tags\":");
}
[Fact]
public void EventServerInfo_Metadata_Serialized()
{
var info = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Metadata = new Dictionary<string, string>
{
["cloud"] = "aws",
["zone"] = "us-east-1a",
},
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"metadata\":");
json.ShouldContain("\"cloud\":");
json.ShouldContain("\"aws\"");
}
[Fact]
public void EventServerInfo_NullMetadata_OmittedFromJson()
{
var info = new EventServerInfo { Name = "test-server", Id = "NSVR001" };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"metadata\":");
}
[Fact]
public void EventServerInfo_JetStream_IncludedWhenTrue()
{
var info = new EventServerInfo { Name = "s1", Id = "N1", JetStream = true };
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"jetstream\":true");
}
[Fact]
public void EventServerInfo_JetStream_OmittedWhenFalse()
{
// Go: omitempty — JetStream false is not serialized.
var info = new EventServerInfo { Name = "s1", Id = "N1", JetStream = false };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"jetstream\":");
}
// ========================================================================
// EventClientInfo
// Go reference: events_test.go TestGatewayNameClientInfo
// ========================================================================
[Fact]
public void EventClientInfo_AllFields_Serialized()
{
// Go: TestGatewayNameClientInfo — client info includes all connection metadata.
var info = new EventClientInfo
{
Id = 42,
Account = "MYACCOUNT",
User = "alice",
Name = "test-client",
Lang = "go",
Version = "1.30.0",
RttNanos = 1_500_000, // 1.5ms
Host = "192.168.1.100",
Kind = "Client",
ClientType = "nats",
Tags = ["role:publisher"],
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"id\":42");
json.ShouldContain("\"acc\":\"MYACCOUNT\"");
json.ShouldContain("\"user\":\"alice\"");
json.ShouldContain("\"name\":\"test-client\"");
json.ShouldContain("\"lang\":\"go\"");
json.ShouldContain("\"rtt\":");
json.ShouldContain("\"kind\":\"Client\"");
}
[Fact]
public void EventClientInfo_MqttClient_Serialized()
{
// Go: MQTT client ID is included in client info when present.
var info = new EventClientInfo
{
Id = 10,
MqttClient = "mqtt-device-42",
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"client_id\":\"mqtt-device-42\"");
}
[Fact]
public void EventClientInfo_NullOptionalFields_OmittedFromJson()
{
// Go: omitempty — null optional fields are not serialized.
var info = new EventClientInfo { Id = 1 };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"acc\":");
json.ShouldNotContain("\"user\":");
json.ShouldNotContain("\"name\":");
json.ShouldNotContain("\"lang\":");
json.ShouldNotContain("\"kind\":");
json.ShouldNotContain("\"tags\":");
}
// ========================================================================
// Event Subject Patterns
// Go reference: events.go subject constants
// ========================================================================
[Fact]
public void EventSubjects_ConnectEvent_Format()
{
// Go: $SYS.ACCOUNT.%s.CONNECT
var subject = string.Format(EventSubjects.ConnectEvent, "$G");
subject.ShouldBe("$SYS.ACCOUNT.$G.CONNECT");
}
[Fact]
public void EventSubjects_DisconnectEvent_Format()
{
// Go: $SYS.ACCOUNT.%s.DISCONNECT
var subject = string.Format(EventSubjects.DisconnectEvent, "$G");
subject.ShouldBe("$SYS.ACCOUNT.$G.DISCONNECT");
}
[Fact]
public void EventSubjects_AccountConns_Format()
{
// Go: $SYS.ACCOUNT.%s.SERVER.CONNS (new format)
var subject = string.Format(EventSubjects.AccountConnsNew, "MYACCOUNT");
subject.ShouldBe("$SYS.ACCOUNT.MYACCOUNT.SERVER.CONNS");
}
[Fact]
public void EventSubjects_AccountConnsOld_Format()
{
// Go: $SYS.SERVER.ACCOUNT.%s.CONNS (old format for backward compat)
var subject = string.Format(EventSubjects.AccountConnsOld, "MYACCOUNT");
subject.ShouldBe("$SYS.SERVER.ACCOUNT.MYACCOUNT.CONNS");
}
[Fact]
public void EventSubjects_ServerStats_Format()
{
// Go: $SYS.SERVER.%s.STATSZ
var subject = string.Format(EventSubjects.ServerStats, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.STATSZ");
}
[Fact]
public void EventSubjects_ServerShutdown_Format()
{
// Go: $SYS.SERVER.%s.SHUTDOWN
var subject = string.Format(EventSubjects.ServerShutdown, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.SHUTDOWN");
}
[Fact]
public void EventSubjects_ServerLameDuck_Format()
{
// Go: $SYS.SERVER.%s.LAMEDUCK
var subject = string.Format(EventSubjects.ServerLameDuck, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.LAMEDUCK");
}
[Fact]
public void EventSubjects_AuthError_Format()
{
// Go: $SYS.SERVER.%s.CLIENT.AUTH.ERR
var subject = string.Format(EventSubjects.AuthError, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.CLIENT.AUTH.ERR");
}
[Fact]
public void EventSubjects_AuthErrorAccount_IsConstant()
{
// Go: $SYS.ACCOUNT.CLIENT.AUTH.ERR (no server ID interpolation)
EventSubjects.AuthErrorAccount.ShouldBe("$SYS.ACCOUNT.CLIENT.AUTH.ERR");
}
[Fact]
public void EventSubjects_ServerPing_Format()
{
// Go: $SYS.REQ.SERVER.PING.%s (e.g., STATSZ, VARZ)
var subject = string.Format(EventSubjects.ServerPing, "STATSZ");
subject.ShouldBe("$SYS.REQ.SERVER.PING.STATSZ");
}
[Fact]
public void EventSubjects_ServerReq_Format()
{
// Go: $SYS.REQ.SERVER.%s.%s (server ID + request type)
var subject = string.Format(EventSubjects.ServerReq, "NSVR001", "VARZ");
subject.ShouldBe("$SYS.REQ.SERVER.NSVR001.VARZ");
}
[Fact]
public void EventSubjects_AccountReq_Format()
{
// Go: $SYS.REQ.ACCOUNT.%s.%s (account + request type)
var subject = string.Format(EventSubjects.AccountReq, "MYACCOUNT", "CONNZ");
subject.ShouldBe("$SYS.REQ.ACCOUNT.MYACCOUNT.CONNZ");
}
// ========================================================================
// Event filtering by tag
// Go reference: events_test.go TestServerEventsFilteredByTag
// ========================================================================
[Fact]
public void EventServerInfo_TagFiltering_MatchesTag()
{
// Go: TestServerEventsFilteredByTag — servers can be filtered by tag value.
var server = new EventServerInfo
{
Name = "s1",
Id = "NSVR001",
Tags = ["region:us-east-1", "env:prod"],
};
// Simulate filtering: check if server has a specific tag.
server.Tags.ShouldContain("region:us-east-1");
server.Tags.ShouldContain("env:prod");
server.Tags.ShouldNotContain("region:eu-west-1");
}
[Fact]
public void EventServerInfo_TagFiltering_EmptyTags_NoMatch()
{
// Go: TestServerEventsFilteredByTag — server with no tags does not match any filter.
var server = new EventServerInfo { Name = "s1", Id = "NSVR001" };
server.Tags.ShouldBeNull();
}
[Fact]
public void EventServerInfo_FilterByServerId()
{
// Go: TestServerEventsPingStatsZFilter — filter stats events by server ID.
var servers = new[]
{
new EventServerInfo { Name = "s1", Id = "NSVR001" },
new EventServerInfo { Name = "s2", Id = "NSVR002" },
new EventServerInfo { Name = "s3", Id = "NSVR003" },
};
var filtered = servers.Where(s => s.Id == "NSVR002").ToArray();
filtered.Length.ShouldBe(1);
filtered[0].Name.ShouldBe("s2");
}
[Fact]
public void EventServerInfo_FilterByServerId_NoMatch()
{
// Go: TestServerEventsPingStatsZFailFilter — non-existent server ID returns nothing.
var servers = new[]
{
new EventServerInfo { Name = "s1", Id = "NSVR001" },
};
var filtered = servers.Where(s => s.Id == "NONEXISTENT").ToArray();
filtered.Length.ShouldBe(0);
}
// ========================================================================
// Event JSON roundtrip via source-generated context
// Go reference: events_test.go TestServerEventsReceivedByQSubs
// ========================================================================
[Fact]
public void ConnectEventMsg_RoundTrip_ViaContext()
{
// Go: TestServerEventsReceivedByQSubs — events received and parsed correctly.
var original = new ConnectEventMsg
{
Id = "roundtrip-001",
Time = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
Client = new EventClientInfo { Id = 42, Account = "$G", User = "alice" },
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.ConnectEventMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.ConnectEventMsg);
deserialized.ShouldNotBeNull();
deserialized!.Id.ShouldBe("roundtrip-001");
deserialized.Type.ShouldBe(ConnectEventMsg.EventType);
deserialized.Server.Name.ShouldBe("s1");
deserialized.Client.Id.ShouldBe(42UL);
deserialized.Client.Account.ShouldBe("$G");
}
[Fact]
public void DisconnectEventMsg_RoundTrip_ViaContext()
{
var original = new DisconnectEventMsg
{
Id = "roundtrip-002",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
Client = new EventClientInfo { Id = 99 },
Sent = new DataStats { Msgs = 100, Bytes = 1024 },
Received = new DataStats { Msgs = 50, Bytes = 512 },
Reason = "Client Closed",
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.DisconnectEventMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.DisconnectEventMsg);
deserialized.ShouldNotBeNull();
deserialized!.Reason.ShouldBe("Client Closed");
deserialized.Sent.Msgs.ShouldBe(100);
deserialized.Received.Bytes.ShouldBe(512);
}
[Fact]
public void ServerStatsMsg_RoundTrip_ViaContext()
{
var original = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "s1", Id = "NSVR001", JetStream = true },
Stats = new ServerStatsData
{
Mem = 134217728,
Cores = 8,
Connections = 10,
Subscriptions = 42,
Sent = new DataStats { Msgs = 1000, Bytes = 65536 },
Received = new DataStats { Msgs = 500, Bytes = 32768 },
},
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.ServerStatsMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.ServerStatsMsg);
deserialized.ShouldNotBeNull();
deserialized!.Server.JetStream.ShouldBeTrue();
deserialized.Stats.Mem.ShouldBe(134217728);
deserialized.Stats.Connections.ShouldBe(10);
}
[Fact]
public void AccountNumConns_RoundTrip_ViaContext()
{
var original = new AccountNumConns
{
Id = "roundtrip-004",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
AccountName = "$G",
Connections = 5,
TotalConnections = 20,
NumSubscriptions = 15,
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.AccountNumConns);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.AccountNumConns);
deserialized.ShouldNotBeNull();
deserialized!.AccountName.ShouldBe("$G");
deserialized.Connections.ShouldBe(5);
deserialized.TotalConnections.ShouldBe(20);
}
[Fact]
public void AuthErrorEventMsg_RoundTrip_ViaContext()
{
var original = new AuthErrorEventMsg
{
Id = "roundtrip-005",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
Client = new EventClientInfo { Id = 99, Host = "10.0.0.1" },
Reason = "Authorization Violation",
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.AuthErrorEventMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.AuthErrorEventMsg);
deserialized.ShouldNotBeNull();
deserialized!.Reason.ShouldBe("Authorization Violation");
deserialized.Type.ShouldBe(AuthErrorEventMsg.EventType);
}
// ========================================================================
// Event subject $SYS prefix validation
// Go reference: events.go — all system subjects start with $SYS
// ========================================================================
[Fact]
public void AllEventSubjects_StartWithSysDollarPrefix()
{
// Go: All system event subjects must start with $SYS.
EventSubjects.ConnectEvent.ShouldStartWith("$SYS.");
EventSubjects.DisconnectEvent.ShouldStartWith("$SYS.");
EventSubjects.AccountConnsNew.ShouldStartWith("$SYS.");
EventSubjects.AccountConnsOld.ShouldStartWith("$SYS.");
EventSubjects.ServerStats.ShouldStartWith("$SYS.");
EventSubjects.ServerShutdown.ShouldStartWith("$SYS.");
EventSubjects.ServerLameDuck.ShouldStartWith("$SYS.");
EventSubjects.AuthError.ShouldStartWith("$SYS.");
EventSubjects.AuthErrorAccount.ShouldStartWith("$SYS.");
EventSubjects.ServerPing.ShouldStartWith("$SYS.");
EventSubjects.ServerReq.ShouldStartWith("$SYS.");
EventSubjects.AccountReq.ShouldStartWith("$SYS.");
EventSubjects.InboxResponse.ShouldStartWith("$SYS.");
}
}

View File

@@ -0,0 +1,469 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests that all event DTOs have complete JSON fields matching Go's output.
/// Go reference: events.go:100-300 — TypedEvent, ServerInfo, ClientInfo,
/// DataStats, ServerStats, ConnectEventMsg, DisconnectEventMsg, AccountNumConns.
/// </summary>
public class EventPayloadTests
{
// --- EventServerInfo ---
[Fact]
public void EventServerInfo_serializes_all_fields_matching_Go()
{
var info = new EventServerInfo
{
Name = "test-server",
Host = "127.0.0.1",
Id = "ABCDEF123456",
Cluster = "test-cluster",
Domain = "test-domain",
Version = "2.10.0",
Tags = ["tag1", "tag2"],
Metadata = new Dictionary<string, string> { ["env"] = "test" },
JetStream = true,
Flags = 1,
Seq = 42,
Time = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
};
var json = JsonSerializer.Serialize(info);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("name").GetString().ShouldBe("test-server");
root.GetProperty("host").GetString().ShouldBe("127.0.0.1");
root.GetProperty("id").GetString().ShouldBe("ABCDEF123456");
root.GetProperty("cluster").GetString().ShouldBe("test-cluster");
root.GetProperty("domain").GetString().ShouldBe("test-domain");
root.GetProperty("ver").GetString().ShouldBe("2.10.0");
root.GetProperty("tags").GetArrayLength().ShouldBe(2);
root.GetProperty("metadata").GetProperty("env").GetString().ShouldBe("test");
root.GetProperty("jetstream").GetBoolean().ShouldBeTrue();
root.GetProperty("flags").GetUInt64().ShouldBe(1UL);
root.GetProperty("seq").GetUInt64().ShouldBe(42UL);
root.GetProperty("time").GetDateTime().Year.ShouldBe(2025);
}
[Fact]
public void EventServerInfo_omits_null_optional_fields()
{
var info = new EventServerInfo
{
Name = "s",
Id = "ID",
};
var json = JsonSerializer.Serialize(info);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("cluster", out _).ShouldBeFalse();
root.TryGetProperty("domain", out _).ShouldBeFalse();
root.TryGetProperty("tags", out _).ShouldBeFalse();
root.TryGetProperty("metadata", out _).ShouldBeFalse();
}
// --- EventClientInfo ---
[Fact]
public void EventClientInfo_serializes_all_fields_matching_Go()
{
var ci = new EventClientInfo
{
Start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Stop = new DateTime(2025, 1, 1, 1, 0, 0, DateTimeKind.Utc),
Host = "10.0.0.1",
Id = 99,
Account = "$G",
Service = "orders",
User = "admin",
Name = "my-client",
Lang = "go",
Version = "1.30.0",
RttNanos = 5_000_000, // 5ms
Server = "srv-1",
Cluster = "cluster-east",
Alternates = ["alt1", "alt2"],
Jwt = "eyJ...",
IssuerKey = "OABC...",
NameTag = "test-tag",
Tags = ["dev"],
Kind = "Client",
ClientType = "nats",
MqttClient = "mqtt-abc",
Nonce = "nonce123",
};
var json = JsonSerializer.Serialize(ci);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("host").GetString().ShouldBe("10.0.0.1");
root.GetProperty("id").GetUInt64().ShouldBe(99UL);
root.GetProperty("acc").GetString().ShouldBe("$G");
root.GetProperty("svc").GetString().ShouldBe("orders");
root.GetProperty("user").GetString().ShouldBe("admin");
root.GetProperty("name").GetString().ShouldBe("my-client");
root.GetProperty("lang").GetString().ShouldBe("go");
root.GetProperty("ver").GetString().ShouldBe("1.30.0");
root.GetProperty("rtt").GetInt64().ShouldBe(5_000_000);
root.GetProperty("server").GetString().ShouldBe("srv-1");
root.GetProperty("cluster").GetString().ShouldBe("cluster-east");
root.GetProperty("alts").GetArrayLength().ShouldBe(2);
root.GetProperty("jwt").GetString().ShouldBe("eyJ...");
root.GetProperty("issuer_key").GetString().ShouldBe("OABC...");
root.GetProperty("name_tag").GetString().ShouldBe("test-tag");
root.GetProperty("tags").GetArrayLength().ShouldBe(1);
root.GetProperty("kind").GetString().ShouldBe("Client");
root.GetProperty("client_type").GetString().ShouldBe("nats");
root.GetProperty("client_id").GetString().ShouldBe("mqtt-abc");
root.GetProperty("nonce").GetString().ShouldBe("nonce123");
}
[Fact]
public void EventClientInfo_omits_null_optional_fields()
{
var ci = new EventClientInfo { Id = 1 };
var json = JsonSerializer.Serialize(ci);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("svc", out _).ShouldBeFalse();
root.TryGetProperty("user", out _).ShouldBeFalse();
root.TryGetProperty("server", out _).ShouldBeFalse();
root.TryGetProperty("cluster", out _).ShouldBeFalse();
root.TryGetProperty("alts", out _).ShouldBeFalse();
root.TryGetProperty("jwt", out _).ShouldBeFalse();
root.TryGetProperty("issuer_key", out _).ShouldBeFalse();
root.TryGetProperty("nonce", out _).ShouldBeFalse();
}
// --- DataStats ---
[Fact]
public void DataStats_serializes_with_optional_sub_stats()
{
var ds = new DataStats
{
Msgs = 100,
Bytes = 2048,
Gateways = new MsgBytesStats { Msgs = 10, Bytes = 256 },
Routes = new MsgBytesStats { Msgs = 50, Bytes = 1024 },
Leafs = new MsgBytesStats { Msgs = 40, Bytes = 768 },
};
var json = JsonSerializer.Serialize(ds);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("msgs").GetInt64().ShouldBe(100);
root.GetProperty("bytes").GetInt64().ShouldBe(2048);
root.GetProperty("gateways").GetProperty("msgs").GetInt64().ShouldBe(10);
root.GetProperty("routes").GetProperty("bytes").GetInt64().ShouldBe(1024);
root.GetProperty("leafs").GetProperty("msgs").GetInt64().ShouldBe(40);
}
[Fact]
public void DataStats_omits_null_sub_stats()
{
var ds = new DataStats { Msgs = 5, Bytes = 50 };
var json = JsonSerializer.Serialize(ds);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("gateways", out _).ShouldBeFalse();
root.TryGetProperty("routes", out _).ShouldBeFalse();
root.TryGetProperty("leafs", out _).ShouldBeFalse();
}
// --- ConnectEventMsg ---
[Fact]
public void ConnectEventMsg_has_correct_type_and_required_fields()
{
var evt = new ConnectEventMsg
{
Id = "evt-1",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Client = new EventClientInfo { Id = 42, Name = "test-client" },
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.client_connect");
root.GetProperty("id").GetString().ShouldBe("evt-1");
root.GetProperty("server").GetProperty("name").GetString().ShouldBe("s1");
root.GetProperty("client").GetProperty("id").GetUInt64().ShouldBe(42UL);
}
// --- DisconnectEventMsg ---
[Fact]
public void DisconnectEventMsg_has_correct_type_and_data_stats()
{
var evt = new DisconnectEventMsg
{
Id = "evt-2",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Client = new EventClientInfo { Id = 42 },
Sent = new DataStats { Msgs = 100, Bytes = 2000 },
Received = new DataStats { Msgs = 50, Bytes = 1000 },
Reason = "Client Closed",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.client_disconnect");
root.GetProperty("sent").GetProperty("msgs").GetInt64().ShouldBe(100);
root.GetProperty("received").GetProperty("bytes").GetInt64().ShouldBe(1000);
root.GetProperty("reason").GetString().ShouldBe("Client Closed");
}
// --- AccountNumConns ---
[Fact]
public void AccountNumConns_serializes_all_Go_AccountStat_fields()
{
var evt = new AccountNumConns
{
Id = "evt-3",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
AccountName = "$G",
Name = "Global",
Connections = 5,
LeafNodes = 2,
TotalConnections = 100,
NumSubscriptions = 42,
Sent = new DataStats { Msgs = 500, Bytes = 10_000 },
Received = new DataStats { Msgs = 400, Bytes = 8_000 },
SlowConsumers = 1,
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.account_connections");
root.GetProperty("acc").GetString().ShouldBe("$G");
root.GetProperty("name").GetString().ShouldBe("Global");
root.GetProperty("conns").GetInt32().ShouldBe(5);
root.GetProperty("leafnodes").GetInt32().ShouldBe(2);
root.GetProperty("total_conns").GetInt32().ShouldBe(100);
root.GetProperty("num_subscriptions").GetUInt32().ShouldBe(42u);
root.GetProperty("sent").GetProperty("msgs").GetInt64().ShouldBe(500);
root.GetProperty("received").GetProperty("bytes").GetInt64().ShouldBe(8_000);
root.GetProperty("slow_consumers").GetInt64().ShouldBe(1);
}
// --- ServerStatsMsg ---
[Fact]
public void ServerStatsMsg_has_sent_received_and_breakdown_fields()
{
var msg = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "s1", Id = "SRV1", Seq = 1 },
Stats = new ServerStatsData
{
Start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Mem = 100_000_000,
Cores = 8,
Cpu = 12.5,
Connections = 10,
TotalConnections = 500,
ActiveAccounts = 3,
Subscriptions = 50,
Sent = new DataStats { Msgs = 1000, Bytes = 50_000 },
Received = new DataStats { Msgs = 800, Bytes = 40_000 },
InMsgs = 800,
OutMsgs = 1000,
InBytes = 40_000,
OutBytes = 50_000,
SlowConsumers = 2,
SlowConsumerStats = new NATS.Server.Events.SlowConsumersStats { Clients = 1, Routes = 1 },
StaleConnections = 3,
StaleConnectionStats = new NATS.Server.Events.StaleConnectionStats { Clients = 2, Leafs = 1 },
ActiveServers = 3,
Routes = [new RouteStat { Id = 1, Name = "r1", Sent = new DataStats { Msgs = 10 }, Received = new DataStats { Msgs = 5 }, Pending = 0 }],
Gateways = [new GatewayStat { Id = 1, Name = "gw1", Sent = new DataStats { Msgs = 20 }, Received = new DataStats { Msgs = 15 }, InboundConnections = 2 }],
},
};
var json = JsonSerializer.Serialize(msg);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var stats = root.GetProperty("statsz");
stats.GetProperty("mem").GetInt64().ShouldBe(100_000_000);
stats.GetProperty("cores").GetInt32().ShouldBe(8);
stats.GetProperty("cpu").GetDouble().ShouldBe(12.5);
stats.GetProperty("connections").GetInt32().ShouldBe(10);
stats.GetProperty("total_connections").GetInt64().ShouldBe(500);
stats.GetProperty("active_accounts").GetInt32().ShouldBe(3);
stats.GetProperty("subscriptions").GetInt64().ShouldBe(50);
stats.GetProperty("sent").GetProperty("msgs").GetInt64().ShouldBe(1000);
stats.GetProperty("received").GetProperty("bytes").GetInt64().ShouldBe(40_000);
stats.GetProperty("in_msgs").GetInt64().ShouldBe(800);
stats.GetProperty("out_msgs").GetInt64().ShouldBe(1000);
stats.GetProperty("slow_consumers").GetInt64().ShouldBe(2);
stats.GetProperty("slow_consumer_stats").GetProperty("clients").GetInt64().ShouldBe(1);
stats.GetProperty("stale_connections").GetInt64().ShouldBe(3);
stats.GetProperty("stale_connection_stats").GetProperty("leafs").GetInt64().ShouldBe(1);
stats.GetProperty("active_servers").GetInt32().ShouldBe(3);
stats.GetProperty("routes").GetArrayLength().ShouldBe(1);
stats.GetProperty("routes")[0].GetProperty("rid").GetUInt64().ShouldBe(1UL);
stats.GetProperty("gateways").GetArrayLength().ShouldBe(1);
stats.GetProperty("gateways")[0].GetProperty("name").GetString().ShouldBe("gw1");
}
// --- AuthErrorEventMsg ---
[Fact]
public void AuthErrorEventMsg_has_correct_type()
{
var evt = new AuthErrorEventMsg
{
Id = "evt-4",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Client = new EventClientInfo { Id = 99, Host = "10.0.0.1" },
Reason = "Authorization Violation",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.client_auth");
root.GetProperty("reason").GetString().ShouldBe("Authorization Violation");
root.GetProperty("client").GetProperty("host").GetString().ShouldBe("10.0.0.1");
}
// --- OcspPeerRejectEventMsg ---
[Fact]
public void OcspPeerRejectEventMsg_has_correct_type()
{
var evt = new OcspPeerRejectEventMsg
{
Id = "evt-5",
Time = DateTime.UtcNow,
Kind = "client",
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Reason = "OCSP revoked",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
root.GetProperty("kind").GetString().ShouldBe("client");
root.GetProperty("reason").GetString().ShouldBe("OCSP revoked");
}
// --- ShutdownEventMsg ---
[Fact]
public void ShutdownEventMsg_serializes_reason()
{
var evt = new ShutdownEventMsg
{
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Reason = "Server Shutdown",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("reason").GetString().ShouldBe("Server Shutdown");
}
// --- AccNumConnsReq ---
[Fact]
public void AccNumConnsReq_serializes_account()
{
var req = new AccNumConnsReq
{
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Account = "myAccount",
};
var json = JsonSerializer.Serialize(req);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("acc").GetString().ShouldBe("myAccount");
}
// --- Round-trip deserialization ---
[Fact]
public void ConnectEventMsg_roundtrips_through_json()
{
var original = new ConnectEventMsg
{
Id = "rt-1",
Time = new DateTime(2025, 6, 15, 12, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo { Name = "srv", Id = "SRV1", Version = "2.10.0", Seq = 5 },
Client = new EventClientInfo
{
Id = 42,
Host = "10.0.0.1",
Account = "$G",
Name = "test",
Lang = "dotnet",
Version = "1.0.0",
RttNanos = 1_000_000,
Kind = "Client",
},
};
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<ConnectEventMsg>(json);
deserialized.ShouldNotBeNull();
deserialized.Type.ShouldBe(ConnectEventMsg.EventType);
deserialized.Id.ShouldBe("rt-1");
deserialized.Server.Name.ShouldBe("srv");
deserialized.Server.Seq.ShouldBe(5UL);
deserialized.Client.Id.ShouldBe(42UL);
deserialized.Client.Kind.ShouldBe("Client");
deserialized.Client.RttNanos.ShouldBe(1_000_000);
}
[Fact]
public void ServerStatsMsg_roundtrips_through_json()
{
var original = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "srv", Id = "SRV1" },
Stats = new ServerStatsData
{
Connections = 10,
Sent = new DataStats { Msgs = 100, Bytes = 5000 },
Received = new DataStats { Msgs = 80, Bytes = 4000 },
InMsgs = 80,
OutMsgs = 100,
},
};
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<ServerStatsMsg>(json);
deserialized.ShouldNotBeNull();
deserialized.Stats.Connections.ShouldBe(10);
deserialized.Stats.Sent.Msgs.ShouldBe(100);
deserialized.Stats.Received.Bytes.ShouldBe(4000);
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
public class EventServerInfoCapabilityParityBatch1Tests
{
[Fact]
public void ServerCapability_flags_match_expected_values()
{
((ulong)ServerCapability.JetStreamEnabled).ShouldBe(1UL << 0);
((ulong)ServerCapability.BinaryStreamSnapshot).ShouldBe(1UL << 1);
((ulong)ServerCapability.AccountNRG).ShouldBe(1UL << 2);
}
[Fact]
public void EventServerInfo_capability_methods_set_and_read_flags()
{
var info = new EventServerInfo();
info.SetJetStreamEnabled();
info.SetBinaryStreamSnapshot();
info.SetAccountNRG();
info.JetStream.ShouldBeTrue();
info.JetStreamEnabled().ShouldBeTrue();
info.BinaryStreamSnapshot().ShouldBeTrue();
info.AccountNRG().ShouldBeTrue();
}
[Fact]
public void ServerID_serializes_with_name_host_id_fields()
{
var payload = new ServerID
{
Name = "srv-a",
Host = "127.0.0.1",
Id = "N1",
};
var json = JsonSerializer.Serialize(payload);
json.ShouldContain("\"name\":\"srv-a\"");
json.ShouldContain("\"host\":\"127.0.0.1\"");
json.ShouldContain("\"id\":\"N1\"");
}
}

View File

@@ -0,0 +1,308 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests that EventBuilder produces fully-populated system event messages
/// and that all field contracts are met.
/// Go reference: events.go sendConnectEventMsg, sendDisconnectEventMsg,
/// sendAccountNumConns, sendStatsz — Gap 10.6.
/// </summary>
public class FullEventPayloadTests
{
// ========================================================================
// BuildConnectEvent
// ========================================================================
[Fact]
public void BuildConnectEvent_AllFieldsPopulated()
{
// Go reference: events.go sendConnectEventMsg — all client fields must be set.
var evt = EventBuilder.BuildConnectEvent(
serverId: "SRV-001",
serverName: "test-server",
cluster: "east-cluster",
clientId: 42,
host: "10.0.0.1",
account: "$G",
user: "alice",
name: "my-client",
lang: "csharp",
version: "1.0.0");
evt.ShouldNotBeNull();
evt.Id.ShouldNotBeNullOrEmpty();
evt.Time.ShouldBeGreaterThan(DateTime.MinValue);
evt.Server.Id.ShouldBe("SRV-001");
evt.Server.Name.ShouldBe("test-server");
evt.Server.Cluster.ShouldBe("east-cluster");
evt.Client.Id.ShouldBe(42UL);
evt.Client.Host.ShouldBe("10.0.0.1");
evt.Client.Account.ShouldBe("$G");
evt.Client.User.ShouldBe("alice");
evt.Client.Name.ShouldBe("my-client");
evt.Client.Lang.ShouldBe("csharp");
evt.Client.Version.ShouldBe("1.0.0");
evt.Client.Start.ShouldBeGreaterThan(DateTime.MinValue);
}
[Fact]
public void BuildConnectEvent_HasCorrectEventType()
{
// Go reference: events.go — connect advisory type constant.
var evt = EventBuilder.BuildConnectEvent(
serverId: "SRV-001",
serverName: "test-server",
cluster: null,
clientId: 1,
host: "127.0.0.1",
account: null,
user: null,
name: null,
lang: null,
version: null);
evt.Type.ShouldBe(ConnectEventMsg.EventType);
evt.Type.ShouldBe("io.nats.server.advisory.v1.client_connect");
}
// ========================================================================
// BuildDisconnectEvent
// ========================================================================
[Fact]
public void BuildDisconnectEvent_AllFieldsPopulated()
{
// Go reference: events.go sendDisconnectEventMsg — sent, received, and reason required.
var sent = new DataStats { Msgs = 100, Bytes = 2_048 };
var received = new DataStats { Msgs = 50, Bytes = 1_024 };
var evt = EventBuilder.BuildDisconnectEvent(
serverId: "SRV-002",
serverName: "test-server",
cluster: "west-cluster",
clientId: 99,
host: "192.168.1.5",
account: "MYACC",
user: "bob",
reason: "Client Closed",
sent: sent,
received: received);
evt.ShouldNotBeNull();
evt.Id.ShouldNotBeNullOrEmpty();
evt.Time.ShouldBeGreaterThan(DateTime.MinValue);
evt.Server.Id.ShouldBe("SRV-002");
evt.Server.Name.ShouldBe("test-server");
evt.Server.Cluster.ShouldBe("west-cluster");
evt.Client.Id.ShouldBe(99UL);
evt.Client.Host.ShouldBe("192.168.1.5");
evt.Client.Account.ShouldBe("MYACC");
evt.Client.User.ShouldBe("bob");
evt.Client.Stop.ShouldBeGreaterThan(DateTime.MinValue);
evt.Reason.ShouldBe("Client Closed");
evt.Sent.Msgs.ShouldBe(100);
evt.Sent.Bytes.ShouldBe(2_048);
evt.Received.Msgs.ShouldBe(50);
evt.Received.Bytes.ShouldBe(1_024);
}
[Fact]
public void BuildDisconnectEvent_HasCorrectEventType()
{
// Go reference: events.go — disconnect advisory type constant.
var evt = EventBuilder.BuildDisconnectEvent(
serverId: "SRV-002",
serverName: "test-server",
cluster: null,
clientId: 1,
host: "127.0.0.1",
account: null,
user: null,
reason: "Server Closed",
sent: new DataStats(),
received: new DataStats());
evt.Type.ShouldBe(DisconnectEventMsg.EventType);
evt.Type.ShouldBe("io.nats.server.advisory.v1.client_disconnect");
}
// ========================================================================
// BuildAccountConnsEvent
// ========================================================================
[Fact]
public void BuildAccountConnsEvent_AllFieldsPopulated()
{
// Go reference: events.go sendAccountNumConns — all AccountStat fields must transfer.
var sent = new DataStats { Msgs = 500, Bytes = 10_000 };
var received = new DataStats { Msgs = 400, Bytes = 8_000 };
var evt = EventBuilder.BuildAccountConnsEvent(
serverId: "SRV-003",
serverName: "test-server",
accountName: "ACCT-A",
connections: 7,
leafNodes: 2,
totalConnections: 150,
numSubscriptions: 33,
sent: sent,
received: received,
slowConsumers: 3);
evt.ShouldNotBeNull();
evt.Id.ShouldNotBeNullOrEmpty();
evt.Time.ShouldBeGreaterThan(DateTime.MinValue);
evt.Server.Id.ShouldBe("SRV-003");
evt.Server.Name.ShouldBe("test-server");
evt.AccountName.ShouldBe("ACCT-A");
evt.Connections.ShouldBe(7);
evt.LeafNodes.ShouldBe(2);
evt.TotalConnections.ShouldBe(150);
evt.NumSubscriptions.ShouldBe(33u);
evt.Sent.Msgs.ShouldBe(500);
evt.Received.Bytes.ShouldBe(8_000);
evt.SlowConsumers.ShouldBe(3);
evt.Type.ShouldBe(AccountNumConns.EventType);
}
// ========================================================================
// BuildServerStats
// ========================================================================
[Fact]
public void BuildServerStats_AllFieldsPopulated()
{
// Go reference: events.go sendStatsz — all stat fields must be set.
var sent = new DataStats { Msgs = 1_000, Bytes = 50_000 };
var received = new DataStats { Msgs = 800, Bytes = 40_000 };
var msg = EventBuilder.BuildServerStats(
serverId: "SRV-004",
serverName: "stats-server",
mem: 134_217_728,
cores: 8,
cpu: 15.5,
connections: 12,
totalConnections: 600,
activeAccounts: 4,
subscriptions: 55,
sent: sent,
received: received);
msg.ShouldNotBeNull();
msg.Server.Id.ShouldBe("SRV-004");
msg.Server.Name.ShouldBe("stats-server");
msg.Stats.Mem.ShouldBe(134_217_728);
msg.Stats.Cores.ShouldBe(8);
msg.Stats.Cpu.ShouldBe(15.5);
msg.Stats.Connections.ShouldBe(12);
msg.Stats.TotalConnections.ShouldBe(600);
msg.Stats.ActiveAccounts.ShouldBe(4);
msg.Stats.Subscriptions.ShouldBe(55);
msg.Stats.Sent.Msgs.ShouldBe(1_000);
msg.Stats.Received.Bytes.ShouldBe(40_000);
// Flat counters mirror the structured stats
msg.Stats.OutMsgs.ShouldBe(1_000);
msg.Stats.InMsgs.ShouldBe(800);
msg.Stats.OutBytes.ShouldBe(50_000);
msg.Stats.InBytes.ShouldBe(40_000);
msg.Stats.Start.ShouldBeGreaterThan(DateTime.MinValue);
}
// ========================================================================
// GenerateEventId
// ========================================================================
[Fact]
public void GenerateEventId_UniquePerCall()
{
// Go reference: events.go uses nuid.Next() — every call produces a distinct ID.
var ids = Enumerable.Range(0, 20).Select(_ => EventBuilder.GenerateEventId()).ToList();
ids.Distinct().Count().ShouldBe(20);
foreach (var id in ids)
{
id.ShouldNotBeNullOrEmpty();
id.Length.ShouldBe(32); // Guid.ToString("N") is always 32 hex chars
}
}
// ========================================================================
// GetTimestamp
// ========================================================================
[Fact]
public void GetTimestamp_ReturnsIso8601()
{
// Go reference: events use RFC3339Nano timestamps; .NET "O" format is ISO 8601.
var ts = EventBuilder.GetTimestamp();
ts.ShouldNotBeNullOrEmpty();
// "O" format: 2025-02-25T12:34:56.7890000Z — parseable as UTC DateTimeOffset
var parsed = DateTimeOffset.Parse(ts);
parsed.Year.ShouldBeGreaterThanOrEqualTo(2024);
}
// ========================================================================
// DataStats default values
// ========================================================================
[Fact]
public void DataStats_DefaultZeroValues()
{
// Go reference: zero-value DataStats is valid and all fields default to 0.
var ds = new DataStats();
ds.Msgs.ShouldBe(0L);
ds.Bytes.ShouldBe(0L);
ds.Gateways.ShouldBeNull();
ds.Routes.ShouldBeNull();
ds.Leafs.ShouldBeNull();
}
// ========================================================================
// JSON roundtrip
// ========================================================================
[Fact]
public void ConnectEventMsg_SerializesToJson()
{
// Go reference: events.go — connect event serializes to JSON and round-trips.
var original = EventBuilder.BuildConnectEvent(
serverId: "SRV-RT",
serverName: "roundtrip-server",
cluster: "rt-cluster",
clientId: 77,
host: "10.1.2.3",
account: "RTACC",
user: "rtuser",
name: "rt-client",
lang: "dotnet",
version: "2.0.0");
var json = JsonSerializer.Serialize(original);
json.ShouldNotBeNullOrEmpty();
json.ShouldContain("\"type\":");
json.ShouldContain(ConnectEventMsg.EventType);
json.ShouldContain("\"server\":");
json.ShouldContain("\"client\":");
json.ShouldContain("\"SRV-RT\"");
var deserialized = JsonSerializer.Deserialize<ConnectEventMsg>(json);
deserialized.ShouldNotBeNull();
deserialized!.Type.ShouldBe(ConnectEventMsg.EventType);
deserialized.Id.ShouldBe(original.Id);
deserialized.Server.Id.ShouldBe("SRV-RT");
deserialized.Server.Name.ShouldBe("roundtrip-server");
deserialized.Server.Cluster.ShouldBe("rt-cluster");
deserialized.Client.Id.ShouldBe(77UL);
deserialized.Client.Account.ShouldBe("RTACC");
deserialized.Client.User.ShouldBe("rtuser");
deserialized.Client.Lang.ShouldBe("dotnet");
deserialized.Client.Version.ShouldBe("2.0.0");
}
}

View File

@@ -0,0 +1,266 @@
// Port of Go server/ocsp_test.go — OCSP peer reject and chain validation advisory tests.
// Go reference: golang/nats-server/server/ocsp.go — postOCSPPeerRejectEvent,
// OCSP chain validation advisory publishing (Gap 10.10).
//
// Tests cover: OcspPeerRejectEventMsg, OcspChainValidationEvent, OcspEventBuilder,
// OcspStatus enum, subject constants, and JSON serialisation round-trip.
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for <see cref="OcspPeerRejectEventMsg"/>, <see cref="OcspChainValidationEvent"/>,
/// <see cref="OcspEventBuilder"/>, and the <see cref="OcspStatus"/> enum.
/// Go reference: ocsp.go — OCSP peer verification advisory events (Gap 10.10).
/// </summary>
public class OcspEventTests
{
// ========================================================================
// OcspPeerRejectEventMsg
// Go reference: ocsp.go postOCSPPeerRejectEvent
// ========================================================================
/// <summary>
/// EventType constant must match the Go advisory type string.
/// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_peer_reject".
/// </summary>
[Fact]
public void OcspPeerRejectEventMsg_HasCorrectEventType()
{
// Go reference: ocsp.go — advisory type constant.
OcspPeerRejectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
var ev = new OcspPeerRejectEventMsg();
ev.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
}
/// <summary>
/// Each OcspPeerRejectEventMsg gets its own unique non-empty Id.
/// Go reference: ocsp.go — nuid.Next() generates a unique event ID per advisory.
/// </summary>
[Fact]
public void OcspPeerRejectEventMsg_GeneratesUniqueId()
{
// Go reference: ocsp.go — each advisory is assigned a unique ID.
var ev1 = new OcspPeerRejectEventMsg { Id = EventBuilder.GenerateEventId() };
var ev2 = new OcspPeerRejectEventMsg { Id = EventBuilder.GenerateEventId() };
ev1.Id.ShouldNotBeNullOrEmpty();
ev2.Id.ShouldNotBeNullOrEmpty();
ev1.Id.ShouldNotBe(ev2.Id);
}
// ========================================================================
// OcspChainValidationEvent
// Go reference: ocsp.go — OCSP chain validation advisory
// ========================================================================
/// <summary>
/// EventType constant must match the advisory type string.
/// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_chain_validation".
/// </summary>
[Fact]
public void OcspChainValidationEvent_HasCorrectEventType()
{
// Go reference: ocsp.go — chain validation advisory type constant.
OcspChainValidationEvent.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_chain_validation");
var ev = new OcspChainValidationEvent();
ev.Type.ShouldBe(OcspChainValidationEvent.EventType);
}
/// <summary>
/// All optional fields on OcspChainValidationEvent can be assigned.
/// Go reference: ocsp.go — chain validation advisory carries cert metadata.
/// </summary>
[Fact]
public void OcspChainValidationEvent_AllFieldsSettable()
{
// Go reference: ocsp.go — OCSP advisory fields: subject, issuer, serial, status.
var server = new EventServerInfo { Id = "srv1", Name = "test-server" };
var checkedAt = new DateTime(2026, 2, 25, 12, 0, 0, DateTimeKind.Utc);
var ev = new OcspChainValidationEvent
{
Server = server,
CertSubject = "CN=leaf.example.com",
CertIssuer = "CN=Intermediate CA",
SerialNumber = "0123456789abcdef",
OcspStatus = "good",
CheckedAt = checkedAt,
Error = null,
};
ev.Server.ShouldBeSameAs(server);
ev.CertSubject.ShouldBe("CN=leaf.example.com");
ev.CertIssuer.ShouldBe("CN=Intermediate CA");
ev.SerialNumber.ShouldBe("0123456789abcdef");
ev.OcspStatus.ShouldBe("good");
ev.CheckedAt.ShouldBe(checkedAt);
ev.Error.ShouldBeNull();
}
// ========================================================================
// OcspEventBuilder
// Go reference: ocsp.go — postOCSPPeerRejectEvent, chain validation advisory
// ========================================================================
/// <summary>
/// BuildPeerReject populates all required fields of OcspPeerRejectEventMsg.
/// Go reference: ocsp.go postOCSPPeerRejectEvent — kind and reason always set.
/// </summary>
[Fact]
public void BuildPeerReject_PopulatesAllFields()
{
// Go reference: ocsp.go — peer reject advisory carries kind + reason.
var ev = OcspEventBuilder.BuildPeerReject(
serverId: "srv-abc",
serverName: "my-server",
kind: "client",
reason: "certificate revoked");
ev.Id.ShouldNotBeNullOrEmpty();
ev.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
ev.Server.ShouldNotBeNull();
ev.Server.Id.ShouldBe("srv-abc");
ev.Server.Name.ShouldBe("my-server");
ev.Kind.ShouldBe("client");
ev.Reason.ShouldBe("certificate revoked");
ev.Time.ShouldNotBe(default);
}
/// <summary>
/// BuildChainValidation populates all fields of OcspChainValidationEvent.
/// Go reference: ocsp.go — chain validation advisory carries full cert metadata.
/// </summary>
[Fact]
public void BuildChainValidation_PopulatesAllFields()
{
// Go reference: ocsp.go — chain validation advisory fields.
var before = DateTime.UtcNow;
var ev = OcspEventBuilder.BuildChainValidation(
serverId: "srv-xyz",
serverName: "nats-1",
certSubject: "CN=client.example.com",
certIssuer: "CN=Root CA",
serialNumber: "deadbeef",
ocspStatus: "good",
error: null);
var after = DateTime.UtcNow;
ev.Id.ShouldNotBeNullOrEmpty();
ev.Type.ShouldBe(OcspChainValidationEvent.EventType);
ev.Server.ShouldNotBeNull();
ev.Server!.Id.ShouldBe("srv-xyz");
ev.Server.Name.ShouldBe("nats-1");
ev.CertSubject.ShouldBe("CN=client.example.com");
ev.CertIssuer.ShouldBe("CN=Root CA");
ev.SerialNumber.ShouldBe("deadbeef");
ev.OcspStatus.ShouldBe("good");
ev.CheckedAt.ShouldNotBeNull();
ev.CheckedAt!.Value.ShouldBeInRange(before, after);
ev.Error.ShouldBeNull();
}
// ========================================================================
// OcspStatus enum — ParseStatus
// Go reference: ocsp.go — ocspStatusGood, ocspStatusRevoked constants
// ========================================================================
/// <summary>
/// ParseStatus("good") returns OcspStatus.Good.
/// Go reference: ocsp.go — ocspStatusGood = "good".
/// </summary>
[Fact]
public void ParseStatus_Good()
{
// Go reference: ocsp.go — ocspStatusGood.
OcspEventBuilder.ParseStatus("good").ShouldBe(OcspStatus.Good);
OcspEventBuilder.ParseStatus("Good").ShouldBe(OcspStatus.Good);
OcspEventBuilder.ParseStatus("GOOD").ShouldBe(OcspStatus.Good);
}
/// <summary>
/// ParseStatus("revoked") returns OcspStatus.Revoked.
/// Go reference: ocsp.go — ocspStatusRevoked = "revoked".
/// </summary>
[Fact]
public void ParseStatus_Revoked()
{
// Go reference: ocsp.go — ocspStatusRevoked.
OcspEventBuilder.ParseStatus("revoked").ShouldBe(OcspStatus.Revoked);
OcspEventBuilder.ParseStatus("Revoked").ShouldBe(OcspStatus.Revoked);
OcspEventBuilder.ParseStatus("REVOKED").ShouldBe(OcspStatus.Revoked);
}
/// <summary>
/// ParseStatus with null or unrecognised string returns OcspStatus.Unknown.
/// Go reference: ocsp.go — unknown/unrecognised OCSP status treated as unknown.
/// </summary>
[Fact]
public void ParseStatus_Unknown()
{
// Go reference: ocsp.go — default case maps to unknown status.
OcspEventBuilder.ParseStatus(null).ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("unknown").ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("").ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("invalid-status").ShouldBe(OcspStatus.Unknown);
}
// ========================================================================
// JSON serialisation round-trip
// Go reference: ocsp.go — advisories are published as JSON payloads
// ========================================================================
/// <summary>
/// OcspPeerRejectEventMsg and OcspChainValidationEvent round-trip through JSON
/// preserving all set fields.
/// Go reference: ocsp.go — OCSP advisories serialised as JSON before publishing.
/// </summary>
[Fact]
public void OcspEvents_SerializeToJson()
{
// Go reference: ocsp.go — advisories are JSON-encoded for publishing.
var peerReject = OcspEventBuilder.BuildPeerReject(
serverId: "srv-1",
serverName: "node-a",
kind: "route",
reason: "OCSP status revoked");
var chainValidation = OcspEventBuilder.BuildChainValidation(
serverId: "srv-2",
serverName: "node-b",
certSubject: "CN=server.nats.io",
certIssuer: "CN=NATS CA",
serialNumber: "cafebabe",
ocspStatus: "revoked",
error: "certificate has been revoked");
// Serialise OcspPeerRejectEventMsg
var rejectJson = JsonSerializer.Serialize(peerReject);
rejectJson.ShouldContain("io.nats.server.advisory.v1.ocsp_peer_reject");
rejectJson.ShouldContain("route");
rejectJson.ShouldContain("OCSP status revoked");
var rejectDeserialized = JsonSerializer.Deserialize<OcspPeerRejectEventMsg>(rejectJson);
rejectDeserialized.ShouldNotBeNull();
rejectDeserialized!.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
rejectDeserialized.Kind.ShouldBe("route");
rejectDeserialized.Reason.ShouldBe("OCSP status revoked");
rejectDeserialized.Server.Id.ShouldBe("srv-1");
rejectDeserialized.Server.Name.ShouldBe("node-a");
// Serialise OcspChainValidationEvent
var chainJson = JsonSerializer.Serialize(chainValidation);
chainJson.ShouldContain("io.nats.server.advisory.v1.ocsp_chain_validation");
chainJson.ShouldContain("CN=server.nats.io");
chainJson.ShouldContain("revoked");
var chainDeserialized = JsonSerializer.Deserialize<OcspChainValidationEvent>(chainJson);
chainDeserialized.ShouldNotBeNull();
chainDeserialized!.Type.ShouldBe(OcspChainValidationEvent.EventType);
chainDeserialized.CertSubject.ShouldBe("CN=server.nats.io");
chainDeserialized.CertIssuer.ShouldBe("CN=NATS CA");
chainDeserialized.SerialNumber.ShouldBe("cafebabe");
chainDeserialized.OcspStatus.ShouldBe("revoked");
chainDeserialized.Error.ShouldBe("certificate has been revoked");
}
}

View File

@@ -0,0 +1,200 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for remote server and leaf node event DTOs and subject constants.
/// Go reference: events.go — remote server lifecycle, leaf node advisory subjects.
/// Gap 10.8: RemoteServerShutdown, RemoteServerUpdate, LeafNodeConnected events.
/// </summary>
public class RemoteServerEventTests
{
// --- RemoteServerShutdownEvent ---
[Fact]
public void RemoteServerShutdownEvent_HasCorrectEventType()
{
RemoteServerShutdownEvent.EventType.ShouldBe("io.nats.server.advisory.v1.remote_shutdown");
var ev = new RemoteServerShutdownEvent();
ev.Type.ShouldBe(RemoteServerShutdownEvent.EventType);
}
[Fact]
public void RemoteServerShutdownEvent_GeneratesUniqueId()
{
var ev1 = new RemoteServerShutdownEvent();
var ev2 = new RemoteServerShutdownEvent();
ev1.Id.ShouldNotBeNullOrEmpty();
ev2.Id.ShouldNotBeNullOrEmpty();
ev1.Id.ShouldNotBe(ev2.Id);
}
// --- RemoteServerUpdateEvent ---
[Fact]
public void RemoteServerUpdateEvent_HasCorrectEventType()
{
RemoteServerUpdateEvent.EventType.ShouldBe("io.nats.server.advisory.v1.remote_update");
var ev = new RemoteServerUpdateEvent();
ev.Type.ShouldBe(RemoteServerUpdateEvent.EventType);
}
[Fact]
public void RemoteServerUpdateEvent_AllFieldsSettable()
{
var server = new EventServerInfo { Id = "srv1", Name = "my-server" };
var ev = new RemoteServerUpdateEvent
{
Server = server,
RemoteServerId = "remote-id-123",
RemoteServerName = "remote-server",
UpdateType = "routes_changed",
};
ev.Server.ShouldBeSameAs(server);
ev.RemoteServerId.ShouldBe("remote-id-123");
ev.RemoteServerName.ShouldBe("remote-server");
ev.UpdateType.ShouldBe("routes_changed");
}
// --- LeafNodeConnectEvent ---
[Fact]
public void LeafNodeConnectEvent_HasCorrectEventType()
{
LeafNodeConnectEvent.EventType.ShouldBe("io.nats.server.advisory.v1.leafnode_connect");
var ev = new LeafNodeConnectEvent();
ev.Type.ShouldBe(LeafNodeConnectEvent.EventType);
}
[Fact]
public void LeafNodeConnectEvent_AllFieldsSettable()
{
var server = new EventServerInfo { Id = "srv1", Name = "hub" };
var ev = new LeafNodeConnectEvent
{
Server = server,
LeafNodeId = "leaf-id-abc",
LeafNodeName = "leaf-node-1",
RemoteUrl = "nats://10.0.0.1:7422",
Account = "ACC",
};
ev.Server.ShouldBeSameAs(server);
ev.LeafNodeId.ShouldBe("leaf-id-abc");
ev.LeafNodeName.ShouldBe("leaf-node-1");
ev.RemoteUrl.ShouldBe("nats://10.0.0.1:7422");
ev.Account.ShouldBe("ACC");
}
// --- LeafNodeDisconnectEvent ---
[Fact]
public void LeafNodeDisconnectEvent_HasCorrectEventType()
{
LeafNodeDisconnectEvent.EventType.ShouldBe("io.nats.server.advisory.v1.leafnode_disconnect");
var ev = new LeafNodeDisconnectEvent();
ev.Type.ShouldBe(LeafNodeDisconnectEvent.EventType);
}
[Fact]
public void LeafNodeDisconnectEvent_AllFieldsSettable()
{
var server = new EventServerInfo { Id = "srv1", Name = "hub" };
var ev = new LeafNodeDisconnectEvent
{
Server = server,
LeafNodeId = "leaf-id-xyz",
Reason = "connection closed",
};
ev.Server.ShouldBeSameAs(server);
ev.LeafNodeId.ShouldBe("leaf-id-xyz");
ev.Reason.ShouldBe("connection closed");
}
// --- EventSubjects ---
[Fact]
public void EventSubjects_RemoteShutdown_HasPlaceholder()
{
EventSubjects.RemoteServerShutdown.ShouldContain("{0}");
EventSubjects.RemoteServerUpdate.ShouldContain("{0}");
EventSubjects.LeafNodeConnected.ShouldContain("{0}");
EventSubjects.LeafNodeDisconnected.ShouldContain("{0}");
// Verify format strings produce expected subjects when formatted
var serverId = "ABCDEF123456";
string.Format(EventSubjects.RemoteServerShutdown, serverId)
.ShouldBe($"$SYS.SERVER.{serverId}.REMOTE.SHUTDOWN");
string.Format(EventSubjects.LeafNodeConnected, serverId)
.ShouldBe($"$SYS.ACCOUNT.{serverId}.LEAFNODE.CONNECT");
}
// --- JSON serialization ---
[Fact]
public void AllRemoteEvents_SerializeToJson()
{
var server = new EventServerInfo { Id = "srv-id", Name = "test-server" };
var shutdown = new RemoteServerShutdownEvent
{
Server = server,
RemoteServerId = "r1",
RemoteServerName = "remote",
Reason = "graceful",
};
var shutdownJson = JsonSerializer.Serialize(shutdown);
var shutdownDoc = JsonDocument.Parse(shutdownJson).RootElement;
shutdownDoc.GetProperty("type").GetString().ShouldBe(RemoteServerShutdownEvent.EventType);
shutdownDoc.GetProperty("id").GetString().ShouldNotBeNullOrEmpty();
shutdownDoc.GetProperty("remote_server_id").GetString().ShouldBe("r1");
shutdownDoc.GetProperty("reason").GetString().ShouldBe("graceful");
var update = new RemoteServerUpdateEvent
{
Server = server,
RemoteServerId = "r2",
RemoteServerName = "remote2",
UpdateType = "config_updated",
};
var updateJson = JsonSerializer.Serialize(update);
var updateDoc = JsonDocument.Parse(updateJson).RootElement;
updateDoc.GetProperty("type").GetString().ShouldBe(RemoteServerUpdateEvent.EventType);
updateDoc.GetProperty("update_type").GetString().ShouldBe("config_updated");
var connect = new LeafNodeConnectEvent
{
Server = server,
LeafNodeId = "leaf1",
LeafNodeName = "leaf-node",
RemoteUrl = "nats://10.0.0.1:7422",
Account = "ACC",
};
var connectJson = JsonSerializer.Serialize(connect);
var connectDoc = JsonDocument.Parse(connectJson).RootElement;
connectDoc.GetProperty("type").GetString().ShouldBe(LeafNodeConnectEvent.EventType);
connectDoc.GetProperty("leaf_node_id").GetString().ShouldBe("leaf1");
connectDoc.GetProperty("account").GetString().ShouldBe("ACC");
var disconnect = new LeafNodeDisconnectEvent
{
Server = server,
LeafNodeId = "leaf1",
Reason = "timeout",
};
var disconnectJson = JsonSerializer.Serialize(disconnect);
var disconnectDoc = JsonDocument.Parse(disconnectJson).RootElement;
disconnectDoc.GetProperty("type").GetString().ShouldBe(LeafNodeDisconnectEvent.EventType);
disconnectDoc.GetProperty("leaf_node_id").GetString().ShouldBe("leaf1");
disconnectDoc.GetProperty("reason").GetString().ShouldBe("timeout");
// Roundtrip: deserialize back and verify type field survives
var shutdownRt = JsonSerializer.Deserialize<RemoteServerShutdownEvent>(shutdownJson);
shutdownRt.ShouldNotBeNull();
shutdownRt!.Type.ShouldBe(RemoteServerShutdownEvent.EventType);
shutdownRt.RemoteServerId.ShouldBe("r1");
}
}

View File

@@ -0,0 +1,421 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Events;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for server lifecycle events, stats tracking, advisory messages, and
/// $SYS subject infrastructure.
/// Go reference: events_test.go (51 tests).
/// </summary>
public class ServerEventTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _port;
private readonly CancellationTokenSource _cts = new();
public ServerEventTests()
{
_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();
}
private async Task<Socket> ConnectAndHandshakeAsync()
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _port);
// Read INFO
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None);
// Send CONNECT + PING
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
// Read PONG (may include -ERR or other lines)
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
return sock;
}
// -----------------------------------------------------------------------
// Server lifecycle events
// -----------------------------------------------------------------------
/// <summary>
/// Server exposes Stats property at startup with all counters at zero.
/// Go reference: events_test.go TestServerEventsStatsZ (line ~100).
/// </summary>
[Fact]
public void Server_stats_initialized_to_zero_at_startup()
{
var stats = _server.Stats;
stats.InMsgs.ShouldBe(0L);
stats.OutMsgs.ShouldBe(0L);
stats.InBytes.ShouldBe(0L);
stats.OutBytes.ShouldBe(0L);
stats.SlowConsumers.ShouldBe(0L);
}
/// <summary>
/// TotalConnections increments each time a new client connects.
/// Go reference: events_test.go TestServerEventsTotalConnections (line ~150).
/// </summary>
[Fact]
public async Task TotalConnections_increments_on_each_new_connection()
{
var before = Interlocked.Read(ref _server.Stats.TotalConnections);
using var c1 = await ConnectAndHandshakeAsync();
using var c2 = await ConnectAndHandshakeAsync();
var after = Interlocked.Read(ref _server.Stats.TotalConnections);
(after - before).ShouldBeGreaterThanOrEqualTo(2L);
}
/// <summary>
/// ClientCount reflects only currently connected clients.
/// Go reference: events_test.go TestServerEventsStatsCID (line ~200).
/// </summary>
[Fact]
public async Task ClientCount_decrements_when_client_disconnects()
{
var sock = await ConnectAndHandshakeAsync();
var countWhileConnected = _server.ClientCount;
countWhileConnected.ShouldBeGreaterThanOrEqualTo(1);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
// Allow server time to process the disconnection
await Task.Delay(100);
_server.ClientCount.ShouldBeLessThan(countWhileConnected + 1);
}
/// <summary>
/// Multiple simultaneous connections are tracked independently.
/// Go reference: events_test.go TestServerEventsConcurrentConns (line ~230).
/// </summary>
[Fact]
public async Task Multiple_connections_tracked_independently()
{
var before = Interlocked.Read(ref _server.Stats.TotalConnections);
using var c1 = await ConnectAndHandshakeAsync();
using var c2 = await ConnectAndHandshakeAsync();
using var c3 = await ConnectAndHandshakeAsync();
var after = Interlocked.Read(ref _server.Stats.TotalConnections);
(after - before).ShouldBeGreaterThanOrEqualTo(3L);
}
/// <summary>
/// Stats are accurate after rapid connect/disconnect cycles.
/// Go reference: events_test.go TestServerEventsStatsCounting (line ~260).
/// </summary>
[Fact]
public async Task Stats_accurate_after_rapid_connect_disconnect()
{
var before = Interlocked.Read(ref _server.Stats.TotalConnections);
for (var i = 0; i < 5; i++)
{
using var sock = await ConnectAndHandshakeAsync();
}
var after = Interlocked.Read(ref _server.Stats.TotalConnections);
(after - before).ShouldBeGreaterThanOrEqualTo(5L);
}
// -----------------------------------------------------------------------
// ServerStats counters — message/byte tracking
// -----------------------------------------------------------------------
/// <summary>
/// InMsgs and InBytes increment when clients publish.
/// Go reference: events_test.go TestServerEventsStatsz (line ~100).
/// </summary>
[Fact]
public async Task InMsgs_and_InBytes_increment_on_publish()
{
using var sock = await ConnectAndHandshakeAsync();
var beforeMsgs = Interlocked.Read(ref _server.Stats.InMsgs);
var beforeBytes = Interlocked.Read(ref _server.Stats.InBytes);
var payload = "Hello"u8.ToArray();
var pub = Encoding.ASCII.GetBytes($"PUB test.subject {payload.Length}\r\nHello\r\n");
await sock.SendAsync(pub);
// Flush via PING/PONG
await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
var afterMsgs = Interlocked.Read(ref _server.Stats.InMsgs);
var afterBytes = Interlocked.Read(ref _server.Stats.InBytes);
(afterMsgs - beforeMsgs).ShouldBeGreaterThanOrEqualTo(1L);
(afterBytes - beforeBytes).ShouldBeGreaterThanOrEqualTo(payload.Length);
}
/// <summary>
/// OutMsgs and OutBytes increment when messages are delivered to subscribers.
/// Go reference: events_test.go TestServerEventsStatsz (line ~100).
/// </summary>
[Fact]
public async Task OutMsgs_and_OutBytes_increment_on_delivery()
{
using var sub = await ConnectAndHandshakeAsync();
using var pub = await ConnectAndHandshakeAsync();
// Subscribe
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB test.out 1\r\nPING\r\n"));
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
var beforeOut = Interlocked.Read(ref _server.Stats.OutMsgs);
var payload = "World"u8.ToArray();
await pub.SendAsync(Encoding.ASCII.GetBytes($"PUB test.out {payload.Length}\r\nWorld\r\n"));
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
await SocketTestHelper.ReadUntilAsync(pub, "PONG");
// Give delivery loop time to flush
await SocketTestHelper.ReadUntilAsync(sub, "World", timeoutMs: 2000);
var afterOut = Interlocked.Read(ref _server.Stats.OutMsgs);
(afterOut - beforeOut).ShouldBeGreaterThanOrEqualTo(1L);
}
// -----------------------------------------------------------------------
// Account stats events
// -----------------------------------------------------------------------
/// <summary>
/// Account.InMsgs and InBytes track messages received by clients in that account.
/// Go reference: events_test.go TestServerEventsStatsz (line ~100),
/// TestAccountStats (line ~400).
/// </summary>
[Fact]
public void Account_InMsgs_and_InBytes_increment_correctly()
{
// Account.IncrementInbound is the mechanism tracked server-side
var account = new Account("test-account");
account.IncrementInbound(3, 300);
account.InMsgs.ShouldBe(3L);
account.InBytes.ShouldBe(300L);
}
/// <summary>
/// Account.OutMsgs and OutBytes track messages delivered to clients in that account.
/// Go reference: events_test.go TestAccountStats (line ~400).
/// </summary>
[Fact]
public void Account_OutMsgs_and_OutBytes_increment_correctly()
{
var account = new Account("test-account");
account.IncrementOutbound(2, 200);
account.OutMsgs.ShouldBe(2L);
account.OutBytes.ShouldBe(200L);
}
/// <summary>
/// Per-account stats are isolated — changes to one account do not affect another.
/// Go reference: events_test.go TestAccountStats, TestServerEventsAccountIsolation (line ~420).
/// </summary>
[Fact]
public void Account_stats_are_isolated_between_accounts()
{
var a1 = new Account("account-one");
var a2 = new Account("account-two");
a1.IncrementInbound(10, 1000);
a2.IncrementInbound(5, 500);
a1.InMsgs.ShouldBe(10L);
a2.InMsgs.ShouldBe(5L);
a1.InBytes.ShouldBe(1000L);
a2.InBytes.ShouldBe(500L);
}
/// <summary>
/// Account stats start at zero and are independent of each other.
/// Go reference: events_test.go TestAccountStats (line ~400).
/// </summary>
[Fact]
public void Account_stats_start_at_zero()
{
var account = new Account("fresh");
account.InMsgs.ShouldBe(0L);
account.OutMsgs.ShouldBe(0L);
account.InBytes.ShouldBe(0L);
account.OutBytes.ShouldBe(0L);
}
// -----------------------------------------------------------------------
// Advisory messages — slow consumers, stale connections
// -----------------------------------------------------------------------
/// <summary>
/// ServerStats contains SlowConsumers counter for aggregate slow consumer tracking.
/// Go reference: events_test.go TestServerEventsSlowConsumer (line ~500).
/// </summary>
[Fact]
public void Stats_has_SlowConsumers_field()
{
var stats = _server.Stats;
// Field exists and starts at zero
Interlocked.Read(ref stats.SlowConsumers).ShouldBe(0L);
}
/// <summary>
/// ServerStats differentiates slow consumers by connection type.
/// Go reference: events_test.go TestServerEventsSlowConsumer (line ~500).
/// </summary>
[Fact]
public void Stats_has_per_type_SlowConsumer_fields()
{
var stats = _server.Stats;
// All per-type slow-consumer counters exist and start at zero
Interlocked.Read(ref stats.SlowConsumerClients).ShouldBe(0L);
Interlocked.Read(ref stats.SlowConsumerRoutes).ShouldBe(0L);
Interlocked.Read(ref stats.SlowConsumerLeafs).ShouldBe(0L);
Interlocked.Read(ref stats.SlowConsumerGateways).ShouldBe(0L);
}
/// <summary>
/// StaleConnections and per-type stale counters are tracked in ServerStats.
/// Go reference: events_test.go TestServerEventsStaleConnection (line ~550).
/// </summary>
[Fact]
public void Stats_has_StaleConnection_fields()
{
var stats = _server.Stats;
Interlocked.Read(ref stats.StaleConnections).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionClients).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionRoutes).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionLeafs).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionGateways).ShouldBe(0L);
}
// -----------------------------------------------------------------------
// JetStream API stats
// -----------------------------------------------------------------------
/// <summary>
/// JetStreamApiTotal and JetStreamApiErrors counters exist in ServerStats.
/// Go reference: events_test.go TestServerEventsStatsZ JetStream fields (line ~100).
/// </summary>
[Fact]
public void Stats_has_JetStream_api_counters()
{
var stats = _server.Stats;
Interlocked.Read(ref stats.JetStreamApiTotal).ShouldBe(0L);
Interlocked.Read(ref stats.JetStreamApiErrors).ShouldBe(0L);
}
// -----------------------------------------------------------------------
// $SYS subject event infrastructure
// -----------------------------------------------------------------------
/// <summary>
/// EventSubjects constants use $SYS prefix matching Go's event subject patterns.
/// Go reference: events.go:41-97 subject constants.
/// </summary>
[Fact]
public void EventSubjects_have_correct_SYS_prefixes()
{
EventSubjects.ConnectEvent.ShouldStartWith("$SYS.ACCOUNT.");
EventSubjects.DisconnectEvent.ShouldStartWith("$SYS.ACCOUNT.");
EventSubjects.ServerStats.ShouldStartWith("$SYS.SERVER.");
EventSubjects.ServerShutdown.ShouldStartWith("$SYS.SERVER.");
EventSubjects.AuthError.ShouldStartWith("$SYS.SERVER.");
}
/// <summary>
/// EventSubjects include format placeholders for account and server IDs.
/// Go reference: events.go:41-97 format string subject constants.
/// </summary>
[Fact]
public void EventSubjects_format_correctly_with_account_and_server_ids()
{
var connectSubject = string.Format(EventSubjects.ConnectEvent, "MY_ACCOUNT");
connectSubject.ShouldBe("$SYS.ACCOUNT.MY_ACCOUNT.CONNECT");
var statsSubject = string.Format(EventSubjects.ServerStats, "SERVER123");
statsSubject.ShouldBe("$SYS.SERVER.SERVER123.STATSZ");
var shutdownSubject = string.Format(EventSubjects.ServerShutdown, "SERVER123");
shutdownSubject.ShouldBe("$SYS.SERVER.SERVER123.SHUTDOWN");
}
/// <summary>
/// NatsServer exposes a non-null EventSystem after startup.
/// Go reference: events.go initEventTracking — event system initialised during server start.
/// </summary>
[Fact]
public void Server_has_EventSystem_after_start()
{
_server.EventSystem.ShouldNotBeNull();
}
/// <summary>
/// InternalEventSystem.PublishServerStats produces a ServerStatsMsg with server
/// identity and current stats data without throwing.
/// Go reference: events.go sendStatsz (line ~495).
/// </summary>
[Fact]
public void EventSystem_PublishServerStats_does_not_throw()
{
var eventSystem = _server.EventSystem;
eventSystem.ShouldNotBeNull();
// Calling PublishServerStats directly must not throw
var ex = Record.Exception(() => eventSystem!.PublishServerStats());
ex.ShouldBeNull();
}
/// <summary>
/// InternalEventSystem generates unique, monotonically increasing sequence numbers.
/// Go reference: events.go NextSequence / sequence counter (line ~59).
/// </summary>
[Fact]
public void EventSystem_sequence_numbers_are_monotonically_increasing()
{
var es = _server.EventSystem;
es.ShouldNotBeNull();
var s1 = es!.NextSequence();
var s2 = es.NextSequence();
var s3 = es.NextSequence();
s2.ShouldBeGreaterThan(s1);
s3.ShouldBeGreaterThan(s2);
}
/// <summary>
/// BuildEventServerInfo embeds the server name and ID in advisory messages.
/// Go reference: events.go serverInfo() helper (line ~1368 in NatsServer.cs).
/// </summary>
[Fact]
public void BuildEventServerInfo_contains_server_identity()
{
var info = _server.BuildEventServerInfo();
info.ShouldNotBeNull();
info.Id.ShouldNotBeNullOrWhiteSpace();
info.Name.ShouldNotBeNullOrWhiteSpace();
}
}