using NATS.Server.Mqtt; namespace NATS.Server.Mqtt.Tests; /// /// Tests for MqttTopicMapper with full Go parity including dots in topics, /// empty levels, '$' prefix protection, and leading/trailing slashes. /// Go reference: mqtt.go mqttToNATSSubjectConversion ~line 2200. /// public class MqttTopicMapperTests { // ----------------------------------------------------------------------- // MqttToNats — basic mapping // ----------------------------------------------------------------------- [Theory] [InlineData("a/b/c", "a.b.c")] [InlineData("sensor/temp", "sensor.temp")] [InlineData("home/living/light", "home.living.light")] public void MqttToNats_separator_mapping(string mqtt, string nats) { MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats); } [Theory] [InlineData("+", "*")] [InlineData("sensor/+", "sensor.*")] [InlineData("+/temp", "*.temp")] [InlineData("+/+/+", "*.*.*")] public void MqttToNats_single_level_wildcard(string mqtt, string nats) { MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats); } [Theory] [InlineData("#", ">")] [InlineData("sensor/#", "sensor.>")] [InlineData("home/+/#", "home.*.>")] public void MqttToNats_multi_level_wildcard(string mqtt, string nats) { MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats); } [Fact] public void MqttToNats_empty_string() { MqttTopicMapper.MqttToNats("").ShouldBe(""); } // ----------------------------------------------------------------------- // MqttToNats — dot escaping (Go parity) // ----------------------------------------------------------------------- [Theory] [InlineData("a.b/c", "a_DOT_b.c")] [InlineData("host.name/metric", "host_DOT_name.metric")] [InlineData("a.b.c", "a_DOT_b_DOT_c")] public void MqttToNats_dots_in_topic_are_escaped(string mqtt, string nats) { MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats); } // ----------------------------------------------------------------------- // MqttToNats — empty levels (leading/trailing/consecutive slashes) // ----------------------------------------------------------------------- [Theory] [InlineData("/a/b", ".a.b")] [InlineData("a/b/", "a.b.")] [InlineData("a//b", "a..b")] [InlineData("//", "..")] public void MqttToNats_empty_levels(string mqtt, string nats) { MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats); } // ----------------------------------------------------------------------- // NatsToMqtt — reverse mapping // ----------------------------------------------------------------------- [Theory] [InlineData("a.b.c", "a/b/c")] [InlineData("sensor.temp", "sensor/temp")] [InlineData("*", "+")] [InlineData(">", "#")] [InlineData("sensor.*", "sensor/+")] [InlineData("sensor.>", "sensor/#")] public void NatsToMqtt_basic_reverse(string nats, string mqtt) { MqttTopicMapper.NatsToMqtt(nats).ShouldBe(mqtt); } [Fact] public void NatsToMqtt_empty_string() { MqttTopicMapper.NatsToMqtt("").ShouldBe(""); } [Theory] [InlineData("a_DOT_b.c", "a.b/c")] [InlineData("host_DOT_name.metric", "host.name/metric")] public void NatsToMqtt_dot_escape_reversed(string nats, string mqtt) { MqttTopicMapper.NatsToMqtt(nats).ShouldBe(mqtt); } // ----------------------------------------------------------------------- // Round-trip: MqttToNats → NatsToMqtt should be identity // ----------------------------------------------------------------------- [Theory] [InlineData("a/b/c")] [InlineData("sensor/+/data")] [InlineData("home/#")] [InlineData("a.b/c.d")] [InlineData("/leading")] [InlineData("trailing/")] [InlineData("a//b")] public void RoundTrip_mqtt_to_nats_and_back(string mqtt) { var nats = MqttTopicMapper.MqttToNats(mqtt); var roundTripped = MqttTopicMapper.NatsToMqtt(nats); roundTripped.ShouldBe(mqtt); } // ----------------------------------------------------------------------- // Dollar topic protection (MQTT spec [MQTT-4.7.2-1]) // ----------------------------------------------------------------------- [Fact] public void IsDollarTopic_detects_system_topics() { MqttTopicMapper.IsDollarTopic("$SYS/info").ShouldBeTrue(); MqttTopicMapper.IsDollarTopic("$share/group/topic").ShouldBeTrue(); MqttTopicMapper.IsDollarTopic("normal/topic").ShouldBeFalse(); MqttTopicMapper.IsDollarTopic("").ShouldBeFalse(); } [Fact] public void WildcardMatchesDollarTopic_enforces_spec() { // Wildcard filters should NOT match $ topics MqttTopicMapper.WildcardMatchesDollarTopic("#", "$SYS/info").ShouldBeFalse(); MqttTopicMapper.WildcardMatchesDollarTopic("+/info", "$SYS/info").ShouldBeFalse(); // Explicit $ filters match $ topics MqttTopicMapper.WildcardMatchesDollarTopic("$SYS/#", "$SYS/info").ShouldBeTrue(); MqttTopicMapper.WildcardMatchesDollarTopic("$SYS/+", "$SYS/info").ShouldBeTrue(); // Non-$ topics always matchable MqttTopicMapper.WildcardMatchesDollarTopic("#", "normal/topic").ShouldBeTrue(); MqttTopicMapper.WildcardMatchesDollarTopic("+/topic", "normal/topic").ShouldBeTrue(); } }