Fix E2E test gaps and add comprehensive E2E + parity test suites

- Fix pull consumer fetch: send original stream subject in HMSG (not inbox)
  so NATS client distinguishes data messages from control messages
- Fix MaxAge expiry: add background timer in StreamManager for periodic pruning
- Fix JetStream wire format: Go-compatible anonymous objects with string enums,
  proper offset-based pagination for stream/consumer list APIs
- Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream)
- Add ~1000 parity tests across all subsystems (gaps closure)
- Update gap inventory docs to reflect implementation status
This commit is contained in:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View File

@@ -0,0 +1,63 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftConfigAndStateParityBatch1Tests
{
[Fact]
public void RaftState_string_matches_go_labels()
{
RaftState.Follower.String().ShouldBe("Follower");
RaftState.Leader.String().ShouldBe("Leader");
RaftState.Candidate.String().ShouldBe("Candidate");
RaftState.Closed.String().ShouldBe("Closed");
}
[Fact]
public void RaftConfig_exposes_go_shape_fields()
{
var cfg = new RaftConfig
{
Name = "META",
Store = new object(),
Log = new object(),
Track = true,
Observer = true,
Recovering = true,
ScaleUp = true,
};
cfg.Name.ShouldBe("META");
cfg.Store.ShouldNotBeNull();
cfg.Log.ShouldNotBeNull();
cfg.Track.ShouldBeTrue();
cfg.Observer.ShouldBeTrue();
cfg.Recovering.ShouldBeTrue();
cfg.ScaleUp.ShouldBeTrue();
}
[Fact]
public void RaftNode_group_defaults_to_id_when_not_supplied()
{
using var node = new RaftNode("N1");
node.GroupName.ShouldBe("N1");
}
[Fact]
public void RaftNode_group_uses_explicit_value_when_supplied()
{
using var node = new RaftNode("N1", group: "G1");
node.GroupName.ShouldBe("G1");
}
[Fact]
public void RaftNode_created_utc_is_set_on_construction()
{
var before = DateTime.UtcNow;
using var node = new RaftNode("N1");
var after = DateTime.UtcNow;
node.CreatedUtc.ShouldBeGreaterThanOrEqualTo(before);
node.CreatedUtc.ShouldBeLessThanOrEqualTo(after);
}
}

View File

