Files
natsdotnet/tests/NATS.Server.Tests/Mqtt/MqttTopicMappingParityTests.cs
Joseph Doherty 9554d53bf5 feat: Wave 6 batch 1 — monitoring, config reload, client protocol, MQTT, leaf node tests
Port 405 new test methods across 5 subsystems for Go parity:
- Monitoring: 102 tests (varz, connz, routez, subsz, stacksz)
- Leaf Nodes: 85 tests (connection, forwarding, loop detection, subject filter, JetStream)
- MQTT Bridge: 86 tests (advanced, auth, retained messages, topic mapping, will messages)
- Client Protocol: 73 tests (connection handling, protocol violations, limits)
- Config Reload: 59 tests (hot reload, option changes, permission updates)

Total: 1,678 tests passing, 0 failures, 3 skipped
2026-02-23 21:40:29 -05:00

385 lines
13 KiB
C#

// Ports MQTT topic/subject conversion behavior from Go reference:
// golang/nats-server/server/mqtt_test.go — TestMQTTTopicAndSubjectConversion,
// TestMQTTFilterConversion, TestMQTTTopicWithDot, TestMQTTSubjectWildcardStart
// golang/nats-server/server/mqtt.go — mqttTopicToNATSPubSubject, mqttFilterToNATSSubject,
// natsSubjectToMQTTTopic, mqttToNATSSubjectConversion
namespace NATS.Server.Tests.Mqtt;
/// <summary>
/// Tests MQTT topic to NATS subject conversion and vice versa, porting the
/// Go TestMQTTTopicAndSubjectConversion and TestMQTTFilterConversion tests.
/// These are pure-logic conversion tests -- no server needed.
/// </summary>
public class MqttTopicMappingParityTests
{
// -------------------------------------------------------------------------
// Helper: MQTT topic -> NATS subject conversion
// Mirrors Go: mqttTopicToNATSPubSubject / mqttToNATSSubjectConversion(mt, false)
// -------------------------------------------------------------------------
private static string MqttTopicToNatsSubject(string mqttTopic)
{
var mt = mqttTopic.AsSpan();
var res = new List<char>(mt.Length + 10);
var end = mt.Length - 1;
for (var i = 0; i < mt.Length; i++)
{
switch (mt[i])
{
case '/':
if (i == 0 || (res.Count > 0 && res[^1] == '.'))
{
res.Add('/');
res.Add('.');
}
else if (i == end || mt[i + 1] == '/')
{
res.Add('.');
res.Add('/');
}
else
{
res.Add('.');
}
break;
case ' ':
throw new FormatException("spaces not supported in MQTT topic");
case '.':
res.Add('/');
res.Add('/');
break;
case '+':
case '#':
throw new FormatException("wildcards not allowed in publish topic");
default:
res.Add(mt[i]);
break;
}
}
if (res.Count > 0 && res[^1] == '.')
{
res.Add('/');
}
return new string(res.ToArray());
}
// -------------------------------------------------------------------------
// Helper: MQTT filter -> NATS subject conversion (wildcards allowed)
// Mirrors Go: mqttFilterToNATSSubject / mqttToNATSSubjectConversion(filter, true)
// -------------------------------------------------------------------------
private static string MqttFilterToNatsSubject(string mqttFilter)
{
var mt = mqttFilter.AsSpan();
var res = new List<char>(mt.Length + 10);
var end = mt.Length - 1;
for (var i = 0; i < mt.Length; i++)
{
switch (mt[i])
{
case '/':
if (i == 0 || (res.Count > 0 && res[^1] == '.'))
{
res.Add('/');
res.Add('.');
}
else if (i == end || mt[i + 1] == '/')
{
res.Add('.');
res.Add('/');
}
else
{
res.Add('.');
}
break;
case ' ':
throw new FormatException("spaces not supported in MQTT topic");
case '.':
res.Add('/');
res.Add('/');
break;
case '+':
res.Add('*');
break;
case '#':
res.Add('>');
break;
default:
res.Add(mt[i]);
break;
}
}
if (res.Count > 0 && res[^1] == '.')
{
res.Add('/');
}
return new string(res.ToArray());
}
// -------------------------------------------------------------------------
// Helper: NATS subject -> MQTT topic conversion
// Mirrors Go: natsSubjectToMQTTTopic
// -------------------------------------------------------------------------
private static string NatsSubjectToMqttTopic(string natsSubject)
{
var subject = natsSubject.AsSpan();
var topic = new char[subject.Length];
var end = subject.Length - 1;
var j = 0;
for (var i = 0; i < subject.Length; i++)
{
switch (subject[i])
{
case '/':
if (i < end)
{
var c = subject[i + 1];
if (c == '.' || c == '/')
{
topic[j] = c == '.' ? '/' : '.';
j++;
i++;
}
}
break;
case '.':
topic[j] = '/';
j++;
break;
default:
topic[j] = subject[i];
j++;
break;
}
}
return new string(topic, 0, j);
}
// =========================================================================
// Go: TestMQTTTopicAndSubjectConversion server/mqtt_test.go:1779
// =========================================================================
[Theory]
[InlineData("/", "/./")]
[InlineData("//", "/././")]
[InlineData("///", "/./././")]
[InlineData("////", "/././././")]
[InlineData("foo", "foo")]
[InlineData("/foo", "/.foo")]
[InlineData("//foo", "/./.foo")]
[InlineData("///foo", "/././.foo")]
[InlineData("///foo/", "/././.foo./")]
[InlineData("///foo//", "/././.foo././")]
[InlineData("///foo///", "/././.foo./././")]
[InlineData("//.foo.//", "/././/foo//././")]
[InlineData("foo/bar", "foo.bar")]
[InlineData("/foo/bar", "/.foo.bar")]
[InlineData("/foo/bar/", "/.foo.bar./")]
[InlineData("foo/bar/baz", "foo.bar.baz")]
[InlineData("/foo/bar/baz", "/.foo.bar.baz")]
[InlineData("/foo/bar/baz/", "/.foo.bar.baz./")]
[InlineData("bar/", "bar./")]
[InlineData("bar//", "bar././")]
[InlineData("bar///", "bar./././")]
[InlineData("foo//bar", "foo./.bar")]
[InlineData("foo///bar", "foo././.bar")]
[InlineData("foo////bar", "foo./././.bar")]
[InlineData(".", "//")]
[InlineData("..", "////")]
[InlineData("...", "//////")]
[InlineData("./", "//./")]
[InlineData(".//.", "//././/")]
[InlineData("././.", "//.//.//")]
[InlineData("././/.", "//.//././/")]
[InlineData(".foo", "//foo")]
[InlineData("foo.", "foo//")]
[InlineData(".foo.", "//foo//")]
[InlineData("foo../bar/", "foo////.bar./")]
[InlineData("foo../bar/.", "foo////.bar.//")]
[InlineData("/foo/", "/.foo./")]
[InlineData("./foo/.", "//.foo.//")]
[InlineData("foo.bar/baz", "foo//bar.baz")]
public void Topic_to_nats_subject_converts_correctly(string mqttTopic, string expectedNatsSubject)
{
// Go: mqttTopicToNATSPubSubject server/mqtt_test.go:1779
var natsSubject = MqttTopicToNatsSubject(mqttTopic);
natsSubject.ShouldBe(expectedNatsSubject);
}
[Theory]
[InlineData("/", "/./")]
[InlineData("//", "/././")]
[InlineData("foo", "foo")]
[InlineData("foo/bar", "foo.bar")]
[InlineData("/foo/bar", "/.foo.bar")]
[InlineData(".", "//")]
[InlineData(".foo", "//foo")]
[InlineData("foo.", "foo//")]
[InlineData("foo.bar/baz", "foo//bar.baz")]
[InlineData("foo//bar", "foo./.bar")]
[InlineData("/foo/", "/.foo./")]
public void Topic_round_trips_through_nats_subject_and_back(string mqttTopic, string natsSubject)
{
// Go: TestMQTTTopicAndSubjectConversion verifies round-trip server/mqtt_test.go:1843
var converted = MqttTopicToNatsSubject(mqttTopic);
converted.ShouldBe(natsSubject);
var backToMqtt = NatsSubjectToMqttTopic(converted);
backToMqtt.ShouldBe(mqttTopic);
}
[Theory]
[InlineData("foo/+", "wildcards not allowed")]
[InlineData("foo/#", "wildcards not allowed")]
[InlineData("foo bar", "not supported")]
public void Topic_to_nats_subject_rejects_invalid_topics(string mqttTopic, string expectedErrorSubstring)
{
// Go: TestMQTTTopicAndSubjectConversion error cases server/mqtt_test.go:1826
var ex = Should.Throw<FormatException>(() => MqttTopicToNatsSubject(mqttTopic));
ex.Message.ShouldContain(expectedErrorSubstring, Case.Insensitive);
}
// =========================================================================
// Go: TestMQTTFilterConversion server/mqtt_test.go:1852
// =========================================================================
[Theory]
[InlineData("+", "*")]
[InlineData("/+", "/.*")]
[InlineData("+/", "*./")]
[InlineData("/+/", "/.*./")]
[InlineData("foo/+", "foo.*")]
[InlineData("foo/+/", "foo.*./")]
[InlineData("foo/+/bar", "foo.*.bar")]
[InlineData("foo/+/+", "foo.*.*")]
[InlineData("foo/+/+/", "foo.*.*./")]
[InlineData("foo/+/+/bar", "foo.*.*.bar")]
[InlineData("foo//+", "foo./.*")]
[InlineData("foo//+/", "foo./.*./")]
[InlineData("foo//+//", "foo./.*././")]
[InlineData("foo//+//bar", "foo./.*./.bar")]
[InlineData("foo///+///bar", "foo././.*././.bar")]
[InlineData("foo.bar///+///baz", "foo//bar././.*././.baz")]
public void Filter_single_level_wildcard_converts_plus_to_star(string mqttFilter, string expectedNatsSubject)
{
// Go: TestMQTTFilterConversion single level wildcard server/mqtt_test.go:1860
var natsSubject = MqttFilterToNatsSubject(mqttFilter);
natsSubject.ShouldBe(expectedNatsSubject);
}
[Theory]
[InlineData("#", ">")]
[InlineData("/#", "/.>")]
[InlineData("/foo/#", "/.foo.>")]
[InlineData("foo/#", "foo.>")]
[InlineData("foo//#", "foo./.>")]
[InlineData("foo///#", "foo././.>")]
[InlineData("foo/bar/#", "foo.bar.>")]
[InlineData("foo/bar.baz/#", "foo.bar//baz.>")]
public void Filter_multi_level_wildcard_converts_hash_to_greater_than(string mqttFilter, string expectedNatsSubject)
{
// Go: TestMQTTFilterConversion multi level wildcard server/mqtt_test.go:1877
var natsSubject = MqttFilterToNatsSubject(mqttFilter);
natsSubject.ShouldBe(expectedNatsSubject);
}
// =========================================================================
// Go: TestMQTTTopicWithDot server/mqtt_test.go:7674
// =========================================================================
[Theory]
[InlineData("foo//bar", "foo.bar")]
[InlineData("//foo", ".foo")]
[InlineData("foo//", "foo.")]
[InlineData("//", ".")]
public void Nats_subject_with_slash_slash_converts_to_mqtt_dot(string natsSubject, string expectedMqttTopic)
{
// Go: natsSubjectToMQTTTopic converts '//' back to '.'
var mqttTopic = NatsSubjectToMqttTopic(natsSubject);
mqttTopic.ShouldBe(expectedMqttTopic);
}
[Fact]
public void Nats_subject_dot_becomes_mqtt_topic_slash()
{
// Go: basic '.' -> '/' conversion
var result = NatsSubjectToMqttTopic("foo.bar.baz");
result.ShouldBe("foo/bar/baz");
}
// =========================================================================
// Additional conversion edge cases
// =========================================================================
[Fact]
public void Empty_topic_converts_to_empty_subject()
{
var result = MqttTopicToNatsSubject(string.Empty);
result.ShouldBe(string.Empty);
}
[Fact]
public void Single_character_topic_converts_identity()
{
var result = MqttTopicToNatsSubject("a");
result.ShouldBe("a");
}
[Fact]
public void Nats_subject_to_mqtt_topic_simple_passes_through()
{
var result = NatsSubjectToMqttTopic("foo");
result.ShouldBe("foo");
}
[Fact]
public void Filter_conversion_preserves_mixed_wildcards()
{
var result = MqttFilterToNatsSubject("+/foo/#");
result.ShouldBe("*.foo.>");
}
[Theory]
[InlineData("+", "*")]
[InlineData("+/foo", "*.foo")]
[InlineData("+/+", "*.*")]
[InlineData("#", ">")]
public void Filter_starting_with_wildcard_converts_correctly(string mqttFilter, string expectedNatsSubject)
{
// Go: TestMQTTSubjectWildcardStart server/mqtt_test.go:7552
var result = MqttFilterToNatsSubject(mqttFilter);
result.ShouldBe(expectedNatsSubject);
}
// =========================================================================
// Go: TestMQTTPublishTopicErrors server/mqtt_test.go:4084
// =========================================================================
[Theory]
[InlineData("foo/+")]
[InlineData("foo/#")]
public void Publish_topic_with_wildcards_throws(string mqttTopic)
{
Should.Throw<FormatException>(() => MqttTopicToNatsSubject(mqttTopic));
}
[Fact]
public void Publish_topic_with_space_throws()
{
Should.Throw<FormatException>(() => MqttTopicToNatsSubject("foo bar"));
}
}