refactor: rename remaining tests to NATS.Server.Core.Tests
- Rename tests/NATS.Server.Tests -> tests/NATS.Server.Core.Tests - Update solution file, InternalsVisibleTo, and csproj references - Remove JETSTREAM_INTEGRATION_MATRIX and NATS.NKeys from csproj (moved to JetStream.Tests and Auth.Tests) - Update all namespaces from NATS.Server.Tests.* to NATS.Server.Core.Tests.* - Replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls - Fix stale namespace in Transport.Tests/NetworkingGoParityTests.cs
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// Go reference: server/client.go routeCache, maxResultCacheSize=8192.
|
||||
|
||||
using NATS.Server.Subscriptions;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for RouteResultCache — the per-account LRU cache that stores
|
||||
/// SubListResult objects keyed by subject string to avoid repeated SubList.Match() calls.
|
||||
/// Go reference: server/client.go routeCache field, maxResultCacheSize=8192.
|
||||
/// </summary>
|
||||
public class RouteResultCacheTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static SubListResult MakeResult(string subject)
|
||||
{
|
||||
var sub = new Subscription { Subject = subject, Sid = "1" };
|
||||
return new SubListResult([sub], []);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Basic lookup behaviour
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An empty cache must return false for any subject.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryGet_returns_false_on_empty_cache()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
|
||||
var found = cache.TryGet("foo.bar", out var result);
|
||||
|
||||
found.ShouldBeFalse();
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A result placed with Set must be retrievable via TryGet.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Set_and_TryGet_round_trips_result()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
var expected = MakeResult("foo");
|
||||
cache.Set("foo", expected);
|
||||
|
||||
var found = cache.TryGet("foo", out var actual);
|
||||
|
||||
found.ShouldBeTrue();
|
||||
actual.ShouldBeSameAs(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LRU eviction
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// When a capacity+1 entry is added, the oldest (LRU) entry must be evicted.
|
||||
/// Go reference: maxResultCacheSize=8192 with LRU eviction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LRU_eviction_at_capacity()
|
||||
{
|
||||
const int cap = 4;
|
||||
var cache = new RouteResultCache(capacity: cap);
|
||||
|
||||
// Fill to capacity — "first" is the oldest (LRU)
|
||||
cache.Set("first", MakeResult("first"));
|
||||
cache.Set("second", MakeResult("second"));
|
||||
cache.Set("third", MakeResult("third"));
|
||||
cache.Set("fourth", MakeResult("fourth"));
|
||||
|
||||
// Adding a 5th entry must evict "first"
|
||||
cache.Set("fifth", MakeResult("fifth"));
|
||||
|
||||
cache.Count.ShouldBe(cap);
|
||||
cache.TryGet("first", out _).ShouldBeFalse();
|
||||
cache.TryGet("fifth", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After accessing A, B, C (capacity=3), accessing A promotes it to MRU.
|
||||
/// The next Set evicts B (now the true LRU) rather than A.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LRU_order_updated_on_access()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 3);
|
||||
cache.Set("A", MakeResult("A"));
|
||||
cache.Set("B", MakeResult("B"));
|
||||
cache.Set("C", MakeResult("C"));
|
||||
|
||||
// Access A so it becomes MRU; now LRU order is: B, A, C (front=C, back=B)
|
||||
cache.TryGet("A", out _);
|
||||
|
||||
// D evicts the LRU entry — should be B, not A
|
||||
cache.Set("D", MakeResult("D"));
|
||||
|
||||
cache.TryGet("A", out _).ShouldBeTrue();
|
||||
cache.TryGet("B", out _).ShouldBeFalse();
|
||||
cache.TryGet("C", out _).ShouldBeTrue();
|
||||
cache.TryGet("D", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Generation / invalidation
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// After Invalidate(), the next TryGet must detect the generation mismatch,
|
||||
/// clear all entries, and return false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Invalidate_clears_on_next_access()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
cache.Set("a", MakeResult("a"));
|
||||
cache.Set("b", MakeResult("b"));
|
||||
|
||||
cache.Invalidate();
|
||||
|
||||
cache.TryGet("a", out var result).ShouldBeFalse();
|
||||
result.ShouldBeNull();
|
||||
cache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each call to Invalidate() must increment the generation counter by 1.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Generation_increments_on_invalidate()
|
||||
{
|
||||
var cache = new RouteResultCache();
|
||||
var gen0 = cache.Generation;
|
||||
|
||||
cache.Invalidate();
|
||||
cache.Generation.ShouldBe(gen0 + 1);
|
||||
|
||||
cache.Invalidate();
|
||||
cache.Generation.ShouldBe(gen0 + 2);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Statistics
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Hits counter increments on each successful TryGet.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Hits_incremented_on_cache_hit()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
cache.Set("subject", MakeResult("subject"));
|
||||
|
||||
cache.TryGet("subject", out _);
|
||||
cache.TryGet("subject", out _);
|
||||
|
||||
cache.Hits.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Misses counter increments on each failed TryGet.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Misses_incremented_on_cache_miss()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
|
||||
cache.TryGet("nope", out _);
|
||||
cache.TryGet("also.nope", out _);
|
||||
|
||||
cache.Misses.ShouldBe(2);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clear
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Clear() must remove all entries immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Clear_removes_all_entries()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
cache.Set("x", MakeResult("x"));
|
||||
cache.Set("y", MakeResult("y"));
|
||||
|
||||
cache.Clear();
|
||||
|
||||
cache.Count.ShouldBe(0);
|
||||
cache.TryGet("x", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Update existing entry
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Setting the same key twice must replace the value; TryGet returns the latest result.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Set_updates_existing_entry()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
var first = MakeResult("v1");
|
||||
var second = MakeResult("v2");
|
||||
|
||||
cache.Set("key", first);
|
||||
cache.Set("key", second);
|
||||
|
||||
cache.Count.ShouldBe(1);
|
||||
cache.TryGet("key", out var returned).ShouldBeTrue();
|
||||
returned.ShouldBeSameAs(second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.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,869 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Single-token subject round-trips through insert and match.
|
||||
/// Ref: TestSublistInit / TestSublistInsertCount (sublist_test.go:117,122)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A multi-token literal subject matches itself exactly.
|
||||
/// Ref: TestSublistSimpleMultiTokens (sublist_test.go:154)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A partial wildcard at the end of a pattern matches the final literal token.
|
||||
/// Ref: TestSublistPartialWildcardAtEnd (sublist_test.go:190)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subjects with two tokens do not match a single-token subscription.
|
||||
/// Ref: TestSublistTwoTokenPubMatchSingleTokenSub (sublist_test.go:749)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Removing wildcard subscriptions decrements the count and clears match results.
|
||||
/// Ref: TestSublistRemoveWildcard (sublist_test.go:255)
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty result is a shared singleton — two calls that yield no matches return
|
||||
/// the same object reference.
|
||||
/// Ref: TestSublistSharedEmptyResult (sublist_test.go:1049)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// IsValidPublishSubject (IsLiteral) rejects wildcard tokens and partial-wildcard
|
||||
/// embedded in longer tokens is treated as a literal.
|
||||
/// Ref: TestSublistValidLiteralSubjects (sublist_test.go:585)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsValidSubject accepts subjects with embedded wildcard characters
|
||||
/// that are not standalone tokens, and rejects subjects with empty tokens.
|
||||
/// Ref: TestSublistValidSubjects (sublist_test.go:612)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsLiteral correctly identifies subjects with embedded wildcard characters
|
||||
/// (but not standalone wildcard tokens) as literal.
|
||||
/// Ref: TestSubjectIsLiteral (sublist_test.go:673)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MatchLiteral handles embedded wildcard-chars-as-literals correctly.
|
||||
/// Ref: TestSublistMatchLiterals (sublist_test.go:644)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// SubjectsCollide correctly identifies whether two subject patterns can
|
||||
/// match the same literal subject.
|
||||
/// Ref: TestSublistSubjectCollide (sublist_test.go:1548)
|
||||
/// </summary>
|
||||
[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)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stats.NumCache is 0 when cache is empty (no matches have been performed yet).
|
||||
/// Ref: TestSublistNoCacheStats (sublist_test.go:1064)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// HasInterest returns true for subjects with matching subscriptions and false
|
||||
/// otherwise, including after removal. Wildcard subscriptions match correctly.
|
||||
/// Ref: TestSublistHasInterest (sublist_test.go:1609)
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HasInterest handles queue subscriptions: a queue sub creates interest
|
||||
/// even though PlainSubs is empty.
|
||||
/// Ref: TestSublistHasInterest (queue part) (sublist_test.go:1682)
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HasInterest correctly handles overlapping subscriptions where a literal
|
||||
/// subject coexists with a wildcard at the same level.
|
||||
/// Ref: TestSublistHasInterestOverlapping (sublist_test.go:1775)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// NumInterest returns counts of plain and queue subscribers separately for
|
||||
/// literal subjects, wildcards, and queue-group subjects.
|
||||
/// Ref: TestSublistNumInterest (sublist_test.go:1783)
|
||||
/// </summary>
|
||||
[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
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ReverseMatch finds a subscription even when the query has extra wildcard
|
||||
/// tokens beyond what the stored pattern has.
|
||||
/// Ref: TestSublistReverseMatchWider (sublist_test.go:1508)
|
||||
/// </summary>
|
||||
[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)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="InterestChange"/> event instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InterestChanged_fires_on_first_insert_and_last_remove()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var events = new List<InterestChange>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InterestChanged events are raised for queue subscriptions with the correct
|
||||
/// Queue field populated.
|
||||
/// Ref: TestSublistRegisterInterestNotification (queue sub section) (sublist_test.go:1321)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InterestChanged_carries_queue_name_for_queue_subs()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var events = new List<InterestChange>();
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemoveBatch_removes_all_and_subscription_count_drops_to_zero()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var inserts = new List<InterestChange>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.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.Core.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.Core.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