feat(mqtt): MqttDriverForm + MqttDeviceForm — the driver/device config forms P1 omitted
The P1 live gate had to insert MQTT driver config directly via SQL: DriverConfigModal and DeviceModal both fell through to "No typed config form for driver type Mqtt" with no raw-JSON fallback, so broker host/port/TLS/credentials were unauthorable from the AdminUI. Task 12 built the *tag* editor; these are the driver + device surfaces. MqttDriverForm authors the whole MqttDriverOptions connection surface (host/port/clientId, TLS + CA pin, credentials, protocol version/clean-session/keep-alive, connect timeout, reconnect backoffs, mode, maxPayloadBytes, and the Plain sub-object). The Sparkplug sub-object stays P2 — a marked placeholder mirroring MqttTagConfigEditor's stub, with any existing sparkplug keys preserved untouched. The connection is authored on the DRIVER, not the device — unlike Modbus/S7/OpcUaClient and contrary to what docs/drivers/Mqtt.md said. DriverDeviceConfigMerger merges a device's keys up only when the driver has exactly ONE device, so a device-authored broker connection vanishes silently the moment a second device is added. MqttDeviceForm is therefore informational (the GalaxyDeviceForm shape) and round-trips DeviceConfig verbatim; it flags legacy connection keys left on a device by a pre-form deployment, because those still win via merge-up. Serialization goes through the ONE shared MqttJson.Options instance (Task 9's decision), never a fifth per-form copy — pinned by an assertion that an ordinal-only reader CANNOT bind the emitted blob, and by extending the fleet-wide DriverPageJsonConverterTests guard to resolve a form's serializer from an explicit external-instance registry when it has no _jsonOpts field. Dropping the converter from MqttJson.Options reddens 3 tests and leaves the symmetric round-trip green — the Task 9 lesson, reproduced. RawTags is stripped from the emitted blob (the deploy artifact owns it) and unknown top-level keys survive a load->save. Validation mirrors the driver's own [Range] bounds inline, and ToOptions() additionally clamps, so an ignored error still cannot persist a connectTimeoutSeconds:0 driver-brick. A non-blank clientId raises the P1 live-gate warning inline (fixed ids make redundant pair nodes evict each other while both report Healthy). AdminUI 761/761 green (was 730), TWAE-clean; Driver.Mqtt 266/266 green. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
@@ -23,15 +24,45 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
/// </summary>
|
||||
public sealed class DriverPageJsonConverterTests
|
||||
{
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly that declares a
|
||||
/// <c>_jsonOpts</c> config serializer.</summary>
|
||||
/// <summary>
|
||||
/// Driver forms whose config serializer does NOT live in a private static <c>_jsonOpts</c> field
|
||||
/// on the form, mapped to the options instance they actually use. Each entry is an explicit,
|
||||
/// reviewed exception — the guard below still asserts the converter on whatever instance is
|
||||
/// named here, so this narrows <em>where</em> the options live, never <em>whether</em> they are
|
||||
/// checked.
|
||||
/// <para><c>MqttDriverForm</c>: the MQTT driver deliberately keeps <b>one</b>
|
||||
/// <see cref="JsonSerializerOptions"/> for all five of its config seams (runtime factory,
|
||||
/// Test-connect probe, driver re-parse, address-picker browser, this form) in
|
||||
/// <c>MqttJson.Options</c> — a per-form copy would be a fifth divergent instance, i.e. exactly
|
||||
/// the bug this whole file guards. The form itself is a shell over the pure
|
||||
/// <c>MqttDriverFormModel</c>, which serialises through that shared instance.</para>
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<Type, JsonSerializerOptions> ExternalConfigSerializers { get; } =
|
||||
new Dictionary<Type, JsonSerializerOptions>
|
||||
{
|
||||
[typeof(MqttDriverForm)] = MqttJson.Options,
|
||||
};
|
||||
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
|
||||
private static IReadOnlyList<Type> DriverFormTypes { get; } =
|
||||
typeof(ModbusDriverForm).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Resolves a form's config serializer — its own <c>_jsonOpts</c> field, else the
|
||||
/// explicitly-registered external instance — or <c>null</c> when it has neither.</summary>
|
||||
/// <param name="formType">A driver form component type.</param>
|
||||
/// <returns>The options the form serialises config through, or <c>null</c>.</returns>
|
||||
private static JsonSerializerOptions? ResolveConfigSerializer(Type formType)
|
||||
{
|
||||
if (formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is { } field)
|
||||
{
|
||||
return (JsonSerializerOptions?)field.GetValue(null);
|
||||
}
|
||||
return ExternalConfigSerializers.GetValueOrDefault(formType);
|
||||
}
|
||||
|
||||
/// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary>
|
||||
public static TheoryData<Type> DriverFormsWithJsonOpts()
|
||||
{
|
||||
@@ -48,24 +79,28 @@ public sealed class DriverPageJsonConverterTests
|
||||
[MemberData(nameof(DriverFormsWithJsonOpts))]
|
||||
public void Driver_form_json_options_register_string_enum_converter(Type formType)
|
||||
{
|
||||
var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var opts = (JsonSerializerOptions)field!.GetValue(null)!;
|
||||
var opts = ResolveConfigSerializer(formType);
|
||||
opts.ShouldNotBeNull(
|
||||
$"{formType.Name} must serialise config through a private static _jsonOpts field, or be listed in " +
|
||||
"ExternalConfigSerializers with the shared options instance it uses — otherwise its enum handling " +
|
||||
"is unguarded.");
|
||||
opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
|
||||
$"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
|
||||
"enum config fields serialise as numbers and the string-typed driver factory throws on parse.");
|
||||
$"{formType.Name}'s config serializer must register a JsonStringEnumConverter; otherwise " +
|
||||
"AdminUI-authored enum config fields serialise as numbers and the string-typed driver factory " +
|
||||
"throws on parse.");
|
||||
}
|
||||
|
||||
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
|
||||
/// <c>_jsonOpts</c> field — otherwise a new form that serialised config a different way would slip
|
||||
/// past the converter guard above. Also a floor check so a rename can't silently shrink the set.</summary>
|
||||
/// serializer the guard above can reach — otherwise a new form that serialised config a different way
|
||||
/// would slip past it. Also a floor check so a rename can't silently shrink the set.</summary>
|
||||
[Fact]
|
||||
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
|
||||
{
|
||||
var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.ToList();
|
||||
allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet");
|
||||
DriverFormTypes.Count.ShouldBe(allDriverForms.Count,
|
||||
"every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
|
||||
DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet");
|
||||
|
||||
var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList();
|
||||
unguarded.ShouldBeEmpty(
|
||||
"every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " +
|
||||
"entry) so the string-enum converter guard covers it");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Guards for the MQTT <b>device</b> model. MQTT holds ONE broker connection per driver, so — like
|
||||
/// Galaxy — the device carries no endpoint of its own and the model round-trips its DeviceConfig
|
||||
/// verbatim. The one behaviour it does add is detecting <i>legacy</i> connection keys left on a
|
||||
/// DeviceConfig by a pre-form (hand-authored / SQL-seeded) deployment, because
|
||||
/// <c>DriverDeviceConfigMerger</c> merges a sole device's keys UP over the driver's and they would
|
||||
/// otherwise silently win over what the driver form shows.
|
||||
/// </summary>
|
||||
public sealed class MqttDeviceModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Round_trips_every_key_verbatim()
|
||||
{
|
||||
const string inbound = """{"Host":"10.100.0.35","Port":8883,"anything":{"nested":true}}""";
|
||||
|
||||
var json = MqttDeviceModel.FromJson(inbound).ToJson();
|
||||
var o = (JsonObject)JsonNode.Parse(json)!;
|
||||
|
||||
o["Host"]!.GetValue<string>().ShouldBe("10.100.0.35");
|
||||
o["Port"]!.GetValue<int>().ShouldBe(8883);
|
||||
o["anything"]!["nested"]!.GetValue<bool>().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_device_serializes_to_an_empty_object()
|
||||
=> MqttDeviceModel.FromJson(null).ToJson().ShouldBe("{}");
|
||||
|
||||
[Fact]
|
||||
public void A_malformed_blob_degrades_to_an_empty_object_rather_than_throwing()
|
||||
=> MqttDeviceModel.FromJson("}{ not json").ToJson().ShouldBe("{}");
|
||||
|
||||
[Fact]
|
||||
public void Validate_always_passes_because_there_is_no_per_device_endpoint()
|
||||
=> MqttDeviceModel.FromJson("""{"Host":"x"}""").Validate().ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Legacy_connection_keys_are_reported_case_insensitively()
|
||||
{
|
||||
var model = MqttDeviceModel.FromJson("""{"host":"h","PORT":1883,"useTls":false,"unrelated":1}""");
|
||||
|
||||
model.LegacyConnectionKeys.ShouldBe(["host", "PORT", "useTls"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_device_reports_no_legacy_connection_keys()
|
||||
=> MqttDeviceModel.FromJson("{}").LegacyConnectionKeys.ShouldBeEmpty();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip, enum-serialization and validation guards for the MQTT <b>driver</b> config form model.
|
||||
/// <para>
|
||||
/// The load-bearing assertion is <see cref="Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options"/>:
|
||||
/// it deserializes this form's output through <see cref="MqttJson.Options"/> — the exact instance
|
||||
/// <c>MqttDriverFactoryExtensions.CreateInstance</c> and <c>MqttDriverProbe</c> use — so an
|
||||
/// AdminUI-authored blob that Test-connect accepts cannot fault the deployed driver. That is this
|
||||
/// repo's documented systemic driver-enum-serialization bug, reproduced as a guard.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MqttDriverFormModelTests
|
||||
{
|
||||
private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!;
|
||||
|
||||
private static bool HasKeyIgnoringCase(JsonObject o, string name)
|
||||
=> o.Any(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// --- defaults -------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Defaults_match_the_driver_options_secure_posture()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
var driverDefaults = new MqttDriverOptions();
|
||||
|
||||
model.Port.ShouldBe(driverDefaults.Port);
|
||||
model.UseTls.ShouldBeTrue();
|
||||
model.UseTls.ShouldBe(driverDefaults.UseTls);
|
||||
model.AllowUntrustedServerCertificate.ShouldBeFalse();
|
||||
model.AllowUntrustedServerCertificate.ShouldBe(driverDefaults.AllowUntrustedServerCertificate);
|
||||
model.ProtocolVersion.ShouldBe(driverDefaults.ProtocolVersion);
|
||||
model.Mode.ShouldBe(driverDefaults.Mode);
|
||||
model.MaxPayloadBytes.ShouldBe(driverDefaults.MaxPayloadBytes);
|
||||
|
||||
// The redundancy-pair eviction guard: an unauthored driver leaves clientId unset so MQTTnet
|
||||
// generates a unique id per connection (P1 live-gate finding).
|
||||
model.ClientId.ShouldBe("");
|
||||
}
|
||||
|
||||
// --- round-trip -----------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_every_authored_field()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Host = "broker.internal";
|
||||
model.Port = 1883;
|
||||
model.ClientId = "otopcua-site-a";
|
||||
model.Username = "otopcua";
|
||||
model.Password = "s3cret";
|
||||
model.UseTls = false;
|
||||
model.AllowUntrustedServerCertificate = true;
|
||||
model.CaCertificatePath = "/etc/ssl/ca.pem";
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
model.CleanSession = false;
|
||||
model.KeepAliveSeconds = 45;
|
||||
model.ConnectTimeoutSeconds = 9;
|
||||
model.ReconnectMinBackoffSeconds = 2;
|
||||
model.ReconnectMaxBackoffSeconds = 60;
|
||||
model.Mode = MqttMode.Plain;
|
||||
model.MaxPayloadBytes = 4096;
|
||||
model.TopicPrefix = "otopcua/fixture/";
|
||||
model.DefaultQos = 2;
|
||||
|
||||
var back = MqttDriverFormModel.FromJson(model.ToJson());
|
||||
|
||||
back.Host.ShouldBe("broker.internal");
|
||||
back.Port.ShouldBe(1883);
|
||||
back.ClientId.ShouldBe("otopcua-site-a");
|
||||
back.Username.ShouldBe("otopcua");
|
||||
back.Password.ShouldBe("s3cret");
|
||||
back.UseTls.ShouldBeFalse();
|
||||
back.AllowUntrustedServerCertificate.ShouldBeTrue();
|
||||
back.CaCertificatePath.ShouldBe("/etc/ssl/ca.pem");
|
||||
back.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
|
||||
back.CleanSession.ShouldBeFalse();
|
||||
back.KeepAliveSeconds.ShouldBe(45);
|
||||
back.ConnectTimeoutSeconds.ShouldBe(9);
|
||||
back.ReconnectMinBackoffSeconds.ShouldBe(2);
|
||||
back.ReconnectMaxBackoffSeconds.ShouldBe(60);
|
||||
back.Mode.ShouldBe(MqttMode.Plain);
|
||||
back.MaxPayloadBytes.ShouldBe(4096);
|
||||
back.TopicPrefix.ShouldBe("otopcua/fixture/");
|
||||
back.DefaultQos.ShouldBe(2);
|
||||
}
|
||||
|
||||
// --- the factory-parity guard (the point of the whole file) ---------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Host = "10.100.0.35";
|
||||
model.Port = 8883;
|
||||
model.Username = "otopcua";
|
||||
model.Password = "pw";
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.ConnectTimeoutSeconds = 7;
|
||||
model.DefaultQos = 0;
|
||||
|
||||
// The exact instance the runtime factory + probe + driver + browser parse through.
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options);
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.Host.ShouldBe("10.100.0.35");
|
||||
options.Port.ShouldBe(8883);
|
||||
options.Username.ShouldBe("otopcua");
|
||||
options.Password.ShouldBe("pw");
|
||||
options.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
|
||||
options.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
options.ConnectTimeoutSeconds.ShouldBe(7);
|
||||
options.Plain.ShouldNotBeNull();
|
||||
options.Plain.DefaultQos.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enums_serialize_as_names_never_as_ordinals()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
|
||||
var json = model.ToJson();
|
||||
|
||||
json.ShouldContain("\"Mode\":\"SparkplugB\"");
|
||||
json.ShouldContain("\"ProtocolVersion\":\"V311\"");
|
||||
// The historical bug: a numerically-serialized enum that the string-typed runtime binder faults on.
|
||||
json.ShouldNotContain("\"Mode\":1", Case.Sensitive);
|
||||
json.ShouldNotContain("\"ProtocolVersion\":0", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinal_only_reader_cannot_bind_the_blob_which_proves_the_enums_are_names()
|
||||
{
|
||||
// The string-match-free half of the enum pin, and a falsifiability control for the test above.
|
||||
// A JsonSerializerOptions WITHOUT a JsonStringEnumConverter can read a NUMERIC enum but not a
|
||||
// named one — so if this form ever regressed to writing ordinals (the historical driver bug),
|
||||
// this deserialize would start SUCCEEDING and the test would fail. Deliberately independent of
|
||||
// MqttJson.Options, because a round-trip through one shared instance is symmetric and would
|
||||
// happily pass on ordinals at both ends.
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.ProtocolVersion = MqttProtocolVersion.V311;
|
||||
|
||||
var ordinalOnlyReader = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
|
||||
Should.Throw<JsonException>(
|
||||
() => JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), ordinalOnlyReader));
|
||||
}
|
||||
|
||||
// --- blob hygiene ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RawTags_are_never_written_into_the_driver_config()
|
||||
{
|
||||
// The deploy artifact injects RawTags via DriverDeviceConfigMerger; a form-authored copy would
|
||||
// be dead weight at best and a stale shadow at worst.
|
||||
const string inbound = """{"Host":"h","rawTags":[{"RawPath":"a/b","TagConfig":"{}"}]}""";
|
||||
|
||||
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
|
||||
|
||||
HasKeyIgnoringCase(Parse(json), "RawTags").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_keys_and_the_sparkplug_subobject_survive_a_load_save()
|
||||
{
|
||||
const string inbound = """
|
||||
{"Host":"h","sparkplug":{"GroupId":"G1","HostId":"H1","ActAsPrimaryHost":true},"aFutureKey":42}
|
||||
""";
|
||||
|
||||
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
|
||||
var o = Parse(json);
|
||||
|
||||
// Sparkplug is P2 (Task 21+) — this form never authors it, and must never drop it.
|
||||
o["sparkplug"].ShouldNotBeNull();
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("G1");
|
||||
o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
|
||||
o["aFutureKey"]!.GetValue<int>().ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_camelCase_inbound_key_is_replaced_not_duplicated()
|
||||
{
|
||||
// MqttJson.Options is PropertyNameCaseInsensitive, so a hand-edited camelCase blob binds; the
|
||||
// form must overwrite that key rather than leave "host" and "Host" fighting in one object.
|
||||
const string inbound = """{"host":"old","port":1111}""";
|
||||
|
||||
var model = MqttDriverFormModel.FromJson(inbound);
|
||||
model.Host.ShouldBe("old");
|
||||
model.Port.ShouldBe(1111);
|
||||
model.Host = "new";
|
||||
|
||||
var o = Parse(model.ToJson());
|
||||
|
||||
o.Count(p => string.Equals(p.Key, "Host", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
|
||||
JsonSerializer.Deserialize<MqttDriverOptions>(o.ToJsonString(), MqttJson.Options)!.Host.ShouldBe("new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_optional_strings_are_omitted_so_the_driver_defaults_apply()
|
||||
{
|
||||
var json = MqttDriverFormModel.FromJson(null).ToJson();
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options)!;
|
||||
|
||||
options.ClientId.ShouldBeNull();
|
||||
options.Username.ShouldBeNull();
|
||||
options.CaCertificatePath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_merged_deploy_blob_keeps_the_form_connection_and_gains_the_artifact_RawTags()
|
||||
{
|
||||
// The real deploy seam: DriverDeviceConfigMerger folds this form's DriverConfig together with the
|
||||
// (empty) MQTT DeviceConfig and injects the authored raw tags. MQTT authors its connection on the
|
||||
// DRIVER precisely so this survives 0, 1 or N devices.
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.Host = "broker.internal";
|
||||
model.Port = 1883;
|
||||
model.UseTls = false;
|
||||
model.Mode = MqttMode.Plain;
|
||||
model.DefaultQos = 2;
|
||||
|
||||
var rawTags = new[]
|
||||
{
|
||||
new RawTagEntry("Plant/Mqtt/Dev1/Temp", """{"topic":"a/b"}""", WriteIdempotent: false, DeviceName: "Dev1"),
|
||||
};
|
||||
|
||||
foreach (var devices in new[]
|
||||
{
|
||||
Array.Empty<DriverDeviceConfigMerger.DeviceRow>(),
|
||||
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")],
|
||||
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")],
|
||||
})
|
||||
{
|
||||
var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags);
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(merged, MqttJson.Options)!;
|
||||
|
||||
options.Host.ShouldBe("broker.internal", $"device count {devices.Length}");
|
||||
options.Port.ShouldBe(1883, $"device count {devices.Length}");
|
||||
options.UseTls.ShouldBeFalse($"device count {devices.Length}");
|
||||
options.Plain!.DefaultQos.ShouldBe(2, $"device count {devices.Length}");
|
||||
options.RawTags.Count.ShouldBe(1, $"device count {devices.Length}");
|
||||
options.RawTags[0].RawPath.ShouldBe("Plant/Mqtt/Dev1/Temp");
|
||||
}
|
||||
}
|
||||
|
||||
// --- validation -----------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_a_default_model()
|
||||
=> MqttDriverFormModel.FromJson(null).Validate().ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("Host", "")]
|
||||
[InlineData("Port", 0)]
|
||||
[InlineData("Port", 70000)]
|
||||
[InlineData("KeepAliveSeconds", 0)]
|
||||
[InlineData("ConnectTimeoutSeconds", 0)]
|
||||
[InlineData("ReconnectMinBackoffSeconds", 0)]
|
||||
[InlineData("ReconnectMaxBackoffSeconds", -1)]
|
||||
[InlineData("MaxPayloadBytes", 0)]
|
||||
[InlineData("DefaultQos", 3)]
|
||||
[InlineData("DefaultQos", -1)]
|
||||
public void Validate_rejects_an_out_of_range_field(string field, object value)
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
typeof(MqttDriverFormModel).GetProperty(field)!.SetValue(model, value);
|
||||
|
||||
model.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_a_max_backoff_below_the_min()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.ReconnectMinBackoffSeconds = 30;
|
||||
model.ReconnectMaxBackoffSeconds = 5;
|
||||
|
||||
model.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ignored_validation_error_still_cannot_produce_a_driver_bricking_blob()
|
||||
{
|
||||
// Documented finding: `timeoutSeconds: 0` was an operator-authorable driver-brick. Validate()
|
||||
// reports it; the serializer clamps it, so even a saved-anyway blob stays inside the driver's
|
||||
// own [Range] bounds.
|
||||
var model = MqttDriverFormModel.FromJson(null);
|
||||
model.ConnectTimeoutSeconds = 0;
|
||||
model.KeepAliveSeconds = -5;
|
||||
model.ReconnectMinBackoffSeconds = 0;
|
||||
model.ReconnectMaxBackoffSeconds = -3;
|
||||
model.MaxPayloadBytes = 0;
|
||||
model.Port = 99999;
|
||||
model.DefaultQos = 9;
|
||||
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options)!;
|
||||
|
||||
options.ConnectTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.KeepAliveSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.ReconnectMinBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.ReconnectMaxBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.MaxPayloadBytes.ShouldBeGreaterThanOrEqualTo(1);
|
||||
options.Port.ShouldBeInRange(1, 65535);
|
||||
options.Plain!.DefaultQos.ShouldBeInRange(0, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_malformed_inbound_blob_degrades_to_defaults_rather_than_throwing()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson("}{ not json");
|
||||
|
||||
model.Host.ShouldBe(new MqttDriverOptions().Host);
|
||||
model.UseTls.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user