From a6e7778c6c5cd6d8d860ef73581471038a7d80f1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 13:11:58 -0500 Subject: [PATCH] feat: complete system event payload fields (Gap 10.6) Add EventBuilder static class to EventTypes.cs with helpers for constructing fully-populated ConnectEventMsg, DisconnectEventMsg, AccountNumConns, and ServerStatsMsg. Also add RemoteServerShutdownEvent, RemoteServerUpdateEvent, LeafNodeConnectEvent, and LeafNodeDisconnectEvent advisory types. Add FullEventPayloadTests.cs (10 tests) covering all builders, GenerateEventId uniqueness, GetTimestamp ISO 8601 format, DataStats zero defaults, and ConnectEventMsg JSON roundtrip. --- src/NATS.Server/Events/EventTypes.cs | 282 ++++++++++++++++ src/NATS.Server/Monitoring/Connz.cs | 120 +++++++ .../Events/FullEventPayloadTests.cs | 308 ++++++++++++++++++ .../Monitoring/ClosedReasonTests.cs | 152 +++++++++ 4 files changed, 862 insertions(+) create mode 100644 tests/NATS.Server.Tests/Events/FullEventPayloadTests.cs create mode 100644 tests/NATS.Server.Tests/Monitoring/ClosedReasonTests.cs 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(); + } +}