// Go parity: golang/nats-server/server/jetstream_cluster.go
// Covers: RaftGroup member management, peer add/remove/preferred,
// factory method via PlacementEngine, replication health properties,
// and quorum size calculation.
using NATS.Server.JetStream.Cluster;
namespace NATS.Server.Tests.JetStream.Cluster;
///
/// Tests for RaftGroup lifecycle: membership helpers, factory method,
/// replication status properties, and quorum size.
/// Go reference: jetstream_cluster.go:154-163 raftGroup struct and peer management.
///
public class RaftGroupLifecycleTests
{
// ---------------------------------------------------------------
// IsMember — membership check
// Go reference: jetstream_cluster.go isMember helper
// ---------------------------------------------------------------
[Fact]
public void IsMember_returns_true_for_existing_peer()
{
// Go reference: jetstream_cluster.go isMember — checks rg.Peers contains id
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"] };
group.IsMember("peer-2").ShouldBeTrue();
}
[Fact]
public void IsMember_returns_false_for_non_member()
{
// Go reference: jetstream_cluster.go isMember — returns false when not in Peers
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] };
group.IsMember("peer-9").ShouldBeFalse();
}
// ---------------------------------------------------------------
// SetPreferred — assign preferred peer
// Go reference: jetstream_cluster.go setPreferred / rg.Preferred
// ---------------------------------------------------------------
[Fact]
public void SetPreferred_sets_preferred_peer()
{
// Go reference: jetstream_cluster.go setPreferred — assigns rg.Preferred when member
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"] };
group.SetPreferred("peer-3");
group.Preferred.ShouldBe("peer-3");
}
[Fact]
public void SetPreferred_throws_for_non_member()
{
// Go reference: jetstream_cluster.go setPreferred — validates membership before setting
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] };
Should.Throw(() => group.SetPreferred("peer-99"));
}
// ---------------------------------------------------------------
// RemovePeer — remove a peer from the group
// Go reference: jetstream_cluster.go removePeer
// ---------------------------------------------------------------
[Fact]
public void RemovePeer_removes_existing_peer()
{
// Go reference: jetstream_cluster.go removePeer — removes peer from rg.Peers
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"] };
var removed = group.RemovePeer("peer-2");
removed.ShouldBeTrue();
group.Peers.ShouldNotContain("peer-2");
group.Peers.Count.ShouldBe(2);
}
[Fact]
public void RemovePeer_clears_preferred_when_removing_preferred()
{
// Go reference: jetstream_cluster.go removePeer — clears rg.Preferred if it matches removed peer
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"], Preferred = "peer-2" };
group.RemovePeer("peer-2");
group.Preferred.ShouldBe(string.Empty);
}
[Fact]
public void RemovePeer_returns_false_for_non_member()
{
// Go reference: jetstream_cluster.go removePeer — returns false when peer not found
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] };
var removed = group.RemovePeer("peer-99");
removed.ShouldBeFalse();
group.Peers.Count.ShouldBe(2);
}
// ---------------------------------------------------------------
// AddPeer — add a new peer to the group
// Go reference: jetstream_cluster.go addPeer / expandGroup
// ---------------------------------------------------------------
[Fact]
public void AddPeer_adds_new_peer()
{
// Go reference: jetstream_cluster.go addPeer — appends peer to rg.Peers
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] };
var added = group.AddPeer("peer-3");
added.ShouldBeTrue();
group.Peers.ShouldContain("peer-3");
group.Peers.Count.ShouldBe(3);
}
[Fact]
public void AddPeer_returns_false_for_existing_peer()
{
// Go reference: jetstream_cluster.go addPeer — skips duplicate, returns false
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] };
var added = group.AddPeer("peer-1");
added.ShouldBeFalse();
group.Peers.Count.ShouldBe(2);
}
// ---------------------------------------------------------------
// CreateRaftGroup factory — uses PlacementEngine
// Go reference: jetstream_cluster.go:7212 selectPeerGroup called from createGroupForStream
// ---------------------------------------------------------------
[Fact]
public void CreateRaftGroup_uses_placement_engine()
{
// Go reference: jetstream_cluster.go createGroupForStream — calls selectPeerGroup
var peers = new List
{
new() { PeerId = "peer-A", Available = true, AvailableStorage = 9000 },
new() { PeerId = "peer-B", Available = true, AvailableStorage = 8000 },
new() { PeerId = "peer-C", Available = true, AvailableStorage = 7000 },
};
var group = RaftGroup.CreateRaftGroup("my-stream", 3, peers);
group.Name.ShouldBe("my-stream");
group.Peers.Count.ShouldBe(3);
group.Peers.ShouldContain("peer-A");
group.Peers.ShouldContain("peer-B");
group.Peers.ShouldContain("peer-C");
}
[Fact]
public void CreateRaftGroup_sets_desired_replicas()
{
// Go reference: jetstream_cluster.go rg.DesiredReplicas = replicas after group creation
var peers = new List
{
new() { PeerId = "peer-X", Available = true },
new() { PeerId = "peer-Y", Available = true },
new() { PeerId = "peer-Z", Available = true },
};
var group = RaftGroup.CreateRaftGroup("replicated-stream", 3, peers);
group.DesiredReplicas.ShouldBe(3);
group.HasDesiredReplicas.ShouldBeTrue();
}
// ---------------------------------------------------------------
// IsUnderReplicated — replication health
// Go reference: jetstream_cluster.go missingPeers — len(Peers) < DesiredReplicas
// ---------------------------------------------------------------
[Fact]
public void IsUnderReplicated_true_when_peers_less_than_desired()
{
// Go reference: jetstream_cluster.go:2284 sa.missingPeers()
var group = new RaftGroup { Name = "test", Peers = ["peer-1"], DesiredReplicas = 3 };
group.IsUnderReplicated.ShouldBeTrue();
}
[Fact]
public void IsUnderReplicated_false_when_peers_equal_desired()
{
// Go reference: jetstream_cluster.go:2284 sa.missingPeers() — no deficit when equal
var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"], DesiredReplicas = 3 };
group.IsUnderReplicated.ShouldBeFalse();
}
[Fact]
public void IsUnderReplicated_false_when_no_desired_replicas_set()
{
// Go reference: jetstream_cluster.go — without DesiredReplicas set, no under-replication
var group = new RaftGroup { Name = "test", Peers = ["peer-1"] };
group.IsUnderReplicated.ShouldBeFalse();
}
// ---------------------------------------------------------------
// IsOverReplicated — excess replication detection
// Go reference: jetstream_cluster.go extraPeers — len(Peers) > DesiredReplicas
// ---------------------------------------------------------------
[Fact]
public void IsOverReplicated_true_when_peers_more_than_desired()
{
// Go reference: jetstream_cluster.go extraPeers detection for scale-down
var group = new RaftGroup { Name = "test", Peers = ["p1", "p2", "p3", "p4"], DesiredReplicas = 3 };
group.IsOverReplicated.ShouldBeTrue();
}
[Fact]
public void IsOverReplicated_false_when_peers_equal_desired()
{
// Go reference: jetstream_cluster.go — no excess when equal
var group = new RaftGroup { Name = "test", Peers = ["p1", "p2", "p3"], DesiredReplicas = 3 };
group.IsOverReplicated.ShouldBeFalse();
}
[Fact]
public void IsOverReplicated_false_when_no_desired_replicas_set()
{
// Go reference: jetstream_cluster.go — without DesiredReplicas set, no over-replication
var group = new RaftGroup { Name = "test", Peers = ["p1", "p2", "p3", "p4"] };
group.IsOverReplicated.ShouldBeFalse();
}
// ---------------------------------------------------------------
// QuorumSize — majority quorum calculation
// Go reference: jetstream_cluster.go quorumNeeded — (n/2)+1
// ---------------------------------------------------------------
[Theory]
[InlineData(1, 1)] // R=1 → quorum=1
[InlineData(3, 2)] // R=3 → quorum=2
[InlineData(5, 3)] // R=5 → quorum=3
[InlineData(2, 2)] // R=2 → quorum=2 (degenerate, but formula consistent)
[InlineData(4, 3)] // R=4 → quorum=3
public void QuorumSize_correct_for_various_counts(int peerCount, int expectedQuorum)
{
// Go reference: jetstream_cluster.go quorumNeeded — (n/2)+1
var peers = Enumerable.Range(1, peerCount).Select(i => $"peer-{i}").ToList();
var group = new RaftGroup { Name = "quorum-test", Peers = peers };
group.QuorumSize.ShouldBe(expectedQuorum);
}
}