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:
@@ -0,0 +1,46 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubListCtorAndNotificationParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_with_enableCache_false_disables_cache()
|
||||
{
|
||||
var subList = new SubList(enableCache: false);
|
||||
|
||||
subList.CacheEnabled().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSublistNoCache_factory_disables_cache()
|
||||
{
|
||||
var subList = SubList.NewSublistNoCache();
|
||||
|
||||
subList.CacheEnabled().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterNotification_emits_true_on_first_interest_and_false_on_last_interest()
|
||||
{
|
||||
var subList = new SubList();
|
||||
var notifications = new List<bool>();
|
||||
subList.RegisterNotification(v => notifications.Add(v));
|
||||
|
||||
var sub = new Subscription { Subject = "foo", Sid = "1" };
|
||||
subList.Insert(sub);
|
||||
subList.Remove(sub);
|
||||
|
||||
notifications.ShouldBe([true, false]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectMatch_alias_helpers_match_existing_behavior()
|
||||
{
|
||||
SubjectMatch.SubjectHasWildcard("foo.*").ShouldBeTrue();
|
||||
SubjectMatch.SubjectHasWildcard("foo.bar").ShouldBeFalse();
|
||||
|
||||
SubjectMatch.IsValidLiteralSubject("foo.bar").ShouldBeTrue();
|
||||
SubjectMatch.IsValidLiteralSubject("foo.*").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubListParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterQueueNotification_tracks_first_and_last_exact_queue_interest()
|
||||
{
|
||||
var subList = new SubList();
|
||||
var notifications = new List<bool>();
|
||||
Action<bool> callback = hasInterest => notifications.Add(hasInterest);
|
||||
|
||||
subList.RegisterQueueNotification("foo.bar", "q", callback).ShouldBeTrue();
|
||||
notifications.ShouldBe([false]);
|
||||
|
||||
var sub1 = new Subscription { Subject = "foo.bar", Queue = "q", Sid = "1" };
|
||||
var sub2 = new Subscription { Subject = "foo.bar", Queue = "q", Sid = "2" };
|
||||
|
||||
subList.Insert(sub1);
|
||||
subList.Insert(sub2);
|
||||
notifications.ShouldBe([false, true]);
|
||||
|
||||
subList.Remove(sub1);
|
||||
notifications.ShouldBe([false, true]);
|
||||
|
||||
subList.Remove(sub2);
|
||||
notifications.ShouldBe([false, true, false]);
|
||||
|
||||
subList.ClearQueueNotification("foo.bar", "q", callback).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRemoteQSub_updates_queue_weight_for_match_remote()
|
||||
{
|
||||
var subList = new SubList();
|
||||
var original = new RemoteSubscription("foo.bar", "q", "R1", Account: "A", QueueWeight: 1);
|
||||
subList.ApplyRemoteSub(original);
|
||||
subList.MatchRemote("A", "foo.bar").Count.ShouldBe(1);
|
||||
|
||||
subList.UpdateRemoteQSub(original with { QueueWeight = 3 });
|
||||
subList.MatchRemote("A", "foo.bar").Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubListStats_Add_aggregates_stats_like_go()
|
||||
{
|
||||
var stats = new SubListStats
|
||||
{
|
||||
NumSubs = 1,
|
||||
NumCache = 2,
|
||||
NumInserts = 3,
|
||||
NumRemoves = 4,
|
||||
NumMatches = 10,
|
||||
MaxFanout = 5,
|
||||
TotalFanout = 8,
|
||||
CacheEntries = 2,
|
||||
CacheHits = 6,
|
||||
};
|
||||
|
||||
stats.Add(new SubListStats
|
||||
{
|
||||
NumSubs = 2,
|
||||
NumCache = 3,
|
||||
NumInserts = 4,
|
||||
NumRemoves = 5,
|
||||
NumMatches = 30,
|
||||
MaxFanout = 9,
|
||||
TotalFanout = 12,
|
||||
CacheEntries = 3,
|
||||
CacheHits = 15,
|
||||
});
|
||||
|
||||
stats.NumSubs.ShouldBe((uint)3);
|
||||
stats.NumCache.ShouldBe((uint)5);
|
||||
stats.NumInserts.ShouldBe((ulong)7);
|
||||
stats.NumRemoves.ShouldBe((ulong)9);
|
||||
stats.NumMatches.ShouldBe((ulong)40);
|
||||
stats.MaxFanout.ShouldBe((uint)9);
|
||||
stats.AvgFanout.ShouldBe(4.0);
|
||||
stats.CacheHitRate.ShouldBe(0.525);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumLevels_returns_max_trie_depth()
|
||||
{
|
||||
var subList = new SubList();
|
||||
subList.Insert(new Subscription { Subject = "foo.bar.baz", Sid = "1" });
|
||||
subList.Insert(new Subscription { Subject = "foo.bar", Sid = "2" });
|
||||
|
||||
subList.NumLevels().ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalSubs_filters_non_local_kinds_and_optionally_includes_leaf()
|
||||
{
|
||||
var subList = new SubList();
|
||||
subList.Insert(new Subscription { Subject = "foo.a", Sid = "1", Client = new TestClient(ClientKind.Client) });
|
||||
subList.Insert(new Subscription { Subject = "foo.b", Sid = "2", Client = new TestClient(ClientKind.Router) });
|
||||
subList.Insert(new Subscription { Subject = "foo.c", Sid = "3", Client = new TestClient(ClientKind.System) });
|
||||
subList.Insert(new Subscription { Subject = "foo.d", Sid = "4", Client = new TestClient(ClientKind.Leaf) });
|
||||
|
||||
var local = subList.LocalSubs(includeLeafHubs: false).Select(s => s.Sid).OrderBy(x => x).ToArray();
|
||||
local.ShouldBe(["1", "3"]);
|
||||
|
||||
var withLeaf = subList.LocalSubs(includeLeafHubs: true).Select(s => s.Sid).OrderBy(x => x).ToArray();
|
||||
withLeaf.ShouldBe(["1", "3", "4"]);
|
||||
}
|
||||
|
||||
private sealed class TestClient(ClientKind kind) : INatsClient
|
||||
{
|
||||
public ulong Id => 1;
|
||||
public ClientKind Kind => kind;
|
||||
public Account? Account => null;
|
||||
public ClientOptions? ClientOpts => null;
|
||||
public ClientPermissions? Permissions => null;
|
||||
public void SendMessage(string subject, string sid, string? replyTo, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
}
|
||||
|
||||
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
|
||||
|
||||
public void RemoveSubscription(string sid)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubjectSubsetMatchParityBatch1Tests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("foo.bar", "foo.bar", true)]
|
||||
[InlineData("foo.bar", "foo.*", true)]
|
||||
[InlineData("foo.bar", "foo.>", true)]
|
||||
[InlineData("foo.bar", "*.*", true)]
|
||||
[InlineData("foo.bar", ">", true)]
|
||||
[InlineData("foo.bar", "foo.baz", false)]
|
||||
[InlineData("foo.bar.baz", "foo.*", false)]
|
||||
public void SubjectMatchesFilter_matches_go_subset_behavior(string subject, string filter, bool expected)
|
||||
{
|
||||
SubjectMatch.SubjectMatchesFilter(subject, filter).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectIsSubsetMatch_uses_subject_tokens_against_test_pattern()
|
||||
{
|
||||
SubjectMatch.SubjectIsSubsetMatch("foo.*", "foo.*").ShouldBeTrue();
|
||||
SubjectMatch.SubjectIsSubsetMatch("foo.*", "foo.bar").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSubsetMatch_tokenizes_test_subject_and_delegates_to_tokenized_matcher()
|
||||
{
|
||||
SubjectMatch.IsSubsetMatch(["foo", "bar"], "foo.*").ShouldBeTrue();
|
||||
SubjectMatch.IsSubsetMatch(["foo", "bar"], "foo.baz").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSubsetMatchTokenized_handles_fwc_and_rejects_empty_tokens_like_go()
|
||||
{
|
||||
SubjectMatch.IsSubsetMatchTokenized(["foo", "bar"], ["foo", ">"]).ShouldBeTrue();
|
||||
SubjectMatch.IsSubsetMatchTokenized(["foo", "bar"], ["foo", ""]).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubjectTransformParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidateMapping_accepts_supported_templates_and_rejects_invalid_templates()
|
||||
{
|
||||
SubjectTransform.ValidateMapping("dest.$1").ShouldBeTrue();
|
||||
SubjectTransform.ValidateMapping("dest.{{partition(10)}}").ShouldBeTrue();
|
||||
SubjectTransform.ValidateMapping("dest.{{random(5)}}").ShouldBeTrue();
|
||||
|
||||
SubjectTransform.ValidateMapping("dest.*").ShouldBeFalse();
|
||||
SubjectTransform.ValidateMapping("dest.{{wildcard()}}").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSubjectTransformStrict_requires_all_source_wildcards_to_be_used()
|
||||
{
|
||||
SubjectTransform.NewSubjectTransformWithStrict("foo.*.*", "bar.$1", strict: true).ShouldBeNull();
|
||||
SubjectTransform.NewSubjectTransformWithStrict("foo.*.*", "bar.$1", strict: false).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSubjectTransformStrict_accepts_when_all_source_wildcards_are_used()
|
||||
{
|
||||
var transform = SubjectTransform.NewSubjectTransformStrict("foo.*.*", "bar.$2.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.A.B").ShouldBe("bar.B.A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_transform_function_returns_bucket_in_range()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "rand.{{random(3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var output = transform.Apply("foo");
|
||||
output.ShouldNotBeNull();
|
||||
var parts = output!.Split('.');
|
||||
parts.Length.ShouldBe(2);
|
||||
int.TryParse(parts[1], out var bucket).ShouldBeTrue();
|
||||
bucket.ShouldBeGreaterThanOrEqualTo(0);
|
||||
bucket.ShouldBeLessThan(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransformTokenize_and_transformUntokenize_round_trip_wildcards()
|
||||
{
|
||||
var tokenized = SubjectTransform.TransformTokenize("foo.*.*");
|
||||
tokenized.ShouldBe("foo.$1.$2");
|
||||
|
||||
var untokenized = SubjectTransform.TransformUntokenize(tokenized);
|
||||
untokenized.ShouldBe("foo.*.*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reverse_produces_inverse_transform_for_reordered_wildcards()
|
||||
{
|
||||
var forward = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
|
||||
forward.ShouldNotBeNull();
|
||||
|
||||
var reverse = forward.Reverse();
|
||||
reverse.ShouldNotBeNull();
|
||||
|
||||
var mapped = forward.Apply("foo.A.B");
|
||||
mapped.ShouldBe("bar.B.A");
|
||||
reverse.Apply(mapped!).ShouldBe("foo.A.B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransformSubject_applies_transform_without_source_match_guard()
|
||||
{
|
||||
var transform = SubjectTransform.Create("foo.*", "bar.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
|
||||
transform.TransformSubject("baz.qux").ShouldBe("bar.qux");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user