using System.Text.Json; using NATS.Server.Raft; namespace NATS.Server.Raft.Tests.Raft; /// /// Ported from Go: TestNRGAppendEntryEncode in golang/nats-server/server/raft_test.go /// Tests append entry serialization/deserialization and log entry mechanics. /// The Go test validates binary encode/decode of appendEntry; the .NET equivalent /// validates JSON round-trip of RaftLogEntry and log persistence. /// public class RaftAppendEntryTests { [Fact] public void Append_entry_encode_decode_round_trips() { // Reference: TestNRGAppendEntryEncode — test entry serialization. // In .NET the RaftLogEntry is a sealed record serialized via JSON. var original = new RaftLogEntry(Index: 1, Term: 1, Command: "test-command"); var json = JsonSerializer.Serialize(original); json.ShouldNotBeNullOrWhiteSpace(); var decoded = JsonSerializer.Deserialize(json); decoded.ShouldNotBeNull(); decoded.Index.ShouldBe(original.Index); decoded.Term.ShouldBe(original.Term); decoded.Command.ShouldBe(original.Command); } [Fact] public void Append_entry_with_empty_command_round_trips() { // Reference: TestNRGAppendEntryEncode — Go test encodes entry with nil data. var original = new RaftLogEntry(Index: 5, Term: 2, Command: string.Empty); var json = JsonSerializer.Serialize(original); var decoded = JsonSerializer.Deserialize(json); decoded.ShouldNotBeNull(); decoded.Index.ShouldBe(5); decoded.Term.ShouldBe(2); decoded.Command.ShouldBe(string.Empty); } [Fact] public void Multiple_entries_encode_decode_preserves_order() { // Reference: TestNRGAppendEntryEncode — Go test encodes multiple entries. var entries = Enumerable.Range(0, 100) .Select(i => new RaftLogEntry(Index: i + 1, Term: 1, Command: $"cmd-{i}")) .ToList(); var json = JsonSerializer.Serialize(entries); var decoded = JsonSerializer.Deserialize>(json); decoded.ShouldNotBeNull(); decoded.Count.ShouldBe(100); for (var i = 0; i < 100; i++) { decoded[i].Index.ShouldBe(i + 1); decoded[i].Term.ShouldBe(1); decoded[i].Command.ShouldBe($"cmd-{i}"); } } [Fact] public void Log_append_assigns_sequential_indices() { var log = new RaftLog(); var e1 = log.Append(term: 1, command: "first"); var e2 = log.Append(term: 1, command: "second"); var e3 = log.Append(term: 2, command: "third"); e1.Index.ShouldBe(1); e2.Index.ShouldBe(2); e3.Index.ShouldBe(3); log.Entries.Count.ShouldBe(3); log.Entries[0].Command.ShouldBe("first"); log.Entries[1].Command.ShouldBe("second"); log.Entries[2].Command.ShouldBe("third"); } [Fact] public void Log_append_replicated_deduplicates_by_index() { var log = new RaftLog(); var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "cmd"); log.AppendReplicated(entry); log.AppendReplicated(entry); // duplicate should be ignored log.Entries.Count.ShouldBe(1); } [Fact] public void Log_replace_with_snapshot_clears_entries_and_resets_base() { // Reference: TestNRGSnapshotAndRestart — snapshot replaces log. var log = new RaftLog(); log.Append(term: 1, command: "a"); log.Append(term: 1, command: "b"); log.Append(term: 1, command: "c"); log.Entries.Count.ShouldBe(3); var snapshot = new RaftSnapshot { LastIncludedIndex = 3, LastIncludedTerm = 1, }; log.ReplaceWithSnapshot(snapshot); log.Entries.Count.ShouldBe(0); // After snapshot, new entries should start at index 4. var e = log.Append(term: 2, command: "post-snapshot"); e.Index.ShouldBe(4); } [Fact] public async Task Log_persist_and_reload_round_trips() { // Reference: TestNRGSnapshotAndRestart — persistence round-trip. var dir = Path.Combine(Path.GetTempPath(), $"nats-raft-log-test-{Guid.NewGuid():N}"); var logPath = Path.Combine(dir, "log.json"); try { var log = new RaftLog(); log.Append(term: 1, command: "alpha"); log.Append(term: 1, command: "beta"); log.Append(term: 2, command: "gamma"); await log.PersistAsync(logPath, CancellationToken.None); File.Exists(logPath).ShouldBeTrue(); var reloaded = await RaftLog.LoadAsync(logPath, CancellationToken.None); reloaded.Entries.Count.ShouldBe(3); reloaded.Entries[0].Index.ShouldBe(1); reloaded.Entries[0].Term.ShouldBe(1); reloaded.Entries[0].Command.ShouldBe("alpha"); reloaded.Entries[1].Command.ShouldBe("beta"); reloaded.Entries[2].Command.ShouldBe("gamma"); reloaded.Entries[2].Term.ShouldBe(2); } finally { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } } [Fact] public async Task Log_load_returns_empty_for_nonexistent_path() { var logPath = Path.Combine(Path.GetTempPath(), $"nats-raft-noexist-{Guid.NewGuid():N}", "log.json"); var log = await RaftLog.LoadAsync(logPath, CancellationToken.None); log.Entries.Count.ShouldBe(0); } [Fact] public void Entry_record_equality_holds_for_identical_values() { // RaftLogEntry is a sealed record — structural equality should work. var a = new RaftLogEntry(Index: 1, Term: 1, Command: "cmd"); var b = new RaftLogEntry(Index: 1, Term: 1, Command: "cmd"); a.ShouldBe(b); var c = new RaftLogEntry(Index: 2, Term: 1, Command: "cmd"); a.ShouldNotBe(c); } [Fact] public void Entry_term_is_preserved_through_append() { var log = new RaftLog(); var e1 = log.Append(term: 3, command: "term3-entry"); var e2 = log.Append(term: 5, command: "term5-entry"); e1.Term.ShouldBe(3); e2.Term.ShouldBe(5); log.Entries[0].Term.ShouldBe(3); log.Entries[1].Term.ShouldBe(5); } }