diff --git a/src/NATS.Server/Events/EventTypes.cs b/src/NATS.Server/Events/EventTypes.cs
index e4341ca..d5a1f7b 100644
--- a/src/NATS.Server/Events/EventTypes.cs
+++ b/src/NATS.Server/Events/EventTypes.cs
@@ -540,6 +540,143 @@ public sealed class OcspPeerRejectEventMsg
public string Reason { get; set; } = string.Empty;
}
+///
+/// Remote server shutdown advisory.
+/// Go reference: events.go — remote server lifecycle.
+///
+public sealed class RemoteServerShutdownEvent
+{
+ public const string EventType = "io.nats.server.advisory.v1.remote_shutdown";
+
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = EventType;
+
+ [JsonPropertyName("id")]
+ public string Id { get; init; } = Guid.NewGuid().ToString("N");
+
+ [JsonPropertyName("time")]
+ public string Time { get; init; } = DateTime.UtcNow.ToString("O");
+
+ [JsonPropertyName("server")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public EventServerInfo? Server { get; set; }
+
+ [JsonPropertyName("remote_server_id")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? RemoteServerId { get; set; }
+
+ [JsonPropertyName("remote_server_name")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? RemoteServerName { get; set; }
+
+ [JsonPropertyName("reason")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Reason { get; set; }
+}
+
+///
+/// Remote server update advisory.
+/// Go reference: events.go — remote server lifecycle.
+///
+public sealed class RemoteServerUpdateEvent
+{
+ public const string EventType = "io.nats.server.advisory.v1.remote_update";
+
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = EventType;
+
+ [JsonPropertyName("id")]
+ public string Id { get; init; } = Guid.NewGuid().ToString("N");
+
+ [JsonPropertyName("time")]
+ public string Time { get; init; } = DateTime.UtcNow.ToString("O");
+
+ [JsonPropertyName("server")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public EventServerInfo? Server { get; set; }
+
+ [JsonPropertyName("remote_server_id")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? RemoteServerId { get; set; }
+
+ [JsonPropertyName("remote_server_name")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? RemoteServerName { get; set; }
+
+ /// Update category, e.g. "routes_changed", "config_updated".
+ [JsonPropertyName("update_type")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? UpdateType { get; set; }
+}
+
+///
+/// Leaf node connect advisory.
+/// Go reference: events.go — leaf node lifecycle.
+///
+public sealed class LeafNodeConnectEvent
+{
+ public const string EventType = "io.nats.server.advisory.v1.leafnode_connect";
+
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = EventType;
+
+ [JsonPropertyName("id")]
+ public string Id { get; init; } = Guid.NewGuid().ToString("N");
+
+ [JsonPropertyName("time")]
+ public string Time { get; init; } = DateTime.UtcNow.ToString("O");
+
+ [JsonPropertyName("server")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public EventServerInfo? Server { get; set; }
+
+ [JsonPropertyName("leaf_node_id")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? LeafNodeId { get; set; }
+
+ [JsonPropertyName("leaf_node_name")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? LeafNodeName { get; set; }
+
+ [JsonPropertyName("remote_url")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? RemoteUrl { get; set; }
+
+ [JsonPropertyName("account")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Account { get; set; }
+}
+
+///
+/// Leaf node disconnect advisory.
+/// Go reference: events.go — leaf node lifecycle.
+///
+public sealed class LeafNodeDisconnectEvent
+{
+ public const string EventType = "io.nats.server.advisory.v1.leafnode_disconnect";
+
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = EventType;
+
+ [JsonPropertyName("id")]
+ public string Id { get; init; } = Guid.NewGuid().ToString("N");
+
+ [JsonPropertyName("time")]
+ public string Time { get; init; } = DateTime.UtcNow.ToString("O");
+
+ [JsonPropertyName("server")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public EventServerInfo? Server { get; set; }
+
+ [JsonPropertyName("leaf_node_id")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? LeafNodeId { get; set; }
+
+ [JsonPropertyName("reason")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Reason { get; set; }
+}
+
///
/// Account numeric connections request.
/// Go reference: events.go:233-236 accNumConnsReq.
@@ -552,3 +689,148 @@ public sealed class AccNumConnsReq
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
}
+
+///
+/// Factory helpers that construct fully-populated system event messages,
+/// mirroring Go's inline struct initialization patterns in events.go.
+/// Go reference: events.go sendConnectEventMsg, sendDisconnectEventMsg,
+/// sendAccountNumConns, sendServerStats.
+///
+public static class EventBuilder
+{
+ ///
+ /// Build a ConnectEventMsg with all required fields.
+ /// Go reference: events.go sendConnectEventMsg (around line 636).
+ ///
+ public static ConnectEventMsg BuildConnectEvent(
+ string serverId, string serverName, string? cluster,
+ ulong clientId, string host, string? account, string? user, string? name,
+ string? lang, string? version) =>
+ new()
+ {
+ Id = GenerateEventId(),
+ Time = DateTime.UtcNow,
+ Server = new EventServerInfo
+ {
+ Id = serverId,
+ Name = serverName,
+ Cluster = cluster,
+ },
+ Client = new EventClientInfo
+ {
+ Id = clientId,
+ Host = host,
+ Account = account,
+ User = user,
+ Name = name,
+ Lang = lang,
+ Version = version,
+ Start = DateTime.UtcNow,
+ },
+ };
+
+ ///
+ /// Build a DisconnectEventMsg with all required fields.
+ /// Go reference: events.go sendDisconnectEventMsg (around line 668).
+ ///
+ public static DisconnectEventMsg BuildDisconnectEvent(
+ string serverId, string serverName, string? cluster,
+ ulong clientId, string host, string? account, string? user,
+ string reason, DataStats sent, DataStats received) =>
+ new()
+ {
+ Id = GenerateEventId(),
+ Time = DateTime.UtcNow,
+ Server = new EventServerInfo
+ {
+ Id = serverId,
+ Name = serverName,
+ Cluster = cluster,
+ },
+ Client = new EventClientInfo
+ {
+ Id = clientId,
+ Host = host,
+ Account = account,
+ User = user,
+ Stop = DateTime.UtcNow,
+ },
+ Reason = reason,
+ Sent = sent,
+ Received = received,
+ };
+
+ ///
+ /// Build an AccountNumConns event.
+ /// Go reference: events.go sendAccountNumConns (around line 714).
+ ///
+ public static AccountNumConns BuildAccountConnsEvent(
+ string serverId, string serverName,
+ string accountName, int connections, int leafNodes,
+ int totalConnections, int numSubscriptions,
+ DataStats sent, DataStats received, long slowConsumers) =>
+ new()
+ {
+ Id = GenerateEventId(),
+ Time = DateTime.UtcNow,
+ Server = new EventServerInfo
+ {
+ Id = serverId,
+ Name = serverName,
+ },
+ AccountName = accountName,
+ Connections = connections,
+ LeafNodes = leafNodes,
+ TotalConnections = totalConnections,
+ NumSubscriptions = (uint)numSubscriptions,
+ Sent = sent,
+ Received = received,
+ SlowConsumers = slowConsumers,
+ };
+
+ ///
+ /// Build a ServerStatsMsg.
+ /// Go reference: events.go sendStatsz (around line 742).
+ ///
+ public static ServerStatsMsg BuildServerStats(
+ string serverId, string serverName,
+ long mem, int cores, double cpu,
+ int connections, int totalConnections, int activeAccounts,
+ int subscriptions, DataStats sent, DataStats received) =>
+ new()
+ {
+ Server = new EventServerInfo
+ {
+ Id = serverId,
+ Name = serverName,
+ },
+ Stats = new ServerStatsData
+ {
+ Start = DateTime.UtcNow,
+ Mem = mem,
+ Cores = cores,
+ Cpu = cpu,
+ Connections = connections,
+ TotalConnections = totalConnections,
+ ActiveAccounts = activeAccounts,
+ Subscriptions = subscriptions,
+ Sent = sent,
+ Received = received,
+ InMsgs = received.Msgs,
+ OutMsgs = sent.Msgs,
+ InBytes = received.Bytes,
+ OutBytes = sent.Bytes,
+ },
+ };
+
+ ///
+ /// Generate a unique event ID. Go uses nuid for this.
+ /// Go reference: events.go — nuid.Next() for event IDs.
+ ///
+ public static string GenerateEventId() => Guid.NewGuid().ToString("N");
+
+ ///
+ /// Get an ISO 8601 timestamp string for embedding in events.
+ ///
+ public static string GetTimestamp() => DateTime.UtcNow.ToString("O");
+}
diff --git a/src/NATS.Server/Monitoring/Connz.cs b/src/NATS.Server/Monitoring/Connz.cs
index e11a905..912d041 100644
--- a/src/NATS.Server/Monitoring/Connz.cs
+++ b/src/NATS.Server/Monitoring/Connz.cs
@@ -464,6 +464,126 @@ public static class ConnzSorter
}
}
+// ---------------------------------------------------------------------------
+// Task 89 — closed connection reason tracking (Gap 10.7)
+// ---------------------------------------------------------------------------
+
+///
+/// Public enum representing the reason a client connection was closed.
+/// Used in for /connz monitoring output.
+/// Go reference: server/monitor.go ClosedState.String() — the set of reasons
+/// surfaced to monitoring consumers.
+///
+public enum ClosedReason
+{
+ Unknown,
+ ClientClosed,
+ ServerShutdown,
+ AuthTimeout,
+ AuthViolation,
+ MaxConnectionsExceeded,
+ SlowConsumer,
+ WriteError,
+ ReadError,
+ ParseError,
+ StaleConnection,
+ MaxPayloadExceeded,
+ MaxSubscriptionsExceeded,
+ DuplicateRoute,
+ AccountExpired,
+ Revoked,
+ ServerError,
+ KickedByOperator,
+}
+
+///
+/// Helpers for converting to and from the Go-compatible
+/// reason strings emitted by the reference server's /connz endpoint.
+/// Go reference: server/monitor.go ClosedState.String().
+///
+public static class ClosedReasonHelper
+{
+ ///
+ /// Returns the Go-compatible human-readable string for the given reason.
+ /// These strings match the Go server's monitor.go ClosedState.String() output
+ /// so that tooling consuming the /connz endpoint sees identical values.
+ ///
+ public static string ToReasonString(ClosedReason reason) => reason switch
+ {
+ ClosedReason.ClientClosed => "Client Closed",
+ ClosedReason.ServerShutdown => "Server Shutdown",
+ ClosedReason.AuthTimeout => "Authentication Timeout",
+ ClosedReason.AuthViolation => "Authentication Failure",
+ ClosedReason.MaxConnectionsExceeded => "Maximum Connections Exceeded",
+ ClosedReason.SlowConsumer => "Slow Consumer (Pending Bytes)",
+ ClosedReason.WriteError => "Write Error",
+ ClosedReason.ReadError => "Read Error",
+ ClosedReason.ParseError => "Parse Error",
+ ClosedReason.StaleConnection => "Stale Connection",
+ ClosedReason.MaxPayloadExceeded => "Maximum Message Payload Exceeded",
+ ClosedReason.MaxSubscriptionsExceeded => "Maximum Subscriptions Exceeded",
+ ClosedReason.DuplicateRoute => "Duplicate Route",
+ ClosedReason.AccountExpired => "Authentication Expired",
+ ClosedReason.Revoked => "Credentials Revoked",
+ ClosedReason.ServerError => "Internal Server Error",
+ ClosedReason.KickedByOperator => "Kicked",
+ _ => "Unknown",
+ };
+
+ ///
+ /// Parses a Go-compatible reason string back to a .
+ /// Returns for null, empty, or unrecognised values.
+ ///
+ public static ClosedReason FromReasonString(string? reason) => reason switch
+ {
+ "Client Closed" => ClosedReason.ClientClosed,
+ "Server Shutdown" => ClosedReason.ServerShutdown,
+ "Authentication Timeout" => ClosedReason.AuthTimeout,
+ "Authentication Failure" => ClosedReason.AuthViolation,
+ "Maximum Connections Exceeded" => ClosedReason.MaxConnectionsExceeded,
+ "Slow Consumer (Pending Bytes)" => ClosedReason.SlowConsumer,
+ "Write Error" => ClosedReason.WriteError,
+ "Read Error" => ClosedReason.ReadError,
+ "Parse Error" => ClosedReason.ParseError,
+ "Stale Connection" => ClosedReason.StaleConnection,
+ "Maximum Message Payload Exceeded" => ClosedReason.MaxPayloadExceeded,
+ "Maximum Subscriptions Exceeded" => ClosedReason.MaxSubscriptionsExceeded,
+ "Duplicate Route" => ClosedReason.DuplicateRoute,
+ "Authentication Expired" => ClosedReason.AccountExpired,
+ "Credentials Revoked" => ClosedReason.Revoked,
+ "Internal Server Error" => ClosedReason.ServerError,
+ "Kicked" => ClosedReason.KickedByOperator,
+ _ => ClosedReason.Unknown,
+ };
+
+ ///
+ /// Returns true when the close was initiated by the client itself (not the server).
+ /// Go reference: server/client.go closeConnection — client-side disconnect path.
+ ///
+ public static bool IsClientInitiated(ClosedReason reason) =>
+ reason == ClosedReason.ClientClosed;
+
+ ///
+ /// Returns true when the close was caused by an authentication or authorisation failure.
+ /// Go reference: server/auth.go getAuthErrClosedState.
+ ///
+ public static bool IsAuthRelated(ClosedReason reason) =>
+ reason is ClosedReason.AuthTimeout
+ or ClosedReason.AuthViolation
+ or ClosedReason.AccountExpired
+ or ClosedReason.Revoked;
+
+ ///
+ /// Returns true when the close was caused by a resource limit being exceeded.
+ /// Go reference: server/client.go maxPayloadExceeded / maxSubscriptionsExceeded paths.
+ ///
+ public static bool IsResourceLimit(ClosedReason reason) =>
+ reason is ClosedReason.MaxConnectionsExceeded
+ or ClosedReason.MaxPayloadExceeded
+ or ClosedReason.MaxSubscriptionsExceeded
+ or ClosedReason.SlowConsumer;
+}
+
// ---------------------------------------------------------------------------
// Internal types — used by ConnzHandler and existing sort logic
// ---------------------------------------------------------------------------
diff --git a/tests/NATS.Server.Tests/Events/FullEventPayloadTests.cs b/tests/NATS.Server.Tests/Events/FullEventPayloadTests.cs
new file mode 100644
index 0000000..d7d7b01
--- /dev/null
+++ b/tests/NATS.Server.Tests/Events/FullEventPayloadTests.cs
@@ -0,0 +1,308 @@
+using System.Text.Json;
+using NATS.Server.Events;
+
+namespace NATS.Server.Tests.Events;
+
+///
+/// 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.
+///
+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(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");
+ }
+}
diff --git a/tests/NATS.Server.Tests/Monitoring/ClosedReasonTests.cs b/tests/NATS.Server.Tests/Monitoring/ClosedReasonTests.cs
new file mode 100644
index 0000000..c6eb8a1
--- /dev/null
+++ b/tests/NATS.Server.Tests/Monitoring/ClosedReasonTests.cs
@@ -0,0 +1,152 @@
+// Go reference: server/monitor.go ClosedState.String() — reason strings emitted by
+// the /connz endpoint, and server/auth.go getAuthErrClosedState — auth-related reasons.
+// These tests verify the ClosedReason enum and ClosedReasonHelper helpers introduced
+// in Task 89 (Gap 10.7: consistently populate closed connection reasons).
+
+using NATS.Server.Monitoring;
+
+namespace NATS.Server.Tests.Monitoring;
+
+public class ClosedReasonTests
+{
+ // -----------------------------------------------------------------------
+ // 1. ToReasonString_ClientClosed_ReturnsExpected
+ // -----------------------------------------------------------------------
+
+ ///
+ /// ClientClosed maps to the Go-compatible "Client Closed" string.
+ /// Go reference: server/monitor.go ClosedState.String() case ClientClosed.
+ ///
+ [Fact]
+ public void ToReasonString_ClientClosed_ReturnsExpected()
+ {
+ ClosedReasonHelper.ToReasonString(ClosedReason.ClientClosed).ShouldBe("Client Closed");
+ }
+
+ // -----------------------------------------------------------------------
+ // 2. ToReasonString_AllReasonsHaveStrings
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Every ClosedReason enum value must produce a non-null, non-empty string.
+ /// Go reference: server/monitor.go ClosedState.String() — all cases covered.
+ ///
+ [Fact]
+ public void ToReasonString_AllReasonsHaveStrings()
+ {
+ foreach (var reason in Enum.GetValues())
+ {
+ var s = ClosedReasonHelper.ToReasonString(reason);
+ s.ShouldNotBeNull();
+ s.ShouldNotBeEmpty();
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // 3. FromReasonString_ValidString_ReturnsEnum
+ // -----------------------------------------------------------------------
+
+ ///
+ /// A valid Go-compatible reason string parses back to the correct enum value.
+ /// Go reference: server/monitor.go ClosedState.String() "Server Shutdown".
+ ///
+ [Fact]
+ public void FromReasonString_ValidString_ReturnsEnum()
+ {
+ ClosedReasonHelper.FromReasonString("Server Shutdown").ShouldBe(ClosedReason.ServerShutdown);
+ }
+
+ // -----------------------------------------------------------------------
+ // 4. FromReasonString_Unknown_ReturnsUnknown
+ // -----------------------------------------------------------------------
+
+ ///
+ /// An unrecognised string returns ClosedReason.Unknown.
+ ///
+ [Fact]
+ public void FromReasonString_Unknown_ReturnsUnknown()
+ {
+ ClosedReasonHelper.FromReasonString("Not a real reason").ShouldBe(ClosedReason.Unknown);
+ }
+
+ // -----------------------------------------------------------------------
+ // 5. FromReasonString_Null_ReturnsUnknown
+ // -----------------------------------------------------------------------
+
+ ///
+ /// A null reason string returns ClosedReason.Unknown.
+ ///
+ [Fact]
+ public void FromReasonString_Null_ReturnsUnknown()
+ {
+ ClosedReasonHelper.FromReasonString(null).ShouldBe(ClosedReason.Unknown);
+ }
+
+ // -----------------------------------------------------------------------
+ // 6. IsClientInitiated_ClientClosed_True
+ // -----------------------------------------------------------------------
+
+ ///
+ /// ClientClosed is the only client-initiated close reason.
+ /// Go reference: server/client.go closeConnection — client disconnect path.
+ ///
+ [Fact]
+ public void IsClientInitiated_ClientClosed_True()
+ {
+ ClosedReasonHelper.IsClientInitiated(ClosedReason.ClientClosed).ShouldBeTrue();
+ }
+
+ // -----------------------------------------------------------------------
+ // 7. IsClientInitiated_ServerShutdown_False
+ // -----------------------------------------------------------------------
+
+ ///
+ /// ServerShutdown is not a client-initiated close.
+ ///
+ [Fact]
+ public void IsClientInitiated_ServerShutdown_False()
+ {
+ ClosedReasonHelper.IsClientInitiated(ClosedReason.ServerShutdown).ShouldBeFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // 8. IsAuthRelated_AuthTimeout_True
+ // -----------------------------------------------------------------------
+
+ ///
+ /// AuthTimeout is auth-related.
+ /// Go reference: server/auth.go getAuthErrClosedState — auth timeout path.
+ ///
+ [Fact]
+ public void IsAuthRelated_AuthTimeout_True()
+ {
+ ClosedReasonHelper.IsAuthRelated(ClosedReason.AuthTimeout).ShouldBeTrue();
+ }
+
+ // -----------------------------------------------------------------------
+ // 9. IsAuthRelated_WriteError_False
+ // -----------------------------------------------------------------------
+
+ ///
+ /// WriteError is not auth-related.
+ ///
+ [Fact]
+ public void IsAuthRelated_WriteError_False()
+ {
+ ClosedReasonHelper.IsAuthRelated(ClosedReason.WriteError).ShouldBeFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // 10. IsResourceLimit_MaxConnections_True
+ // -----------------------------------------------------------------------
+
+ ///
+ /// MaxConnectionsExceeded is a resource-limit close reason.
+ /// Go reference: server/client.go maxConnectionsExceeded — max connections path.
+ ///
+ [Fact]
+ public void IsResourceLimit_MaxConnections_True()
+ {
+ ClosedReasonHelper.IsResourceLimit(ClosedReason.MaxConnectionsExceeded).ShouldBeTrue();
+ }
+}