Files
natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/SubjectTransformTests.cs
Joseph Doherty 11c0b92fbd 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)
2026-02-26 09:39:36 -05:00

257 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 05 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.");
}
}