// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go // Covers: cluster metadata operations, asset placement planner, // replica group management, stream scaling, config validation, // cluster expand, account info in cluster, max streams. using System.Text; using NATS.Server.Configuration; using NATS.Server.JetStream; using NATS.Server.JetStream.Api; using NATS.Server.JetStream.Cluster; using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Publish; using NATS.Server.JetStream.Validation; namespace NATS.Server.JetStream.Tests.JetStream.Cluster; /// /// Tests covering JetStream cluster metadata operations: asset placement, /// replica group management, config validation, scaling, and account operations. /// Ported from Go jetstream_cluster_1_test.go. /// public class JetStreamClusterMetaTests { // --------------------------------------------------------------- // Go: TestJetStreamClusterConfig server/jetstream_cluster_1_test.go:43 // --------------------------------------------------------------- [Fact] public void Config_requires_server_name_for_jetstream_cluster() { var options = new NatsOptions { ServerName = null, JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, Cluster = new ClusterOptions { Port = 6222 }, }; var result = JetStreamConfigValidator.ValidateClusterConfig(options); result.IsValid.ShouldBeFalse(); result.Message.ShouldContain("server_name"); } [Fact] public void Config_requires_cluster_name_for_jetstream_cluster() { var options = new NatsOptions { ServerName = "S1", JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, Cluster = new ClusterOptions { Name = null, Port = 6222 }, }; var result = JetStreamConfigValidator.ValidateClusterConfig(options); result.IsValid.ShouldBeFalse(); result.Message.ShouldContain("cluster.name"); } [Fact] public void Config_valid_when_server_and_cluster_names_set() { var options = new NatsOptions { ServerName = "S1", JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, Cluster = new ClusterOptions { Name = "JSC", Port = 6222 }, }; var result = JetStreamConfigValidator.ValidateClusterConfig(options); result.IsValid.ShouldBeTrue(); } [Fact] public void Config_skips_cluster_checks_when_no_cluster_configured() { var options = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, }; var result = JetStreamConfigValidator.ValidateClusterConfig(options); result.IsValid.ShouldBeTrue(); } [Fact] public void Config_skips_cluster_checks_when_no_jetstream_configured() { var options = new NatsOptions { Cluster = new ClusterOptions { Port = 6222 }, }; var result = JetStreamConfigValidator.ValidateClusterConfig(options); result.IsValid.ShouldBeTrue(); } // --------------------------------------------------------------- // Placement planner tests // --------------------------------------------------------------- [Fact] public void Placement_planner_returns_requested_replica_count() { var planner = new AssetPlacementPlanner(nodes: 5); var placement = planner.PlanReplicas(replicas: 3); placement.Count.ShouldBe(3); } [Fact] public void Placement_planner_caps_at_cluster_size() { var planner = new AssetPlacementPlanner(nodes: 3); var placement = planner.PlanReplicas(replicas: 5); placement.Count.ShouldBe(3); } [Fact] public void Placement_planner_minimum_is_one_replica() { var planner = new AssetPlacementPlanner(nodes: 3); var placement = planner.PlanReplicas(replicas: 0); placement.Count.ShouldBe(1); } [Fact] public void Placement_planner_handles_single_node_cluster() { var planner = new AssetPlacementPlanner(nodes: 1); var placement = planner.PlanReplicas(replicas: 3); placement.Count.ShouldBe(1); } // --------------------------------------------------------------- // Meta group lifecycle tests // --------------------------------------------------------------- [Fact] public void Meta_group_initial_state_is_correct() { var meta = new JetStreamMetaGroup(3); var state = meta.GetState(); state.ClusterSize.ShouldBe(3); state.LeaderId.ShouldNotBeNullOrWhiteSpace(); state.LeadershipVersion.ShouldBe(1); state.Streams.Count.ShouldBe(0); } [Fact] public async Task Meta_group_tracks_stream_proposals() { var meta = new JetStreamMetaGroup(3); await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S1" }, default); await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S2" }, default); var state = meta.GetState(); state.Streams.Count.ShouldBe(2); state.Streams.ShouldContain("S1"); state.Streams.ShouldContain("S2"); } [Fact] public void Meta_group_stepdown_cycles_leader() { var meta = new JetStreamMetaGroup(3); var leader1 = meta.GetState().LeaderId; meta.StepDown(); var leader2 = meta.GetState().LeaderId; leader2.ShouldNotBe(leader1); meta.StepDown(); var leader3 = meta.GetState().LeaderId; leader3.ShouldNotBe(leader2); } [Fact] public void Meta_group_stepdown_wraps_around() { var meta = new JetStreamMetaGroup(2); var leaders = new HashSet(); for (var i = 0; i < 5; i++) { leaders.Add(meta.GetState().LeaderId); meta.StepDown(); } // Should cycle between 2 leaders leaders.Count.ShouldBe(2); } [Fact] public void Meta_group_leadership_version_increments() { var meta = new JetStreamMetaGroup(3); meta.GetState().LeadershipVersion.ShouldBe(1); meta.StepDown(); meta.GetState().LeadershipVersion.ShouldBe(2); meta.StepDown(); meta.GetState().LeadershipVersion.ShouldBe(3); } // --------------------------------------------------------------- // Replica group tests // --------------------------------------------------------------- [Fact] public void Replica_group_creates_correct_node_count() { var group = new StreamReplicaGroup("TEST", replicas: 3); group.Nodes.Count.ShouldBe(3); group.StreamName.ShouldBe("TEST"); } [Fact] public void Replica_group_elects_initial_leader() { var group = new StreamReplicaGroup("TEST", replicas: 3); group.Leader.ShouldNotBeNull(); group.Leader.IsLeader.ShouldBeTrue(); } [Fact] public async Task Replica_group_stepdown_changes_leader() { var group = new StreamReplicaGroup("TEST", replicas: 3); var leaderBefore = group.Leader.Id; await group.StepDownAsync(default); var leaderAfter = group.Leader.Id; leaderAfter.ShouldNotBe(leaderBefore); group.Leader.IsLeader.ShouldBeTrue(); } [Fact] public async Task Replica_group_leader_accepts_proposals() { var group = new StreamReplicaGroup("TEST", replicas: 3); var index = await group.ProposeAsync("PUB test.1", default); index.ShouldBeGreaterThan(0); } [Fact] public async Task Replica_group_apply_placement_scales_up() { var group = new StreamReplicaGroup("TEST", replicas: 1); group.Nodes.Count.ShouldBe(1); await group.ApplyPlacementAsync([1, 2, 3], default); group.Nodes.Count.ShouldBe(3); } [Fact] public async Task Replica_group_apply_placement_scales_down() { var group = new StreamReplicaGroup("TEST", replicas: 5); group.Nodes.Count.ShouldBe(5); await group.ApplyPlacementAsync([1, 2], default); group.Nodes.Count.ShouldBe(2); } [Fact] public async Task Replica_group_apply_same_size_is_noop() { var group = new StreamReplicaGroup("TEST", replicas: 3); var leaderBefore = group.Leader.Id; await group.ApplyPlacementAsync([1, 2, 3], default); group.Nodes.Count.ShouldBe(3); } // --------------------------------------------------------------- // Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94 // --------------------------------------------------------------- [Fact] public async Task Account_info_tracks_streams_and_consumers_in_cluster() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("ACCT1", ["a1.>"], replicas: 3); await fx.CreateStreamAsync("ACCT2", ["a2.>"], replicas: 3); await fx.CreateConsumerAsync("ACCT1", "c1"); await fx.CreateConsumerAsync("ACCT1", "c2"); var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}"); resp.AccountInfo.ShouldNotBeNull(); resp.AccountInfo!.Streams.ShouldBe(2); resp.AccountInfo.Consumers.ShouldBe(2); } // --------------------------------------------------------------- // Go: TestJetStreamClusterExtendedAccountInfo server/jetstream_cluster_1_test.go:3389 // --------------------------------------------------------------- [Fact] public async Task Account_info_after_stream_delete_reflects_removal() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("DEL1", ["d1.>"], replicas: 3); await fx.CreateStreamAsync("DEL2", ["d2.>"], replicas: 3); (await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}DEL1", "{}")).Success.ShouldBeTrue(); var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}"); resp.AccountInfo!.Streams.ShouldBe(1); } // --------------------------------------------------------------- // Go: TestJetStreamClusterAccountPurge server/jetstream_cluster_1_test.go:3891 // --------------------------------------------------------------- [Fact] public async Task Account_purge_returns_success() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("PURGE1", ["pur.>"], replicas: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountPurge}GLOBAL", "{}"); resp.Success.ShouldBeTrue(); } // --------------------------------------------------------------- // Go: TestJetStreamClusterStreamLimitWithAccountDefaults server/jetstream_cluster_1_test.go:124 // --------------------------------------------------------------- [Fact] public async Task Stream_with_max_bytes_and_replicas_created_successfully() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var cfg = new StreamConfig { Name = "MBLIMIT", Subjects = ["mbl.>"], Replicas = 2, MaxBytes = 4 * 1024 * 1024, }; var resp = fx.CreateStreamDirect(cfg); resp.Error.ShouldBeNull(); resp.StreamInfo!.Config.MaxBytes.ShouldBe(4 * 1024 * 1024); } // --------------------------------------------------------------- // Go: TestJetStreamClusterMaxStreamsReached server/jetstream_cluster_1_test.go:3177 // --------------------------------------------------------------- [Fact] public async Task Multiple_streams_tracked_correctly_in_meta() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); for (var i = 0; i < 10; i++) await fx.CreateStreamAsync($"MS{i}", [$"ms{i}.>"], replicas: 3); var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}"); names.StreamNames!.Count.ShouldBe(10); var meta = fx.GetMetaState(); meta.Streams.Count.ShouldBe(10); } // --------------------------------------------------------------- // Direct API tests (DirectGet) // --------------------------------------------------------------- [Fact] public async Task Direct_get_returns_message_by_sequence() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var cfg = new StreamConfig { Name = "DIRECT", Subjects = ["dir.>"], Replicas = 3, AllowDirect = true, }; fx.CreateStreamDirect(cfg); for (var i = 0; i < 5; i++) await fx.PublishAsync("dir.event", $"msg-{i}"); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.DirectGet}DIRECT", """{"seq":3}"""); resp.DirectMessage.ShouldNotBeNull(); resp.DirectMessage!.Sequence.ShouldBe(3UL); resp.DirectMessage.Subject.ShouldBe("dir.event"); } // --------------------------------------------------------------- // Stream message get // --------------------------------------------------------------- [Fact] public async Task Stream_message_get_returns_correct_payload() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("MSGGET", ["mg.>"], replicas: 3); await fx.PublishAsync("mg.event", "payload-1"); await fx.PublishAsync("mg.event", "payload-2"); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageGet}MSGGET", """{"seq":2}"""); resp.StreamMessage.ShouldNotBeNull(); resp.StreamMessage!.Sequence.ShouldBe(2UL); resp.StreamMessage.Payload.ShouldBe("payload-2"); } // --------------------------------------------------------------- // Consumer list and names // --------------------------------------------------------------- [Fact] public async Task Consumer_list_via_api_router() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("CLISTM", ["clm.>"], replicas: 3); await fx.CreateConsumerAsync("CLISTM", "d1"); await fx.CreateConsumerAsync("CLISTM", "d2"); var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLISTM", "{}"); names.ConsumerNames.ShouldNotBeNull(); names.ConsumerNames!.Count.ShouldBe(2); var list = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerList}CLISTM", "{}"); list.ConsumerNames.ShouldNotBeNull(); list.ConsumerNames!.Count.ShouldBe(2); } // --------------------------------------------------------------- // Account stream move returns success shape // --------------------------------------------------------------- [Fact] public async Task Account_stream_move_api_returns_success() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountStreamMove}TEST", "{}"); resp.Success.ShouldBeTrue(); } // --------------------------------------------------------------- // Account stream move cancel returns success shape // --------------------------------------------------------------- [Fact] public async Task Account_stream_move_cancel_api_returns_success() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountStreamMoveCancel}TEST", "{}"); resp.Success.ShouldBeTrue(); } // --------------------------------------------------------------- // Stream create requires name // --------------------------------------------------------------- [Fact] public void Stream_create_without_name_returns_error() { var streamManager = new StreamManager(); var resp = streamManager.CreateOrUpdate(new StreamConfig { Name = "" }); resp.Error.ShouldNotBeNull(); resp.Error!.Description.ShouldContain("name"); } // --------------------------------------------------------------- // NotFound for unknown API subject // --------------------------------------------------------------- [Fact] public async Task Unknown_api_subject_returns_not_found() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var resp = await fx.RequestAsync("$JS.API.UNKNOWN.SUBJECT", "{}"); resp.Error.ShouldNotBeNull(); resp.Error!.Code.ShouldBe(404); } // --------------------------------------------------------------- // Stream info for non-existent stream returns 404 // --------------------------------------------------------------- [Fact] public async Task Stream_info_nonexistent_returns_not_found() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamInfo}NOSTREAM", "{}"); resp.Error.ShouldNotBeNull(); resp.Error!.Code.ShouldBe(404); } // --------------------------------------------------------------- // Consumer info for non-existent consumer returns 404 // --------------------------------------------------------------- [Fact] public async Task Consumer_info_nonexistent_returns_not_found() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("NOCONS", ["nc.>"], replicas: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerInfo}NOCONS.MISSING", "{}"); resp.Error.ShouldNotBeNull(); resp.Error!.Code.ShouldBe(404); } // --------------------------------------------------------------- // Delete non-existent stream returns 404 // --------------------------------------------------------------- [Fact] public async Task Delete_nonexistent_stream_returns_not_found() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}GONE", "{}"); resp.Error.ShouldNotBeNull(); resp.Error!.Code.ShouldBe(404); } // --------------------------------------------------------------- // Delete non-existent consumer returns 404 // --------------------------------------------------------------- [Fact] public async Task Delete_nonexistent_consumer_returns_not_found() { await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); await fx.CreateStreamAsync("NODEL", ["nd.>"], replicas: 3); var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}NODEL.MISSING", "{}"); resp.Error.ShouldNotBeNull(); resp.Error!.Code.ShouldBe(404); } } /// /// Self-contained fixture for JetStream cluster meta tests. /// internal sealed class ClusterMetaFixture : IAsyncDisposable { private readonly JetStreamMetaGroup _metaGroup; private readonly StreamManager _streamManager; private readonly ConsumerManager _consumerManager; private readonly JetStreamApiRouter _router; private readonly JetStreamPublisher _publisher; private ClusterMetaFixture( JetStreamMetaGroup metaGroup, StreamManager streamManager, ConsumerManager consumerManager, JetStreamApiRouter router, JetStreamPublisher publisher) { _metaGroup = metaGroup; _streamManager = streamManager; _consumerManager = consumerManager; _router = router; _publisher = publisher; } public static Task StartAsync(int nodes) { var meta = new JetStreamMetaGroup(nodes); var consumerManager = new ConsumerManager(meta); var streamManager = new StreamManager(meta, consumerManager: consumerManager); var router = new JetStreamApiRouter(streamManager, consumerManager, meta); var publisher = new JetStreamPublisher(streamManager); return Task.FromResult(new ClusterMetaFixture(meta, streamManager, consumerManager, router, publisher)); } public Task CreateStreamAsync(string name, string[] subjects, int replicas) { var response = _streamManager.CreateOrUpdate(new StreamConfig { Name = name, Subjects = [.. subjects], Replicas = replicas, }); if (response.Error is not null) throw new InvalidOperationException(response.Error.Description); return Task.CompletedTask; } public JetStreamApiResponse CreateStreamDirect(StreamConfig config) => _streamManager.CreateOrUpdate(config); public Task CreateConsumerAsync(string stream, string durableName) { return Task.FromResult(_consumerManager.CreateOrUpdate(stream, new ConsumerConfig { DurableName = durableName, })); } public Task PublishAsync(string subject, string payload) { if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack)) return Task.FromResult(ack); throw new InvalidOperationException($"Publish to '{subject}' did not match a stream."); } public MetaGroupState GetMetaState() => _metaGroup.GetState(); public Task RequestAsync(string subject, string payload) => Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); public ValueTask DisposeAsync() => ValueTask.CompletedTask; }