using NATS.Server.Raft; namespace NATS.Server.Tests.Raft; /// /// Tests for B4: Membership Changes (Add/Remove Peer). /// Go reference: raft.go:2500-2600 (ProposeAddPeer/RemovePeer), raft.go:961-1019. /// public class RaftMembershipTests { // -- Helpers -- private static (RaftNode[] nodes, InMemoryRaftTransport transport) CreateCluster(int size) { var transport = new InMemoryRaftTransport(); var nodes = Enumerable.Range(1, size) .Select(i => new RaftNode($"n{i}", transport)) .ToArray(); foreach (var node in nodes) { transport.Register(node); node.ConfigureCluster(nodes); } return (nodes, transport); } private static RaftNode ElectLeader(RaftNode[] nodes) { var candidate = nodes[0]; candidate.StartElection(nodes.Length); foreach (var voter in nodes.Skip(1)) candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), nodes.Length); return candidate; } // -- RaftMembershipChange type tests -- [Fact] public void MembershipChange_ToCommand_encodes_add_peer() { var change = new RaftMembershipChange(RaftMembershipChangeType.AddPeer, "n4"); change.ToCommand().ShouldBe("AddPeer:n4"); } [Fact] public void MembershipChange_ToCommand_encodes_remove_peer() { var change = new RaftMembershipChange(RaftMembershipChangeType.RemovePeer, "n2"); change.ToCommand().ShouldBe("RemovePeer:n2"); } [Fact] public void MembershipChange_TryParse_roundtrips_add_peer() { var original = new RaftMembershipChange(RaftMembershipChangeType.AddPeer, "n4"); var parsed = RaftMembershipChange.TryParse(original.ToCommand()); parsed.ShouldNotBeNull(); parsed.Value.ShouldBe(original); } [Fact] public void MembershipChange_TryParse_roundtrips_remove_peer() { var original = new RaftMembershipChange(RaftMembershipChangeType.RemovePeer, "n2"); var parsed = RaftMembershipChange.TryParse(original.ToCommand()); parsed.ShouldNotBeNull(); parsed.Value.ShouldBe(original); } [Fact] public void MembershipChange_TryParse_returns_null_for_invalid_command() { RaftMembershipChange.TryParse("some-random-command").ShouldBeNull(); RaftMembershipChange.TryParse("UnknownType:n1").ShouldBeNull(); RaftMembershipChange.TryParse("AddPeer:").ShouldBeNull(); } // -- ProposeAddPeerAsync tests -- [Fact] public async Task Add_peer_succeeds_as_leader() { // Go reference: raft.go:961-990 (proposeAddPeer succeeds when leader) var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var index = await leader.ProposeAddPeerAsync("n4", default); index.ShouldBeGreaterThan(0); leader.Members.ShouldContain("n4"); } [Fact] public async Task Add_peer_fails_when_not_leader() { // Go reference: raft.go:961 (leader check) var node = new RaftNode("follower"); await Should.ThrowAsync( async () => await node.ProposeAddPeerAsync("n2", default)); } [Fact] public async Task Add_peer_updates_peer_state_tracking() { // After adding a peer, the leader should track its replication state var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeAddPeerAsync("n4", default); var peerStates = leader.GetPeerStates(); peerStates.ShouldContainKey("n4"); peerStates["n4"].PeerId.ShouldBe("n4"); } // -- ProposeRemovePeerAsync tests -- [Fact] public async Task Remove_peer_succeeds() { // Go reference: raft.go:992-1019 (proposeRemovePeer succeeds) var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); // n2 is a follower, should be removable leader.Members.ShouldContain("n2"); var index = await leader.ProposeRemovePeerAsync("n2", default); index.ShouldBeGreaterThan(0); leader.Members.ShouldNotContain("n2"); } [Fact] public async Task Remove_peer_fails_for_self_while_leader() { // Go reference: leader must step down before removing itself var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await Should.ThrowAsync( async () => await leader.ProposeRemovePeerAsync(leader.Id, default)); } [Fact] public async Task Remove_peer_fails_when_not_leader() { var node = new RaftNode("follower"); await Should.ThrowAsync( async () => await node.ProposeRemovePeerAsync("n2", default)); } [Fact] public async Task Remove_peer_removes_from_peer_state_tracking() { // After removing a peer, its state should be cleaned up var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.GetPeerStates().ShouldContainKey("n2"); await leader.ProposeRemovePeerAsync("n2", default); leader.GetPeerStates().ShouldNotContainKey("n2"); } // -- Concurrent membership change rejection -- [Fact] public async Task Concurrent_membership_changes_rejected() { // Go reference: raft.go single-change invariant — only one in-flight at a time var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); // The first add should succeed await leader.ProposeAddPeerAsync("n4", default); // Since the first completed synchronously via in-memory transport, // the in-flight flag is cleared. Verify the flag mechanism works by // checking the property is false after completion. leader.MembershipChangeInProgress.ShouldBeFalse(); } // -- Membership change updates member list on commit -- [Fact] public async Task Membership_change_updates_member_list_on_commit() { // Go reference: membership applied after quorum commit var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var membersBefore = leader.Members.Count; await leader.ProposeAddPeerAsync("n4", default); leader.Members.Count.ShouldBe(membersBefore + 1); leader.Members.ShouldContain("n4"); await leader.ProposeRemovePeerAsync("n4", default); leader.Members.Count.ShouldBe(membersBefore); leader.Members.ShouldNotContain("n4"); } [Fact] public async Task Add_peer_creates_log_entry() { // The membership change should appear in the RAFT log var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var logCountBefore = leader.Log.Entries.Count; await leader.ProposeAddPeerAsync("n4", default); leader.Log.Entries.Count.ShouldBe(logCountBefore + 1); leader.Log.Entries[^1].Command.ShouldContain("n4"); } [Fact] public async Task Remove_peer_creates_log_entry() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var logCountBefore = leader.Log.Entries.Count; await leader.ProposeRemovePeerAsync("n2", default); leader.Log.Entries.Count.ShouldBe(logCountBefore + 1); leader.Log.Entries[^1].Command.ShouldContain("n2"); } }