- 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)
257 lines
11 KiB
C#
257 lines
11 KiB
C#
// 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.");
|
||
}
|
||
}
|