using NATS.Server; using NATS.Server.Auth; using NATS.Server.Raft; namespace NATS.Server.Tests.Raft; /// /// Tests for NatsRaftTransport — verifies subject routing, wire encoding, /// and that the transport can be constructed with an InternalClient. /// /// Go reference: golang/nats-server/server/raft.go:2192-2230 (subject setup), /// 2854-2970 (send helpers), 2161-2169 (subject constants). /// public class NatsRaftTransportTests { // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- // Go: server/raft.go:2210 — n.vsubj, n.vreply = fmt.Sprintf(raftVoteSubj, n.group)... [Fact] public void Transport_can_be_constructed_with_internal_client() { var account = new Account("$G"); var client = new InternalClient(1UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (subject, reply, payload) => { }); transport.ShouldNotBeNull(); transport.GroupId.ShouldBe("meta"); transport.Client.ShouldBeSameAs(client); } [Fact] public void Transport_exposes_group_id() { var account = new Account("$G"); var client = new InternalClient(2UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "stream-A", (_, _, _) => { }); transport.GroupId.ShouldBe("stream-A"); } [Fact] public void Transport_throws_when_client_is_null() { Should.Throw( () => new NatsRaftTransport(null!, "meta", (_, _, _) => { })); } [Fact] public void Transport_throws_when_groupId_is_empty() { var account = new Account("$G"); var client = new InternalClient(3UL, ClientKind.System, account); Should.Throw( () => new NatsRaftTransport(client, "", (_, _, _) => { })); } [Fact] public void Transport_throws_when_publish_is_null() { var account = new Account("$G"); var client = new InternalClient(4UL, ClientKind.System, account); Should.Throw( () => new NatsRaftTransport(client, "meta", null!)); } // --------------------------------------------------------------------------- // AppendEntries — subject routing // --------------------------------------------------------------------------- // Go: server/raft.go:2164 — n.asubj = fmt.Sprintf(raftAppendSubj, n.group) [Fact] public async Task AppendEntries_publishes_to_NRG_AE_subject() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(10UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (subject, _, _) => capturedSubject = subject); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op"); await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None); capturedSubject.ShouldBe("$NRG.AE.meta"); } // Go: server/raft.go:2164 — subject varies by group name [Fact] public async Task AppendEntries_subject_includes_group_name() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(11UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "stream-orders", (subject, _, _) => capturedSubject = subject); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op"); await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None); capturedSubject.ShouldBe("$NRG.AE.stream-orders"); } // Go: server/raft.go:2167 — reply inbox set to raftReply format [Fact] public async Task AppendEntries_includes_NRG_R_reply_subject() { var capturedReply = string.Empty; var account = new Account("$G"); var client = new InternalClient(12UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, reply, _) => capturedReply = reply ?? string.Empty); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op"); await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None); capturedReply.ShouldStartWith("$NRG.R."); } // --------------------------------------------------------------------------- // AppendEntries — wire encoding // --------------------------------------------------------------------------- // Go: server/raft.go:2662-2711 — appendEntry.encode() [Fact] public async Task AppendEntries_encodes_leader_id_in_wire_payload() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(13UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var entry = new RaftLogEntry(Index: 3, Term: 2, Command: "x"); await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None); capturedPayload.IsEmpty.ShouldBeFalse(); var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span); decoded.LeaderId.ShouldBe("leader1"); } // Go: server/raft.go:2694 — ae.term written to wire [Fact] public async Task AppendEntries_encodes_term_in_wire_payload() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(14UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var entry = new RaftLogEntry(Index: 5, Term: 7, Command: "cmd"); await transport.AppendEntriesAsync("L", ["peer1"], entry, CancellationToken.None); var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span); decoded.Term.ShouldBe(7UL); } // Go: server/raft.go:2699-2705 — entry data encoded in payload [Fact] public async Task AppendEntries_encodes_command_as_normal_entry() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(15UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "hello"); await transport.AppendEntriesAsync("L", ["peer1"], entry, CancellationToken.None); var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span); decoded.Entries.Count.ShouldBe(1); decoded.Entries[0].Type.ShouldBe(RaftEntryType.Normal); System.Text.Encoding.UTF8.GetString(decoded.Entries[0].Data).ShouldBe("hello"); } // AppendEntries returns one result per follower [Fact] public async Task AppendEntries_returns_result_per_follower() { var account = new Account("$G"); var client = new InternalClient(16UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, _) => { }); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op"); var results = await transport.AppendEntriesAsync("L", ["peer1", "peer2", "peer3"], entry, CancellationToken.None); results.Count.ShouldBe(3); results.Select(r => r.FollowerId).ShouldBe(["peer1", "peer2", "peer3"], ignoreOrder: false); } // --------------------------------------------------------------------------- // RequestVote — subject routing // --------------------------------------------------------------------------- // Go: server/raft.go:2163 — n.vsubj = fmt.Sprintf(raftVoteSubj, n.group) [Fact] public async Task RequestVote_publishes_to_NRG_V_subject() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(20UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (subject, _, _) => capturedSubject = subject); var req = new VoteRequest { Term = 3, CandidateId = "cand1" }; await transport.RequestVoteAsync("cand1", "voter1", req, CancellationToken.None); capturedSubject.ShouldBe("$NRG.V.meta"); } // Go: server/raft.go:2163 — subject varies by group name [Fact] public async Task RequestVote_subject_includes_group_name() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(21UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "stream-events", (subject, _, _) => capturedSubject = subject); var req = new VoteRequest { Term = 1, CandidateId = "c" }; await transport.RequestVoteAsync("c", "v", req, CancellationToken.None); capturedSubject.ShouldBe("$NRG.V.stream-events"); } // Go: server/raft.go:2167 — n.vreply = n.newInbox() → "$NRG.R.{suffix}" [Fact] public async Task RequestVote_includes_NRG_R_reply_subject() { var capturedReply = string.Empty; var account = new Account("$G"); var client = new InternalClient(22UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, reply, _) => capturedReply = reply ?? string.Empty); var req = new VoteRequest { Term = 1, CandidateId = "c" }; await transport.RequestVoteAsync("c", "v", req, CancellationToken.None); capturedReply.ShouldStartWith("$NRG.R."); } // --------------------------------------------------------------------------- // RequestVote — wire encoding // --------------------------------------------------------------------------- // Go: server/raft.go:4560-4568 — voteRequest.encode() [Fact] public async Task RequestVote_encodes_term_in_wire_payload() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(23UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var req = new VoteRequest { Term = 9, CandidateId = "cand1" }; await transport.RequestVoteAsync("cand1", "voter1", req, CancellationToken.None); capturedPayload.Length.ShouldBe(RaftWireConstants.VoteRequestLen); // 32 bytes var decoded = RaftVoteRequestWire.Decode(capturedPayload.Span); decoded.Term.ShouldBe(9UL); } // Go: server/raft.go:4567 — candidateId written to wire [Fact] public async Task RequestVote_uses_candidate_id_from_request_when_set() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(24UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var req = new VoteRequest { Term = 2, CandidateId = "cand99" }; await transport.RequestVoteAsync("fallback", "voter1", req, CancellationToken.None); var decoded = RaftVoteRequestWire.Decode(capturedPayload.Span); // CandidateId from request takes precedence, truncated to 8 chars (idLen) decoded.CandidateId.ShouldBe("cand99"); } // Go: server/raft.go:4567 — candidateId falls back to candidateId param when request id is empty [Fact] public async Task RequestVote_uses_caller_candidate_id_when_request_id_empty() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(25UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var req = new VoteRequest { Term = 1, CandidateId = "" }; await transport.RequestVoteAsync("fallbk", "voter1", req, CancellationToken.None); var decoded = RaftVoteRequestWire.Decode(capturedPayload.Span); decoded.CandidateId.ShouldBe("fallbk"); } // --------------------------------------------------------------------------- // InstallSnapshot — subject routing // --------------------------------------------------------------------------- // Go: server/raft.go:2168 — raftCatchupReply = "$NRG.CR.%s" [Fact] public async Task InstallSnapshot_publishes_to_NRG_CR_subject() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(30UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (subject, _, _) => capturedSubject = subject); var snapshot = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 2, Data = [1, 2, 3] }; await transport.InstallSnapshotAsync("leader1", "peer1", snapshot, CancellationToken.None); capturedSubject.ShouldStartWith("$NRG.CR."); } // Go: server/raft.go:2168 — no reply-to for catchup transfers [Fact] public async Task InstallSnapshot_has_no_reply_subject() { string? capturedReply = "not-null"; var account = new Account("$G"); var client = new InternalClient(31UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, reply, _) => capturedReply = reply); var snapshot = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1, Data = [] }; await transport.InstallSnapshotAsync("L", "P", snapshot, CancellationToken.None); capturedReply.ShouldBeNull(); } // --------------------------------------------------------------------------- // InstallSnapshot — wire encoding // --------------------------------------------------------------------------- // Go: server/raft.go:3247 — snapshot encoded as EntryOldSnapshot AppendEntry [Fact] public async Task InstallSnapshot_encodes_data_as_old_snapshot_entry() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(32UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var snapshotData = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; var snapshot = new RaftSnapshot { LastIncludedIndex = 100, LastIncludedTerm = 5, Data = snapshotData }; await transport.InstallSnapshotAsync("L", "P", snapshot, CancellationToken.None); capturedPayload.IsEmpty.ShouldBeFalse(); var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span); decoded.Entries.Count.ShouldBe(1); decoded.Entries[0].Type.ShouldBe(RaftEntryType.OldSnapshot); decoded.Entries[0].Data.ShouldBe(snapshotData); } // --------------------------------------------------------------------------- // ForwardProposal — subject routing // --------------------------------------------------------------------------- // Go: server/raft.go:2165 — n.psubj = fmt.Sprintf(raftPropSubj, n.group) [Fact] public void ForwardProposal_publishes_to_NRG_P_subject() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(40UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (subject, _, _) => capturedSubject = subject); transport.ForwardProposal(new byte[] { 1, 2, 3 }); capturedSubject.ShouldBe("$NRG.P.meta"); } // Go: server/raft.go:2165 — subject varies by group name [Fact] public void ForwardProposal_subject_includes_group_name() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(41UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "stream-inventory", (subject, _, _) => capturedSubject = subject); transport.ForwardProposal(System.Text.Encoding.UTF8.GetBytes("entry")); capturedSubject.ShouldBe("$NRG.P.stream-inventory"); } // Go: server/raft.go:949 — payload forwarded verbatim [Fact] public void ForwardProposal_sends_payload_verbatim() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(42UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); var data = new byte[] { 10, 20, 30, 40 }; transport.ForwardProposal(data); capturedPayload.ToArray().ShouldBe(data); } // --------------------------------------------------------------------------- // ProposeRemovePeer — subject routing // --------------------------------------------------------------------------- // Go: server/raft.go:2166 — n.rpsubj = fmt.Sprintf(raftRemovePeerSubj, n.group) [Fact] public void ProposeRemovePeer_publishes_to_NRG_RP_subject() { var capturedSubject = string.Empty; var account = new Account("$G"); var client = new InternalClient(50UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (subject, _, _) => capturedSubject = subject); transport.ProposeRemovePeer("peer-x"); capturedSubject.ShouldBe("$NRG.RP.meta"); } // Go: server/raft.go:986 — peer name encoded as UTF-8 bytes [Fact] public void ProposeRemovePeer_encodes_peer_name_as_utf8() { ReadOnlyMemory capturedPayload = default; var account = new Account("$G"); var client = new InternalClient(51UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, payload) => capturedPayload = payload); transport.ProposeRemovePeer("peer-abc"); System.Text.Encoding.UTF8.GetString(capturedPayload.Span).ShouldBe("peer-abc"); } // --------------------------------------------------------------------------- // IRaftTransport implementation // --------------------------------------------------------------------------- // NatsRaftTransport must implement IRaftTransport [Fact] public void NatsRaftTransport_implements_IRaftTransport() { var account = new Account("$G"); var client = new InternalClient(60UL, ClientKind.System, account); var transport = new NatsRaftTransport(client, "meta", (_, _, _) => { }); (transport as IRaftTransport).ShouldNotBeNull(); } }