using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Unit tests for 's leg (Gitea #508) — the batch /// contract and the per-slot refusals, up to (not including) the broker publish. /// /// /// The publish itself is the live gate's job: it needs a broker, and asserting it here would only /// prove that a fake was called. What IS worth pinning offline is everything that decides whether a /// write should reach a broker at all — because that is where a silent no-op would hide. /// [Trait("Category", "Unit")] public sealed class MqttDriverWriteTests { private const string Path = "Plant/Mqtt/dev1/Setpoint"; private const string ReadOnlyPath = "Plant/Mqtt/dev1/Temp"; private static RawTagEntry PlainTag(string rawPath, string? writeTopic) { var writeKey = writeTopic is null ? "" : $$""","writeTopic":"{{writeTopic}}" """; return new RawTagEntry( rawPath, $$"""{"topic":"factory/oven/temp","payloadFormat":"Scalar","dataType":"Float64"{{writeKey}}}""", WriteIdempotent: false); } private static MqttDriver PlainDriver(params RawTagEntry[] tags) => new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null); [Fact] public void MqttDriver_implements_IWritable() // The interface's presence is the feature: without it every MQTT node materialises read-only, // regardless of what the operator authored on the tag. => PlainDriver().ShouldBeAssignableTo(); [Fact] public async Task An_unknown_reference_is_refused_in_its_own_slot() { var results = await PlainDriver(PlainTag(Path, "oven/set")) .WriteAsync([new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0)], default); results.Count.ShouldBe(1); results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown } [Fact] public async Task A_tag_with_no_write_topic_is_refused_as_not_writable() { // NOT BadCommunicationError and NOT Good: the tag is addressable and the broker is irrelevant — // the operator simply never said where a write should go. The status has to say that, or the // only diagnosis available is "writes don't work". var results = await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null)) .WriteAsync([new WriteRequest(ReadOnlyPath, 1.0)], default); results[0].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable } [Fact] public async Task A_writable_tag_on_a_disconnected_driver_reports_a_communication_failure() { // Reported, never queued. A write buffered now and flushed at an unbounded later moment is the // replay hazard WriteIdempotent exists to prevent — and this would be the driver deciding it on // the operator's behalf. var results = await PlainDriver(PlainTag(Path, "oven/set")) .WriteAsync([new WriteRequest(Path, 42.5)], default); results[0].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError } [Fact] public async Task One_bad_request_does_not_fail_the_others_in_the_batch() { // Mirrors ReadAsync: failing the whole call would let one stale reference reject every other // write in the same OPC UA request. var driver = PlainDriver(PlainTag(Path, "oven/set"), PlainTag(ReadOnlyPath, writeTopic: null)); var results = await driver.WriteAsync( [ new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0), new WriteRequest(ReadOnlyPath, 1.0), new WriteRequest(Path, 42.5), ], default); // Order-preserving, one status per request, each naming its own distinct cause. results.Count.ShouldBe(3); results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown results[1].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable results[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError — resolved and encodable } [Fact] public async Task An_empty_batch_is_answered_with_an_empty_result() => (await PlainDriver().WriteAsync([], default)).ShouldBeEmpty(); [Fact] public async Task WriteAsync_rejects_a_null_batch() => await Should.ThrowAsync( async () => await PlainDriver().WriteAsync(null!, default)); // ---- Discovery advertises writability consistently with the write path ------------------------ [Fact] public async Task A_tag_with_a_write_topic_is_discovered_as_an_operate_node() { var builder = new WriteCapturingBuilder(); await PlainDriver(PlainTag(Path, "oven/set")).DiscoverAsync(builder, default); builder.Variables.ShouldHaveSingleItem(); builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.Operate); // Default-off regardless: MQTT gives no application-level write ack, and a Sparkplug command is // not necessarily idempotent. Opting in is the operator's call, per tag. builder.Variables[0].WriteIdempotent.ShouldBeFalse(); } [Fact] public async Task A_tag_without_a_write_topic_stays_view_only() { // The browse-time answer must match the runtime one — a node advertised Operate that then // refuses every write would be the silent-no-op failure in a new costume. var builder = new WriteCapturingBuilder(); await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null)).DiscoverAsync(builder, default); builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } /// Minimal capturing builder — this project does not reference Commons' own. private sealed class WriteCapturingBuilder : IAddressSpaceBuilder { public List Variables { get; } = []; public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add(attributeInfo); return new Handle(attributeInfo.FullName); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } }