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
385 lines
13 KiB
C#
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"));
|
|
}
|
|
}
|