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(); + } +}