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}"); } }