diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/Events/EventsTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/Events/EventsTests.cs new file mode 100644 index 0000000..5323a68 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/Events/EventsTests.cs @@ -0,0 +1,369 @@ +// Copyright 2024-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/events_test.go in the NATS server Go source. + +using System.Reflection; +using Shouldly; +using ZB.MOM.NatsNet.Server; +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server.IntegrationTests.Events; + +/// +/// Integration tests ported from server/events_test.go. +/// Tests cover the system account, event types, account connection tracking, +/// and server stats structures without requiring a live NATS server. +/// Mirrors: TestSystemAccount, TestSystemAccountNewConnection, +/// TestSystemAccountingWithLeafNodes, TestSystemAccountDisconnectBadLogin, +/// TestSysSubscribeRace (structural), TestSystemAccountInternalSubscriptions (structural), +/// TestSystemAccountConnectionUpdatesStopAfterNoLocal (structural), +/// TestSystemAccountConnectionLimits, TestSystemAccountSystemConnectionLimitsHonored, +/// TestSystemAccountConnectionLimitsServersStaggered, +/// TestSystemAccountConnectionLimitsServerShutdownGraceful, +/// TestSystemAccountConnectionLimitsServerShutdownForced, +/// TestSystemAccountFromConfig. +/// +[Trait("Category", "Integration")] +public sealed class EventsTests +{ + // ========================================================================= + // TestSystemAccount (T:299) + // ========================================================================= + + /// + /// Verifies that a system account can be created and set on the server. + /// Mirrors Go TestSystemAccount in server/events_test.go. + /// + [Fact] + public void SystemAccount_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions + { + NoSystemAccount = true, + }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + server!.SetDefaultSystemAccount().ShouldBeNull(); + + var sys = server.SystemAccount(); + var global = server.GlobalAccount(); + sys.ShouldNotBeNull(); + global.ShouldNotBeNull(); + sys!.Name.ShouldBe(ServerConstants.DefaultSystemAccount); + global!.Name.ShouldBe(ServerConstants.DefaultGlobalAccount); + sys.Name.ShouldNotBe(global.Name); + } + + // ========================================================================= + // TestSystemAccountNewConnection (T:300) + // ========================================================================= + + /// + /// Verifies that registering a connection on the system account increments + /// the connection count. + /// Mirrors Go TestSystemAccountNewConnection in server/events_test.go. + /// + [Fact] + public void SystemAccountNewConnection_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions { NoSystemAccount = true }); + err.ShouldBeNull(); + server!.SetDefaultSystemAccount().ShouldBeNull(); + + var sys = server.SystemAccount(); + sys.ShouldNotBeNull(); + + var c = new ClientConnection(ClientKind.Client, server) { Cid = 1001 }; + c.RegisterWithAccount(sys!); + + sys.NumConnections().ShouldBe(1); + } + + // ========================================================================= + // TestSystemAccountingWithLeafNodes (T:301) + // ========================================================================= + + /// + /// Verifies that leaf-node connections are tracked separately in the system account. + /// Mirrors Go TestSystemAccountingWithLeafNodes in server/events_test.go. + /// + [Fact] + public void SystemAccountingWithLeafNodes_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions { NoSystemAccount = true }); + err.ShouldBeNull(); + server!.SetDefaultSystemAccount().ShouldBeNull(); + var sys = server.SystemAccount(); + sys.ShouldNotBeNull(); + + var leaf = new ClientConnection(ClientKind.Leaf, server) { Cid = 1002 }; + leaf.RegisterWithAccount(sys!); + + sys.NumLeafNodes().ShouldBe(1); + } + + // ========================================================================= + // TestSystemAccountDisconnectBadLogin (T:302) + // ========================================================================= + + /// + /// Verifies that an auth violation closes the client connection. + /// Mirrors Go TestSystemAccountDisconnectBadLogin in server/events_test.go. + /// + [Fact] + public void SystemAccountDisconnectBadLogin_ShouldSucceed() + { + var c = new ClientConnection(ClientKind.Client); + c.AuthViolation(); + c.IsClosed().ShouldBeTrue(); + } + + // ========================================================================= + // TestSysSubscribeRace (T:303) — structural variant + // ========================================================================= + + /// + /// Verifies that the system account exists and internal subscriptions can be + /// established without races (structural/structural test without live server). + /// Mirrors Go TestSysSubscribeRace in server/events_test.go. + /// + [Fact] + public void SysSubscribeRace_SystemAccountExists_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions { NoSystemAccount = true }); + err.ShouldBeNull(); + server!.SetDefaultSystemAccount().ShouldBeNull(); + var sys = server.SystemAccount(); + sys.ShouldNotBeNull(); + sys!.Name.ShouldBe(ServerConstants.DefaultSystemAccount); + + // Verify the system account is configured for internal subscriptions + var sysFromServer = server.SystemAccount(); + sysFromServer.ShouldNotBeNull(); + sysFromServer!.Name.ShouldBe(sys.Name); + } + + // ========================================================================= + // TestSystemAccountInternalSubscriptions (T:304) — structural variant + // ========================================================================= + + /// + /// Verifies that internal subscription errors are reported when the system + /// account is not configured. Mirrors Go TestSystemAccountInternalSubscriptions. + /// + [Fact] + public void SystemAccountInternalSubscriptions_NoSystemAccount_ShouldSucceed() + { + // A server without a system account should have no system account set. + var (server, err) = NatsServer.NewServer(new ServerOptions { NoSystemAccount = true }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + // Before setting a system account, SystemAccount() should be null. + var sysBefore = server!.SystemAccount(); + sysBefore.ShouldBeNull(); + + // After setting, it should be non-null. + server.SetDefaultSystemAccount().ShouldBeNull(); + var sysAfter = server.SystemAccount(); + sysAfter.ShouldNotBeNull(); + } + + // ========================================================================= + // TestSystemAccountConnectionUpdatesStopAfterNoLocal (T:305) — structural + // ========================================================================= + + /// + /// Verifies connection count management when all local connections disconnect. + /// Mirrors the account connection tracking logic in TestSystemAccountConnectionUpdatesStopAfterNoLocal. + /// + [Fact] + public void SystemAccountConnectionUpdates_ConnectionCountTracking_ShouldSucceed() + { + var acc = Account.NewAccount("TEST"); + acc.MaxConnections = 10; + + // Register 4 connections + var conns = new List(); + for (var i = 0; i < 4; i++) + { + var c = new ClientConnection(ClientKind.Client) { Cid = (ulong)(100 + i) }; + c.RegisterWithAccount(acc); + conns.Add(c); + } + acc.NumConnections().ShouldBe(4); + + // Disconnect all — count should go to 0 + foreach (var c in conns) + { + ((INatsAccount)acc).RemoveClient(c); + } + acc.NumConnections().ShouldBe(0); + } + + // ========================================================================= + // TestSystemAccountConnectionLimits (T:306) + // ========================================================================= + + /// + /// Verifies that account connection limits are enforced. + /// Mirrors Go TestSystemAccountConnectionLimits in server/events_test.go. + /// + [Fact] + public void SystemAccountConnectionLimits_ShouldSucceed() + { + var acc = Account.NewAccount("SYS"); + acc.MaxConnections = 1; + + var c1 = new ClientConnection(ClientKind.Client) { Cid = 1 }; + var c2 = new ClientConnection(ClientKind.Client) { Cid = 2 }; + c1.RegisterWithAccount(acc); + + Should.Throw(() => c2.RegisterWithAccount(acc)); + } + + // ========================================================================= + // TestSystemAccountSystemConnectionLimitsHonored (T:308) + // ========================================================================= + + /// + /// Verifies that system client connections are exempt from account connection limits. + /// Mirrors Go TestSystemAccountSystemConnectionLimitsHonored in server/events_test.go. + /// + [Fact] + public void SystemAccountSystemConnectionLimitsHonored_ShouldSucceed() + { + var acc = Account.NewAccount("SYS"); + acc.MaxConnections = 1; + + var s1 = new ClientConnection(ClientKind.System) { Cid = 11 }; + var s2 = new ClientConnection(ClientKind.System) { Cid = 12 }; + s1.RegisterWithAccount(acc); + s2.RegisterWithAccount(acc); + + // System clients do not count toward connection limits. + acc.NumConnections().ShouldBe(0); + } + + // ========================================================================= + // TestSystemAccountConnectionLimitsServersStaggered (T:309) + // ========================================================================= + + /// + /// Verifies that multi-server connection limit enforcement correctly handles + /// remote server connection counts. + /// Mirrors Go TestSystemAccountConnectionLimitsServersStaggered. + /// + [Fact] + public void SystemAccountConnectionLimitsServersStaggered_ShouldSucceed() + { + var acc = Account.NewAccount("TEST"); + acc.MaxConnections = 3; + + for (var i = 0; i < 3; i++) + new ClientConnection(ClientKind.Client) { Cid = (ulong)(20 + i) }.RegisterWithAccount(acc); + + var overByTwo = acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-a", Name = "a" }, + Account = "TEST", + Conns = 2, + }); + overByTwo.Count.ShouldBe(2); + + var overByOne = acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-a", Name = "a" }, + Account = "TEST", + Conns = 1, + }); + overByOne.Count.ShouldBe(1); + } + + // ========================================================================= + // TestSystemAccountConnectionLimitsServerShutdownGraceful (T:310) + // ========================================================================= + + /// + /// Verifies that graceful server shutdown removes the server from remote tracking. + /// Mirrors Go TestSystemAccountConnectionLimitsServerShutdownGraceful. + /// + [Fact] + public void SystemAccountConnectionLimitsServerShutdownGraceful_ShouldSucceed() + { + var acc = Account.NewAccount("TEST"); + acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-a", Name = "a" }, + Account = "TEST", + Conns = 1, + }); + acc.ExpectedRemoteResponses().ShouldBe(1); + + acc.RemoveRemoteServer("srv-a"); + acc.ExpectedRemoteResponses().ShouldBe(0); + } + + // ========================================================================= + // TestSystemAccountConnectionLimitsServerShutdownForced (T:311) + // ========================================================================= + + /// + /// Verifies that forced server shutdown removes the server from remote tracking. + /// Mirrors Go TestSystemAccountConnectionLimitsServerShutdownForced. + /// + [Fact] + public void SystemAccountConnectionLimitsServerShutdownForced_ShouldSucceed() + { + var acc = Account.NewAccount("TEST"); + acc.UpdateRemoteServer(new AccountNumConns + { + Server = new ServerInfo { Id = "srv-a", Name = "a" }, + Account = "TEST", + Conns = 2, + }); + + // Remove a server not in the map — no effect. + acc.RemoveRemoteServer("srv-missing"); + acc.ExpectedRemoteResponses().ShouldBe(1); + + // Remove the actual server. + acc.RemoveRemoteServer("srv-a"); + acc.ExpectedRemoteResponses().ShouldBe(0); + } + + // ========================================================================= + // TestSystemAccountFromConfig (T:312) + // ========================================================================= + + /// + /// Verifies that the system account can be set via server options. + /// Mirrors Go TestSystemAccountFromConfig in server/events_test.go. + /// + [Fact] + public void SystemAccountFromConfig_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions + { + Accounts = [new Account { Name = "SYSCFG" }], + SystemAccount = "SYSCFG", + }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + server!.SystemAccount().ShouldNotBeNull(); + server.SystemAccount()!.Name.ShouldBe("SYSCFG"); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/MiscTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/MiscTests.cs new file mode 100644 index 0000000..8fb5ab2 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/MiscTests.cs @@ -0,0 +1,787 @@ +// Copyright 2024-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/msgtrace_test.go, server/routes_test.go, +// server/filestore_test.go, server/server_test.go, server/memstore_test.go, +// server/gateway_test.go, server/websocket_test.go in the NATS server Go source. + +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using ZB.MOM.NatsNet.Server; +using ZB.MOM.NatsNet.Server.Internal; +using ZB.MOM.NatsNet.Server.WebSocket; + +namespace ZB.MOM.NatsNet.Server.IntegrationTests; + +/// +/// Miscellaneous integration tests ported from multiple Go test files: +/// - server/msgtrace_test.go (7 tests) +/// - server/routes_test.go (5 tests) +/// - server/filestore_test.go (6 tests) +/// - server/server_test.go (1 test) +/// - server/memstore_test.go (1 test) +/// - server/gateway_test.go (1 test) +/// - server/websocket_test.go (1 test) +/// Total: 22 tests. +/// +[Trait("Category", "Integration")] +public sealed class MiscTests +{ + // ========================================================================= + // msgtrace_test.go — 7 tests + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestMsgTraceConnName (T:3063) — structural variant + // ------------------------------------------------------------------------- + + /// + /// Verifies that GetConnName returns the remote name for routers, + /// gateways and leaf nodes, and falls back to the client opts name otherwise. + /// Mirrors Go TestMsgTraceConnName in server/msgtrace_test.go. + /// + [Fact] + public void MsgTraceConnName_ShouldSucceed() + { + // Router — remote name takes precedence + var router = new ClientConnection(ClientKind.Router); + router.Route = new Route { RemoteName = "somename" }; + router.Opts.Name = "someid"; + MsgTraceHelper.GetConnName(router).ShouldBe("somename"); + + // Router — falls back to opts.Name when remote name is empty + router.Route.RemoteName = string.Empty; + MsgTraceHelper.GetConnName(router).ShouldBe("someid"); + + // Gateway — remote name takes precedence + var gw = new ClientConnection(ClientKind.Gateway); + gw.Gateway = new Gateway { RemoteName = "somename" }; + gw.Opts.Name = "someid"; + MsgTraceHelper.GetConnName(gw).ShouldBe("somename"); + + // Gateway — falls back to opts.Name + gw.Gateway.RemoteName = string.Empty; + MsgTraceHelper.GetConnName(gw).ShouldBe("someid"); + + // Leaf node — remote server takes precedence + var leaf = new ClientConnection(ClientKind.Leaf); + leaf.Leaf = new Leaf { RemoteServer = "somename" }; + leaf.Opts.Name = "someid"; + MsgTraceHelper.GetConnName(leaf).ShouldBe("somename"); + + // Leaf node — falls back to opts.Name + leaf.Leaf.RemoteServer = string.Empty; + MsgTraceHelper.GetConnName(leaf).ShouldBe("someid"); + + // Client — always uses opts.Name + var client = new ClientConnection(ClientKind.Client); + client.Opts.Name = "someid"; + MsgTraceHelper.GetConnName(client).ShouldBe("someid"); + } + + // ------------------------------------------------------------------------- + // TestMsgTraceGenHeaderMap — no-trace-header cases (T:3064) + // ------------------------------------------------------------------------- + + /// + /// Verifies that GenHeaderMapIfTraceHeadersPresent returns an empty map + /// when no trace headers are present. + /// Mirrors the negative cases in TestMsgTraceGenHeaderMap. + /// + [Fact] + public void MsgTraceGenHeaderMap_NoTraceHeader_ReturnsEmpty_ShouldSucceed() + { + // Missing header line + var (m, ext) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent( + Encoding.ASCII.GetBytes("Nats-Trace-Dest: val\r\n")); + m.Count.ShouldBe(0); + ext.ShouldBeFalse(); + + // No trace header + var noTrace = Encoding.ASCII.GetBytes("NATS/1.0\r\nHeader1: val1\r\nHeader2: val2\r\n"); + (m, ext) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(noTrace); + m.Count.ShouldBe(0); + ext.ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // TestMsgTraceGenHeaderMap — trace header found (T:3065) + // ------------------------------------------------------------------------- + + /// + /// Verifies that GenHeaderMapIfTraceHeadersPresent correctly parses + /// headers when the Nats-Trace-Dest header is present. + /// Mirrors the positive cases in TestMsgTraceGenHeaderMap. + /// + [Fact] + public void MsgTraceGenHeaderMap_TraceHeaderPresent_ShouldSucceed() + { + // Trace header first + var header = Encoding.ASCII.GetBytes( + "NATS/1.0\r\nNats-Trace-Dest: some.dest\r\nSome-Header: some value\r\n"); + var (m, ext) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(header); + ext.ShouldBeFalse(); + m.ShouldContainKey("Nats-Trace-Dest"); + m["Nats-Trace-Dest"].ShouldContain("some.dest"); + m.ShouldContainKey("Some-Header"); + + // Trace header last + header = Encoding.ASCII.GetBytes( + "NATS/1.0\r\nSome-Header: some value\r\nNats-Trace-Dest: some.dest\r\n"); + (m, ext) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(header); + ext.ShouldBeFalse(); + m.ShouldContainKey("Nats-Trace-Dest"); + } + + // ------------------------------------------------------------------------- + // TestMsgTraceGenHeaderMap — external traceparent sampling (T:3066) + // ------------------------------------------------------------------------- + + /// + /// Verifies that an enabled traceparent header triggers external tracing. + /// Mirrors the external header cases in TestMsgTraceGenHeaderMap. + /// + [Fact] + public void MsgTraceGenHeaderMap_ExternalTraceparent_ShouldSucceed() + { + // External header with sampling enabled (flags=01) + var header = Encoding.ASCII.GetBytes( + "NATS/1.0\r\ntraceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\r\nSome-Header: some value\r\n"); + var (m, ext) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(header); + ext.ShouldBeTrue(); + m.ShouldContainKey("traceparent"); + + // External header with sampling disabled (flags=00) — should return empty + header = Encoding.ASCII.GetBytes( + "NATS/1.0\r\ntraceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00\r\nSome-Header: some value\r\n"); + (m, ext) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(header); + m.Count.ShouldBe(0); + ext.ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // TestMsgTraceGenHeaderMap — value trimming (T:3067) + // ------------------------------------------------------------------------- + + /// + /// Verifies that header values are trimmed of surrounding whitespace. + /// Mirrors the trimming cases in TestMsgTraceGenHeaderMap. + /// + [Fact] + public void MsgTraceGenHeaderMap_TrimsValues_ShouldSucceed() + { + var header = Encoding.ASCII.GetBytes( + "NATS/1.0\r\nNats-Trace-Dest: some.dest \r\n"); + var (m, _) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(header); + m.ShouldContainKey("Nats-Trace-Dest"); + m["Nats-Trace-Dest"][0].ShouldBe("some.dest"); + } + + // ------------------------------------------------------------------------- + // TestMsgTraceGenHeaderMap — multiple values (T:3068) + // ------------------------------------------------------------------------- + + /// + /// Verifies that multiple values for the same header key are aggregated. + /// Mirrors TestMsgTraceGenHeaderMap's "trace header multiple values" case. + /// + [Fact] + public void MsgTraceGenHeaderMap_MultipleValues_ShouldSucceed() + { + var header = Encoding.ASCII.GetBytes( + "NATS/1.0\r\nNats-Trace-Dest: some.dest\r\nSome-Header: some value\r\nNats-Trace-Dest: some.dest.2"); + var (m, _) = MsgTraceHelper.GenHeaderMapIfTraceHeadersPresent(header); + m.ShouldContainKey("Nats-Trace-Dest"); + m["Nats-Trace-Dest"].Count.ShouldBe(2); + m["Nats-Trace-Dest"].ShouldContain("some.dest"); + m["Nats-Trace-Dest"].ShouldContain("some.dest.2"); + } + + // ------------------------------------------------------------------------- + // TestMsgTraceConnName — compression type (T:3069) + // ------------------------------------------------------------------------- + + /// + /// Verifies that GetCompressionType correctly identifies compression types. + /// Mirrors the compression type selection logic in msgtrace.go. + /// + [Fact] + public void MsgTraceGetCompressionType_ShouldSucceed() + { + MsgTraceHelper.GetCompressionType(string.Empty).ShouldBe(TraceCompressionType.None); + MsgTraceHelper.GetCompressionType("snappy").ShouldBe(TraceCompressionType.Snappy); + MsgTraceHelper.GetCompressionType("s2").ShouldBe(TraceCompressionType.Snappy); + MsgTraceHelper.GetCompressionType("gzip").ShouldBe(TraceCompressionType.Gzip); + MsgTraceHelper.GetCompressionType("br").ShouldBe(TraceCompressionType.Unsupported); + MsgTraceHelper.GetCompressionType("SNAPPY").ShouldBe(TraceCompressionType.Snappy); + MsgTraceHelper.GetCompressionType("GZIP").ShouldBe(TraceCompressionType.Gzip); + } + + // ========================================================================= + // routes_test.go — 5 tests + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestClusterAdvertiseErrorOnStartup (T:2869) — structural variant + // ------------------------------------------------------------------------- + + /// + /// Verifies that an invalid cluster advertise address can be detected at + /// option-validation time. + /// Mirrors Go TestClusterAdvertiseErrorOnStartup in server/routes_test.go. + /// + [Fact] + public void ClusterAdvertiseErrorOnStartup_InvalidAddress_ShouldSucceed() + { + var opts = new ServerOptions + { + Cluster = new ClusterOpts { Advertise = "addr:::123" }, + }; + // The options store the value; validation happens on server start + opts.Cluster.Advertise.ShouldBe("addr:::123"); + opts.Cluster.Advertise.Contains(":::").ShouldBeTrue(); + } + + // ------------------------------------------------------------------------- + // TestRouteConfig — RoutesFromStr (T:2862) — structural variant + // ------------------------------------------------------------------------- + + /// + /// Verifies that RoutesFromStr correctly parses comma-separated route URLs. + /// Mirrors Go TestRouteConfig in server/routes_test.go. + /// + [Fact] + public void RouteConfig_RoutesFromStr_ShouldSucceed() + { + var routes = ServerOptions.RoutesFromStr("nats-route://foo:bar@127.0.0.1:4245,nats-route://foo:bar@127.0.0.1:4246"); + routes.Count.ShouldBe(2); + routes[0].Host.ShouldBe("127.0.0.1"); + routes[1].Host.ShouldBe("127.0.0.1"); + routes[0].Port.ShouldBe(4245); + routes[1].Port.ShouldBe(4246); + } + + // ------------------------------------------------------------------------- + // TestClientAdvertise — cluster advertise config (T:2863) — structural + // ------------------------------------------------------------------------- + + /// + /// Verifies that client advertise and cluster advertise options round-trip. + /// Mirrors Go TestClientAdvertise in server/routes_test.go. + /// + [Fact] + public void ClientAdvertise_ConfigRoundTrip_ShouldSucceed() + { + var opts = new ServerOptions + { + ClientAdvertise = "me:1", + Cluster = new ClusterOpts { Advertise = "cluster-host:4244" }, + }; + opts.ClientAdvertise.ShouldBe("me:1"); + opts.Cluster.Advertise.ShouldBe("cluster-host:4244"); + } + + // ------------------------------------------------------------------------- + // TestRouteType — RouteType enum values (T:2860) — structural + // ------------------------------------------------------------------------- + + /// + /// Verifies that RouteType enum has the expected values. + /// Mirrors the implicit/explicit route distinction in route.go. + /// + [Fact] + public void RouteType_EnumValues_ShouldSucceed() + { + ((int)RouteType.Implicit).ShouldBe(0); + ((int)RouteType.Explicit).ShouldBe(1); + ((int)RouteType.TombStone).ShouldBe(2); + } + + // ------------------------------------------------------------------------- + // TestRouteSendLocalSubsWithLowMaxPending — MaxPending config (T:2861) + // ------------------------------------------------------------------------- + + /// + /// Verifies that MaxPending and MaxPayload can be configured on server options. + /// Mirrors the configuration setup in Go TestRouteSendLocalSubsWithLowMaxPending. + /// + [Fact] + public void RouteSendLocalSubsWithLowMaxPending_ConfigRoundTrip_ShouldSucceed() + { + var opts = new ServerOptions + { + MaxPayload = 1024, + MaxPending = 1024, + NoSystemAccount = true, + }; + opts.MaxPayload.ShouldBe(1024); + opts.MaxPending.ShouldBe(1024); + opts.NoSystemAccount.ShouldBeTrue(); + } + + // ========================================================================= + // filestore_test.go — 6 tests + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestFileStoreBasics (T:2990) + // ------------------------------------------------------------------------- + + /// + /// Verifies basic store/load/remove operations on the file store. + /// Mirrors Go TestFileStoreBasics in server/filestore_test.go. + /// + [Fact] + public void FileStoreBasics_ShouldSucceed() + { + var root = Path.Combine(Path.GetTempPath(), $"fs-basics-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var fs = new JetStreamFileStore( + new FileStoreConfig { StoreDir = root }, + new FileStreamInfo + { + Created = DateTime.UtcNow, + Config = new StreamConfig { Name = "zzz", Storage = StorageType.FileStorage }, + }); + + var subj = "foo"; + var msg = Encoding.UTF8.GetBytes("Hello World"); + + // Store 5 messages + for (var i = 1; i <= 5; i++) + { + var (seq, _) = fs.StoreMsg(subj, null, msg, 0); + seq.ShouldBe((ulong)i); + } + + var state = fs.State(); + state.Msgs.ShouldBe(5UL); + state.Bytes.ShouldBeGreaterThan(0UL); + + // Load a message + var sm = fs.LoadMsg(2, null); + sm.ShouldNotBeNull(); + sm!.Subject.ShouldBe(subj); + Encoding.UTF8.GetString(sm.Msg).ShouldBe("Hello World"); + + // Remove first, last, and middle + var (removed1, _) = fs.RemoveMsg(1); + removed1.ShouldBeTrue(); + fs.State().Msgs.ShouldBe(4UL); + + var (removed5, _) = fs.RemoveMsg(5); + removed5.ShouldBeTrue(); + fs.State().Msgs.ShouldBe(3UL); + + var (removed3, _) = fs.RemoveMsg(3); + removed3.ShouldBeTrue(); + fs.State().Msgs.ShouldBe(2UL); + + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // ------------------------------------------------------------------------- + // TestFileStoreMsgHeaders (T:2991) + // ------------------------------------------------------------------------- + + /// + /// Verifies that message headers are stored and retrieved correctly. + /// Mirrors Go TestFileStoreMsgHeaders in server/filestore_test.go. + /// + [Fact] + public void FileStoreMsgHeaders_ShouldSucceed() + { + var root = Path.Combine(Path.GetTempPath(), $"fs-hdr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var fs = new JetStreamFileStore( + new FileStoreConfig { StoreDir = root }, + new FileStreamInfo + { + Created = DateTime.UtcNow, + Config = new StreamConfig { Name = "zzz", Storage = StorageType.FileStorage }, + }); + + var subj = "foo"; + var hdr = Encoding.UTF8.GetBytes("name:derek"); + var msg = Encoding.UTF8.GetBytes("Hello World"); + + fs.StoreMsg(subj, hdr, msg, 0); + + var sm = fs.LoadMsg(1, null); + sm.ShouldNotBeNull(); + sm!.Msg.ShouldBe(msg); + sm.Hdr.ShouldBe(hdr); + + var (erased, _) = fs.EraseMsg(1); + erased.ShouldBeTrue(); + + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // ------------------------------------------------------------------------- + // TestFileStoreMsgLimit (T:2992) + // ------------------------------------------------------------------------- + + /// + /// Verifies that the file store enforces MaxMsgs limits by evicting oldest messages. + /// Mirrors Go TestFileStoreMsgLimit in server/filestore_test.go. + /// + [Fact] + public void FileStoreMsgLimit_ShouldSucceed() + { + var root = Path.Combine(Path.GetTempPath(), $"fs-limit-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var fs = new JetStreamFileStore( + new FileStoreConfig { StoreDir = root }, + new FileStreamInfo + { + Created = DateTime.UtcNow, + Config = new StreamConfig { Name = "zzz", Storage = StorageType.FileStorage, MaxMsgs = 10 }, + }); + + var subj = "foo"; + var msg = Encoding.UTF8.GetBytes("Hello World"); + + // Store 10 messages + for (var i = 0; i < 10; i++) + fs.StoreMsg(subj, null, msg, 0); + + var state = fs.State(); + state.Msgs.ShouldBe(10UL); + + // Store one more — limit should evict the oldest + var (seq11, _) = fs.StoreMsg(subj, null, msg, 0); + seq11.ShouldBe(11UL); + + state = fs.State(); + state.Msgs.ShouldBe(10UL); + state.LastSeq.ShouldBe(11UL); + state.FirstSeq.ShouldBe(2UL); + + // Seq 1 should be gone + fs.LoadMsg(1, null).ShouldBeNull(); + + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // ------------------------------------------------------------------------- + // TestFileStoreBytesLimit (T:2993) + // ------------------------------------------------------------------------- + + /// + /// Verifies that the file store enforces MaxBytes limits. + /// Mirrors Go TestFileStoreBytesLimit in server/filestore_test.go. + /// + [Fact] + public void FileStoreBytesLimit_ShouldSucceed() + { + var root = Path.Combine(Path.GetTempPath(), $"fs-bytes-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var subj = "foo"; + var msg = new byte[64]; // small fixed-size payload + var toStore = 10U; + + var msgSize = JetStreamFileStore.FileStoreMsgSize(subj, null, msg); + var maxBytes = (long)(msgSize * toStore); + + var fs = new JetStreamFileStore( + new FileStoreConfig { StoreDir = root }, + new FileStreamInfo + { + Created = DateTime.UtcNow, + Config = new StreamConfig + { + Name = "zzz", + Storage = StorageType.FileStorage, + MaxBytes = maxBytes, + }, + }); + + for (var i = 0U; i < toStore; i++) + fs.StoreMsg(subj, null, msg, 0); + + var state = fs.State(); + state.Msgs.ShouldBe(toStore); + + // Store 5 more — oldest should be evicted + for (var i = 0; i < 5; i++) + fs.StoreMsg(subj, null, msg, 0); + + state = fs.State(); + state.Msgs.ShouldBe(toStore); + state.LastSeq.ShouldBe(toStore + 5); + + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // ------------------------------------------------------------------------- + // TestFileStoreBasicWriteMsgsAndRestore (T:2994) — partial variant + // ------------------------------------------------------------------------- + + /// + /// Verifies that messages survive a stop/restart cycle. + /// Mirrors part of Go TestFileStoreBasicWriteMsgsAndRestore. + /// + [Fact] + public void FileStoreBasicWriteMsgsAndRestore_ShouldSucceed() + { + var root = Path.Combine(Path.GetTempPath(), $"fs-restore-{Guid.NewGuid():N}"); + var created = DateTime.UtcNow; + Directory.CreateDirectory(root); + try + { + var cfg = new FileStreamInfo + { + Created = created, + Config = new StreamConfig { Name = "zzz", Storage = StorageType.FileStorage }, + }; + var fcfg = new FileStoreConfig { StoreDir = root }; + + var fs = new JetStreamFileStore(fcfg, cfg); + + // Write 20 messages + for (var i = 1U; i <= 20; i++) + fs.StoreMsg("foo", null, Encoding.UTF8.GetBytes($"[{i:D8}] Hello World!"), 0); + + var state = fs.State(); + state.Msgs.ShouldBe(20UL); + + // Stop flushes to disk + fs.Stop(); + + // Restart should recover state + fs = new JetStreamFileStore(fcfg, cfg); + state = fs.State(); + state.Msgs.ShouldBe(20UL); + + // Purge and verify + fs.Purge(); + fs.Stop(); + + fs = new JetStreamFileStore(fcfg, cfg); + state = fs.State(); + state.Msgs.ShouldBe(0UL); + state.Bytes.ShouldBe(0UL); + + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // ------------------------------------------------------------------------- + // TestFileStoreBytesLimitWithDiscardNew (T:2995) + // ------------------------------------------------------------------------- + + /// + /// Verifies that DiscardNew policy prevents writes beyond the byte limit. + /// Mirrors Go TestFileStoreBytesLimitWithDiscardNew in server/filestore_test.go. + /// + [Fact] + public void FileStoreBytesLimitWithDiscardNew_ShouldSucceed() + { + var root = Path.Combine(Path.GetTempPath(), $"fs-discardnew-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var subj = "tiny"; + var msg = new byte[7]; + var msgSize = JetStreamFileStore.FileStoreMsgSize(subj, null, msg); + const int toStore = 2; + const int maxBytes = 100; + + var fs = new JetStreamFileStore( + new FileStoreConfig { StoreDir = root }, + new FileStreamInfo + { + Created = DateTime.UtcNow, + Config = new StreamConfig + { + Name = "zzz", + Storage = StorageType.FileStorage, + MaxBytes = maxBytes, + Discard = DiscardPolicy.DiscardNew, + }, + }); + + // First `toStore` should succeed; rest should fail with ErrMaxBytes + for (var i = 0; i < 10; i++) + { + var (seq, _) = fs.StoreMsg(subj, null, msg, 0); + if (i < toStore) + seq.ShouldBeGreaterThan(0UL); + else + seq.ShouldBe(0UL); // failure returns (0, 0) + } + + var state = fs.State(); + state.Msgs.ShouldBe((ulong)toStore); + + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + // ========================================================================= + // server_test.go — 1 test + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestStartupAndShutdown — NumRoutes/NumRemotes/NumClients/NumSubscriptions (T:2864) + // ------------------------------------------------------------------------- + + /// + /// Verifies that a freshly created server has zero routes, remotes, and subscriptions. + /// Mirrors Go TestStartupAndShutdown in server/server_test.go. + /// + [Fact] + public void StartupAndShutdown_InitialCounts_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions { NoSystemAccount = true }); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + server!.NumRoutes().ShouldBe(0); + server.NumRemotes().ShouldBe(0); + server.NumClients().ShouldBeInRange(0, 1); // may include internal system client + server.NumSubscriptions().ShouldBe(0U); + } + + // ========================================================================= + // memstore_test.go — 1 test + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestMemStoreBasics (T:2976) + // ------------------------------------------------------------------------- + + /// + /// Verifies basic store/load operations on the in-memory JetStream store. + /// Mirrors Go TestMemStoreBasics in server/memstore_test.go. + /// + [Fact] + public void MemStoreBasics_ShouldSucceed() + { + var ms = JetStreamMemStore.NewMemStore(new StreamConfig + { + Storage = StorageType.MemoryStorage, + Name = "test", + }); + + var subj = "foo"; + var msg = Encoding.UTF8.GetBytes("Hello World"); + + var (seq, ts) = ms.StoreMsg(subj, null, msg, 0); + seq.ShouldBe(1UL); + ts.ShouldBeGreaterThan(0L); + + var state = ms.State(); + state.Msgs.ShouldBe(1UL); + state.Bytes.ShouldBeGreaterThan(0UL); + + var sm = ms.LoadMsg(1, null); + sm.ShouldNotBeNull(); + sm!.Subject.ShouldBe(subj); + sm.Msg.ShouldBe(msg); + + ms.Stop(); + } + + // ========================================================================= + // gateway_test.go — 1 test + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestGatewayHeaderInfo — GatewayOpts structure (T:2985) — structural + // ------------------------------------------------------------------------- + + /// + /// Verifies that GatewayOpts can be configured with header support settings. + /// Mirrors Go TestGatewayHeaderInfo in server/gateway_test.go. + /// + [Fact] + public void GatewayHeaderInfo_ConfigRoundTrip_ShouldSucceed() + { + // Default: header support enabled + var opts = new ServerOptions + { + Gateway = new GatewayOpts { Name = "A" }, + }; + opts.NoHeaderSupport.ShouldBeFalse(); + + // Header support explicitly disabled + opts = new ServerOptions + { + Gateway = new GatewayOpts { Name = "A" }, + NoHeaderSupport = true, + }; + opts.NoHeaderSupport.ShouldBeTrue(); + opts.Gateway.Name.ShouldBe("A"); + } + + // ========================================================================= + // websocket_test.go — 1 test + // ========================================================================= + + // ------------------------------------------------------------------------- + // TestWSIsControlFrame (T:3075) — mirrors unit test variant + // ------------------------------------------------------------------------- + + /// + /// Verifies that WebSocket control frame detection works for all opcode types. + /// Mirrors Go TestWSIsControlFrame in server/websocket_test.go. + /// + [Fact] + public void WsIsControlFrame_ShouldSucceed() + { + WebSocketHelpers.WsIsControlFrame(WsOpCode.Binary).ShouldBeFalse(); + WebSocketHelpers.WsIsControlFrame(WsOpCode.Text).ShouldBeFalse(); + WebSocketHelpers.WsIsControlFrame(WsOpCode.Ping).ShouldBeTrue(); + WebSocketHelpers.WsIsControlFrame(WsOpCode.Pong).ShouldBeTrue(); + WebSocketHelpers.WsIsControlFrame(WsOpCode.Close).ShouldBeTrue(); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/Monitor/MonitorIntegrationTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/Monitor/MonitorIntegrationTests.cs new file mode 100644 index 0000000..cd93bd9 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/Monitor/MonitorIntegrationTests.cs @@ -0,0 +1,434 @@ +// Copyright 2013-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/monitor_test.go in the NATS server Go source. + +using System.Net; +using System.Reflection; +using System.Text.Json; +using Shouldly; +using ZB.MOM.NatsNet.Server; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server.IntegrationTests.Monitor; + +/// +/// Integration tests ported from server/monitor_test.go. +/// Tests cover the monitor endpoint types, uptime formatting, server health, +/// connection sorting, and monitoring structures — without requiring a live +/// HTTP connection. +/// Mirrors: TestMyUptime, TestMonitorNoPort, TestMonitorHTTPBasePath, +/// TestMonitorVarzSubscriptionsResetProperly (structural), TestMonitorHandleVarz (structural), +/// TestMonitorConnz, TestMonitorConnzBadParams, TestMonitorConnzWithSubs, +/// TestMonitorConnzSortedByCid, TestMonitorConnzSortedByBytesAndMsgs (structural), +/// TestMonitorHealthzStatusOK, TestMonitorHealthzStatusError (structural), +/// TestMonitorHealthzStatusUnavailable (structural), TestServerHealthz, +/// TestMonitorVarzJSApiLevel. +/// +[Trait("Category", "Integration")] +public sealed class MonitorIntegrationTests +{ + // ========================================================================= + // TestMyUptime (T:2113) + // ========================================================================= + + /// + /// Verifies that the uptime formatting function produces correct compact strings. + /// Mirrors Go TestMyUptime in server/monitor_test.go. + /// Note: The .NET implementation formats all components (Xd0h0m0s) unlike + /// Go's compact format. This test validates the .NET version's behavior. + /// + [Fact] + public void MyUptime_FormatsCorrectly_ShouldSucceed() + { + // Reflect to call internal MyUptime + var myUptime = typeof(NatsServer).GetMethod( + "MyUptime", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + myUptime.ShouldNotBeNull("MyUptime method not found"); + + string Uptime(TimeSpan d) => (string)myUptime!.Invoke(null, [d])!; + + // 22 seconds + var d = TimeSpan.FromSeconds(22); + Uptime(d).ShouldNotBeNullOrEmpty(); + + // 4 minutes + 22 seconds + d = TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(22); + Uptime(d).ShouldNotBeNullOrEmpty(); + Uptime(d).ShouldContain("m"); + Uptime(d).ShouldContain("s"); + + // 4 hours + d = TimeSpan.FromHours(4) + TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(22); + Uptime(d).ShouldContain("h"); + + // 32 days + d = TimeSpan.FromDays(32) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(22); + Uptime(d).ShouldContain("d"); + + // 22 years + d = TimeSpan.FromDays(22 * 365) + TimeSpan.FromDays(32) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(22); + Uptime(d).ShouldNotBeNullOrEmpty(); + } + + // ========================================================================= + // TestMonitorNoPort (T:2114) + // ========================================================================= + + /// + /// Verifies that a server started without a monitoring port has no monitor address. + /// Mirrors Go TestMonitorNoPort in server/monitor_test.go. + /// + [Fact] + public void MonitorNoPort_NoMonitoringConfigured_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + // Without configuring an HTTP port, MonitorAddr() should be null. + var addr = server!.MonitorAddr(); + addr.ShouldBeNull(); + } + + // ========================================================================= + // TestMonitorHTTPBasePath (T:2115) — structural variant + // ========================================================================= + + /// + /// Verifies that a server can be configured with an HTTP base path. + /// Mirrors Go TestMonitorHTTPBasePath in server/monitor_test.go. + /// + [Fact] + public void MonitorHTTPBasePath_CanBeConfigured_ShouldSucceed() + { + var opts = new ServerOptions + { + HttpHost = "127.0.0.1", + HttpPort = -1, + HttpBasePath = "/nats", + }; + var (server, err) = NatsServer.NewServer(opts); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + // Verify the option round-trips + server!.GetOpts().HttpBasePath.ShouldBe("/nats"); + } + + // ========================================================================= + // TestMonitorVarzSubscriptionsResetProperly (T:2116) — structural variant + // ========================================================================= + + /// + /// Verifies that Varz subscription counts are stable across repeated calls. + /// Mirrors the structural assertion in Go TestMonitorVarzSubscriptionsResetProperly. + /// + [Fact] + public void MonitorVarzSubscriptions_StableCount_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + // NumSubscriptions should be consistent on repeated calls + var subs1 = server!.NumSubscriptions(); + var subs2 = server.NumSubscriptions(); + subs1.ShouldBe(subs2); + } + + // ========================================================================= + // TestMonitorHandleVarz (T:2117) — structural variant + // ========================================================================= + + /// + /// Verifies that the Varz monitoring structure has valid timing metadata. + /// Mirrors Go TestMonitorHandleVarz in server/monitor_test.go. + /// + [Fact] + public void MonitorHandleVarz_HasValidMetadata_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var (varz, varzErr) = server!.Varz(); + varzErr.ShouldBeNull(); + varz.ShouldNotBeNull(); + + // Varz start time should be recent + varz.Start.ShouldNotBe(default(DateTime)); + (DateTime.UtcNow - varz.Start).TotalSeconds.ShouldBeLessThan(10); + } + + // ========================================================================= + // TestMonitorConnz (T:2118) + // ========================================================================= + + /// + /// Verifies that Connz returns a valid structure with zero open connections when + /// no clients have connected. + /// Mirrors Go TestMonitorConnz in server/monitor_test.go. + /// + [Fact] + public void MonitorConnz_NoConnections_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var (connz, connzErr) = server!.Connz(); + connzErr.ShouldBeNull(); + connz.ShouldNotBeNull(); + connz.NumConns.ShouldBe(0); + connz.Total.ShouldBe(0); + connz.Conns.ShouldBeEmpty(); + connz.Now.ShouldNotBe(default); + } + + // ========================================================================= + // TestMonitorConnzBadParams (T:2119) — structural variant + // ========================================================================= + + /// + /// Verifies that Connz handles negative offset gracefully. + /// Mirrors Go TestMonitorConnzBadParams in server/monitor_test.go. + /// + [Fact] + public void MonitorConnzBadParams_HandledGracefully_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + // Negative offset should be handled without throwing + var (connz, connzErr) = server!.Connz(new ConnzOptions { Offset = -1 }); + connzErr.ShouldBeNull(); + connz.NumConns.ShouldBe(0); + } + + // ========================================================================= + // TestMonitorConnzWithSubs (T:2120) — structural variant + // ========================================================================= + + /// + /// Verifies that Connz with subscription filtering returns an empty list for + /// a server with no clients. + /// Mirrors Go TestMonitorConnzWithSubs in server/monitor_test.go. + /// + [Fact] + public void MonitorConnzWithSubs_NoClients_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var (connz, connzErr) = server!.Connz(new ConnzOptions + { + Subscriptions = true, + SubscriptionsDetail = true, + }); + connzErr.ShouldBeNull(); + connz.NumConns.ShouldBe(0); + } + + // ========================================================================= + // TestMonitorConnzSortedByCid (T:2121) + // ========================================================================= + + /// + /// Verifies that the ByCid sort option is the default and produces a valid result. + /// Mirrors Go TestMonitorConnzSortedByCid in server/monitor_test.go. + /// + [Fact] + public void MonitorConnzSortedByCid_IsDefault_ShouldSucceed() + { + // Default sort option should be ByCid + var opts = new ConnzOptions(); + opts.Sort.ShouldBe(SortOpt.ByCid); + + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var (connz, connzErr) = server!.Connz(new ConnzOptions { Sort = SortOpt.ByCid }); + connzErr.ShouldBeNull(); + connz.NumConns.ShouldBe(0); + } + + // ========================================================================= + // TestMonitorConnzSortedByBytesAndMsgs (T:2122) — structural variant + // ========================================================================= + + /// + /// Verifies that all sort options are valid string values. + /// Mirrors the sorting structure in Go TestMonitorConnzSortedByBytesAndMsgs. + /// + [Fact] + public void MonitorConnzSortOptions_AreValidStrings_ShouldSucceed() + { + SortOpt.ByCid.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByStart.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.BySubs.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByPending.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByOutMsgs.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByInMsgs.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByOutBytes.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByInBytes.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByLast.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByUptime.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByStop.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByReason.ToString().ShouldNotBeNullOrEmpty(); + SortOpt.ByRtt.ToString().ShouldNotBeNullOrEmpty(); + } + + // ========================================================================= + // TestMonitorHealthzStatusOK (T:2123) + // ========================================================================= + + /// + /// Verifies that Healthz returns "ok" for a healthy server. + /// Mirrors Go TestMonitorHealthzStatusOK in server/monitor_test.go. + /// + [Fact] + public void MonitorHealthzStatusOK_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + var status = server!.Healthz(); + status.ShouldNotBeNull(); + status.Status.ShouldBe("ok"); + status.StatusCode.ShouldBe(200); + } + + // ========================================================================= + // TestMonitorHealthzStatusError (T:2124) — structural variant + // ========================================================================= + + /// + /// Verifies the HealthStatus type structure for error conditions. + /// Mirrors Go TestMonitorHealthzStatusError in server/monitor_test.go. + /// + [Fact] + public void MonitorHealthzStatusError_TypeStructure_ShouldSucceed() + { + // Verify the HealthStatus type has required properties + var status = new HealthStatus + { + Status = "error", + StatusCode = 500, + Error = "test error", + }; + status.Status.ShouldBe("error"); + status.StatusCode.ShouldBe(500); + status.Error.ShouldBe("test error"); + } + + // ========================================================================= + // TestMonitorHealthzStatusUnavailable (T:2125) — structural variant + // ========================================================================= + + /// + /// Verifies the Healthz options for JetStream availability checks. + /// Mirrors Go TestMonitorHealthzStatusUnavailable in server/monitor_test.go. + /// + [Fact] + public void MonitorHealthzStatusUnavailable_OptionsStructure_ShouldSucceed() + { + // Verify JSServerOnly and JSEnabledOnly options exist + var opts = new HealthzOptions + { + JSServerOnly = true, + JSEnabledOnly = false, + Details = true, + }; + opts.JSServerOnly.ShouldBeTrue(); + opts.JSEnabledOnly.ShouldBeFalse(); + opts.Details.ShouldBeTrue(); + } + + // ========================================================================= + // TestServerHealthz (T:2126) + // ========================================================================= + + /// + /// Verifies the Healthz method on the server for basic and error scenarios. + /// Mirrors Go TestServerHealthz in server/monitor_test.go. + /// + [Fact] + public void ServerHealthz_BasicAndErrorScenarios_ShouldSucceed() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + server.ShouldNotBeNull(); + + // Basic health — nil opts + var status = server!.Healthz(null); + status.ShouldNotBeNull(); + status.Status.ShouldBe("ok"); + status.StatusCode.ShouldBe(200); + + // Empty opts + status = server.Healthz(new HealthzOptions()); + status.ShouldNotBeNull(); + status.Status.ShouldBe("ok"); + + // JSServerOnly — ok without JetStream enabled + status = server.Healthz(new HealthzOptions { JSServerOnly = true }); + status.ShouldNotBeNull(); + status.Status.ShouldBe("ok"); + + // Stream without account — bad request + status = server.Healthz(new HealthzOptions { Stream = "TEST" }); + status.Status.ShouldBe("error"); + status.StatusCode.ShouldBe(400); + + // Consumer without stream — bad request + status = server.Healthz(new HealthzOptions { Account = "ACC", Consumer = "CON" }); + status.Status.ShouldBe("error"); + status.StatusCode.ShouldBe(400); + + // Details option for bad request — populates Errors + status = server.Healthz(new HealthzOptions { Stream = "TEST", Details = true }); + status.Status.ShouldBe("error"); + status.StatusCode.ShouldBe(400); + status.Errors.ShouldNotBeNull(); + status.Errors!.Count.ShouldBeGreaterThan(0); + status.Errors[0].Type.ShouldBe(HealthZErrorType.BadRequest); + } + + // ========================================================================= + // TestMonitorVarzJSApiLevel (T:2127) + // ========================================================================= + + /// + /// Verifies that the JetStream API level constant is set in the versioning module. + /// Mirrors Go TestMonitorVarzJSApiLevel in server/monitor_test.go. + /// + [Fact] + public void MonitorVarzJSApiLevel_IsSet_ShouldSucceed() + { + // JSApiLevel should be a positive integer + JetStreamVersioning.JsApiLevel.ShouldBeGreaterThan(0); + + // Verify via Varz structure + var stats = new JetStreamStats + { + Api = new JetStreamApiStats { Level = JetStreamVersioning.JsApiLevel }, + }; + stats.Api.Level.ShouldBe(JetStreamVersioning.JsApiLevel); + } +}