// Go reference: golang/nats-server/server/sublist_test.go // Ports Go sublist tests not yet covered by SubListTests.cs or the SubList/ subfolder. using NATS.Server.Subscriptions; namespace NATS.Server.Core.Tests; /// /// Go parity tests for SubList ported from sublist_test.go. /// Covers basic multi-token matching, wildcard removal, cache eviction, /// subject-validity helpers, queue results, reverse match, HasInterest, /// NumInterest, and cache hit-rate statistics. /// public class SubListGoParityTests { // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private static Subscription MakeSub(string subject, string? queue = null, string sid = "1") => new() { Subject = subject, Queue = queue, Sid = sid }; // ========================================================================= // Basic insert / match // ========================================================================= /// /// Single-token subject round-trips through insert and match. /// Ref: TestSublistInit / TestSublistInsertCount (sublist_test.go:117,122) /// [Fact] public void Init_count_is_zero_and_grows_with_inserts() { var sl = new SubList(); sl.Count.ShouldBe(0u); sl.Insert(MakeSub("foo", sid: "1")); sl.Insert(MakeSub("bar", sid: "2")); sl.Insert(MakeSub("foo.bar", sid: "3")); sl.Count.ShouldBe(3u); } /// /// A multi-token literal subject matches itself exactly. /// Ref: TestSublistSimpleMultiTokens (sublist_test.go:154) /// [Fact] public void Simple_multi_token_match() { var sl = new SubList(); var sub = MakeSub("foo.bar.baz"); sl.Insert(sub); var r = sl.Match("foo.bar.baz"); r.PlainSubs.ShouldHaveSingleItem(); r.PlainSubs[0].ShouldBeSameAs(sub); } /// /// A partial wildcard at the end of a pattern matches the final literal token. /// Ref: TestSublistPartialWildcardAtEnd (sublist_test.go:190) /// [Fact] public void Partial_wildcard_at_end_matches_final_token() { var sl = new SubList(); var lsub = MakeSub("a.b.c", sid: "1"); var psub = MakeSub("a.b.*", sid: "2"); sl.Insert(lsub); sl.Insert(psub); var r = sl.Match("a.b.c"); r.PlainSubs.Length.ShouldBe(2); r.PlainSubs.ShouldContain(lsub); r.PlainSubs.ShouldContain(psub); } /// /// Subjects with two tokens do not match a single-token subscription. /// Ref: TestSublistTwoTokenPubMatchSingleTokenSub (sublist_test.go:749) /// [Fact] public void Two_token_pub_does_not_match_single_token_sub() { var sl = new SubList(); var sub = MakeSub("foo"); sl.Insert(sub); sl.Match("foo").PlainSubs.ShouldHaveSingleItem(); sl.Match("foo.bar").PlainSubs.ShouldBeEmpty(); } // ========================================================================= // Removal with wildcards // ========================================================================= /// /// Removing wildcard subscriptions decrements the count and clears match results. /// Ref: TestSublistRemoveWildcard (sublist_test.go:255) /// [Fact] public void Remove_wildcard_subscriptions() { var sl = new SubList(); var sub = MakeSub("a.b.c.d", sid: "1"); var psub = MakeSub("a.b.*.d", sid: "2"); var fsub = MakeSub("a.b.>", sid: "3"); sl.Insert(sub); sl.Insert(psub); sl.Insert(fsub); sl.Count.ShouldBe(3u); sl.Match("a.b.c.d").PlainSubs.Length.ShouldBe(3); sl.Remove(sub); sl.Count.ShouldBe(2u); sl.Remove(fsub); sl.Count.ShouldBe(1u); sl.Remove(psub); sl.Count.ShouldBe(0u); sl.Match("a.b.c.d").PlainSubs.ShouldBeEmpty(); } /// /// Inserting a subscription with a wildcard literal token (e.g. "foo.*-") and /// then removing it leaves the list empty and no spurious match on "foo.bar". /// Ref: TestSublistRemoveWithWildcardsAsLiterals (sublist_test.go:789) /// [Theory] [InlineData("foo.*-")] [InlineData("foo.>-")] public void Remove_with_wildcard_as_literal(string subject) { var sl = new SubList(); var sub = MakeSub(subject); sl.Insert(sub); // Removing a non-existent subscription does nothing sl.Remove(MakeSub("foo.bar")); sl.Count.ShouldBe(1u); sl.Remove(sub); sl.Count.ShouldBe(0u); } // ========================================================================= // Cache behaviour // ========================================================================= /// /// After inserting three subscriptions, adding a new wildcard subscription /// invalidates the cached result and subsequent matches include the new sub. /// Ref: TestSublistCache (sublist_test.go:423) /// [Fact] public void Cache_invalidated_by_subsequent_inserts() { var sl = new SubList(); var sub = MakeSub("a.b.c.d", sid: "1"); var psub = MakeSub("a.b.*.d", sid: "2"); var fsub = MakeSub("a.b.>", sid: "3"); sl.Insert(sub); sl.Match("a.b.c.d").PlainSubs.ShouldHaveSingleItem(); sl.Insert(psub); sl.Insert(fsub); sl.Count.ShouldBe(3u); var r = sl.Match("a.b.c.d"); r.PlainSubs.Length.ShouldBe(3); r.PlainSubs.ShouldContain(sub); r.PlainSubs.ShouldContain(psub); r.PlainSubs.ShouldContain(fsub); sl.Remove(sub); sl.Remove(fsub); sl.Remove(psub); sl.Count.ShouldBe(0u); // Cache is cleared by each removal (generation bump), but a subsequent Match // may re-populate it with an empty result — verify no matching subs are found. sl.Match("a.b.c.d").PlainSubs.ShouldBeEmpty(); } /// /// Inserting a fwc sub after cache has been primed causes the next match to /// return all three matching subs. /// Ref: TestSublistCache (wildcard part) (sublist_test.go:465) /// [Fact] public void Cache_updated_when_new_wildcard_inserted() { var sl = new SubList(); sl.Insert(MakeSub("foo.*", sid: "1")); sl.Insert(MakeSub("foo.bar", sid: "2")); sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem(); sl.Match("foo.bar").PlainSubs.Length.ShouldBe(2); sl.Insert(MakeSub("foo.>", sid: "3")); sl.Match("foo.bar").PlainSubs.Length.ShouldBe(3); } [Fact] public void Cache_generation_bump_rebuilds_match_result_after_insert_and_remove() { var sl = new SubList(); var exact = MakeSub("foo.bar", sid: "1"); var wildcard = MakeSub("foo.*", sid: "2"); sl.Insert(exact); var first = sl.Match("foo.bar"); var second = sl.Match("foo.bar"); ReferenceEquals(first, second).ShouldBeTrue(); first.PlainSubs.Select(sub => sub.Sid).ShouldBe(["1"]); sl.Insert(wildcard); var afterInsert = sl.Match("foo.bar"); ReferenceEquals(afterInsert, first).ShouldBeFalse(); afterInsert.PlainSubs.Select(sub => sub.Sid).OrderBy(x => x).ToArray().ShouldBe(["1", "2"]); sl.Remove(wildcard); var afterRemove = sl.Match("foo.bar"); ReferenceEquals(afterRemove, afterInsert).ShouldBeFalse(); afterRemove.PlainSubs.Select(sub => sub.Sid).ShouldBe(["1"]); } /// /// Empty result is a shared singleton — two calls that yield no matches return /// the same object reference. /// Ref: TestSublistSharedEmptyResult (sublist_test.go:1049) /// [Fact] public void Empty_result_is_shared_singleton() { var sl = new SubList(); var r1 = sl.Match("foo"); var r2 = sl.Match("bar"); r1.PlainSubs.ShouldBeEmpty(); r2.PlainSubs.ShouldBeEmpty(); ReferenceEquals(r1, r2).ShouldBeTrue(); } // ========================================================================= // Queue subscriptions // ========================================================================= /// /// After inserting two queue groups, adding a plain sub makes it visible /// in PlainSubs; adding more members to each group expands QueueSubs. /// Removing members correctly shrinks group counts. /// Ref: TestSublistBasicQueueResults (sublist_test.go:486) /// [Fact] public void Basic_queue_results_lifecycle() { var sl = new SubList(); const string subject = "foo"; var sub = MakeSub(subject, sid: "plain"); var sub1 = MakeSub(subject, queue: "bar", sid: "q1"); var sub2 = MakeSub(subject, queue: "baz", sid: "q2"); var sub3 = MakeSub(subject, queue: "bar", sid: "q3"); var sub4 = MakeSub(subject, queue: "baz", sid: "q4"); sl.Insert(sub1); var r = sl.Match(subject); r.PlainSubs.ShouldBeEmpty(); r.QueueSubs.Length.ShouldBe(1); sl.Insert(sub2); r = sl.Match(subject); r.QueueSubs.Length.ShouldBe(2); sl.Insert(sub); r = sl.Match(subject); r.PlainSubs.ShouldHaveSingleItem(); r.QueueSubs.Length.ShouldBe(2); sl.Insert(sub3); sl.Insert(sub4); r = sl.Match(subject); r.PlainSubs.ShouldHaveSingleItem(); r.QueueSubs.Length.ShouldBe(2); // Each group should have 2 members r.QueueSubs.ShouldAllBe(g => g.Length == 2); // Remove the plain sub sl.Remove(sub); r = sl.Match(subject); r.PlainSubs.ShouldBeEmpty(); r.QueueSubs.Length.ShouldBe(2); // Remove one member from "bar" group sl.Remove(sub1); r = sl.Match(subject); r.QueueSubs.Length.ShouldBe(2); // both groups still present // Remove remaining "bar" member sl.Remove(sub3); r = sl.Match(subject); r.QueueSubs.Length.ShouldBe(1); // only "baz" group remains // Remove both "baz" members sl.Remove(sub2); sl.Remove(sub4); r = sl.Match(subject); r.PlainSubs.ShouldBeEmpty(); r.QueueSubs.ShouldBeEmpty(); } // ========================================================================= // Subject validity helpers // ========================================================================= /// /// IsValidPublishSubject (IsLiteral) rejects wildcard tokens and partial-wildcard /// embedded in longer tokens is treated as a literal. /// Ref: TestSublistValidLiteralSubjects (sublist_test.go:585) /// [Theory] [InlineData("foo", true)] [InlineData(".foo", false)] [InlineData("foo.", false)] [InlineData("foo..bar", false)] [InlineData("foo.bar.*", false)] [InlineData("foo.bar.>", false)] [InlineData("*", false)] [InlineData(">", false)] [InlineData("foo*", true)] // embedded * not a wildcard [InlineData("foo**", true)] [InlineData("foo.**", true)] [InlineData("foo*bar", true)] [InlineData("foo.*bar", true)] [InlineData("foo*.bar", true)] [InlineData("*bar", true)] [InlineData("foo>", true)] [InlineData("foo>>", true)] [InlineData("foo.>>", true)] [InlineData("foo>bar", true)] [InlineData("foo.>bar", true)] [InlineData("foo>.bar", true)] [InlineData(">bar", true)] public void IsValidPublishSubject_cases(string subject, bool expected) { // Ref: TestSublistValidLiteralSubjects (sublist_test.go:585) SubjectMatch.IsValidPublishSubject(subject).ShouldBe(expected); } /// /// IsValidSubject accepts subjects with embedded wildcard characters /// that are not standalone tokens, and rejects subjects with empty tokens. /// Ref: TestSublistValidSubjects (sublist_test.go:612) /// [Theory] [InlineData(".", false)] [InlineData(".foo", false)] [InlineData("foo.", false)] [InlineData("foo..bar", false)] [InlineData(">.bar", false)] [InlineData("foo.>.bar", false)] [InlineData("foo", true)] [InlineData("foo.bar.*", true)] [InlineData("foo.bar.>", true)] [InlineData("*", true)] [InlineData(">", true)] [InlineData("foo*", true)] [InlineData("foo**", true)] [InlineData("foo.**", true)] [InlineData("foo*bar", true)] [InlineData("foo.*bar", true)] [InlineData("foo*.bar", true)] [InlineData("*bar", true)] [InlineData("foo>", true)] [InlineData("foo>>", true)] [InlineData("foo.>>", true)] [InlineData("foo>bar", true)] [InlineData("foo.>bar", true)] [InlineData("foo>.bar", true)] [InlineData(">bar", true)] public void IsValidSubject_cases(string subject, bool expected) { // Ref: TestSublistValidSubjects (sublist_test.go:612) SubjectMatch.IsValidSubject(subject).ShouldBe(expected); } /// /// IsLiteral correctly identifies subjects with embedded wildcard characters /// (but not standalone wildcard tokens) as literal. /// Ref: TestSubjectIsLiteral (sublist_test.go:673) /// [Theory] [InlineData("foo", true)] [InlineData("foo.bar", true)] [InlineData("foo*.bar", true)] [InlineData("*", false)] [InlineData(">", false)] [InlineData("foo.*", false)] [InlineData("foo.>", false)] [InlineData("foo.*.>", false)] [InlineData("foo.*.bar", false)] [InlineData("foo.bar.>", false)] public void IsLiteral_cases(string subject, bool expected) { // Ref: TestSubjectIsLiteral (sublist_test.go:673) SubjectMatch.IsLiteral(subject).ShouldBe(expected); } /// /// MatchLiteral handles embedded wildcard-chars-as-literals correctly. /// Ref: TestSublistMatchLiterals (sublist_test.go:644) /// [Theory] [InlineData("foo", "foo", true)] [InlineData("foo", "bar", false)] [InlineData("foo", "*", true)] [InlineData("foo", ">", true)] [InlineData("foo.bar", ">", true)] [InlineData("foo.bar", "foo.>", true)] [InlineData("foo.bar", "bar.>", false)] [InlineData("stats.test.22", "stats.>", true)] [InlineData("stats.test.22", "stats.*.*", true)] [InlineData("foo.bar", "foo", false)] [InlineData("stats.test.foos","stats.test.foos",true)] [InlineData("stats.test.foos","stats.test.foo", false)] [InlineData("stats.test", "stats.test.*", false)] [InlineData("stats.test.foos","stats.*", false)] [InlineData("stats.test.foos","stats.*.*.foos", false)] // Embedded wildcard chars treated as literals [InlineData("*bar", "*bar", true)] [InlineData("foo*", "foo*", true)] [InlineData("foo*bar", "foo*bar", true)] [InlineData("foo.***.bar", "foo.***.bar", true)] [InlineData(">bar", ">bar", true)] [InlineData("foo>", "foo>", true)] [InlineData("foo>bar", "foo>bar", true)] [InlineData("foo.>>>.bar", "foo.>>>.bar", true)] public void MatchLiteral_extended_cases(string literal, string pattern, bool expected) { // Ref: TestSublistMatchLiterals (sublist_test.go:644) SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected); } // ========================================================================= // Subject collide / subset // ========================================================================= /// /// SubjectsCollide correctly identifies whether two subject patterns can /// match the same literal subject. /// Ref: TestSublistSubjectCollide (sublist_test.go:1548) /// [Theory] [InlineData("foo.*", "foo.*.bar.>", false)] [InlineData("foo.*.bar.>", "foo.*", false)] [InlineData("foo.*", "foo.foo", true)] [InlineData("foo.*", "*.foo", true)] [InlineData("foo.bar.>", "*.bar.foo", true)] public void SubjectsCollide_cases(string s1, string s2, bool expected) { // Ref: TestSublistSubjectCollide (sublist_test.go:1548) SubjectMatch.SubjectsCollide(s1, s2).ShouldBe(expected); } // ========================================================================= // tokenAt (0-based in .NET vs 1-based in Go) // ========================================================================= /// /// TokenAt returns the nth dot-separated token (0-based in .NET). /// The Go tokenAt helper uses 1-based indexing with "" for index 0; the .NET /// port uses 0-based indexing throughout. /// Ref: TestSubjectToken (sublist_test.go:707) /// [Theory] [InlineData("foo.bar.baz.*", 0, "foo")] [InlineData("foo.bar.baz.*", 1, "bar")] [InlineData("foo.bar.baz.*", 2, "baz")] [InlineData("foo.bar.baz.*", 3, "*")] [InlineData("foo.bar.baz.*", 4, "")] // out of range public void TokenAt_zero_based(string subject, int index, string expected) { // Ref: TestSubjectToken (sublist_test.go:707) SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected); } // ========================================================================= // Stats / cache hit rate // ========================================================================= /// /// Cache hit rate is computed correctly after 4 Match calls on the same subject /// (first call misses, subsequent three hit the cache). /// Ref: TestSublistAddCacheHitRate (sublist_test.go:1556) /// [Fact] public void Cache_hit_rate_is_computed_correctly() { var sl = new SubList(); sl.Insert(MakeSub("foo")); for (var i = 0; i < 4; i++) sl.Match("foo"); // 4 calls total, first is a cache miss, next 3 hit → 3/4 = 0.75 var stats = sl.Stats(); stats.CacheHitRate.ShouldBe(0.75, 1e-9); } /// /// Stats.NumCache is 0 when cache is empty (no matches have been performed yet). /// Ref: TestSublistNoCacheStats (sublist_test.go:1064) /// [Fact] public void Stats_NumCache_reflects_cache_population() { var sl = new SubList(); sl.Insert(MakeSub("foo", sid: "1")); sl.Insert(MakeSub("bar", sid: "2")); sl.Insert(MakeSub("baz", sid: "3")); sl.Insert(MakeSub("foo.bar.baz", sid: "4")); // No matches performed yet — cache should be empty sl.Stats().NumCache.ShouldBe(0u); sl.Match("a.b.c"); sl.Match("bar"); // Two distinct subjects have been matched, so cache should have 2 entries sl.Stats().NumCache.ShouldBe(2u); } // ========================================================================= // HasInterest // ========================================================================= /// /// HasInterest returns true for subjects with matching subscriptions and false /// otherwise, including after removal. Wildcard subscriptions match correctly. /// Ref: TestSublistHasInterest (sublist_test.go:1609) /// [Fact] public void HasInterest_with_plain_and_wildcard_subs() { var sl = new SubList(); var fooSub = MakeSub("foo", sid: "1"); sl.Insert(fooSub); sl.HasInterest("foo").ShouldBeTrue(); sl.HasInterest("bar").ShouldBeFalse(); sl.Remove(fooSub); sl.HasInterest("foo").ShouldBeFalse(); // Partial wildcard var pwcSub = MakeSub("foo.*", sid: "2"); sl.Insert(pwcSub); sl.HasInterest("foo").ShouldBeFalse(); sl.HasInterest("foo.bar").ShouldBeTrue(); sl.HasInterest("foo.bar.baz").ShouldBeFalse(); sl.Remove(pwcSub); sl.HasInterest("foo.bar").ShouldBeFalse(); // Full wildcard var fwcSub = MakeSub("foo.>", sid: "3"); sl.Insert(fwcSub); sl.HasInterest("foo").ShouldBeFalse(); sl.HasInterest("foo.bar").ShouldBeTrue(); sl.HasInterest("foo.bar.baz").ShouldBeTrue(); sl.Remove(fwcSub); sl.HasInterest("foo.bar").ShouldBeFalse(); sl.HasInterest("foo.bar.baz").ShouldBeFalse(); } /// /// HasInterest handles queue subscriptions: a queue sub creates interest /// even though PlainSubs is empty. /// Ref: TestSublistHasInterest (queue part) (sublist_test.go:1682) /// [Fact] public void HasInterest_with_queue_subscriptions() { var sl = new SubList(); var qsub = MakeSub("foo", queue: "bar", sid: "1"); var qsub2 = MakeSub("foo", queue: "baz", sid: "2"); sl.Insert(qsub); sl.HasInterest("foo").ShouldBeTrue(); sl.HasInterest("foo.bar").ShouldBeFalse(); sl.Insert(qsub2); sl.HasInterest("foo").ShouldBeTrue(); sl.Remove(qsub); sl.HasInterest("foo").ShouldBeTrue(); // qsub2 still present sl.Remove(qsub2); sl.HasInterest("foo").ShouldBeFalse(); } /// /// HasInterest correctly handles overlapping subscriptions where a literal /// subject coexists with a wildcard at the same level. /// Ref: TestSublistHasInterestOverlapping (sublist_test.go:1775) /// [Fact] public void HasInterest_overlapping_subscriptions() { var sl = new SubList(); sl.Insert(MakeSub("stream.A.child", sid: "1")); sl.Insert(MakeSub("stream.*", sid: "2")); sl.HasInterest("stream.A.child").ShouldBeTrue(); sl.HasInterest("stream.A").ShouldBeTrue(); } // ========================================================================= // NumInterest // ========================================================================= /// /// NumInterest returns counts of plain and queue subscribers separately for /// literal subjects, wildcards, and queue-group subjects. /// Ref: TestSublistNumInterest (sublist_test.go:1783) /// [Fact] public void NumInterest_with_plain_subs() { var sl = new SubList(); var fooSub = MakeSub("foo", sid: "1"); sl.Insert(fooSub); var (np, nq) = sl.NumInterest("foo"); np.ShouldBe(1); nq.ShouldBe(0); sl.NumInterest("bar").ShouldBe((0, 0)); sl.Remove(fooSub); sl.NumInterest("foo").ShouldBe((0, 0)); } [Fact] public void NumInterest_with_wildcards() { var sl = new SubList(); var sub = MakeSub("foo.*", sid: "1"); sl.Insert(sub); sl.NumInterest("foo").ShouldBe((0, 0)); sl.NumInterest("foo.bar").ShouldBe((1, 0)); sl.NumInterest("foo.bar.baz").ShouldBe((0, 0)); sl.Remove(sub); sl.NumInterest("foo.bar").ShouldBe((0, 0)); } [Fact] public void NumInterest_with_queue_subs() { var sl = new SubList(); var qsub = MakeSub("foo", queue: "bar", sid: "1"); var qsub2 = MakeSub("foo", queue: "baz", sid: "2"); var qsub3 = MakeSub("foo", queue: "baz", sid: "3"); sl.Insert(qsub); sl.NumInterest("foo").ShouldBe((0, 1)); sl.Insert(qsub2); sl.NumInterest("foo").ShouldBe((0, 2)); sl.Insert(qsub3); sl.NumInterest("foo").ShouldBe((0, 3)); sl.Remove(qsub); sl.NumInterest("foo").ShouldBe((0, 2)); sl.Remove(qsub2); sl.NumInterest("foo").ShouldBe((0, 1)); sl.Remove(qsub3); sl.NumInterest("foo").ShouldBe((0, 0)); } // ========================================================================= // Reverse match // ========================================================================= /// /// ReverseMatch finds registered patterns that would match a given literal or /// wildcard subject, covering all combinations of *, >, and literals. /// Ref: TestSublistReverseMatch (sublist_test.go:1440) /// [Fact] public void ReverseMatch_comprehensive() { var sl = new SubList(); var fooSub = MakeSub("foo", sid: "1"); var barSub = MakeSub("bar", sid: "2"); var fooBarSub = MakeSub("foo.bar", sid: "3"); var fooBazSub = MakeSub("foo.baz", sid: "4"); var fooBarBazSub = MakeSub("foo.bar.baz", sid: "5"); sl.Insert(fooSub); sl.Insert(barSub); sl.Insert(fooBarSub); sl.Insert(fooBazSub); sl.Insert(fooBarBazSub); // ReverseMatch("foo") — only fooSub var r = sl.ReverseMatch("foo"); r.PlainSubs.Length.ShouldBe(1); r.PlainSubs.ShouldContain(fooSub); // ReverseMatch("bar") — only barSub r = sl.ReverseMatch("bar"); r.PlainSubs.ShouldHaveSingleItem(); r.PlainSubs.ShouldContain(barSub); // ReverseMatch("*") — single-token subs: foo and bar r = sl.ReverseMatch("*"); r.PlainSubs.Length.ShouldBe(2); r.PlainSubs.ShouldContain(fooSub); r.PlainSubs.ShouldContain(barSub); // ReverseMatch("baz") — no match sl.ReverseMatch("baz").PlainSubs.ShouldBeEmpty(); // ReverseMatch("foo.*") — foo.bar and foo.baz r = sl.ReverseMatch("foo.*"); r.PlainSubs.Length.ShouldBe(2); r.PlainSubs.ShouldContain(fooBarSub); r.PlainSubs.ShouldContain(fooBazSub); // ReverseMatch("*.*") — same two r = sl.ReverseMatch("*.*"); r.PlainSubs.Length.ShouldBe(2); r.PlainSubs.ShouldContain(fooBarSub); r.PlainSubs.ShouldContain(fooBazSub); // ReverseMatch("*.bar") — only fooBarSub r = sl.ReverseMatch("*.bar"); r.PlainSubs.ShouldHaveSingleItem(); r.PlainSubs.ShouldContain(fooBarSub); // ReverseMatch("bar.*") — no match sl.ReverseMatch("bar.*").PlainSubs.ShouldBeEmpty(); // ReverseMatch("foo.>") — 3 subs under foo r = sl.ReverseMatch("foo.>"); r.PlainSubs.Length.ShouldBe(3); r.PlainSubs.ShouldContain(fooBarSub); r.PlainSubs.ShouldContain(fooBazSub); r.PlainSubs.ShouldContain(fooBarBazSub); // ReverseMatch(">") — all 5 subs r = sl.ReverseMatch(">"); r.PlainSubs.Length.ShouldBe(5); } /// /// ReverseMatch finds a subscription even when the query has extra wildcard /// tokens beyond what the stored pattern has. /// Ref: TestSublistReverseMatchWider (sublist_test.go:1508) /// [Fact] public void ReverseMatch_wider_query() { var sl = new SubList(); var sub = MakeSub("uplink.*.*.>"); sl.Insert(sub); sl.ReverseMatch("uplink.1.*.*.>").PlainSubs.ShouldHaveSingleItem(); sl.ReverseMatch("uplink.1.2.3.>").PlainSubs.ShouldHaveSingleItem(); } // ========================================================================= // Match with empty tokens (should yield no results) // ========================================================================= /// /// Subjects with empty tokens (leading/trailing/double dots) never match any /// subscription, even when a catch-all '>' subscription is present. /// Ref: TestSublistMatchWithEmptyTokens (sublist_test.go:1522) /// [Theory] [InlineData(".foo")] [InlineData("..foo")] [InlineData("foo..")] [InlineData("foo.")] [InlineData("foo..bar")] [InlineData("foo...bar")] public void Match_with_empty_tokens_returns_empty(string badSubject) { // Ref: TestSublistMatchWithEmptyTokens (sublist_test.go:1522) var sl = new SubList(); sl.Insert(MakeSub(">", sid: "1")); sl.Insert(MakeSub(">", queue: "queue", sid: "2")); var r = sl.Match(badSubject); r.PlainSubs.ShouldBeEmpty(); r.QueueSubs.ShouldBeEmpty(); } // ========================================================================= // Interest notification (adapted from Go's channel-based API to .NET events) // ========================================================================= /// /// The InterestChanged event fires when subscriptions are inserted or removed. /// Inserting the first subscriber fires LocalAdded; removing the last fires /// LocalRemoved. Adding a second subscriber does not fire a redundant event. /// Ref: TestSublistRegisterInterestNotification (sublist_test.go:1126) — /// the Go API uses RegisterNotification with a channel; the .NET port exposes /// an event instead. /// [Fact] public void InterestChanged_fires_on_first_insert_and_last_remove() { using var sl = new SubList(); var events = new List(); sl.InterestChanged += e => events.Add(e); var sub1 = MakeSub("foo", sid: "1"); var sub2 = MakeSub("foo", sid: "2"); sl.Insert(sub1); events.Count.ShouldBe(1); events[0].Kind.ShouldBe(InterestChangeKind.LocalAdded); events[0].Subject.ShouldBe("foo"); sl.Insert(sub2); events.Count.ShouldBe(2); // second insert still fires (event per operation) sl.Remove(sub1); events.Count.ShouldBe(3); events[2].Kind.ShouldBe(InterestChangeKind.LocalRemoved); sl.Remove(sub2); events.Count.ShouldBe(4); events[3].Kind.ShouldBe(InterestChangeKind.LocalRemoved); } /// /// InterestChanged events are raised for queue subscriptions with the correct /// Queue field populated. /// Ref: TestSublistRegisterInterestNotification (queue sub section) (sublist_test.go:1321) /// [Fact] public void InterestChanged_carries_queue_name_for_queue_subs() { using var sl = new SubList(); var events = new List(); sl.InterestChanged += e => events.Add(e); var qsub = MakeSub("foo.bar.baz", queue: "q1", sid: "1"); sl.Insert(qsub); events[0].Queue.ShouldBe("q1"); events[0].Subject.ShouldBe("foo.bar.baz"); events[0].Kind.ShouldBe(InterestChangeKind.LocalAdded); sl.Remove(qsub); events[1].Kind.ShouldBe(InterestChangeKind.LocalRemoved); events[1].Queue.ShouldBe("q1"); } /// /// RemoveBatch removes all specified subscriptions in a single operation. /// Unlike individual Remove calls, RemoveBatch performs the removal atomically /// under a single write lock and does not fire InterestChanged per element — /// it is optimised for bulk teardown (e.g. client disconnect). /// After the batch, Match confirms that all removed subjects are gone. /// Ref: TestSublistRegisterInterestNotification (batch insert/remove) (sublist_test.go:1311) /// [Fact] public void RemoveBatch_removes_all_and_subscription_count_drops_to_zero() { using var sl = new SubList(); var inserts = new List(); sl.InterestChanged += e => { if (e.Kind == InterestChangeKind.LocalAdded) inserts.Add(e); }; var subs = Enumerable.Range(1, 4) .Select(i => MakeSub("foo", sid: i.ToString())) .ToArray(); foreach (var s in subs) sl.Insert(s); inserts.Count.ShouldBe(4); sl.Count.ShouldBe(4u); // RemoveBatch atomically removes all — count goes to zero sl.RemoveBatch(subs); sl.Count.ShouldBe(0u); sl.Match("foo").PlainSubs.ShouldBeEmpty(); } }