From f79d13e2d85f106016924e2e2da4f8279c13a88e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:24:13 -0400 Subject: [PATCH] feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt, direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin block — a probe wired only on driver nodes makes Test-connect silently dead). Carried-forward items: - Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions — MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the probe's internal JsonOpts and the browser's separate private copy; the factory, the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through it. .Contracts is the only assembly all four consumers reference, and the browser's reference to the runtime .Driver project is a documented layering exception scheduled for removal — anchoring the options there would resurrect the duplicate the day it goes away. - Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and MqttDriver with the constant (string value unchanged). - Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only Ok == false + non-empty message, which is also exactly what a JSON-parse failure produces — so it stayed green under the very regression it names. It now asserts the probe got past the parse and reached the network. Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9 tests across 4 suites, including the tightened probe test (message becomes "Config JSON is invalid: The JSON value could not be converted to MqttProtocolVersion") — which the pre-fix assertions would have passed. Also references the MQTT driver from Core.Abstractions.Tests so DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the constant/factory parity check stays honest. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../DriverTypeNames.cs | 4 + .../MqttDriverBrowser.cs | 26 +-- .../MqttJson.cs | 53 ++++++ .../MqttDriver.cs | 15 +- .../MqttDriverFactoryExtensions.cs | 84 ++++++++++ .../MqttDriverProbe.cs | 26 +-- .../Drivers/DriverFactoryBootstrap.cs | 3 + .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 3 +- .../MqttDriverFactoryExtensionsTests.cs | 151 ++++++++++++++++++ .../MqttDriverOptionsTests.cs | 13 +- .../MqttDriverProbeTests.cs | 25 ++- 12 files changed, 344 insertions(+), 60 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index d4ba0417..6e65b231 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -53,6 +53,9 @@ public static class DriverTypeNames /// Calculation pseudo-driver — tags computed by C# scripts over other tags' live values. public const string Calculation = "Calculation"; + /// MQTT / Sparkplug B broker-subscription driver. + public const string Mqtt = "Mqtt"; + /// /// Every driver-type string declared above, for callers that need to enumerate /// the full set (e.g. validation of an authored DriverType). @@ -68,5 +71,6 @@ public static class DriverTypeNames OpcUaClient, Galaxy, Calculation, + Mqtt, ]; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs index ce2f3366..42c049d6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs @@ -1,11 +1,11 @@ using System.Buffers; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using MQTTnet; using MQTTnet.Protocol; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; @@ -39,19 +39,6 @@ public sealed class MqttDriverBrowser : IDriverBrowser /// Marks the transient browse identity in the broker's client-id/session logs. internal const string BrowseClientIdPrefix = "-browse-"; - /// - /// Matches the runtime driver factory's parsing so a given DriverConfig JSON means the same - /// thing to the browser as it does to the deployed driver. JsonStringEnumConverter lets - /// mode / protocolVersion be authored as their string names — the natural form - /// for AdminUI-emitted JSON — while still accepting numeric ordinals. - /// - private static readonly JsonSerializerOptions JsonOpts = new() - { - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() }, - }; - private readonly ILogger _logger; /// @@ -64,10 +51,7 @@ public sealed class MqttDriverBrowser : IDriverBrowser _logger = logger ?? NullLogger.Instance; /// - // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and - // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the - // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). - public string DriverType => "Mqtt"; + public string DriverType => DriverTypeNames.Mqtt; /// /// @@ -77,7 +61,11 @@ public sealed class MqttDriverBrowser : IDriverBrowser /// public async Task OpenAsync(string configJson, CancellationToken cancellationToken) { - var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + // MqttJson.Options — the ONE shared instance across factory / probe / driver / browser + // (see its remarks). Parsing the same DriverConfig blob through a second, divergent + // JsonSerializerOptions is this repo's documented systemic enum bug: the picker would accept + // a `mode` / `protocolVersion` spelling the deployed driver rejects, or vice versa. + var opts = JsonSerializer.Deserialize(configJson, MqttJson.Options) ?? throw new InvalidOperationException("Mqtt options deserialized to null."); if (opts.Mode == MqttMode.SparkplugB) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs new file mode 100644 index 00000000..944f653d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// The single instance every MQTT driver-config seam +/// parses through — the runtime factory +/// (MqttDriverFactoryExtensions), the Test-connect probe (MqttDriverProbe), the +/// driver's own ReinitializeAsync re-parse, and the address-picker browser +/// (MqttDriverBrowser). +/// +/// +/// +/// Why one instance, and why here. Divergent per-seam options are this repo's +/// documented systemic enum bug: an AdminUI-authored config with an enum-valued field is +/// accepted by the seam that carries a and faults the +/// one that does not, so "Test connect" goes green and the deployed driver dies. Two other +/// drivers already carry a copy per seam. Rather than repeat that, this driver keeps exactly +/// one instance. +/// +/// +/// It lives in .Contracts — the assembly that owns and +/// the three enums the converter exists for — rather than in the factory or the probe, +/// because .Contracts is the only assembly all four consumers already reference. +/// In particular the browser lives in its own assembly and reaches the runtime .Driver +/// project only through a deliberate, documented layering exception that is scheduled to be +/// removed (see the ProjectReference comment in the browser's csproj); anchoring the +/// options in .Driver would resurrect the duplicate the day that reference goes away. +/// +/// +/// Not a general-purpose JSON policy. UnmappedMemberHandling.Skip means an +/// unknown key is ignored rather than rejected — deliberate, so a config blob authored +/// against a newer driver still binds — and PropertyNameCaseInsensitive accepts both +/// the camelCase the AdminUI emits and the PascalCase a hand-edited blob may carry. A +/// becomes read-only on first use, so this instance is +/// safe to share across threads and must never be mutated after startup. +/// +/// +public static class MqttJson +{ + /// + /// The shared options. Enum-valued knobs (, + /// , ) round-trip by + /// name; numeric ordinals still bind, so an older blob is not broken by this. + /// + public static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs index 5481f1cd..b7db7439 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs @@ -123,11 +123,7 @@ public sealed class MqttDriver public string DriverInstanceId => _driverInstanceId; /// - // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it and - // owns that file. The string must stay EXACTLY "Mqtt" and match MqttDriverProbe.DriverType: a - // DriverType that drifts from the persisted one silently breaks dispatch (the ModbusTcp/Modbus - // incident). - public string DriverType => "Mqtt"; + public string DriverType => DriverTypeNames.Mqtt; /// /// The ingest path this driver composes. Internal: the P2 Sparkplug handler feeds the same @@ -513,10 +509,11 @@ public sealed class MqttDriver try { - // The probe's shared instance, deliberately: enums must round-trip by NAME. A second, - // divergent JsonSerializerOptions is this repo's documented systemic enum bug, where an - // AdminUI-authored config with a numeric enum field faults the driver. - return JsonSerializer.Deserialize(driverConfigJson, MqttDriverProbe.JsonOpts); + // MqttJson.Options — the ONE shared instance (factory / probe / this / browser), + // deliberately: enums must round-trip by NAME. A second, divergent + // JsonSerializerOptions is this repo's documented systemic enum bug, where an + // AdminUI-authored config with a string enum field faults the driver. + return JsonSerializer.Deserialize(driverConfigJson, MqttJson.Options); } catch (JsonException ex) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs new file mode 100644 index 00000000..dc6d9bea --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// Registers the MQTT / Sparkplug B driver with the . The +/// Host's DriverFactoryBootstrap calls once at startup; the +/// driver-instance bootstrapper then materialises DriverInstance rows of type +/// into live instances. +/// +/// +/// +/// Direct deserialization, no intermediate DTO. The DriverConfig blob binds +/// straight onto — the same shape +/// , MqttDriverBrowser and +/// already bind, and the shape the options record +/// was designed for (every knob carries its own default, and rawTags is a plain +/// list needing no translation). Mirrors +/// OpcUaClientDriverFactoryExtensions. ModbusDriverFactoryExtensions' separate +/// DTO exists to service a legacy nullable-everything blob plus string→enum parsing this +/// driver does with a converter instead; copying it here would add a fourth parse +/// shape for one config, which is precisely the divergence +/// exists to prevent. +/// +/// +/// Connection-free. parses and constructs only — the +/// constructor touches no network, and the registry contract +/// forbids the factory from calling InitializeAsync itself (the driver host owns the +/// retry semantics). +/// +/// +public static class MqttDriverFactoryExtensions +{ + /// + /// Driver type name — matches DriverInstance.DriverType values. Sourced from + /// so the registration key can never drift from the + /// constant the dispatch maps, the probe and the browser reference. + /// + public const string DriverTypeName = DriverTypeNames.Mqtt; + + /// + /// Register the MQTT factory with the driver registry. The optional + /// is captured at registration time and used to construct an + /// per driver instance — without it the driver runs with no + /// logger (standalone/test callers stay unchanged). + /// + /// The driver factory registry to register with. + /// Optional logger factory used to create per-instance loggers. + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + } + + /// Public for the Server-side bootstrapper + test consumers. + /// Stable logical id of the driver instance. + /// The DriverConfig JSON blob for the instance. + /// Optional logger factory for the per-instance logger. + /// A configured, not-yet-connected . + /// + /// or is blank. + /// + /// The config blob deserialised to null. + /// The config blob is not valid JSON for the options shape. + public static MqttDriver CreateInstance( + string driverInstanceId, + string driverConfigJson, + ILoggerFactory? loggerFactory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + // MqttJson.Options — the one shared instance (see its remarks). A local copy here is the + // repo's documented systemic enum bug in the making. + var options = JsonSerializer.Deserialize(driverConfigJson, MqttJson.Options) + ?? throw new InvalidOperationException( + $"MQTT driver config for '{driverInstanceId}' deserialised to null"); + + return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger()); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs index ce8a71cc..4a6e973a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Net.Sockets; using System.Security.Authentication; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Extensions.Logging.Abstractions; using MQTTnet; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -55,26 +54,8 @@ public sealed class MqttDriverProbe : IDriverProbe /// internal const string ProbeClientIdPrefix = "-probe-"; - /// - /// Shared JSON options for every MQTT config-parsing seam in this driver: enums - /// (, , protocolVersion) - /// round-trip by name, never ordinal — the repo's documented enum-serialization - /// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's - /// MqttDriverFactoryExtensions is expected to converge on this same instance - /// rather than defining its own. - /// - internal static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - Converters = { new JsonStringEnumConverter() }, - }; - /// - // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and - // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the - // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). - public string DriverType => "Mqtt"; + public string DriverType => DriverTypeNames.Mqtt; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) @@ -82,7 +63,10 @@ public sealed class MqttDriverProbe : IDriverProbe MqttDriverOptions? options; try { - options = JsonSerializer.Deserialize(configJson, JsonOpts); + // MqttJson.Options — the one shared instance across factory / probe / driver / browser + // (see its remarks): enums must round-trip by NAME or an AdminUI-authored config that + // Test-connect accepts would fault the deployed driver. + options = JsonSerializer.Deserialize(configJson, MqttJson.Options); } catch (Exception ex) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 71b5d0b7..fe19a128 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe; using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; using CalculationProbe = Driver.Calculation.CalculationDriverProbe; +using MqttProbe = Driver.Mqtt.MqttDriverProbe; /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) @@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } @@ -144,6 +146,7 @@ public static class DriverFactoryBootstrap Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); + Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index dd7fbd9a..ebcea6c5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -74,6 +74,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index b89bfe91..1e32d03c 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -21,11 +21,12 @@ - + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs new file mode 100644 index 00000000..ad4e2191 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Tests for — the seam that turns a persisted +/// DriverInstance.DriverConfig blob into a live . Mirrors +/// OpcUaClientDriverFactoryExtensions' shape (direct deserialization into the options +/// record, no intermediate DTO). +/// +[Trait("Category", "Unit")] +public sealed class MqttDriverFactoryExtensionsTests +{ + [Fact] + public void Register_ThenCreate_BuildsMqttDriver() + { + var registry = new DriverFactoryRegistry(); + MqttDriverFactoryExtensions.Register(registry); + + var factory = registry.TryGet(MqttDriverFactoryExtensions.DriverTypeName); + factory.ShouldNotBeNull(); + + var driver = factory("d1", """{"host":"h","port":1883,"mode":"Plain"}"""); + + driver.ShouldBeOfType(); + } + + /// + /// The factory's registration key and the canonical constant must be the same string. A + /// drift here is invisible at compile time and silently breaks dispatch — the repo's + /// ModbusTcp/Modbus incident. + /// + [Fact] + public void DriverTypeName_MatchesConstant() + => MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt); + + /// + /// Third independent pin on the literal itself: both the constant and the factory name could + /// be renamed together and still agree with each other while disagreeing with every persisted + /// DriverInstance.DriverType row. + /// + [Fact] + public void DriverTypeName_IsExactlyMqtt() + { + MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt"); + DriverTypeNames.Mqtt.ShouldBe("Mqtt"); + DriverTypeNames.All.ShouldContain("Mqtt"); + } + + /// + /// rawTags is the ONLY source of the driver's discoverable node set (plain MQTT has no + /// browsable address space), so a factory that parsed everything else correctly but dropped + /// this array would produce a driver that connects, subscribes to nothing and materializes + /// nothing — green everywhere, dark in production. + /// + [Fact] + public async Task CreateInstance_CarriesRawTagsThroughToDiscovery() + { + const string tagConfig = """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}"""; + var json = JsonSerializer.Serialize(new + { + host = "h", + port = 1883, + mode = "Plain", + rawTags = new[] + { + new { rawPath = "Plant/Mqtt/dev1/Temp", tagConfig, writeIdempotent = false }, + }, + }); + + var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json); + + var builder = new RecordingBuilder(); + await driver.DiscoverAsync(builder, CancellationToken.None); + + builder.VariableFullNames.ShouldBe(["Plant/Mqtt/dev1/Temp"]); + } + + /// + /// Enum-valued knobs must bind from their string NAMES — the repo's documented systemic + /// enum-serialization bug is an AdminUI-authored config whose enum field one seam can read + /// and another cannot. Removing from + /// makes this throw. + /// + [Fact] + public void CreateInstance_EnumsAuthoredAsNames_Bind() + { + const string json = """ + {"host":"h","port":8883,"mode":"SparkplugB","protocolVersion":"V311", + "sparkplug":{"groupId":"Plant1","hostId":"h1"}} + """; + + var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json); + + driver.ShouldBeOfType(); + } + + /// + /// Direct pin on the single shared instance: the enum converter is what the test above + /// exercises behaviourally, and this asserts it structurally so a reader can see the one + /// instance every MQTT config seam parses through. + /// + [Fact] + public void SharedJsonOptions_CarryTheStringEnumConverter() + => MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter); + + [Fact] + public void CreateInstance_BlankConfig_Throws() + => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", " ")); + + [Fact] + public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation() + => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null")); + + /// + /// Minimal discovery recorder — local to this suite because the driver test project does not + /// reference Commons, where the runtime's capturing builder lives (same reason + /// MqttDriverDiscoveryTests carries its own). + /// + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List VariableFullNames { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + VariableFullNames.Add(attributeInfo.FullName); + 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) { } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs index ce7e2c3c..d6fc1cb9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; @@ -8,12 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; public sealed class MqttDriverOptionsTests { - private static readonly JsonSerializerOptions J = new() - { - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - Converters = { new JsonStringEnumConverter() }, - }; + /// + /// The production instance, not a look-alike copy. A local clone would keep passing after + /// lost its JsonStringEnumConverter — testing the copy + /// instead of the thing every seam actually parses through. + /// + private static readonly JsonSerializerOptions J = MqttJson.Options; [Fact] public void Deserialize_SparkplugConfig_ReadsModeAndSubObject() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs index 14c70eab..5f3e1b5e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs @@ -161,10 +161,20 @@ public sealed class MqttDriverProbeTests r.Message.ShouldNotContain(secret); } - /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented - /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs. + /// + /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented + /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs. + /// + /// The assertions are the whole point. "Ok is false with a non-empty message" is + /// ALSO what a JSON-parse failure produces, so asserting only that would stay green if + /// JsonStringEnumConverter were ever dropped from + /// — i.e. the exact regression this test exists to catch. So it asserts the probe got + /// PAST the parse (message is not the "Config JSON is invalid" shape) and reached + /// the network (the message names the target endpoint). + /// + /// [Fact] - public async Task ProbeAsync_EnumAsName_DoesNotThrow() + public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork() { var listener = StartListener(); var port = ListenerPort(listener); @@ -178,5 +188,14 @@ public sealed class MqttDriverProbeTests r.Ok.ShouldBeFalse(); r.Message.ShouldNotBeNullOrEmpty(); + r.Message.ShouldNotStartWith( + "Config JSON is invalid", + Case.Insensitive, + "the probe never parsed the config — 'V500' was rejected, so the shared JSON options " + + "lost their JsonStringEnumConverter"); + r.Message.ShouldNotStartWith("Config JSON deserialized to null"); + r.Message.ShouldNotStartWith("Config has no host/port"); + // A network-level marker: the probe got as far as dialling the (closed) loopback port. + r.Message.ShouldContain($"127.0.0.1:{port}"); } }