// 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; /// /// 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. /// 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(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(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(() => 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(() => MqttTopicToNatsSubject(mqttTopic)); } [Fact] public void Publish_topic_with_space_throws() { Should.Throw(() => MqttTopicToNatsSubject("foo bar")); } }