feat: port session 02 — Utilities & Queues (util, ipqueue, scheduler, subject_transform)
- ServerUtilities: version helpers, parseSize/parseInt64, parseHostPort, URL redaction, comma formatting, refCountedUrlSet, TCP helpers, parallelTaskQueue - IpQueue<T>: generic intra-process queue with 1-slot Channel<bool> notification signal, optional size/len limits, ConcurrentDictionary registry, single-slot List<T> pool - MsgScheduling: per-subject scheduled message tracking via HashWheel TTLs, binary encode/decode with zigzag varint, Timer-based firing - SubjectTransform: full NATS subject mapping engine (11 transform types: Wildcard, Partition, SplitFromLeft, SplitFromRight, SliceFromLeft, SliceFromRight, Split, Left, Right, Random, NoTransform), FNV-1a partition hash - 20 tests (7 util, 9 ipqueue, 4 subject_transform); 45 benchmarks/split tests marked n/a - All 113 tests pass (112 unit + 1 integration) - DB: features 328/3673 complete, tests 139/3257 complete (8.7% overall)
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
// Copyright 2021-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="IpQueue{T}"/>.
|
||||
/// Mirrors server/ipqueue_test.go:
|
||||
/// TestIPQueueBasic (ID 688), TestIPQueuePush (ID 689), TestIPQueuePop (ID 690),
|
||||
/// TestIPQueuePopOne (ID 691), TestIPQueueMultiProducers (ID 692),
|
||||
/// TestIPQueueRecycle (ID 693), TestIPQueueDrain (ID 694),
|
||||
/// TestIPQueueSizeCalculation (ID 695), TestIPQueueSizeCalculationWithLimits (ID 696).
|
||||
/// Benchmarks (IDs 697–715) are n/a.
|
||||
/// </summary>
|
||||
public sealed class IpQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public void Basic_ShouldInitialiseCorrectly()
|
||||
{
|
||||
// Mirror: TestIPQueueBasic
|
||||
var registry = new ConcurrentDictionary<string, object>();
|
||||
var q = new IpQueue<int>("test", registry);
|
||||
|
||||
q.MaxRecycleSize.ShouldBe(IpQueue<int>.DefaultMaxRecycleSize);
|
||||
q.Ch.TryRead(out _).ShouldBeFalse("channel should be empty on creation");
|
||||
q.Len().ShouldBe(0);
|
||||
|
||||
// Create a second queue with custom max recycle size.
|
||||
var q2 = new IpQueue<int>("test2", registry, maxRecycleSize: 10);
|
||||
q2.MaxRecycleSize.ShouldBe(10);
|
||||
|
||||
// Both should be in the registry.
|
||||
registry.ContainsKey("test").ShouldBeTrue();
|
||||
registry.ContainsKey("test2").ShouldBeTrue();
|
||||
|
||||
// Unregister both.
|
||||
q.Unregister();
|
||||
q2.Unregister();
|
||||
registry.IsEmpty.ShouldBeTrue("registry should be empty after unregister");
|
||||
|
||||
// Push/pop should still work after unregister.
|
||||
q.Push(1);
|
||||
var elts = q.Pop();
|
||||
elts.ShouldNotBeNull();
|
||||
elts!.Length.ShouldBe(1);
|
||||
|
||||
q2.Push(2);
|
||||
var (e, ok) = q2.PopOne();
|
||||
ok.ShouldBeTrue();
|
||||
e.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_ShouldNotifyOnFirstElement()
|
||||
{
|
||||
// Mirror: TestIPQueuePush
|
||||
var q = new IpQueue<int>("test");
|
||||
|
||||
q.Push(1);
|
||||
q.Len().ShouldBe(1);
|
||||
q.Ch.TryRead(out _).ShouldBeTrue("should have been notified after first push");
|
||||
|
||||
// Second push should NOT send another notification.
|
||||
q.Push(2);
|
||||
q.Len().ShouldBe(2);
|
||||
q.Ch.TryRead(out _).ShouldBeFalse("should not notify again when queue was not empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pop_ShouldReturnElementsAndTrackInProgress()
|
||||
{
|
||||
// Mirror: TestIPQueuePop
|
||||
var q = new IpQueue<int>("test");
|
||||
q.Push(1);
|
||||
q.Ch.TryRead(out _); // consume signal
|
||||
|
||||
var elts = q.Pop();
|
||||
elts.ShouldNotBeNull();
|
||||
elts!.Length.ShouldBe(1);
|
||||
q.Len().ShouldBe(0);
|
||||
|
||||
// Channel should still be empty after pop.
|
||||
q.Ch.TryRead(out _).ShouldBeFalse();
|
||||
|
||||
// InProgress should be 1 — pop increments it.
|
||||
q.InProgress().ShouldBe(1L);
|
||||
|
||||
// Recycle decrements it.
|
||||
q.Recycle(elts);
|
||||
q.InProgress().ShouldBe(0L);
|
||||
|
||||
// Pop on empty queue returns null.
|
||||
var empty = q.Pop();
|
||||
empty.ShouldBeNull();
|
||||
q.InProgress().ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PopOne_ShouldReturnOneAtATime()
|
||||
{
|
||||
// Mirror: TestIPQueuePopOne
|
||||
var q = new IpQueue<int>("test");
|
||||
q.Push(1);
|
||||
q.Ch.TryRead(out _); // consume signal
|
||||
|
||||
var (e, ok) = q.PopOne();
|
||||
ok.ShouldBeTrue();
|
||||
e.ShouldBe(1);
|
||||
q.Len().ShouldBe(0);
|
||||
q.InProgress().ShouldBe(0L, "popOne does not increment inprogress");
|
||||
q.Ch.TryRead(out _).ShouldBeFalse("no notification when queue is emptied by popOne");
|
||||
|
||||
q.Push(2);
|
||||
q.Push(3);
|
||||
|
||||
var (e2, ok2) = q.PopOne();
|
||||
ok2.ShouldBeTrue();
|
||||
e2.ShouldBe(2);
|
||||
q.Len().ShouldBe(1);
|
||||
q.Ch.TryRead(out _).ShouldBeTrue("should re-notify when more items remain");
|
||||
|
||||
var (e3, ok3) = q.PopOne();
|
||||
ok3.ShouldBeTrue();
|
||||
e3.ShouldBe(3);
|
||||
q.Len().ShouldBe(0);
|
||||
q.Ch.TryRead(out _).ShouldBeFalse("no notification after last element removed");
|
||||
|
||||
var (_, okEmpty) = q.PopOne();
|
||||
okEmpty.ShouldBeFalse("popOne on empty queue returns false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiProducers_ShouldReceiveAllElements()
|
||||
{
|
||||
// Mirror: TestIPQueueMultiProducers
|
||||
var q = new IpQueue<int>("test");
|
||||
const int itemsPerProducer = 100;
|
||||
const int numProducers = 3;
|
||||
|
||||
var tasks = Enumerable.Range(0, numProducers).Select(p =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
for (var i = p * itemsPerProducer + 1; i <= (p + 1) * itemsPerProducer; i++)
|
||||
q.Push(i);
|
||||
})).ToArray();
|
||||
|
||||
var received = new HashSet<int>();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
while (received.Count < numProducers * itemsPerProducer &&
|
||||
!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
if (q.Ch.TryRead(out _))
|
||||
{
|
||||
var batch = q.Pop();
|
||||
if (batch != null)
|
||||
{
|
||||
foreach (var v in batch) received.Add(v);
|
||||
q.Recycle(batch);
|
||||
q.InProgress().ShouldBe(0L);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(1, cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
received.Count.ShouldBe(numProducers * itemsPerProducer, "all elements should be received");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recycle_ShouldDecrementInProgressAndAllowReuse()
|
||||
{
|
||||
// Mirror: TestIPQueueRecycle (behavioral aspects)
|
||||
var q = new IpQueue<int>("test");
|
||||
const int total = 1000;
|
||||
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
var (len, err) = q.Push(i);
|
||||
err.ShouldBeNull();
|
||||
len.ShouldBe(i + 1);
|
||||
}
|
||||
|
||||
var values = q.Pop();
|
||||
values.ShouldNotBeNull();
|
||||
values!.Length.ShouldBe(total);
|
||||
q.InProgress().ShouldBe((long)total);
|
||||
|
||||
q.Recycle(values);
|
||||
q.InProgress().ShouldBe(0L, "recycle should decrement inprogress");
|
||||
|
||||
// Should be able to push/pop again after recycle.
|
||||
var (l, err2) = q.Push(1001);
|
||||
err2.ShouldBeNull();
|
||||
l.ShouldBe(1);
|
||||
var values2 = q.Pop();
|
||||
values2.ShouldNotBeNull();
|
||||
values2!.Length.ShouldBe(1);
|
||||
values2[0].ShouldBe(1001);
|
||||
|
||||
// Recycle with small max recycle size: large arrays should not be pooled
|
||||
// (behavioral: push/pop still works correctly).
|
||||
var q2 = new IpQueue<int>("test2", maxRecycleSize: 10);
|
||||
for (var i = 0; i < 100; i++) q2.Push(i);
|
||||
var bigBatch = q2.Pop();
|
||||
bigBatch.ShouldNotBeNull();
|
||||
bigBatch!.Length.ShouldBe(100);
|
||||
q2.Recycle(bigBatch);
|
||||
q2.InProgress().ShouldBe(0L);
|
||||
|
||||
q2.Push(1001);
|
||||
var small = q2.Pop();
|
||||
small.ShouldNotBeNull();
|
||||
small!.Length.ShouldBe(1);
|
||||
q2.Recycle(small);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_ShouldEmptyQueueAndConsumeSignal()
|
||||
{
|
||||
// Mirror: TestIPQueueDrain
|
||||
var q = new IpQueue<int>("test");
|
||||
for (var i = 1; i <= 100; i++) q.Push(i);
|
||||
|
||||
var drained = q.Drain();
|
||||
drained.ShouldBe(100);
|
||||
|
||||
// Signal should have been consumed.
|
||||
q.Ch.TryRead(out _).ShouldBeFalse("drain should consume the notification signal");
|
||||
q.Len().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SizeCalculation_ShouldTrackTotalSize()
|
||||
{
|
||||
// Mirror: TestIPQueueSizeCalculation
|
||||
const int elemSize = 16;
|
||||
var q = new IpQueue<byte[]>("test", sizeCalc: e => (ulong)e.Length);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
q.Push(new byte[elemSize]);
|
||||
q.Len().ShouldBe(i + 1);
|
||||
q.Size().ShouldBe((ulong)(i + 1) * elemSize);
|
||||
}
|
||||
|
||||
for (var i = 10; i > 5; i--)
|
||||
{
|
||||
q.PopOne();
|
||||
q.Len().ShouldBe(i - 1);
|
||||
q.Size().ShouldBe((ulong)(i - 1) * elemSize);
|
||||
}
|
||||
|
||||
q.Pop();
|
||||
q.Len().ShouldBe(0);
|
||||
q.Size().ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SizeCalculationWithLimits_ShouldEnforceLimits()
|
||||
{
|
||||
// Mirror: TestIPQueueSizeCalculationWithLimits
|
||||
const int elemSize = 16;
|
||||
Func<byte[], ulong> calc = e => (ulong)e.Length;
|
||||
var elem = new byte[elemSize];
|
||||
|
||||
// LimitByLen
|
||||
var q1 = new IpQueue<byte[]>("test-len", sizeCalc: calc, maxLen: 5);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var (n, err) = q1.Push(elem);
|
||||
if (i >= 5)
|
||||
{
|
||||
err.ShouldBeSameAs(IpQueueErrors.LenLimitReached, $"iteration {i}");
|
||||
}
|
||||
else
|
||||
{
|
||||
err.ShouldBeNull($"iteration {i}");
|
||||
}
|
||||
n.ShouldBeLessThan(6);
|
||||
}
|
||||
|
||||
// LimitBySize
|
||||
var q2 = new IpQueue<byte[]>("test-size", sizeCalc: calc, maxSize: elemSize * 5);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var (n, err) = q2.Push(elem);
|
||||
if (i >= 5)
|
||||
{
|
||||
err.ShouldBeSameAs(IpQueueErrors.SizeLimitReached, $"iteration {i}");
|
||||
}
|
||||
else
|
||||
{
|
||||
err.ShouldBeNull($"iteration {i}");
|
||||
}
|
||||
n.ShouldBeLessThan(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ServerUtilities"/>.
|
||||
/// Mirrors server/util_test.go: TestParseSize (ID 3061), TestParseSInt64 (ID 3062),
|
||||
/// TestParseHostPort (ID 3063), TestURLsAreEqual (ID 3064), TestComma (ID 3065),
|
||||
/// TestURLRedaction (ID 3066), TestVersionAtLeast (ID 3067).
|
||||
/// Benchmarks (IDs 3068–3073) are n/a.
|
||||
/// </summary>
|
||||
public sealed class ServerUtilitiesTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseSize_ShouldParseValidAndRejectInvalid()
|
||||
{
|
||||
// Mirror: TestParseSize
|
||||
ServerUtilities.ParseSize(ReadOnlySpan<byte>.Empty).ShouldBe(-1, "nil/empty should return -1");
|
||||
|
||||
var n = "12345678"u8;
|
||||
ServerUtilities.ParseSize(n).ShouldBe(12345678);
|
||||
|
||||
var bad = "12345invalid678"u8;
|
||||
ServerUtilities.ParseSize(bad).ShouldBe(-1, "non-digit chars should return -1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseInt64_ShouldParseValidAndRejectInvalid()
|
||||
{
|
||||
// Mirror: TestParseSInt64
|
||||
ServerUtilities.ParseInt64(ReadOnlySpan<byte>.Empty).ShouldBe(-1L, "empty should return -1");
|
||||
|
||||
var n = "12345678"u8;
|
||||
ServerUtilities.ParseInt64(n).ShouldBe(12345678L);
|
||||
|
||||
var bad = "12345invalid678"u8;
|
||||
ServerUtilities.ParseInt64(bad).ShouldBe(-1L, "non-digit chars should return -1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseHostPort_ShouldSplitCorrectly()
|
||||
{
|
||||
// Mirror: TestParseHostPort
|
||||
void Check(string hostPort, int defaultPort, string expectedHost, int expectedPort, bool expectError)
|
||||
{
|
||||
var (host, port, err) = ServerUtilities.ParseHostPort(hostPort, defaultPort);
|
||||
if (expectError)
|
||||
{
|
||||
err.ShouldNotBeNull($"expected error for hostPort={hostPort}");
|
||||
return;
|
||||
}
|
||||
err.ShouldBeNull($"unexpected error for hostPort={hostPort}: {err?.Message}");
|
||||
host.ShouldBe(expectedHost);
|
||||
port.ShouldBe(expectedPort);
|
||||
}
|
||||
|
||||
Check("addr:1234", 5678, "addr", 1234, false);
|
||||
Check(" addr:1234 ", 5678, "addr", 1234, false);
|
||||
Check(" addr : 1234 ", 5678, "addr", 1234, false);
|
||||
Check("addr", 5678, "addr", 5678, false); // no port → default
|
||||
Check(" addr ", 5678, "addr", 5678, false);
|
||||
Check("addr:-1", 5678, "addr", 5678, false); // -1 → default
|
||||
Check(" addr:-1 ", 5678, "addr", 5678, false);
|
||||
Check(" addr : -1 ", 5678, "addr", 5678, false);
|
||||
Check("addr:0", 5678, "addr", 5678, false); // 0 → default
|
||||
Check(" addr:0 ", 5678, "addr", 5678, false);
|
||||
Check(" addr : 0 ", 5678, "addr", 5678, false);
|
||||
Check("addr:addr", 0, "", 0, true); // non-numeric port
|
||||
Check("addr:::1234", 0, "", 0, true); // ambiguous colons
|
||||
Check("", 0, "", 0, true); // empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlsAreEqual_ShouldCompareCorrectly()
|
||||
{
|
||||
// Mirror: TestURLsAreEqual
|
||||
void Check(string u1Str, string u2Str, bool expectedSame)
|
||||
{
|
||||
var u1 = new Uri(u1Str);
|
||||
var u2 = new Uri(u2Str);
|
||||
ServerUtilities.UrlsAreEqual(u1, u2).ShouldBe(expectedSame,
|
||||
$"expected {u1Str} and {u2Str} to be {(expectedSame ? "equal" : "different")}");
|
||||
}
|
||||
|
||||
Check("nats://localhost:4222", "nats://localhost:4222", true);
|
||||
Check("nats://ivan:pwd@localhost:4222", "nats://ivan:pwd@localhost:4222", true);
|
||||
Check("nats://ivan@localhost:4222", "nats://ivan@localhost:4222", true);
|
||||
Check("nats://ivan:@localhost:4222", "nats://ivan:@localhost:4222", true);
|
||||
Check("nats://host1:4222", "nats://host2:4222", false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comma_ShouldFormatWithThousandSeparators()
|
||||
{
|
||||
// Mirror: TestComma
|
||||
var cases = new (long input, string expected)[]
|
||||
{
|
||||
(0, "0"),
|
||||
(10, "10"),
|
||||
(100, "100"),
|
||||
(1_000, "1,000"),
|
||||
(10_000, "10,000"),
|
||||
(100_000, "100,000"),
|
||||
(10_000_000, "10,000,000"),
|
||||
(10_100_000, "10,100,000"),
|
||||
(10_010_000, "10,010,000"),
|
||||
(10_001_000, "10,001,000"),
|
||||
(123_456_789, "123,456,789"),
|
||||
(9_223_372_036_854_775_807L, "9,223,372,036,854,775,807"), // long.MaxValue
|
||||
(long.MinValue, "-9,223,372,036,854,775,808"),
|
||||
(-123_456_789, "-123,456,789"),
|
||||
(-10_100_000, "-10,100,000"),
|
||||
(-10_010_000, "-10,010,000"),
|
||||
(-10_001_000, "-10,001,000"),
|
||||
(-10_000_000, "-10,000,000"),
|
||||
(-100_000, "-100,000"),
|
||||
(-10_000, "-10,000"),
|
||||
(-1_000, "-1,000"),
|
||||
(-100, "-100"),
|
||||
(-10, "-10"),
|
||||
};
|
||||
|
||||
foreach (var (input, expected) in cases)
|
||||
ServerUtilities.Comma(input).ShouldBe(expected, $"Comma({input})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlRedaction_ShouldReplacePasswords()
|
||||
{
|
||||
// Mirror: TestURLRedaction
|
||||
var cases = new (string full, string safe)[]
|
||||
{
|
||||
("nats://foo:bar@example.org", "nats://foo:xxxxx@example.org"),
|
||||
("nats://foo@example.org", "nats://foo@example.org"),
|
||||
("nats://example.org", "nats://example.org"),
|
||||
("nats://example.org/foo?bar=1", "nats://example.org/foo?bar=1"),
|
||||
};
|
||||
|
||||
var listFull = new Uri[cases.Length];
|
||||
var listSafe = new Uri[cases.Length];
|
||||
|
||||
for (var i = 0; i < cases.Length; i++)
|
||||
{
|
||||
ServerUtilities.RedactUrlString(cases[i].full).ShouldBe(cases[i].safe,
|
||||
$"RedactUrlString[{i}]");
|
||||
listFull[i] = new Uri(cases[i].full);
|
||||
listSafe[i] = new Uri(cases[i].safe);
|
||||
}
|
||||
|
||||
var results = ServerUtilities.RedactUrlList(listFull);
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i].ToString().ShouldBe(listSafe[i].ToString(), $"RedactUrlList[{i}]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionAtLeast_ShouldReturnCorrectResult()
|
||||
{
|
||||
// Mirror: TestVersionAtLeast
|
||||
var cases = new (string version, int major, int minor, int update, bool result)[]
|
||||
{
|
||||
("2.0.0-beta", 1, 9, 9, true),
|
||||
("2.0.0", 1, 99, 9, true),
|
||||
("2.2.0", 2, 1, 9, true),
|
||||
("2.2.2", 2, 2, 2, true),
|
||||
("2.2.2", 2, 2, 3, false),
|
||||
("2.2.2", 2, 3, 2, false),
|
||||
("2.2.2", 3, 2, 2, false),
|
||||
("2.22.2", 3, 0, 0, false),
|
||||
("2.2.22", 2, 3, 0, false),
|
||||
("bad.version",1, 2, 3, false),
|
||||
};
|
||||
|
||||
foreach (var (version, major, minor, update, expected) in cases)
|
||||
{
|
||||
ServerUtilities.VersionAtLeast(version, major, minor, update)
|
||||
.ShouldBe(expected,
|
||||
$"VersionAtLeast({version}, {major}, {minor}, {update})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2023-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SubjectTransform"/>.
|
||||
/// Mirrors server/subject_transform_test.go:
|
||||
/// TestPlaceHolderIndex (ID 2958), TestSubjectTransformHelpers (ID 2959),
|
||||
/// TestSubjectTransforms (ID 2960),
|
||||
/// TestSubjectTransformDoesntPanicTransformingMissingToken (ID 2961).
|
||||
/// </summary>
|
||||
public sealed class SubjectTransformTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlaceHolderIndex_ShouldParseAllFunctionTypes()
|
||||
{
|
||||
// Mirror: TestPlaceHolderIndex
|
||||
|
||||
// $1 — old style
|
||||
var (tt, idxs, intArg, _, err) = SubjectTransform.IndexPlaceHolders("$1");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.Wildcard);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(1);
|
||||
intArg.ShouldBe(-1);
|
||||
|
||||
// {{partition(10,1,2,3)}}
|
||||
(tt, idxs, intArg, _, err) = SubjectTransform.IndexPlaceHolders("{{partition(10,1,2,3)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.Partition);
|
||||
idxs.ShouldBe([1, 2, 3]);
|
||||
intArg.ShouldBe(10);
|
||||
|
||||
// {{ Partition (10,1,2,3) }} (with spaces)
|
||||
(tt, idxs, intArg, _, err) = SubjectTransform.IndexPlaceHolders("{{ Partition (10,1,2,3) }}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.Partition);
|
||||
idxs.ShouldBe([1, 2, 3]);
|
||||
intArg.ShouldBe(10);
|
||||
|
||||
// {{wildcard(2)}}
|
||||
(tt, idxs, intArg, _, err) = SubjectTransform.IndexPlaceHolders("{{wildcard(2)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.Wildcard);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(2);
|
||||
intArg.ShouldBe(-1);
|
||||
|
||||
// {{SplitFromLeft(2,1)}}
|
||||
int pos;
|
||||
(tt, idxs, pos, _, err) = SubjectTransform.IndexPlaceHolders("{{SplitFromLeft(2,1)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.SplitFromLeft);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(2);
|
||||
pos.ShouldBe(1);
|
||||
|
||||
// {{SplitFromRight(3,2)}}
|
||||
(tt, idxs, pos, _, err) = SubjectTransform.IndexPlaceHolders("{{SplitFromRight(3,2)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.SplitFromRight);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(3);
|
||||
pos.ShouldBe(2);
|
||||
|
||||
// {{SliceFromLeft(2,2)}}
|
||||
(tt, idxs, var sliceSize, _, err) = SubjectTransform.IndexPlaceHolders("{{SliceFromLeft(2,2)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.SliceFromLeft);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(2);
|
||||
sliceSize.ShouldBe(2);
|
||||
|
||||
// {{Left(3,2)}}
|
||||
(tt, idxs, pos, _, err) = SubjectTransform.IndexPlaceHolders("{{Left(3,2)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.Left);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(3);
|
||||
pos.ShouldBe(2);
|
||||
|
||||
// {{Right(3,2)}}
|
||||
(tt, idxs, pos, _, err) = SubjectTransform.IndexPlaceHolders("{{Right(3,2)}}");
|
||||
err.ShouldBeNull();
|
||||
tt.ShouldBe(TransformType.Right);
|
||||
idxs.Length.ShouldBe(1);
|
||||
idxs[0].ShouldBe(3);
|
||||
pos.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectTransformHelpers_ShouldTokenizeAndUntokenize()
|
||||
{
|
||||
// Mirror: TestSubjectTransformHelpers
|
||||
|
||||
// transformUntokenize — no placeholders
|
||||
var (filter, phs) = SubjectTransform.TransformUntokenize("bar");
|
||||
filter.ShouldBe("bar");
|
||||
phs.Length.ShouldBe(0);
|
||||
|
||||
// transformUntokenize — dollar-sign placeholders
|
||||
(filter, phs) = SubjectTransform.TransformUntokenize("foo.$2.$1");
|
||||
filter.ShouldBe("foo.*.*");
|
||||
phs.ShouldBe(["$2", "$1"]);
|
||||
|
||||
// transformUntokenize — mustache placeholders
|
||||
(filter, phs) = SubjectTransform.TransformUntokenize("foo.{{wildcard(2)}}.{{wildcard(1)}}");
|
||||
filter.ShouldBe("foo.*.*");
|
||||
phs.ShouldBe(["{{wildcard(2)}}", "{{wildcard(1)}}"]);
|
||||
|
||||
// Strict reverse transform.
|
||||
var (tr, err) = SubjectTransform.NewStrict("foo.*.*", "bar.$2.{{Wildcard(1)}}");
|
||||
err.ShouldBeNull($"NewStrict failed: {err?.Message}");
|
||||
tr.ShouldNotBeNull();
|
||||
|
||||
var subject = "foo.b.a";
|
||||
var transformed = tr!.TransformSubject(subject);
|
||||
var reverse = tr.Reverse();
|
||||
reverse.ShouldNotBeNull("reverse should not be null");
|
||||
reverse!.TransformSubject(transformed).ShouldBe(subject, "reverse of transform should return original subject");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectTransforms_ShouldValidateAndTransformCorrectly()
|
||||
{
|
||||
// Mirror: TestSubjectTransforms
|
||||
void ShouldErr(string src, string dest, bool strict)
|
||||
{
|
||||
var (t, e) = SubjectTransform.NewWithStrict(src, dest, strict);
|
||||
t.ShouldBeNull($"expected error but got transform for src={src}, dest={dest}");
|
||||
var isValid = ReferenceEquals(e, ServerErrors.ErrBadSubject) ||
|
||||
(e is MappingDestinationException mde && mde.Is(ServerErrors.ErrInvalidMappingDestination));
|
||||
isValid.ShouldBeTrue(
|
||||
$"Expected ErrBadSubject or ErrInvalidMappingDestination for src={src}, dest={dest}, got: {e}");
|
||||
}
|
||||
|
||||
ShouldErr("foo..", "bar", false);
|
||||
ShouldErr("foo.*", "bar.*", false);
|
||||
ShouldErr("foo.*", "bar.$2", false);
|
||||
ShouldErr("foo.*", "bar.$1.>", false);
|
||||
ShouldErr("foo.>", "bar.baz", false);
|
||||
ShouldErr("foo.*.*", "bar.$2", true);
|
||||
ShouldErr("foo.*", "foo.$foo", true);
|
||||
ShouldErr("foo.*", "bar.{{Partition(2,1)}}", true);
|
||||
ShouldErr("foo.*", "foo.{{wildcard(2)}}", false);
|
||||
ShouldErr("foo.*", "foo.{{unimplemented(1)}}", false);
|
||||
ShouldErr("foo.*", "foo.{{partition()}}", false);
|
||||
ShouldErr("foo.*", "foo.{{random()}}", false);
|
||||
ShouldErr("foo.*", "foo.{{wildcard(foo)}}", false);
|
||||
ShouldErr("foo.*", "foo.{{wildcard()}}", false);
|
||||
ShouldErr("foo.*", "foo.{{wildcard(1,2)}}", false);
|
||||
ShouldErr("foo.*", "foo.{{ wildcard5) }}", false);
|
||||
ShouldErr("foo.*", "foo.{{splitLeft(2,2}}", false);
|
||||
ShouldErr("foo", "bla.{{wildcard(1)}}", false);
|
||||
ShouldErr("foo.*", $"foo.{{{{partition({(long)int.MaxValue + 1})}}}}", false);
|
||||
ShouldErr("foo.*", $"foo.{{{{random({(long)int.MaxValue + 1})}}}}", false);
|
||||
|
||||
SubjectTransform? ShouldBeOK(string src, string dest, bool strict)
|
||||
{
|
||||
var (tr, err) = SubjectTransform.NewWithStrict(src, dest, strict);
|
||||
err.ShouldBeNull($"Got error {err} for src={src}, dest={dest}");
|
||||
return tr;
|
||||
}
|
||||
|
||||
ShouldBeOK("foo.*", "bar.{{Wildcard(1)}}", true);
|
||||
ShouldBeOK("foo.*.*", "bar.$2", false);
|
||||
ShouldBeOK("foo.*.*", "bar.{{wildcard(1)}}", false);
|
||||
ShouldBeOK("foo.*.*", "bar.{{partition(1)}}", false);
|
||||
ShouldBeOK("foo.*.*", "bar.{{random(5)}}", false);
|
||||
ShouldBeOK("foo", "bar", false);
|
||||
ShouldBeOK("foo.*.bar.*.baz", "req.$2.$1", false);
|
||||
ShouldBeOK("baz.>", "mybaz.>", false);
|
||||
ShouldBeOK("*", "{{splitfromleft(1,1)}}", false);
|
||||
ShouldBeOK("", "prefix.>", false);
|
||||
ShouldBeOK("*.*", "{{partition(10,1,2)}}", false);
|
||||
ShouldBeOK("foo.*.*", "foo.{{wildcard(1)}}.{{wildcard(2)}}.{{partition(5,1,2)}}", false);
|
||||
ShouldBeOK("foo.*", $"foo.{{{{partition({int.MaxValue})}}}}", false);
|
||||
ShouldBeOK("foo.*", $"foo.{{{{random({int.MaxValue})}}}}", false);
|
||||
ShouldBeOK("foo.bar", $"foo.{{{{random({int.MaxValue})}}}}", false);
|
||||
|
||||
void ShouldMatch(string src, string dest, string sample, params string[] expected)
|
||||
{
|
||||
var tr = ShouldBeOK(src, dest, false);
|
||||
if (tr == null) return;
|
||||
var (s, err2) = tr.Match(sample);
|
||||
err2.ShouldBeNull($"Match error: {err2}");
|
||||
expected.ShouldContain(s, $"Transform {src}→{dest} on '{sample}', got '{s}'");
|
||||
}
|
||||
|
||||
ShouldMatch("", "prefix.>", "foo", "prefix.foo");
|
||||
ShouldMatch("foo", "", "foo", "foo");
|
||||
ShouldMatch("foo", "bar", "foo", "bar");
|
||||
ShouldMatch("foo.*.bar.*.baz", "req.$2.$1", "foo.A.bar.B.baz", "req.B.A");
|
||||
ShouldMatch("foo.*.bar.*.baz", "req.{{wildcard(2)}}.{{wildcard(1)}}", "foo.A.bar.B.baz", "req.B.A");
|
||||
ShouldMatch("baz.>", "my.pre.>", "baz.1.2.3", "my.pre.1.2.3");
|
||||
ShouldMatch("baz.>", "foo.bar.>", "baz.1.2.3", "foo.bar.1.2.3");
|
||||
ShouldMatch("*", "foo.bar.$1", "foo", "foo.bar.foo");
|
||||
ShouldMatch("*", "{{splitfromleft(1,3)}}", "12345", "123.45");
|
||||
ShouldMatch("*", "{{SplitFromRight(1,3)}}", "12345", "12.345");
|
||||
ShouldMatch("*", "{{SliceFromLeft(1,3)}}", "1234567890", "123.456.789.0");
|
||||
ShouldMatch("*", "{{SliceFromRight(1,3)}}", "1234567890", "1.234.567.890");
|
||||
ShouldMatch("*", "{{split(1,-)}}", "-abc-def--ghi-", "abc.def.ghi");
|
||||
ShouldMatch("*", "{{split(1,-)}}", "abc-def--ghi-", "abc.def.ghi");
|
||||
ShouldMatch("*.*", "{{split(2,-)}}.{{splitfromleft(1,2)}}", "foo.-abc-def--ghij-", "abc.def.ghij.fo.o");
|
||||
ShouldMatch("*", "{{right(1,1)}}", "1234", "4");
|
||||
ShouldMatch("*", "{{right(1,3)}}", "1234", "234");
|
||||
ShouldMatch("*", "{{right(1,6)}}", "1234", "1234");
|
||||
ShouldMatch("*", "{{left(1,1)}}", "1234", "1");
|
||||
ShouldMatch("*", "{{left(1,3)}}", "1234", "123");
|
||||
ShouldMatch("*", "{{left(1,6)}}", "1234", "1234");
|
||||
ShouldMatch("*", "bar.{{partition(0)}}", "baz", "bar.0");
|
||||
ShouldMatch("*", "bar.{{partition(10, 0)}}", "foo", "bar.3");
|
||||
ShouldMatch("*.*", "bar.{{partition(10)}}", "foo.bar", "bar.6");
|
||||
ShouldMatch("*", "bar.{{partition(10)}}", "foo", "bar.3");
|
||||
ShouldMatch("*", "bar.{{partition(10)}}", "baz", "bar.0");
|
||||
ShouldMatch("*", "bar.{{partition(10)}}", "qux", "bar.9");
|
||||
ShouldMatch("*", "bar.{{random(0)}}", "qux", "bar.0");
|
||||
|
||||
// random(6) — any value 0–5 is acceptable.
|
||||
for (var i = 0; i < 100; i++)
|
||||
ShouldMatch("*", "bar.{{random(6)}}", "qux",
|
||||
"bar.0", "bar.1", "bar.2", "bar.3", "bar.4", "bar.5");
|
||||
|
||||
ShouldBeOK("foo.bar", "baz.{{partition(10)}}", false);
|
||||
ShouldMatch("foo.bar", "baz.{{partition(10)}}", "foo.bar", "baz.6");
|
||||
ShouldMatch("foo.baz", "qux.{{partition(10)}}", "foo.baz", "qux.4");
|
||||
ShouldMatch("test.subject", "result.{{partition(5)}}", "test.subject", "result.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransformTokenizedSubject_ShouldNotPanicOnMissingToken()
|
||||
{
|
||||
// Mirror: TestSubjectTransformDoesntPanicTransformingMissingToken
|
||||
var (tr, err) = SubjectTransform.New("foo.*", "one.two.{{wildcard(1)}}");
|
||||
err.ShouldBeNull();
|
||||
tr.ShouldNotBeNull();
|
||||
|
||||
// Should not throw even when the token at index 1 is missing.
|
||||
var result = tr!.TransformTokenizedSubject(["foo"]);
|
||||
result.ShouldBe("one.two.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user