@@ -0,0 +1,149 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftNodeParityBatch2Tests
{
private static RaftNode ElectSingleNodeLeader()
{
var node = new RaftNode("n1");
node.ConfigureCluster([node]);
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
return node;
}
[Fact]
public void Leader_tracking_flags_update_on_election_and_heartbeat()
{
var node1 = new RaftNode("n1");
var node2 = new RaftNode("n2");
var node3 = new RaftNode("n3");
node1.ConfigureCluster([node1, node2, node3]);
node2.ConfigureCluster([node1, node2, node3]);
node3.ConfigureCluster([node1, node2, node3]);
node1.StartElection(3);
node1.ReceiveVote(node2.GrantVote(node1.Term, node1.Id), 3);
node1.IsLeader.ShouldBeTrue();
node1.GroupLeader.ShouldBe("n1");
node1.Leaderless.ShouldBeFalse();
node1.HadPreviousLeader.ShouldBeTrue();
node1.LeaderSince.ShouldNotBeNull();
node2.ReceiveHeartbeat(node1.Term, fromPeerId: "n1");
node2.IsLeader.ShouldBeFalse();
node2.GroupLeader.ShouldBe("n1");
node2.Leaderless.ShouldBeFalse();
node2.HadPreviousLeader.ShouldBeTrue();
node2.LeaderSince.ShouldBeNull();
}
[Fact]
public void Stepdown_clears_group_leader_and_leader_since()
{
using var leader = ElectSingleNodeLeader();
leader.GroupLeader.ShouldBe("n1");
leader.LeaderSince.ShouldNotBeNull();
leader.RequestStepDown();
leader.Leaderless.ShouldBeTrue();
leader.GroupLeader.ShouldBe(RaftNode.NoLeader);
leader.LeaderSince.ShouldBeNull();
}
[Fact]
public void Observer_mode_can_be_toggled()
{
using var node = new RaftNode("n1");
node.IsObserver.ShouldBeFalse();
node.SetObserver(true);
node.IsObserver.ShouldBeTrue();
node.SetObserver(false);
node.IsObserver.ShouldBeFalse();
}
[Fact]
public void Cluster_size_adjustments_enforce_boot_and_leader_rules()
{
using var node = new RaftNode("n1");
node.ClusterSize().ShouldBe(1);
node.AdjustBootClusterSize(1).ShouldBeTrue();
node.ClusterSize().ShouldBe(2); // floor is 2
node.ConfigureCluster([node]);
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
node.AdjustClusterSize(5).ShouldBeTrue();
node.ClusterSize().ShouldBe(5);
node.AdjustBootClusterSize(7).ShouldBeFalse();
}
[Fact]
public async Task Progress_size_and_applied_accessors_report_expected_values()
{
using var leader = ElectSingleNodeLeader();
await leader.ProposeAsync("abc", CancellationToken.None);
await leader.ProposeAsync("de", CancellationToken.None);
var progress = leader.Progress();
progress.Index.ShouldBe(2);
progress.Commit.ShouldBe(2);
progress.Applied.ShouldBe(2);
var size = leader.Size();
size.Entries.ShouldBe(2);
size.Bytes.ShouldBe(5);
var applied = leader.Applied(1);
applied.Entries.ShouldBe(1);
applied.Bytes.ShouldBe(3);
leader.ProcessedIndex.ShouldBe(1);
}
[Fact]
public void Campaign_timeout_randomization_and_defaults_match_go_constants()
{
using var node = new RaftNode("n1");
for (var i = 0; i < 20; i++)
{
var timeout = node.RandomizedCampaignTimeout();
timeout.ShouldBeGreaterThanOrEqualTo(RaftNode.MinCampaignTimeoutDefault);
timeout.ShouldBeLessThan(RaftNode.MaxCampaignTimeoutDefault);
}
RaftNode.HbIntervalDefault.ShouldBe(TimeSpan.FromSeconds(1));
RaftNode.LostQuorumIntervalDefault.ShouldBe(TimeSpan.FromSeconds(10));
RaftNode.ObserverModeIntervalDefault.ShouldBe(TimeSpan.FromHours(48));
RaftNode.PeerRemoveTimeoutDefault.ShouldBe(TimeSpan.FromMinutes(5));
RaftNode.NoLeader.ShouldBe(string.Empty);
RaftNode.NoVote.ShouldBe(string.Empty);
}
[Fact]
public void Stop_wait_for_stop_and_delete_set_lifecycle_state()
{
var path = Path.Combine(Path.GetTempPath(), $"raft-node-delete-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
File.WriteAllText(Path.Combine(path, "marker.txt"), "x");
using var node = new RaftNode("n1", persistDirectory: path);
node.IsDeleted.ShouldBeFalse();
node.Stop();
node.WaitForStop();
node.IsDeleted.ShouldBeFalse();
Directory.Exists(path).ShouldBeTrue();
node.Delete();
node.IsDeleted.ShouldBeTrue();
Directory.Exists(path).ShouldBeFalse();
}
}

View File

@@ -0,0 +1,79 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftParityBatch3Tests
{
[Fact]
public async Task ProposeMulti_proposes_entries_in_order()
{
using var leader = ElectSingleNodeLeader();
var indexes = await leader.ProposeMultiAsync(["cmd-1", "cmd-2", "cmd-3"], CancellationToken.None);
indexes.Count.ShouldBe(3);
indexes[0].ShouldBe(1);
indexes[1].ShouldBe(2);
indexes[2].ShouldBe(3);
leader.Log.Entries.Count.ShouldBe(3);
}
[Fact]
public void PeerState_tracks_lag_and_current_flags()
{
var peer = new RaftPeerState
{
PeerId = "n2",
NextIndex = 10,
MatchIndex = 7,
LastContact = DateTime.UtcNow,
};
peer.RecalculateLag();
peer.RefreshCurrent(TimeSpan.FromSeconds(1));
peer.Lag.ShouldBe(2);
peer.Current.ShouldBeTrue();
peer.LastContact = DateTime.UtcNow - TimeSpan.FromSeconds(5);
peer.RefreshCurrent(TimeSpan.FromSeconds(1));
peer.Current.ShouldBeFalse();
}
[Fact]
public void CommittedEntry_contains_index_and_entries()
{
var entries = new[]
{
new RaftLogEntry(42, 3, "set x"),
new RaftLogEntry(43, 3, "set y"),
};
var committed = new CommittedEntry(43, entries);
committed.Index.ShouldBe(43);
committed.Entries.Count.ShouldBe(2);
committed.Entries[0].Command.ShouldBe("set x");
}
[Fact]
public void RaftEntry_roundtrips_to_wire_shape()
{
var entry = new RaftEntry(RaftEntryType.AddPeer, new byte[] { 1, 2, 3 });
var wire = entry.ToWire();
var decoded = RaftEntry.FromWire(wire);
decoded.Type.ShouldBe(RaftEntryType.AddPeer);
decoded.Data.ShouldBe(new byte[] { 1, 2, 3 });
}
private static RaftNode ElectSingleNodeLeader()
{
var node = new RaftNode("n1");
node.ConfigureCluster([node]);
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
return node;
}
}