using System.Globalization; using System.Text; using Google.Protobuf; using Org.Eclipse.Tahu.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Unit tests for — the pure half of MQTT write-through (Gitea #508): /// what a write encodes to, and which writes are refused before anything reaches a broker. /// [Trait("Category", "Unit")] public sealed class MqttWriteEncoderTests { private static MqttTagDefinition Plain( string? writeTopic, MqttPayloadFormat format = MqttPayloadFormat.Scalar, DriverDataType dataType = DriverDataType.Float64) => new( Name: "Plant/Mqtt/dev1/Setpoint", Topic: "factory/oven/temp", PayloadFormat: format, JsonPath: "$", DataType: dataType, Qos: null, RetainSeed: true, WriteTopic: writeTopic); private static MqttTagDefinition Sparkplug( string? deviceId, DriverDataType dataType = DriverDataType.Float64, string metricName = "Speed") => new( Name: "Plant/Mqtt/dev1/Speed", Topic: "", PayloadFormat: MqttPayloadFormat.Json, JsonPath: "$", DataType: dataType, Qos: null, RetainSeed: true, GroupId: "OtOpcUaSim", EdgeNodeId: "EdgeA", DeviceId: deviceId, MetricName: metricName); // ---- The refusal that is the whole point of the feature ------------------------------------- [Fact] public void A_plain_tag_with_no_write_topic_is_refused_rather_than_published_nowhere() { // Before #508 there was no IWritable at all — honest read-only. The one outcome that would be // WORSE is a node that accepts a write, reports Good and publishes nothing. So this must be a // refusal, and specifically the "no destination" one, which the driver maps to BadNotWritable. var error = MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic: null), 42.5, out var encoded); error.ShouldBe(MqttWriteEncodeError.NoWriteDestination); encoded.Topic.ShouldBeNull(); encoded.Payload.ShouldBeNull(); } [Theory] [InlineData("")] [InlineData(" ")] public void A_blank_write_topic_is_treated_as_absent(string writeTopic) => MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out _) .ShouldBe(MqttWriteEncodeError.NoWriteDestination); [Fact] public void A_sparkplug_tag_with_an_incomplete_tuple_has_no_command_address() { // A Sparkplug tag's command address IS its binding tuple, so an incomplete one is the same // failure as a missing write topic, not a different kind. var def = Sparkplug(deviceId: null) with { MetricName = null }; MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _) .ShouldBe(MqttWriteEncodeError.NoWriteDestination); } // ---- Plain encoding -------------------------------------------------------------------------- [Fact] public void Plain_publishes_to_the_write_topic_not_the_read_topic() { // The two-field split is the point: publishing a command onto the tag's own read topic would // echo back and read as a confirmed device value. var def = Plain("factory/oven/temp/set"); MqttWriteEncoder.TryEncode(MqttMode.Plain, def, 42.5, out var encoded) .ShouldBe(MqttWriteEncodeError.None); encoded.Topic.ShouldBe("factory/oven/temp/set"); encoded.Topic.ShouldNotBe(def.Topic); } [Theory] [InlineData(MqttPayloadFormat.Scalar)] [InlineData(MqttPayloadFormat.Raw)] public void Scalar_and_raw_encode_the_value_as_utf8_text(MqttPayloadFormat format) { MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set", format), 42.5, out var encoded) .ShouldBe(MqttWriteEncodeError.None); Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5"); } [Fact] public void Numbers_are_encoded_with_the_invariant_culture() { // A comma decimal separator would publish a number the device cannot parse — and it would only // reproduce on a host with a European locale, which is the worst kind of bug to chase. var previous = Thread.CurrentThread.CurrentCulture; Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); try { MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), 42.5, out var encoded) .ShouldBe(MqttWriteEncodeError.None); Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5"); } finally { Thread.CurrentThread.CurrentCulture = previous; } } [Fact] public void Json_encodes_a_bare_json_literal() { MqttWriteEncoder.TryEncode( MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json), 42.5, out var encoded) .ShouldBe(MqttWriteEncodeError.None); Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5"); } [Fact] public void Json_quotes_a_string_value() => Encoding.UTF8.GetString( EncodeOrThrow(MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json, DriverDataType.String), "on")) .ShouldBe("\"on\""); [Fact] public void A_null_value_is_refused() // A zero-length MQTT payload is a legitimate clear-the-retained-message instruction — an // entirely different operation. Encoding null as "no bytes" would conflate the two. => MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), null, out _) .ShouldBe(MqttWriteEncodeError.ValueNotRepresentable); // ---- Sparkplug command encoding --------------------------------------------------------------- [Fact] public void A_device_metric_encodes_as_a_DCMD_addressed_to_that_device() { // Addressing a device metric with NCMD would command the NODE, which does not own it. MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5, out var encoded) .ShouldBe(MqttWriteEncodeError.None); encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1"); } [Fact] public void An_edge_node_metric_encodes_as_an_NCMD() => EncodeTopic(MqttMode.SparkplugB, Sparkplug(deviceId: null)) .ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA"); [Fact] public void The_command_carries_the_metric_by_name_with_an_explicit_datatype() { // By NAME, never by alias: an alias may be reused for a different metric across a rebirth. // Datatype explicitly, because a node that treats it as ambiguous drops the command SILENTLY — // the exact failure mode this feature must not have. var bytes = EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5); var payload = Payload.Parser.ParseFrom(bytes); payload.Metrics.Count.ShouldBe(1); var metric = payload.Metrics[0]; metric.Name.ShouldBe("Speed"); metric.Alias.ShouldBe(0ul); metric.Datatype.ShouldBe((uint)TahuDataType.Double); metric.DoubleValue.ShouldBe(12.5); metric.Timestamp.ShouldBeGreaterThan(0ul); payload.Timestamp.ShouldBeGreaterThan(0ul); } [Fact] public void The_command_carries_no_sequence_number() { // seq belongs to the messages an edge node itself publishes. A strict node validating a // host-supplied one against its own counter would have grounds to reject the command. var payload = Payload.Parser.ParseFrom(EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0)); payload.HasSeq.ShouldBeFalse(); } [Theory] [InlineData(DriverDataType.Boolean, true, (uint)TahuDataType.Boolean)] [InlineData(DriverDataType.Int32, 7, (uint)TahuDataType.Int32)] [InlineData(DriverDataType.Int64, 7L, (uint)TahuDataType.Int64)] [InlineData(DriverDataType.UInt32, 7, (uint)TahuDataType.Uint32)] [InlineData(DriverDataType.Float32, 1.5f, (uint)TahuDataType.Float)] [InlineData(DriverDataType.String, "x", (uint)TahuDataType.String)] public void The_declared_data_type_selects_the_wire_datatype( DriverDataType dataType, object value, uint expectedWireType) { var payload = Payload.Parser.ParseFrom( EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1", dataType), value)); payload.Metrics[0].Datatype.ShouldBe(expectedWireType); } [Fact] public void An_unrepresentable_value_is_refused_rather_than_coerced() // "not-a-number" into a Float64 tag must not silently become 0 on a live plant. => MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _) .ShouldBe(MqttWriteEncodeError.ValueNotRepresentable); /// /// A Sparkplug DateTime is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant has /// no representation at all. The (ulong) cast wrapped the negative long around, so /// writing 1969-12-31 published a timestamp roughly 584 million years in the future — a /// coercion, and the file's own rule is that unrepresentable values are refused, never coerced. /// /// The authored instant, in UTC. [Theory] [InlineData("1969-12-31T23:59:59Z")] [InlineData("1900-01-01T00:00:00Z")] [InlineData("0001-01-01T00:00:00Z")] public void A_pre_epoch_date_time_is_refused_rather_than_wrapped(string iso) { var value = DateTime.Parse(iso, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal); MqttWriteEncoder.TryEncode( MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value, out _) .ShouldBe(MqttWriteEncodeError.ValueNotRepresentable); } /// /// Falsification control for the epoch bound: a post-1970 instant still encodes, and encodes to /// the right number. A guard that refused every DateTime would satisfy the theory above. /// [Fact] public void A_post_epoch_date_time_still_encodes_to_its_unix_milliseconds() { var value = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); var payload = Payload.Parser.ParseFrom(EncodeOrThrow( MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value)); payload.Metrics[0].Datatype.ShouldBe((uint)TahuDataType.DateTime); payload.Metrics[0].LongValue.ShouldBe((ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds()); } /// /// A instant must encode to the SAME wire value on every /// node. ToUniversalTime() treats an unspecified kind as LOCAL, so the identical authored /// value encoded differently depending on the node's timezone — which the two nodes of a /// redundant pair are not required to share. It is read as UTC rather than refused because /// Convert.ToDateTime("2026-08-10T12:00:00") — an ordinary authored string — produces /// exactly this kind. /// [Fact] public void An_unspecified_kind_date_time_encodes_as_utc_not_as_node_local_time() { var unspecified = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Unspecified); var sameInstantUtc = DateTime.SpecifyKind(unspecified, DateTimeKind.Utc); var fromUnspecified = Payload.Parser.ParseFrom(EncodeOrThrow( MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), unspecified)); var fromUtc = Payload.Parser.ParseFrom(EncodeOrThrow( MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), sameInstantUtc)); fromUnspecified.Metrics[0].LongValue.ShouldBe(fromUtc.Metrics[0].LongValue); fromUnspecified.Metrics[0].LongValue .ShouldBe((ulong)new DateTimeOffset(sameInstantUtc).ToUnixTimeMilliseconds()); } [Fact] public void A_type_with_no_command_encoding_is_refused_rather_than_stringified() // Reference has no Sparkplug wire representation. Falling back to String would put a value on // the wire that is not what the tag's type says it is. => MqttWriteEncoder.TryEncode( MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Reference), "ref", out _) .ShouldBe(MqttWriteEncodeError.UnsupportedDataType); // ---- Birth-typed writes keep the birth's OWN Sparkplug datatype (review R5 MINOR-4) ---------- private static Payload.Types.Metric EncodeBirth(TahuDataType birth, object value) { var error = MqttWriteEncoder.TryEncode( MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Float64) with { DataTypeAuthored = false }, value, out var encoded, birth.ToDriverDataType(), birth); error.ShouldBe(MqttWriteEncodeError.None); return Payload.Parser.ParseFrom(encoded.Payload).Metrics.Single(); } [Theory] [InlineData(TahuDataType.Int8, (sbyte)-5)] [InlineData(TahuDataType.Uint8, (byte)200)] [InlineData(TahuDataType.Text, "hello")] [InlineData(TahuDataType.Uuid, "123e4567-e89b-12d3-a456-426614174000")] public void A_birth_typed_write_is_commanded_with_the_births_own_datatype(TahuDataType birth, object value) => ((TahuDataType)EncodeBirth(birth, value).Datatype).ShouldBe(birth); [Fact] public void A_birth_typed_bytes_write_carries_bytes_with_the_bytes_datatype() { var metric = EncodeBirth(TahuDataType.Bytes, new byte[] { 1, 2, 3 }); ((TahuDataType)metric.Datatype).ShouldBe(TahuDataType.Bytes); metric.BytesValue.ToByteArray().ShouldBe([1, 2, 3]); } [Theory] [InlineData(TahuDataType.Int32Array)] [InlineData(TahuDataType.DoubleArray)] [InlineData(TahuDataType.DataSet)] [InlineData(TahuDataType.Template)] public void A_birth_type_with_no_command_encoding_is_refused_never_sent_as_another_type(TahuDataType birth) { MqttWriteEncoder.TryEncode( MqttMode.SparkplugB, Sparkplug("Filler1") with { DataTypeAuthored = false }, 1, out var encoded, birth.ToDriverDataType(), birth) .ShouldBe(MqttWriteEncodeError.UnsupportedDataType); encoded.Payload.ShouldBeNull(); } [Fact] public void An_authored_type_still_wins_over_the_birth_type() { var def = Sparkplug("Filler1", DriverDataType.Int32) with { DataTypeAuthored = true }; MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, null, TahuDataType.Text) .ShouldBe(MqttWriteEncodeError.None); ((TahuDataType)Payload.Parser.ParseFrom(encoded.Payload).Metrics.Single().Datatype).ShouldBe(TahuDataType.Int32); } [Fact] public void The_mode_decides_the_shape_not_the_blob() { // A tag can legitimately carry both shapes' keys (the editor preserves unknown keys across a // retype), so sniffing would make one authored tag mean two different things. var both = Sparkplug("Filler1") with { WriteTopic = "plain/set" }; EncodeTopic(MqttMode.Plain, both).ShouldBe("plain/set"); EncodeTopic(MqttMode.SparkplugB, both).ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1"); } // ---- A destination that cannot be published to is not a destination ------------------------- [Theory] [InlineData("factory/+/set")] [InlineData("factory/oven/#")] public void A_wildcard_write_topic_is_refused_rather_than_published(string writeTopic) { // MQTT-3.3.2-2: a PUBLISH topic MUST NOT contain a wildcard, and a conformant broker answers a // protocol violation by CLOSING the connection — which, for this driver, is the same connection // every subscription lives on. So one write to a mis-authored tag takes the WHOLE driver dark, // not just that tag. The typed AdminUI editor already refuses this, but a raw-JSON / CSV / // API-authored blob reaches the encoder untouched, so the refusal has to live here too. MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out var encoded) .ShouldBe(MqttWriteEncodeError.NoWriteDestination); encoded.Topic.ShouldBeNull(); } [Theory] [InlineData("Ot/Sim", "EdgeA", null)] [InlineData("OtSim", "Edge+A", null)] [InlineData("OtSim", "EdgeA", "Fill#er")] [InlineData("OtSim", "EdgeA", "Fill/er")] public void A_binding_tuple_that_is_not_a_legal_topic_segment_has_no_command_address( string groupId, string edgeNodeId, string? deviceId) { // The same protocol hazard on the Sparkplug side, plus one the Plain side does not have: the // tuple IS the command topic, so a '/' silently re-shapes it into a DIFFERENT address (an // NCMD for group "Ot" node "Sim" rather than the tag's own node) while '+'/'#' make it // unpublishable. Refusing is the only honest verdict for all three. var def = Sparkplug(deviceId) with { GroupId = groupId, EdgeNodeId = edgeNodeId }; MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _) .ShouldBe(MqttWriteEncodeError.NoWriteDestination); } [Fact] public void A_metric_name_containing_a_slash_still_encodes() { // The falsification control for the rule above. Sparkplug's own canonical metric names use '/' // (`Node Control/Rebirth`), and metricName rides in the PAYLOAD, never in the topic — so the // segment rule must not reach it or every conformant node-control write would be refused. MqttWriteEncoder.TryEncode( MqttMode.SparkplugB, Sparkplug(deviceId: null, DriverDataType.Boolean, metricName: "Node Control/Rebirth"), true, out var encoded) .ShouldBe(MqttWriteEncodeError.None); encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA"); Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Name.ShouldBe("Node Control/Rebirth"); } // ---- STAB-25 / STAB-26 (#602) --------------------------------------------------------------------- [Fact] public void A_plain_write_publishes_at_QoS1_so_Good_means_the_broker_accepted_it() { MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("oven/set"), 42.5, out var encoded) .ShouldBe(MqttWriteEncodeError.None); encoded.Qos.ShouldBe(1); } [Fact] public void A_sparkplug_command_stays_at_QoS0() { // Pinned beside the Plain case so the two cannot be "unified" by accident: an NCMD/DCMD retry from // QoS 1 redelivery would re-apply a command the device already took. MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0, out var encoded) .ShouldBe(MqttWriteEncodeError.None); encoded.Qos.ShouldBe(0); } [Fact] public void An_unauthored_sparkplug_type_is_commanded_with_the_birth_declared_type() { // DataTypeAuthored=false: the record's Float64 is only a default. The birth said Int32, so the DCMD // must carry Int32 — a Double-typed command to an Int32 metric is the silent-drop shape. var def = Sparkplug("Filler1") with { DataTypeAuthored = false }; MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, DriverDataType.Int32) .ShouldBe(MqttWriteEncodeError.None); var metric = Payload.Parser.ParseFrom(encoded.Payload).Metrics[0]; metric.Datatype.ShouldBe((uint)TahuDataType.Int32); metric.IntValue.ShouldBe(7u); } [Fact] public void An_authored_sparkplug_type_wins_over_the_birth_type() { // Same precedence as the ingest path: authored beats birth. var def = Sparkplug("Filler1", DriverDataType.Float64) with { DataTypeAuthored = true }; MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, DriverDataType.Int32) .ShouldBe(MqttWriteEncodeError.None); Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Datatype.ShouldBe((uint)TahuDataType.Double); } private static string EncodeTopic(MqttMode mode, MqttTagDefinition def) { MqttWriteEncoder.TryEncode(mode, def, 1.0, out var encoded).ShouldBe(MqttWriteEncodeError.None); return encoded.Topic; } private static byte[] EncodeOrThrow(MqttMode mode, MqttTagDefinition def, object value) { MqttWriteEncoder.TryEncode(mode, def, value, out var encoded).ShouldBe(MqttWriteEncodeError.None); return encoded.Payload; } }