// Go parity: golang/nats-server/server/raft_test.go // Covers the behavioral intent of the Go NRG (NATS RAFT Group) tests, // ported to the .NET RaftNode / RaftLog / RaftSnapshot infrastructure. // Each test cites the corresponding Go function and approximate line. using NATS.Server.Raft; namespace NATS.Server.Tests.Raft; /// /// Go-parity tests for the NATS RAFT implementation. Tests cover election, /// log replication, snapshot/catchup, membership changes, quorum accounting, /// observer mode semantics, and peer tracking. Each test cites the Go test /// function it maps to in server/raft_test.go. /// public class RaftGoParityTests { // --------------------------------------------------------------- // Helpers — self-contained; no shared TestHelpers class // --------------------------------------------------------------- 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; } private static (RaftNode leader, RaftNode[] followers) CreateLeaderWithFollowers(int followerCount) { var total = followerCount + 1; var nodes = Enumerable.Range(1, total) .Select(i => new RaftNode($"n{i}")) .ToArray(); foreach (var n in nodes) n.ConfigureCluster(nodes); var candidate = nodes[0]; candidate.StartElection(total); foreach (var voter in nodes.Skip(1)) candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), total); return (candidate, nodes.Skip(1).ToArray()); } // --------------------------------------------------------------- // Go: TestNRGSimple server/raft_test.go:35 // --------------------------------------------------------------- // Go reference: TestNRGSimple — basic single-node leader election [Fact] public void Single_node_becomes_leader_on_election() { var node = new RaftNode("solo"); node.StartElection(clusterSize: 1); node.IsLeader.ShouldBeTrue(); node.Term.ShouldBe(1); node.Role.ShouldBe(RaftRole.Leader); } // Go reference: TestNRGSimple — three-node cluster elects one leader [Fact] public void Three_node_cluster_elects_single_leader() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.IsLeader.ShouldBeTrue(); nodes.Count(n => n.IsLeader).ShouldBe(1); nodes.Count(n => n.Role == RaftRole.Follower).ShouldBe(2); } // Go reference: TestNRGSimple — term increments on election start [Fact] public void Term_increments_on_each_election() { var node = new RaftNode("n1"); node.Term.ShouldBe(0); node.StartElection(1); node.Term.ShouldBe(1); node.RequestStepDown(); node.StartElection(1); node.Term.ShouldBe(2); } // --------------------------------------------------------------- // Go: TestNRGSimpleElection server/raft_test.go:296 // --------------------------------------------------------------- // Go reference: TestNRGSimpleElection — five-node election [Fact] public void Five_node_cluster_elects_leader_with_three_vote_quorum() { var (nodes, _) = CreateCluster(5); var leader = ElectLeader(nodes); leader.IsLeader.ShouldBeTrue(); } // Go reference: TestNRGSimpleElection — candidate self-votes [Fact] public void Candidate_records_self_vote_on_start() { var node = new RaftNode("n1"); node.StartElection(clusterSize: 3); node.Role.ShouldBe(RaftRole.Candidate); node.TermState.VotedFor.ShouldBe("n1"); } // Go reference: TestNRGSimpleElection — two votes out of three wins [Fact] public void Majority_vote_wins_three_node_election() { var (nodes, _) = CreateCluster(3); var candidate = nodes[0]; candidate.StartElection(nodes.Length); candidate.IsLeader.ShouldBeFalse(); // only self-vote so far var vote = nodes[1].GrantVote(candidate.Term, candidate.Id); vote.Granted.ShouldBeTrue(); candidate.ReceiveVote(vote, nodes.Length); candidate.IsLeader.ShouldBeTrue(); } // --------------------------------------------------------------- // Go: TestNRGSingleNodeElection server/raft_test.go:? // --------------------------------------------------------------- // Go reference: TestNRGSingleNodeElection — single node after peers removed elects itself [Fact] public void Single_remaining_node_can_elect_itself() { var node = new RaftNode("solo2"); node.StartElection(1); node.IsLeader.ShouldBeTrue(); } // --------------------------------------------------------------- // Go: TestNRGInlineStepdown server/raft_test.go:194 // --------------------------------------------------------------- // Go reference: TestNRGInlineStepdown — leader transitions to follower on stepdown [Fact] public void Leader_becomes_follower_on_stepdown() { var node = new RaftNode("n1"); node.StartElection(1); node.IsLeader.ShouldBeTrue(); node.RequestStepDown(); node.IsLeader.ShouldBeFalse(); node.Role.ShouldBe(RaftRole.Follower); } // Go reference: TestNRGInlineStepdown — stepdown clears voted-for [Fact] public void Stepdown_clears_voted_for_state() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.IsLeader.ShouldBeTrue(); leader.RequestStepDown(); leader.TermState.VotedFor.ShouldBeNull(); } // --------------------------------------------------------------- // Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154 // --------------------------------------------------------------- // Go reference: TestNRGRecoverFromFollowingNoLeader — higher-term heartbeat causes stepdown [Fact] public void Higher_term_heartbeat_causes_candidate_to_become_follower() { var node = new RaftNode("n1"); node.StartElection(3); node.Role.ShouldBe(RaftRole.Candidate); node.ReceiveHeartbeat(term: 5); node.Role.ShouldBe(RaftRole.Follower); node.Term.ShouldBe(5); } // Go reference: TestNRGRecoverFromFollowingNoLeader — leader steps down on higher-term HB [Fact] public void Leader_steps_down_on_higher_term_heartbeat() { var node = new RaftNode("n1"); node.StartElection(1); node.IsLeader.ShouldBeTrue(); node.ReceiveHeartbeat(term: 10); node.Role.ShouldBe(RaftRole.Follower); node.Term.ShouldBe(10); } // Go reference: TestNRGRecoverFromFollowingNoLeader — lower-term heartbeat is ignored [Fact] public void Stale_heartbeat_is_ignored() { var node = new RaftNode("n1"); node.StartElection(1); node.IsLeader.ShouldBeTrue(); node.Term.ShouldBe(1); node.ReceiveHeartbeat(term: 0); node.IsLeader.ShouldBeTrue(); node.Term.ShouldBe(1); } // --------------------------------------------------------------- // Go: TestNRGStepDownOnSameTermDoesntClearVote server/raft_test.go:447 // --------------------------------------------------------------- // Go reference: TestNRGStepDownOnSameTermDoesntClearVote — same term vote denied to second candidate [Fact] public void Vote_denied_to_second_candidate_in_same_term() { var voter = new RaftNode("voter"); voter.GrantVote(term: 1, candidateId: "candidate-a").Granted.ShouldBeTrue(); voter.GrantVote(term: 1, candidateId: "candidate-b").Granted.ShouldBeFalse(); voter.TermState.VotedFor.ShouldBe("candidate-a"); } // --------------------------------------------------------------- // Go: TestNRGAssumeHighTermAfterCandidateIsolation server/raft_test.go:662 // --------------------------------------------------------------- // Go reference: TestNRGAssumeHighTermAfterCandidateIsolation — isolated candidate bumps term high [Fact] public void Vote_request_with_high_term_updates_receiver_term() { var voter = new RaftNode("voter"); voter.TermState.CurrentTerm = 5; var resp = voter.GrantVote(term: 100, candidateId: "isolated"); voter.TermState.CurrentTerm.ShouldBe(100); } // --------------------------------------------------------------- // Go: TestNRGCandidateDoesntRevertTermAfterOldAE server/raft_test.go:792 // --------------------------------------------------------------- // Go reference: TestNRGCandidateDoesntRevertTermAfterOldAE — stale heartbeat does not revert term [Fact] public void Stale_heartbeat_does_not_revert_candidate_term() { var node = new RaftNode("n1"); node.StartElection(3); // term = 1 node.StartElection(3); // term = 2 node.Term.ShouldBe(2); node.ReceiveHeartbeat(term: 1); // stale node.Term.ShouldBe(2); } // --------------------------------------------------------------- // Go: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm server/raft_test.go:972 // --------------------------------------------------------------- // Go reference: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm [Fact] public void Candidate_ignores_heartbeat_from_previous_term_leader() { var node = new RaftNode("n1"); node.TermState.CurrentTerm = 10; node.StartElection(3); // term = 11 node.Role.ShouldBe(RaftRole.Candidate); node.ReceiveHeartbeat(term: 5); node.Role.ShouldBe(RaftRole.Candidate); node.Term.ShouldBe(11); } // --------------------------------------------------------------- // Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708 // --------------------------------------------------------------- // Go reference: TestNRGHeartbeatOnLeaderChange — heartbeat updates follower term [Fact] public void Heartbeat_updates_follower_to_new_term() { var follower = new RaftNode("f1"); follower.TermState.CurrentTerm = 2; follower.ReceiveHeartbeat(term: 7); follower.Term.ShouldBe(7); follower.Role.ShouldBe(RaftRole.Follower); } // --------------------------------------------------------------- // Go: TestNRGLeaderTransfer server/raft_test.go:? // --------------------------------------------------------------- // Go reference: TestNRGLeaderTransfer — leadership transfers via stepdown and re-election [Fact] public void Leadership_transfer_via_stepdown_and_reelection() { var (nodes, _) = CreateCluster(3); var firstLeader = ElectLeader(nodes); firstLeader.IsLeader.ShouldBeTrue(); firstLeader.RequestStepDown(); firstLeader.IsLeader.ShouldBeFalse(); // Elect a different node var newCandidate = nodes.First(n => n.Id != firstLeader.Id); newCandidate.StartElection(nodes.Length); foreach (var voter in nodes.Where(n => n.Id != newCandidate.Id)) newCandidate.ReceiveVote(voter.GrantVote(newCandidate.Term, newCandidate.Id), nodes.Length); newCandidate.IsLeader.ShouldBeTrue(); newCandidate.Id.ShouldNotBe(firstLeader.Id); } // --------------------------------------------------------------- // Go: TestNRGMustNotResetVoteOnStepDownOrLeaderTransfer server/raft_test.go:? // --------------------------------------------------------------- // Go reference: TestNRGMustNotResetVoteOnStepDownOrLeaderTransfer — vote not reset on same-term stepdown [Fact] public void Stepdown_preserves_vote_state_until_new_term() { var voter = new RaftNode("voter"); voter.GrantVote(term: 1, candidateId: "a").Granted.ShouldBeTrue(); voter.TermState.VotedFor.ShouldBe("a"); // Receiving a same-term heartbeat (stepdown) from a leader should NOT clear the vote voter.ReceiveHeartbeat(term: 1); // Vote should remain — same term heartbeat does not clear votedFor voter.TermState.VotedFor.ShouldBe("a"); } // --------------------------------------------------------------- // Go: TestNRGVoteResponseEncoding server/raft_test.go:? // --------------------------------------------------------------- // Go reference: TestNRGVoteResponseEncoding — vote response round-trip [Fact] public void Vote_response_carries_granted_true_on_success() { var voter = new RaftNode("voter"); var resp = voter.GrantVote(term: 3, candidateId: "cand"); resp.Granted.ShouldBeTrue(); } // Go reference: TestNRGVoteResponseEncoding — denied vote carries granted=false [Fact] public void Vote_response_carries_granted_false_on_denial() { var voter = new RaftNode("voter"); voter.GrantVote(term: 1, candidateId: "a"); // vote for a in term 1 var denied = voter.GrantVote(term: 1, candidateId: "b"); // denied denied.Granted.ShouldBeFalse(); } // --------------------------------------------------------------- // Go: TestNRGSimple server/raft_test.go:35 — log replication // --------------------------------------------------------------- // Go reference: TestNRGSimple — propose adds entry to leader log [Fact] public async Task Leader_propose_adds_entry_to_log() { var (leader, _) = CreateLeaderWithFollowers(2); var idx = await leader.ProposeAsync("set-x=1", default); idx.ShouldBe(1); leader.Log.Entries.Count.ShouldBe(1); leader.Log.Entries[0].Command.ShouldBe("set-x=1"); } // Go reference: TestNRGSimple — follower receives replicated entry [Fact] public async Task Followers_receive_replicated_entries() { var (leader, followers) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("replicated-cmd", default); foreach (var f in followers) { f.Log.Entries.Count.ShouldBe(1); f.Log.Entries[0].Command.ShouldBe("replicated-cmd"); } } // Go reference: TestNRGSimple — commit index advances after quorum [Fact] public async Task Commit_index_advances_after_quorum_replication() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("committed", default); leader.AppliedIndex.ShouldBeGreaterThan(0); } // Go reference: TestNRGSimple — sequential indices preserved [Fact] public async Task Sequential_proposals_use_monotonically_increasing_indices() { var (leader, _) = CreateLeaderWithFollowers(2); var i1 = await leader.ProposeAsync("cmd-1", default); var i2 = await leader.ProposeAsync("cmd-2", default); var i3 = await leader.ProposeAsync("cmd-3", default); i1.ShouldBe(1); i2.ShouldBe(2); i3.ShouldBe(3); } // --------------------------------------------------------------- // Go: TestNRGWALEntryWithoutQuorumMustTruncate server/raft_test.go:1063 // --------------------------------------------------------------- // Go reference: TestNRGWALEntryWithoutQuorumMustTruncate — follower cannot propose [Fact] public async Task Follower_throws_on_propose() { var (_, followers) = CreateLeaderWithFollowers(2); await Should.ThrowAsync( async () => await followers[0].ProposeAsync("should-fail", default)); } // --------------------------------------------------------------- // Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156 // --------------------------------------------------------------- // Go reference: TestNRGTermNoDecreaseAfterWALReset — stale-term append rejected [Fact] public async Task Stale_term_append_entry_is_rejected() { var node = new RaftNode("n1"); node.StartElection(1); // term = 1 var stale = new RaftLogEntry(Index: 1, Term: 0, Command: "stale"); await Should.ThrowAsync( async () => await node.TryAppendFromLeaderAsync(stale, default)); } // Go reference: TestNRGTermNoDecreaseAfterWALReset — current-term append accepted [Fact] public async Task Current_term_append_entry_is_accepted() { var node = new RaftNode("n1"); node.TermState.CurrentTerm = 3; var entry = new RaftLogEntry(Index: 1, Term: 3, Command: "valid"); await node.TryAppendFromLeaderAsync(entry, default); node.Log.Entries.Count.ShouldBe(1); } // --------------------------------------------------------------- // Go: TestNRGNoResetOnAppendEntryResponse server/raft_test.go:912 // --------------------------------------------------------------- // Go reference: TestNRGNoResetOnAppendEntryResponse — no quorum means applied stays 0 [Fact] public async Task Propose_without_follower_quorum_does_not_advance_applied() { // Single node is its own quorum, so use a special test node var node = new RaftNode("n1"); node.StartElection(5); // needs 3 votes but only has 1 node.IsLeader.ShouldBeFalse(); // candidate, not leader // Only leader can propose — this tests that the gate works await Should.ThrowAsync( async () => await node.ProposeAsync("no-quorum", default)); } // --------------------------------------------------------------- // Go: TestNRGSnapshotAndRestart server/raft_test.go:49 // --------------------------------------------------------------- // Go reference: TestNRGSnapshotAndRestart — snapshot creation captures index and term [Fact] public async Task Snapshot_creation_records_applied_index_and_term() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("cmd-a", default); await leader.ProposeAsync("cmd-b", default); var snap = await leader.CreateSnapshotAsync(default); snap.LastIncludedIndex.ShouldBe(leader.AppliedIndex); snap.LastIncludedTerm.ShouldBe(leader.Term); } // Go reference: TestNRGSnapshotAndRestart — installing snapshot updates applied index [Fact] public async Task Installing_snapshot_updates_applied_index_on_follower() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("snap-1", default); await leader.ProposeAsync("snap-2", default); var snap = await leader.CreateSnapshotAsync(default); var newNode = new RaftNode("latecomer"); await newNode.InstallSnapshotAsync(snap, default); newNode.AppliedIndex.ShouldBe(snap.LastIncludedIndex); } // Go reference: TestNRGSnapshotAndRestart — log is cleared after snapshot install [Fact] public async Task Log_is_cleared_when_snapshot_is_installed() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("pre-snap", default); var snap = await leader.CreateSnapshotAsync(default); var follower = new RaftNode("f-snap"); await follower.InstallSnapshotAsync(snap, default); follower.Log.Entries.Count.ShouldBe(0); } // --------------------------------------------------------------- // Go: TestNRGSnapshotCatchup / TestNRGSimpleCatchup server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGSimpleCatchup — lagging follower catches up via log entries [Fact] public async Task Lagging_follower_catches_up_via_replicated_entries() { var (leader, followers) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("e1", default); await leader.ProposeAsync("e2", default); await leader.ProposeAsync("e3", default); followers[0].Log.Entries.Count.ShouldBe(3); } // Go reference: TestNRGSnapshotCatchup — snapshot + subsequent entries applied correctly [Fact] public async Task Snapshot_install_followed_by_new_entries_uses_correct_base_index() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("early", default); var snap = await leader.CreateSnapshotAsync(default); var newNode = new RaftNode("catchup"); await newNode.InstallSnapshotAsync(snap, default); // After snapshot, new log entries should continue from snapshot index var postEntry = newNode.Log.Append(term: 1, command: "post-snap"); postEntry.Index.ShouldBe(snap.LastIncludedIndex + 1); } // --------------------------------------------------------------- // Go: TestNRGDrainAndReplaySnapshot server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGDrainAndReplaySnapshot — DrainAndReplaySnapshot resets commit queue [Fact] public async Task DrainAndReplaySnapshot_advances_applied_and_commit_indices() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("pre", default); var snap = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 2 }; await leader.DrainAndReplaySnapshotAsync(snap, default); leader.AppliedIndex.ShouldBe(50); leader.CommitIndex.ShouldBe(50); } // Go reference: TestNRGDrainAndReplaySnapshot — log is replaced by snapshot [Fact] public async Task DrainAndReplaySnapshot_replaces_log() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("a", default); await leader.ProposeAsync("b", default); var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1 }; await leader.DrainAndReplaySnapshotAsync(snap, default); leader.Log.Entries.Count.ShouldBe(0); leader.Log.BaseIndex.ShouldBe(5); } // --------------------------------------------------------------- // Go: TestNRGSnapshotAndTruncateToApplied server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGSnapshotAndTruncateToApplied — checkpoint compacts log [Fact] public async Task Snapshot_checkpoint_compacts_log_to_applied_index() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("a", default); await leader.ProposeAsync("b", default); await leader.ProposeAsync("c", default); leader.Log.Entries.Count.ShouldBe(3); await leader.CreateSnapshotCheckpointAsync(default); leader.Log.Entries.Count.ShouldBe(0); } // Go reference: TestNRGSnapshotAndTruncateToApplied — base index matches snapshot [Fact] public async Task Snapshot_checkpoint_sets_base_index_to_applied() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("x", default); await leader.ProposeAsync("y", default); var applied = leader.AppliedIndex; await leader.CreateSnapshotCheckpointAsync(default); leader.Log.BaseIndex.ShouldBe(applied); } // --------------------------------------------------------------- // Go: TestNRGIgnoreDoubleSnapshot server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGIgnoreDoubleSnapshot — installing same snapshot twice is idempotent [Fact] public async Task Installing_same_snapshot_twice_is_idempotent() { var node = new RaftNode("n1"); var snap = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 }; await node.InstallSnapshotAsync(snap, default); await node.InstallSnapshotAsync(snap, default); node.AppliedIndex.ShouldBe(10); node.Log.Entries.Count.ShouldBe(0); } // --------------------------------------------------------------- // Go: TestNRGProposeRemovePeer server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGProposeRemovePeer — remove follower peer succeeds [Fact] public async Task Remove_peer_removes_member_from_cluster() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.Members.ShouldContain("n2"); await leader.ProposeRemovePeerAsync("n2", default); leader.Members.ShouldNotContain("n2"); } // Go reference: TestNRGProposeRemovePeer — remove creates log entry [Fact] public async Task Remove_peer_creates_log_entry() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var before = leader.Log.Entries.Count; await leader.ProposeRemovePeerAsync("n2", default); leader.Log.Entries.Count.ShouldBe(before + 1); } // Go reference: TestNRGProposeRemovePeerLeader — leader cannot remove itself [Fact] public async Task Leader_cannot_remove_itself() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await Should.ThrowAsync( async () => await leader.ProposeRemovePeerAsync(leader.Id, default)); } // --------------------------------------------------------------- // Go: TestNRGAddPeers server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGAddPeers — add peer adds to member set [Fact] public async Task Add_peer_adds_to_member_set() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.Members.ShouldNotContain("n4"); await leader.ProposeAddPeerAsync("n4", default); leader.Members.ShouldContain("n4"); } // Go reference: TestNRGAddPeers — add peer creates log entry [Fact] public async Task Add_peer_creates_log_entry() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var before = leader.Log.Entries.Count; await leader.ProposeAddPeerAsync("n4", default); leader.Log.Entries.Count.ShouldBe(before + 1); } // Go reference: TestNRGAddPeers — add peer tracks peer state [Fact] public async Task Add_peer_initializes_peer_state_tracking() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeAddPeerAsync("n4", default); leader.GetPeerStates().ShouldContainKey("n4"); } // --------------------------------------------------------------- // Go: TestNRGProposeRemovePeerAll server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGProposeRemovePeerAll — removing all followers leaves single node [Fact] public async Task Removing_all_followers_leaves_single_leader_node() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeRemovePeerAsync("n2", default); await leader.ProposeRemovePeerAsync("n3", default); leader.Members.Count.ShouldBe(1); leader.Members.ShouldContain(leader.Id); } // --------------------------------------------------------------- // Go: TestNRGLeaderResurrectsRemovedPeers server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGLeaderResurrectsRemovedPeers — can re-add a previously removed peer [Fact] public async Task Previously_removed_peer_can_be_re_added() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeRemovePeerAsync("n2", default); leader.Members.ShouldNotContain("n2"); await leader.ProposeAddPeerAsync("n2", default); leader.Members.ShouldContain("n2"); } // --------------------------------------------------------------- // Go: TestNRGUncommittedMembershipChangeGetsTruncated server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGUncommittedMembershipChangeGetsTruncated — membership change in-progress flag clears [Fact] public async Task Membership_change_in_progress_flag_clears_after_completion() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeAddPeerAsync("n4", default); leader.MembershipChangeInProgress.ShouldBeFalse(); } // --------------------------------------------------------------- // Go: TestNRGProposeRemovePeerQuorum server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGProposeRemovePeerQuorum — remove/add sequence maintains quorum [Fact] public async Task Sequential_add_and_remove_maintains_consistent_member_count() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var before = leader.Members.Count; await leader.ProposeAddPeerAsync("n4", default); leader.Members.Count.ShouldBe(before + 1); await leader.ProposeRemovePeerAsync("n4", default); leader.Members.Count.ShouldBe(before); } // --------------------------------------------------------------- // Go: TestNRGReplayAddPeerKeepsClusterSize server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGReplayAddPeerKeepsClusterSize — cluster size accurate after membership change [Fact] public async Task Cluster_size_reflects_membership_changes_correctly() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.Members.Count.ShouldBe(3); await leader.ProposeAddPeerAsync("n4", default); leader.Members.Count.ShouldBe(4); await leader.ProposeRemovePeerAsync("n4", default); leader.Members.Count.ShouldBe(3); } // --------------------------------------------------------------- // Go: TestNRGInitSingleMemRaftNodeDefaults server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has expected defaults [Fact] public void New_node_has_zero_term_and_follower_role() { var node = new RaftNode("defaults-test"); node.Term.ShouldBe(0); node.Role.ShouldBe(RaftRole.Follower); node.IsLeader.ShouldBeFalse(); node.AppliedIndex.ShouldBe(0); } // Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has itself as sole member [Fact] public void New_node_contains_itself_as_initial_member() { var node = new RaftNode("solo-member"); node.Members.ShouldContain("solo-member"); } // Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has empty log [Fact] public void New_node_has_empty_log() { var node = new RaftNode("empty-log"); node.Log.Entries.Count.ShouldBe(0); node.Log.BaseIndex.ShouldBe(0); } // --------------------------------------------------------------- // Go: TestNRGProcessed server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGProcessed — MarkProcessed advances processed index [Fact] public void MarkProcessed_advances_processed_index() { var node = new RaftNode("proc-test"); node.ProcessedIndex.ShouldBe(0); node.MarkProcessed(5); node.ProcessedIndex.ShouldBe(5); node.MarkProcessed(3); // lower value should not regress node.ProcessedIndex.ShouldBe(5); } // Go reference: TestNRGProcessed — processed index does not regress [Fact] public void MarkProcessed_does_not_allow_regression() { var node = new RaftNode("proc-floor"); node.MarkProcessed(10); node.MarkProcessed(5); node.ProcessedIndex.ShouldBe(10); } // --------------------------------------------------------------- // Go: TestNRGSizeAndApplied server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGSizeAndApplied — applied index matches number of committed entries [Fact] public async Task Applied_index_matches_committed_entry_count() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("e1", default); await leader.ProposeAsync("e2", default); await leader.ProposeAsync("e3", default); leader.AppliedIndex.ShouldBe(3); } // --------------------------------------------------------------- // Go: TestNRGForwardProposalResponse server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGForwardProposalResponse — follower can receive entries from leader [Fact] public async Task Follower_can_receive_entries_forwarded_from_leader() { var follower = new RaftNode("follower"); follower.TermState.CurrentTerm = 2; var entry = new RaftLogEntry(Index: 1, Term: 2, Command: "forwarded"); await follower.TryAppendFromLeaderAsync(entry, default); follower.Log.Entries.Count.ShouldBe(1); follower.Log.Entries[0].Command.ShouldBe("forwarded"); } // --------------------------------------------------------------- // Go: TestNRGQuorumAccounting server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGQuorumAccounting — correct quorum sizes for various cluster sizes [Theory] [InlineData(3, 2)] [InlineData(5, 3)] [InlineData(7, 4)] public void Cluster_quorum_requires_majority_votes(int clusterSize, int neededVotes) { var node = new RaftNode("qtest"); node.StartElection(clusterSize); node.IsLeader.ShouldBeFalse(); // only self-vote so far (2+ node cluster) for (int i = 1; i < neededVotes; i++) node.ReceiveVote(new VoteResponse { Granted = true }, clusterSize); node.IsLeader.ShouldBeTrue(); } // Go reference: TestNRGQuorumAccounting — single node cluster immediately becomes leader [Fact] public void Single_node_cluster_reaches_quorum_with_self_vote() { var node = new RaftNode("solo"); node.StartElection(clusterSize: 1); node.IsLeader.ShouldBeTrue(); // single-node: self-vote is quorum } // --------------------------------------------------------------- // Go: TestNRGTrackPeerActive server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGTrackPeerActive — leader tracks peer states after cluster formation [Fact] public void Leader_tracks_peer_state_for_all_followers() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var peers = leader.GetPeerStates(); peers.ShouldContainKey("n2"); peers.ShouldContainKey("n3"); peers.ShouldNotContainKey("n1"); // self is not in peer states } // Go reference: TestNRGTrackPeerActive — peer state contains correct peer ID [Fact] public void Peer_state_contains_correct_peer_id() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var peers = leader.GetPeerStates(); peers["n2"].PeerId.ShouldBe("n2"); peers["n3"].PeerId.ShouldBe("n3"); } // --------------------------------------------------------------- // Go: TestNRGRevalidateQuorumAfterLeaderChange server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGRevalidateQuorumAfterLeaderChange — new leader commits after re-election [Fact] public async Task New_leader_can_commit_entries_after_re_election() { var (nodes, _) = CreateCluster(3); var firstLeader = ElectLeader(nodes); await firstLeader.ProposeAsync("pre-stepdown", default); firstLeader.RequestStepDown(); // Elect a new leader var newLeader = nodes.First(n => n.Id != firstLeader.Id); newLeader.StartElection(nodes.Length); foreach (var v in nodes.Where(n => n.Id != newLeader.Id)) newLeader.ReceiveVote(v.GrantVote(newLeader.Term, newLeader.Id), nodes.Length); newLeader.IsLeader.ShouldBeTrue(); var idx = await newLeader.ProposeAsync("post-election", default); idx.ShouldBeGreaterThan(0); newLeader.AppliedIndex.ShouldBeGreaterThan(0); } // --------------------------------------------------------------- // Go: TestNRGQuorumAfterLeaderStepdown server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGQuorumAfterLeaderStepdown — quorum maintained after leader stepdown [Fact] public void Cluster_maintains_quorum_after_leader_stepdown() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); leader.IsLeader.ShouldBeTrue(); leader.RequestStepDown(); leader.IsLeader.ShouldBeFalse(); // The cluster can still elect a new leader var newCandidate = nodes.First(n => n.Id != leader.Id); newCandidate.StartElection(nodes.Length); foreach (var v in nodes.Where(n => n.Id != newCandidate.Id)) newCandidate.ReceiveVote(v.GrantVote(newCandidate.Term, newCandidate.Id), nodes.Length); newCandidate.IsLeader.ShouldBeTrue(); } // --------------------------------------------------------------- // Go: TestNRGSendAppendEntryNotLeader server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGSendAppendEntryNotLeader — non-leader cannot propose [Fact] public async Task Non_leader_cannot_send_append_entries() { var node = new RaftNode("follower-node"); // node stays as follower, never elected await Should.ThrowAsync( async () => await node.ProposeAsync("should-reject", default)); } // --------------------------------------------------------------- // Go: TestNRGInstallSnapshotFromCheckpoint server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGInstallSnapshotFromCheckpoint — chunked snapshot assembly [Fact] public async Task Chunked_snapshot_assembles_correctly() { var node = new RaftNode("n1"); var chunk1 = new byte[] { 0x01, 0x02, 0x03 }; var chunk2 = new byte[] { 0x04, 0x05, 0x06 }; await node.InstallSnapshotFromChunksAsync([chunk1, chunk2], snapshotIndex: 20, snapshotTerm: 3, default); node.AppliedIndex.ShouldBe(20); node.CommitIndex.ShouldBe(20); } // Go reference: TestNRGInstallSnapshotFromCheckpoint — snapshot clears log [Fact] public async Task Chunked_snapshot_clears_log() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("a", default); await leader.InstallSnapshotFromChunksAsync([[0x01]], snapshotIndex: 10, snapshotTerm: 1, default); leader.Log.Entries.Count.ShouldBe(0); leader.Log.BaseIndex.ShouldBe(10); } // --------------------------------------------------------------- // Go: TestNRGInstallSnapshotForce server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGInstallSnapshotForce — forced snapshot installation overwrites state [Fact] public async Task Force_snapshot_install_overrides_higher_applied_index() { var node = new RaftNode("n1"); node.AppliedIndex = 100; // simulate advanced state // Installing an older snapshot should reset to snapshot index var snap = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 1 }; await node.InstallSnapshotAsync(snap, default); node.AppliedIndex.ShouldBe(50); } // --------------------------------------------------------------- // Go: TestNRGMultipleStopsDontPanic server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGMultipleStopsDontPanic — multiple disposals do not throw [Fact] public void Multiple_disposals_do_not_throw() { var node = new RaftNode("n1"); Should.NotThrow(() => node.Dispose()); Should.NotThrow(() => node.Dispose()); } // --------------------------------------------------------------- // Go: TestNRGMemoryWALEmptiesSnapshotsDir server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGMemoryWALEmptiesSnapshotsDir — log compaction empties entries [Fact] public async Task Log_compaction_removes_entries_below_snapshot_index() { var (leader, _) = CreateLeaderWithFollowers(2); await leader.ProposeAsync("e1", default); await leader.ProposeAsync("e2", default); await leader.ProposeAsync("e3", default); leader.Log.Entries.Count.ShouldBe(3); leader.Log.Compact(2); leader.Log.Entries.Count.ShouldBe(1); // only e3 remains } // --------------------------------------------------------------- // Go: TestNRGDisjointMajorities server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGDisjointMajorities — split cluster — two candidates, neither reaches quorum [Fact] public void Split_cluster_produces_no_leader_without_quorum() { var (nodes, _) = CreateCluster(5); // n1 gets 2 votes (including self) out of 5 — not enough nodes[0].StartElection(5); nodes[0].ReceiveVote(new VoteResponse { Granted = true }, 5); nodes[0].IsLeader.ShouldBeFalse(); // 2/5, needs 3 // n2 gets 2 votes (including self) out of 5 — not enough nodes[1].StartElection(5); nodes[1].ReceiveVote(new VoteResponse { Granted = true }, 5); nodes[1].IsLeader.ShouldBeFalse(); // 2/5, needs 3 } // --------------------------------------------------------------- // Go: TestNRGAppendEntryResurrectsLeader server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGAppendEntryResurrectsLeader — higher-term AE makes follower follow new leader [Fact] public void Higher_term_append_entry_switches_follower_to_new_term() { var follower = new RaftNode("f1"); follower.TermState.CurrentTerm = 2; // Append entry from higher-term leader follower.ReceiveHeartbeat(term: 5); follower.Term.ShouldBe(5); follower.Role.ShouldBe(RaftRole.Follower); } // --------------------------------------------------------------- // Go: TestNRGObserverMode server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGObserverMode — observer receives entries but does not campaign [Fact] public async Task Observer_node_receives_replicated_entries_without_campaigning() { // Observer = a follower that is told not to campaign var observer = new RaftNode("observer"); observer.PreVoteEnabled = false; // disable pre-vote to prevent auto-campaigning var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "observed"); observer.ReceiveReplicatedEntry(entry); observer.Log.Entries.Count.ShouldBe(1); observer.Role.ShouldBe(RaftRole.Follower); } // --------------------------------------------------------------- // Go: TestNRGAEFromOldLeader server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGAEFromOldLeader — stale AE from old leader term rejected [Fact] public async Task Append_entry_from_stale_term_leader_is_rejected() { var node = new RaftNode("n1"); node.TermState.CurrentTerm = 5; var staleEntry = new RaftLogEntry(Index: 1, Term: 2, Command: "stale-ae"); await Should.ThrowAsync( async () => await node.TryAppendFromLeaderAsync(staleEntry, default)); } // --------------------------------------------------------------- // Go: TestNRGElectionTimerAfterObserver server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGElectionTimerAfterObserver — election timer can be started and stopped [Fact] public void Election_timer_can_be_started_and_stopped_without_throwing() { var node = new RaftNode("timer-test"); Should.NotThrow(() => node.StartElectionTimer()); Should.NotThrow(() => node.StopElectionTimer()); } // --------------------------------------------------------------- // Go: TestNRGSnapshotRecovery server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGSnapshotRecovery — snapshot followed by new entries produces correct index sequence [Fact] public async Task After_snapshot_new_entries_have_sequential_indices() { var node = new RaftNode("n1"); var snap = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 }; await node.InstallSnapshotAsync(snap, default); var e1 = node.Log.Append(term: 2, command: "after-snap-1"); var e2 = node.Log.Append(term: 2, command: "after-snap-2"); e1.Index.ShouldBe(11); e2.Index.ShouldBe(12); } // --------------------------------------------------------------- // Go: TestNRGReplayOnSnapshotSameTerm / TestNRGReplayOnSnapshotDifferentTerm // --------------------------------------------------------------- // Go reference: TestNRGReplayOnSnapshotSameTerm — entries in same term as snapshot are handled [Fact] public async Task Entry_in_same_term_as_snapshot_is_accepted_after_install() { var node = new RaftNode("n1"); var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 2 }; await node.InstallSnapshotAsync(snap, default); node.TermState.CurrentTerm = 2; var entry = new RaftLogEntry(Index: 6, Term: 2, Command: "same-term"); await node.TryAppendFromLeaderAsync(entry, default); node.Log.Entries.Count.ShouldBe(1); } // Go reference: TestNRGReplayOnSnapshotDifferentTerm — entries in new term after snapshot [Fact] public async Task Entry_in_different_term_after_snapshot_is_accepted() { var node = new RaftNode("n1"); var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1 }; await node.InstallSnapshotAsync(snap, default); node.TermState.CurrentTerm = 3; var entry = new RaftLogEntry(Index: 6, Term: 3, Command: "new-term"); await node.TryAppendFromLeaderAsync(entry, default); node.Log.Entries.Count.ShouldBe(1); } // --------------------------------------------------------------- // Go: TestNRGTruncateDownToCommitted server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGTruncateDownToCommitted — Compact removes entries up to committed index [Fact] public void Compact_removes_entries_up_to_given_index() { var log = new RaftLog(); log.Append(1, "a"); log.Append(1, "b"); log.Append(1, "c"); log.Append(1, "d"); log.Compact(2); log.Entries.Count.ShouldBe(2); log.Entries[0].Command.ShouldBe("c"); log.Entries[1].Command.ShouldBe("d"); } // Go reference: TestNRGTruncateDownToCommitted — base index set to compact point [Fact] public void Compact_sets_base_index_correctly() { var log = new RaftLog(); log.Append(1, "a"); log.Append(1, "b"); log.Append(1, "c"); log.Compact(2); log.BaseIndex.ShouldBe(2); } // --------------------------------------------------------------- // Go: TestNRGPendingAppendEntryCacheInvalidation server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGPendingAppendEntryCacheInvalidation — duplicate entries deduplicated [Fact] public void Duplicate_replicated_entries_are_deduplicated_by_index() { var log = new RaftLog(); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "once"); log.AppendReplicated(entry); log.AppendReplicated(entry); log.AppendReplicated(entry); log.Entries.Count.ShouldBe(1); } // --------------------------------------------------------------- // Go: TestNRGDontRemoveSnapshotIfTruncateToApplied server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGDontRemoveSnapshotIfTruncateToApplied — snapshot data preserved [Fact] public async Task Snapshot_data_is_preserved_after_install() { var node = new RaftNode("n1"); var snap = new RaftSnapshot { LastIncludedIndex = 7, LastIncludedTerm = 2, Data = [0xAB, 0xCD] }; await node.InstallSnapshotAsync(snap, default); node.AppliedIndex.ShouldBe(7); } // --------------------------------------------------------------- // Log base index continuity after repeated compactions // --------------------------------------------------------------- // Go reference: multiple compaction rounds produce correct running base index [Fact] public void Multiple_compaction_rounds_maintain_correct_base_index() { var log = new RaftLog(); for (int i = 0; i < 10; i++) log.Append(1, $"cmd-{i}"); log.Compact(3); log.BaseIndex.ShouldBe(3); log.Entries.Count.ShouldBe(7); log.Compact(7); log.BaseIndex.ShouldBe(7); log.Entries.Count.ShouldBe(3); } // --------------------------------------------------------------- // Go: TestNRGHealthCheckWaitForCatchup (via peer state) // --------------------------------------------------------------- // Go reference: TestNRGHealthCheckWaitForCatchup — peer state reflects last contact [Fact] public void Peer_state_last_contact_updated_when_peer_responds() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); var peerState = leader.GetPeerStates()["n2"]; // MatchIndex is 0 before any replication peerState.MatchIndex.ShouldBe(0); } // Go reference: TestNRGHealthCheckWaitForCatchup — match index updates after proposal [Fact] public async Task Peer_match_index_updates_after_successful_replication() { var (nodes, _) = CreateCluster(3); var leader = ElectLeader(nodes); await leader.ProposeAsync("sync-check", default); var peerState = leader.GetPeerStates()["n2"]; peerState.MatchIndex.ShouldBeGreaterThan(0); } // --------------------------------------------------------------- // Go: TestNRGSignalLeadChangeFalseIfCampaignImmediately server/raft_test.go // --------------------------------------------------------------- // Go reference: TestNRGSignalLeadChangeFalseIfCampaignImmediately — CampaignImmediately fires election [Fact] public void CampaignImmediately_triggers_election() { var (nodes, _) = CreateCluster(3); // Disable pre-vote for direct testing nodes[0].PreVoteEnabled = false; nodes[0].ConfigureCluster(nodes); nodes[0].CampaignImmediately(); // After campaign-immediate, node is at least a candidate (nodes[0].Role == RaftRole.Candidate || nodes[0].Role == RaftRole.Leader).ShouldBeTrue(); } }