Merge branch 'feat/mqtt-sparkplug-driver'
# Conflicts: # src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj # src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
+139
-4
@@ -1,3 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -7,17 +10,23 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
||||
|
||||
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
|
||||
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
|
||||
/// timeout enforcement.</summary>
|
||||
/// on open, NotFound semantics on unknown tokens, exception swallowing, per-call
|
||||
/// timeout enforcement, and the DriverOperator gate on the one write-capable member.</summary>
|
||||
public sealed class BrowserSessionServiceTests
|
||||
{
|
||||
private static BrowserSessionService NewService(
|
||||
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
|
||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
|
||||
NewService(registry, new FakeUniversalDriverBrowser(), browsers);
|
||||
|
||||
private static BrowserSessionService NewService(
|
||||
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
|
||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
|
||||
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal,
|
||||
new FakeAuthorizationService(succeeds: true), new FakeAuthenticationStateProvider());
|
||||
|
||||
private static BrowserSessionService NewServiceWithAuthz(
|
||||
BrowseSessionRegistry registry, bool authorized) =>
|
||||
new([], registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser(),
|
||||
new FakeAuthorizationService(authorized), new FakeAuthenticationStateProvider());
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
|
||||
@@ -226,4 +235,130 @@ public sealed class BrowserSessionServiceTests
|
||||
// Unknown token is a no-op.
|
||||
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- RequestRebirthAsync: the one write path
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_WithoutDriverOperator_IsRefusedAndNeverReachesTheSession()
|
||||
{
|
||||
// A publishing action that skips authz is a security bug, not a style issue: this one makes a
|
||||
// live plant broker receive a command. The refusal must happen BEFORE the session is touched.
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var session = new FakeRebirthCapableBrowseSession();
|
||||
registry.Register(session);
|
||||
var service = NewServiceWithAuthz(registry, authorized: false);
|
||||
|
||||
await Should.ThrowAsync<UnauthorizedAccessException>(
|
||||
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
|
||||
|
||||
session.Scopes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_WithDriverOperator_DispatchesTheScopeAndReturnsTheCount()
|
||||
{
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var session = new FakeRebirthCapableBrowseSession { Published = 2 };
|
||||
registry.Register(session);
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
var published = await service.RequestRebirthAsync(session.Token, "Plant1", CancellationToken.None);
|
||||
|
||||
published.ShouldBe(2);
|
||||
session.Scopes.ShouldBe(["Plant1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_OnANonRebirthCapableSession_IsNotSupported()
|
||||
{
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var session = new FakeBrowseSession();
|
||||
registry.Register(session);
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(
|
||||
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_UnknownToken_ThrowsBrowseSessionNotFound()
|
||||
{
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
await Should.ThrowAsync<BrowseSessionNotFoundException>(
|
||||
() => service.RequestRebirthAsync(Guid.NewGuid(), "Plant1/EdgeA", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRequestRebirth_IsTrue_OnlyForASessionThatAdvertisesTheAction()
|
||||
{
|
||||
// The picker draws its Request-rebirth affordance off this. A session type CAN re-announce
|
||||
// (MqttBrowseSession implements the interface) while a given INSTANCE cannot — a plain-MQTT
|
||||
// window publishes nothing, ever — so a type test alone would draw a button that only throws.
|
||||
var registry = new BrowseSessionRegistry();
|
||||
var sparkplug = new FakeRebirthCapableBrowseSession();
|
||||
var plain = new FakeRebirthCapableBrowseSession { RebirthAvailable = false };
|
||||
var plainOldSession = new FakeBrowseSession();
|
||||
registry.Register(sparkplug);
|
||||
registry.Register(plain);
|
||||
registry.Register(plainOldSession);
|
||||
var service = NewServiceWithAuthz(registry, authorized: true);
|
||||
|
||||
service.CanRequestRebirth(sparkplug.Token).ShouldBeTrue();
|
||||
service.CanRequestRebirth(plain.Token).ShouldBeFalse();
|
||||
service.CanRequestRebirth(plainOldSession.Token).ShouldBeFalse();
|
||||
service.CanRequestRebirth(Guid.NewGuid()).ShouldBeFalse(); // unknown/reaped token
|
||||
}
|
||||
|
||||
/// <summary>A browse session that records the rebirth scopes it was asked for.</summary>
|
||||
private sealed class FakeRebirthCapableBrowseSession : IRebirthCapableBrowseSession
|
||||
{
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
public DateTime LastUsedUtc { get; } = DateTime.UtcNow;
|
||||
|
||||
public int Published { get; init; } = 1;
|
||||
|
||||
/// <summary>Whether this fake session advertises the action (a plain-MQTT window does not).</summary>
|
||||
public bool RebirthAvailable { get; init; } = true;
|
||||
|
||||
public List<string> Scopes { get; } = [];
|
||||
|
||||
public Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
Scopes.Add(scope);
|
||||
return Task.FromResult(Published);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
|
||||
|
||||
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
|
||||
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IAuthorizationService"/> whose verdict the test dictates.</summary>
|
||||
private sealed class FakeAuthorizationService(bool succeeds) : IAuthorizationService
|
||||
{
|
||||
public Task<AuthorizationResult> AuthorizeAsync(
|
||||
ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements) =>
|
||||
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
|
||||
|
||||
public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) =>
|
||||
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
|
||||
}
|
||||
|
||||
/// <summary>A fixed authenticated identity for the circuit under test.</summary>
|
||||
private sealed class FakeAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
Task.FromResult(new AuthenticationState(
|
||||
new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "tester")], "test"))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,85 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the <c>/raw</c> "New driver" picker against driver-type drift.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="RawDriverTypeDialog"/> is the <b>only</b> surface an operator can author a
|
||||
/// <c>/raw</c> driver from, and its option list is a hand-maintained array — nothing tied it to
|
||||
/// <see cref="DriverTypeNames"/>. The MQTT P1 live gate found the consequence: the driver shipped
|
||||
/// complete (contracts, driver, browser, factory + host registration, typed tag editor, 266 green
|
||||
/// unit tests) and was still <b>unauthorable in production</b>, because nobody added the row to
|
||||
/// this array. Every offline test passed the whole way — the array is not reachable from any of
|
||||
/// them, and AdminUI has no bUnit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So this asserts parity by reflection rather than re-listing the drivers: a new
|
||||
/// <see cref="DriverTypeNames"/> constant that nobody surfaces in the picker fails here instead of
|
||||
/// at someone's live gate. It reads the private static <c>Types</c> field because that array is
|
||||
/// the component's own source of truth for the rendered <c><option></c> set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RawDriverTypeDialogParityTests
|
||||
{
|
||||
private static IReadOnlyList<(string Label, string Value)> DialogTypes()
|
||||
{
|
||||
var field = typeof(RawDriverTypeDialog).GetField(
|
||||
"Types",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
field.ShouldNotBeNull(
|
||||
"RawDriverTypeDialog.Types was renamed or removed — this guard reads it by name and " +
|
||||
"silently guards nothing once it cannot find it.");
|
||||
|
||||
var raw = (Array)field!.GetValue(null)!;
|
||||
|
||||
return raw.Cast<object>()
|
||||
.Select(t =>
|
||||
{
|
||||
var type = t.GetType();
|
||||
return ((string)type.GetField("Item1")!.GetValue(t)!,
|
||||
(string)type.GetField("Item2")!.GetValue(t)!);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_is_offered_by_the_new_driver_picker()
|
||||
{
|
||||
var offered = DialogTypes().Select(t => t.Value).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = DriverTypeNames.All.Where(n => !offered.Contains(n)).ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"These driver types are declared in DriverTypeNames but cannot be authored from the " +
|
||||
$"/raw New-driver picker, so they are unreachable in production: {string.Join(", ", missing)}. " +
|
||||
"Add a row to RawDriverTypeDialog.Types.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_picker_offers_no_driver_type_that_is_not_declared()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var unknown = DialogTypes().Select(t => t.Value).Where(v => !declared.Contains(v)).ToList();
|
||||
|
||||
unknown.ShouldBeEmpty(
|
||||
$"The /raw New-driver picker offers driver types with no DriverTypeNames constant, so " +
|
||||
$"authoring one produces a driver no factory can build: {string.Join(", ", unknown)}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_is_authorable_from_the_new_driver_picker()
|
||||
{
|
||||
// The specific regression the P1 live gate caught, pinned by name so a future refactor of the
|
||||
// parity facts above cannot quietly drop MQTT again.
|
||||
DialogTypes().Select(t => t.Value).ShouldContain(DriverTypeNames.Mqtt);
|
||||
}
|
||||
}
|
||||
@@ -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,437 @@
|
||||
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);
|
||||
|
||||
// The blob has no Mode key, so it is Plain — and in Plain mode this form does not author the
|
||||
// Sparkplug sub-object at all, so it must come back byte-identical.
|
||||
o["sparkplug"].ShouldNotBeNull();
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("G1");
|
||||
o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
|
||||
o["aFutureKey"]!.GetValue<int>().ShouldBe(42);
|
||||
}
|
||||
|
||||
// --- Sparkplug B authoring -------------------------------------------------------------------
|
||||
//
|
||||
// These pin the defect the P2 live gate found: MqttDriverForm shipped its Sparkplug branch as a
|
||||
// "not implemented yet" placeholder, so once Sparkplug ingest landed the group id — the driver's
|
||||
// ENTIRE subscription filter, spBv1.0/{GroupId}/# — could not be authored from the AdminUI at all.
|
||||
// The driver deployed connected, Healthy, and ingesting nothing.
|
||||
|
||||
[Fact]
|
||||
public void Sparkplug_mode_round_trips_the_group_id_through_a_load_save_load()
|
||||
{
|
||||
var authored = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
authored.Mode = MqttMode.SparkplugB;
|
||||
authored.SparkplugGroupId = "OtOpcUaSim";
|
||||
authored.SparkplugRequestRebirthOnGap = true;
|
||||
|
||||
var reloaded = MqttDriverFormModel.FromJson(authored.ToJson());
|
||||
|
||||
reloaded.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
reloaded.SparkplugGroupId.ShouldBe("OtOpcUaSim");
|
||||
reloaded.SparkplugRequestRebirthOnGap.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sparkplug_mode_emits_a_group_id_the_driver_options_actually_bind()
|
||||
{
|
||||
// The whole point of the round-trip: what this form writes must deserialize back through the
|
||||
// SAME shared MqttJson.Options the runtime factory uses, or the driver sees a null group.
|
||||
var authored = MqttDriverFormModel.FromJson(null);
|
||||
authored.Mode = MqttMode.SparkplugB;
|
||||
authored.SparkplugGroupId = "OtOpcUaSim";
|
||||
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(authored.ToJson(), MqttJson.Options)!;
|
||||
|
||||
options.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
options.Sparkplug.ShouldNotBeNull();
|
||||
options.Sparkplug!.GroupId.ShouldBe("OtOpcUaSim");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sparkplug_authoring_merges_over_the_subobject_rather_than_replacing_it()
|
||||
{
|
||||
// Same preserve-what-you-do-not-author discipline the top-level bag applies: a key a newer
|
||||
// driver adds INSIDE the sub-object must survive an older AdminUI's save.
|
||||
const string inbound = """
|
||||
{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old","aFutureSpbKey":7}}
|
||||
""";
|
||||
|
||||
var model = MqttDriverFormModel.FromJson(inbound);
|
||||
model.SparkplugGroupId = "New";
|
||||
|
||||
var o = Parse(model.ToJson());
|
||||
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
|
||||
o["sparkplug"]!["aFutureSpbKey"]!.GetValue<int>().ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_camelCase_sparkplug_key_is_merged_not_duplicated()
|
||||
{
|
||||
const string inbound = """{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old"}}""";
|
||||
|
||||
var model = MqttDriverFormModel.FromJson(inbound);
|
||||
model.SparkplugGroupId = "New";
|
||||
|
||||
var o = Parse(model.ToJson());
|
||||
|
||||
o.Count(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
|
||||
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plain_mode_never_writes_a_sparkplug_subobject_onto_a_blob_that_had_none()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
model.SparkplugGroupId = "TypedThenSwitchedAway"; // Mode is still Plain.
|
||||
|
||||
HasKeyIgnoringCase(Parse(model.ToJson()), "Sparkplug").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blank_group_id_is_refused_in_sparkplug_mode_only()
|
||||
{
|
||||
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
|
||||
// Plain does not care — it binds by topic.
|
||||
model.Validate().ShouldBeNull();
|
||||
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.Validate().ShouldNotBeNull();
|
||||
model.Validate()!.ShouldContain("Sparkplug group ID is required");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Plant/1")]
|
||||
[InlineData("Plant+1")]
|
||||
[InlineData("Plant#1")]
|
||||
public void A_group_id_carrying_a_topic_metacharacter_is_refused(string groupId)
|
||||
{
|
||||
// It is a literal segment inside spBv1.0/{GroupId}/#, so '/' silently widens the filter and
|
||||
// '+'/'#' are not even legal mid-segment. Mirrors MqttTagConfigModel's tag-side rule.
|
||||
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
|
||||
model.Mode = MqttMode.SparkplugB;
|
||||
model.SparkplugGroupId = groupId;
|
||||
|
||||
model.Validate()!.ShouldContain("cannot appear in an MQTT topic segment");
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
// Typed AdminUI editor model for the Mqtt driver — Plain (topic-bound) and Sparkplug B (birth/alias
|
||||
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
|
||||
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
|
||||
// untouched.
|
||||
//
|
||||
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
|
||||
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
|
||||
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
|
||||
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
|
||||
public sealed class MqttTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("not json at all")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(json);
|
||||
|
||||
m.Mode.ShouldBe(MqttMode.Plain);
|
||||
m.Topic.ShouldBe("");
|
||||
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
// An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is
|
||||
// visible and one click away — it is a guide, not a requirement (blank is accepted, see below).
|
||||
m.JsonPath.ShouldBe("$");
|
||||
m.DataType.ShouldBe(DriverDataType.String);
|
||||
m.Qos.ShouldBeNull();
|
||||
m.RetainSeed.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_every_plain_field()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(
|
||||
"""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}""");
|
||||
|
||||
m.Mode.ShouldBe(MqttMode.Plain);
|
||||
m.Topic.ShouldBe("factory/oven/temp");
|
||||
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
m.JsonPath.ShouldBe("$.value");
|
||||
m.DataType.ShouldBe(DriverDataType.Float64);
|
||||
m.Qos.ShouldBe(1);
|
||||
m.RetainSeed.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_every_plain_field()
|
||||
{
|
||||
var m = new MqttTagConfigModel
|
||||
{
|
||||
Topic = "line3/press",
|
||||
PayloadFormat = MqttPayloadFormat.Scalar,
|
||||
JsonPath = "$.a.b",
|
||||
DataType = DriverDataType.Int32,
|
||||
Qos = 2,
|
||||
RetainSeed = true,
|
||||
};
|
||||
|
||||
var m2 = MqttTagConfigModel.FromJson(m.ToJson());
|
||||
|
||||
m2.Topic.ShouldBe("line3/press");
|
||||
m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar);
|
||||
m2.JsonPath.ShouldBe("$.a.b");
|
||||
m2.DataType.ShouldBe(DriverDataType.Int32);
|
||||
m2.Qos.ShouldBe(2);
|
||||
m2.RetainSeed.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key()
|
||||
{
|
||||
var json = new MqttTagConfigModel
|
||||
{
|
||||
Topic = "a/b",
|
||||
PayloadFormat = MqttPayloadFormat.Raw,
|
||||
DataType = DriverDataType.Boolean,
|
||||
}.ToJson();
|
||||
|
||||
json.ShouldContain("\"topic\":\"a/b\"");
|
||||
// Enums MUST serialize as names — the driver factory reads them strictly by name, and an
|
||||
// ordinal would be rejected outright (the systemic enum-serialization trap).
|
||||
json.ShouldContain("\"payloadFormat\":\"Raw\"");
|
||||
json.ShouldContain("\"dataType\":\"Boolean\"");
|
||||
json.ShouldNotContain("\"payloadFormat\":1");
|
||||
json.ShouldNotContain("\"dataType\":0");
|
||||
// TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key.
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_absent_optional_keys()
|
||||
{
|
||||
var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
|
||||
|
||||
// Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true,
|
||||
// jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob.
|
||||
json.ShouldNotContain("qos");
|
||||
json.ShouldNotContain("retainSeed");
|
||||
json.ShouldNotContain("jsonPath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_a_blank_topic()
|
||||
{
|
||||
// Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind.
|
||||
var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
|
||||
|
||||
json.ShouldNotContain("topic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""
|
||||
{"topic":"a/b","payloadFormat":"Raw","dataType":"String",
|
||||
"isHistorized":true,"historianTagname":"Plant.A.B",
|
||||
"array":{"valueRank":1,"dimensions":[4]},
|
||||
"alarm":{"type":"Hi","limit":90.5},
|
||||
"someFutureScalar":"keep me"}
|
||||
""").ToJson();
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
// History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would
|
||||
// silently un-historize the tag on the next edit.
|
||||
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
|
||||
o["historianTagname"]!.GetValue<string>().ShouldBe("Plant.A.B");
|
||||
// Nested objects/arrays must survive structurally, not just as scalars.
|
||||
o["array"]!["valueRank"]!.GetValue<int>().ShouldBe(1);
|
||||
o["array"]!["dimensions"]!.AsArray()[0]!.GetValue<int>().ShouldBe(4);
|
||||
o["alarm"]!["limit"]!.GetValue<double>().ShouldBe(90.5);
|
||||
o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["groupId"]!.GetValue<string>().ShouldBe("Plant1");
|
||||
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
|
||||
// TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
|
||||
// Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
|
||||
// required, deviceId optional, dataType/qos read strictly but only when present. See the model's
|
||||
// Validate() remarks for the one place this editor is deliberately STRICTER than the factory
|
||||
// (illegal characters in the id segments) and why metricName is exempt from that rule.
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingMetricName_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingGroupId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingEdgeNodeId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugValid_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
|
||||
// must validate cleanly without one.
|
||||
[Fact]
|
||||
public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
|
||||
// an absent dataType is not an error.
|
||||
[Fact]
|
||||
public void Validate_SparkplugAbsentDataType_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
|
||||
// and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
|
||||
[Fact]
|
||||
public void Validate_SparkplugTypoedDataType_Fails()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
$$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
|
||||
// decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
|
||||
// bind. Covers all three id fields plus both illegal characters.
|
||||
[Theory]
|
||||
[InlineData("groupId", "Plant1/A")]
|
||||
[InlineData("groupId", "Plant+1")]
|
||||
[InlineData("groupId", "Plant#1")]
|
||||
[InlineData("edgeNodeId", "Edge/A")]
|
||||
[InlineData("edgeNodeId", "Edge+A")]
|
||||
[InlineData("deviceId", "Dev/1")]
|
||||
[InlineData("deviceId", "Dev#1")]
|
||||
public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["groupId"] = "Plant1",
|
||||
["edgeNodeId"] = "EdgeA",
|
||||
["deviceId"] = "Filler1",
|
||||
["metricName"] = "Temperature",
|
||||
};
|
||||
fields[field] = value;
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(fields);
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
// metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
|
||||
// (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
|
||||
// metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ToJson_SparkplugWithoutOverride_OmitsDataType()
|
||||
{
|
||||
var json = MqttTagConfigModel
|
||||
.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldNotContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("\"groupId\":\"Plant1\"");
|
||||
json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
|
||||
json.ShouldContain("\"deviceId\":\"Filler1\"");
|
||||
json.ShouldContain("\"metricName\":\"Temperature\"");
|
||||
json.ShouldContain("\"dataType\":\"Float32\"");
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
|
||||
// descriptor keys it writes re-infer the mode on the next load.
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
|
||||
m.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("\"mode\"");
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys()
|
||||
=> MqttTagConfigModel
|
||||
.FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||
.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
|
||||
// ── Validate: Plain rules ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidPlain_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingTopic_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/+/c")]
|
||||
[InlineData("a/#")]
|
||||
public void Validate_PlainWildcardTopic_Fails(string topic)
|
||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root
|
||||
// ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would
|
||||
// make the editor refuse what the driver accepts, and would block CSV-import batches (this validator
|
||||
// also gates RawManualTagEntryModal's review grid) over a sane default.
|
||||
[Fact]
|
||||
public void Validate_JsonWithoutPath_IsAccepted()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// …and the key stays ABSENT through a load→save, so the driver applies its own default rather than
|
||||
// this editor freezing "$" into an already-deployed blob.
|
||||
[Fact]
|
||||
public void ToJson_leaves_an_existing_blank_jsonPath_absent()
|
||||
{
|
||||
var json = MqttTagConfigModel
|
||||
.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldNotContain("jsonPath");
|
||||
}
|
||||
|
||||
// The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an
|
||||
// explicit "$" once the operator touches anything, which is the guided half of the deal.
|
||||
[Fact]
|
||||
public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(null);
|
||||
m.Topic = "a/b";
|
||||
|
||||
m.ToJson().ShouldContain("\"jsonPath\":\"$\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_JsonWithPath_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Validate_NonJsonFormat_DoesNotRequireJsonPath()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("\"high\"")]
|
||||
[InlineData("1.5")]
|
||||
public void Validate_InvalidQos_Fails(string qosLiteral)
|
||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void Validate_LegalQos_ReturnsNull(int qos)
|
||||
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright
|
||||
// (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while
|
||||
// silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field.
|
||||
[Fact]
|
||||
public void Validate_TypoedPayloadFormat_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_TypoedDataType_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the
|
||||
// typed field's name), so the strict check above can never wedge a tag the editor itself produced.
|
||||
// NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath —
|
||||
// so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule.
|
||||
[Fact]
|
||||
public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum()
|
||||
{
|
||||
var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""";
|
||||
MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace(); // pre-condition
|
||||
|
||||
var repaired = MqttTagConfigModel.FromJson(typoed).ToJson();
|
||||
|
||||
repaired.ShouldContain("\"dataType\":\"String\"");
|
||||
MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
// A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
|
||||
// Plain "topic is required" check would reject every Sparkplug tag.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ── Dispatch registration ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TagConfigEditorMap_resolves_the_Mqtt_editor()
|
||||
=> TagConfigEditorMap.Resolve("Mqtt").ShouldBe(
|
||||
typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor));
|
||||
|
||||
[Fact]
|
||||
public void TagConfigValidator_dispatches_Mqtt()
|
||||
=> TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
@@ -96,6 +97,204 @@ public sealed class RawBrowseCommitMapperTests
|
||||
JsonNode.Parse(json)!.AsObject()["address"]!.GetValue<string>().ShouldBe("40001");
|
||||
}
|
||||
|
||||
// ---- BuildTagConfig: MQTT (the address is a DESCRIPTOR, not a single reference string) --------
|
||||
|
||||
/// <summary>The Sparkplug address a browse session states for a metric under a device.</summary>
|
||||
/// <param name="metricName">The metric name to state.</param>
|
||||
/// <returns>The stated address fields.</returns>
|
||||
private static Dictionary<string, string> SparkplugFields(string metricName = "Temperature") => new()
|
||||
{
|
||||
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
|
||||
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
|
||||
[MqttTagConfigKeys.DeviceId] = "Filler1",
|
||||
[MqttTagConfigKeys.MetricName] = metricName,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_plain_writes_the_topic_key_the_factory_actually_reads()
|
||||
{
|
||||
// The generic "address" fallback produced a blob MqttTagDefinitionFactory cannot read at all —
|
||||
// a browse-committed tag that deploys clean and then reports BadNodeIdUnknown forever.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"otopcua/fixture/oven/temp",
|
||||
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
|
||||
|
||||
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_plain_falls_back_to_the_node_id_when_no_field_was_stated()
|
||||
{
|
||||
// A Plain browse node id IS the topic, so the fallback is exact — unlike Sparkplug, where the
|
||||
// tuple is unrecoverable and DescribeUncommittableLeaf refuses instead.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "otopcua/fixture/oven/temp");
|
||||
|
||||
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_writes_the_binding_tuple_from_the_stated_fields()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt", "OtOpcUaSim/EdgeA/Filler1::Temperature", SparkplugFields());
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["groupId"]!.GetValue<string>().ShouldBe("OtOpcUaSim");
|
||||
o["edgeNodeId"]!.GetValue<string>().ShouldBe("EdgeA");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
|
||||
o.ContainsKey("topic").ShouldBeFalse(); // a Sparkplug tag has no per-tag topic
|
||||
o.ContainsKey("address").ShouldBeFalse(); // and never the generic fallback key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_node_level_metric_omits_deviceId_entirely()
|
||||
{
|
||||
var fields = SparkplugFields();
|
||||
fields.Remove(MqttTagConfigKeys.DeviceId);
|
||||
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA::Temperature", fields);
|
||||
|
||||
JsonNode.Parse(json)!.AsObject().ContainsKey("deviceId").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_takes_the_metric_name_from_the_fields_not_the_node_id()
|
||||
{
|
||||
// THE case the whole seam exists for: a metric name containing '/' makes the node id
|
||||
// '{group}/{edge}/{device}::{metric}' un-splittable — any parse would bind the wrong metric.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
|
||||
SparkplugFields("Node Control/Rebirth"));
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_survives_a_group_id_containing_the_metric_separator()
|
||||
{
|
||||
// A group id is an arbitrary MQTT topic segment and MAY contain '::'. Splitting the node id on
|
||||
// the first '::' would read the group as 'odd' and the metric as 'group/EdgeA::Temperature'.
|
||||
var fields = SparkplugFields();
|
||||
fields[MqttTagConfigKeys.GroupId] = "odd::group";
|
||||
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt", "odd::group/EdgeA/Filler1::Temperature", fields);
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["groupId"]!.GetValue<string>().ShouldBe("odd::group");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_sparkplug_blob_deserializes_through_the_REAL_driver_factory()
|
||||
{
|
||||
// The round trip that would have caught the defect: the committed blob is fed to the factory the
|
||||
// deployed driver actually uses. Anything else is a test of the mapper agreeing with itself.
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
|
||||
SparkplugFields("Node Control/Rebirth"));
|
||||
|
||||
MqttTagDefinitionFactory
|
||||
.FromSparkplugTagConfig(json, "Plant/Mqtt/dev1/Rebirth", out var def)
|
||||
.ShouldBeTrue();
|
||||
|
||||
def.Name.ShouldBe("Plant/Mqtt/dev1/Rebirth"); // v3: identity is the RawPath
|
||||
def.GroupId.ShouldBe("OtOpcUaSim");
|
||||
def.EdgeNodeId.ShouldBe("EdgeA");
|
||||
def.DeviceId.ShouldBe("Filler1");
|
||||
def.MetricName.ShouldBe("Node Control/Rebirth");
|
||||
def.DataTypeAuthored.ShouldBeFalse(); // no dataType key ⇒ take the birth's declared type
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Mqtt_plain_blob_deserializes_through_the_REAL_driver_factory()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
"Mqtt",
|
||||
"otopcua/fixture/oven/temp",
|
||||
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
|
||||
|
||||
MqttTagDefinitionFactory.FromTagConfig(json, "Plant/Mqtt/dev1/Temp", out var def).ShouldBeTrue();
|
||||
def.Topic.ShouldBe("otopcua/fixture/oven/temp");
|
||||
def.Name.ShouldBe("Plant/Mqtt/dev1/Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_round_trip_test_is_falsifiable_a_wrong_key_name_is_rejected_by_the_factory()
|
||||
{
|
||||
// Control: mutate the emitted key name (what the pre-fix mapper effectively did, writing
|
||||
// "address") and the factory must REFUSE it. Without this, the round-trip assertions above could
|
||||
// pass off any blob the factory happened to tolerate.
|
||||
var wrong = new JsonObject
|
||||
{
|
||||
["group"] = "OtOpcUaSim", // not "groupId"
|
||||
["edgeNodeId"] = "EdgeA",
|
||||
["metricName"] = "Temperature",
|
||||
}.ToJsonString();
|
||||
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(wrong, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
||||
|
||||
// And the exact blob the pre-fix generic fallback produced.
|
||||
var legacy = new JsonObject { ["address"] = "OtOpcUaSim/EdgeA/Filler1::Temperature" }.ToJsonString();
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
||||
MqttTagDefinitionFactory.FromTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_refuses_an_mqtt_leaf_whose_address_was_never_stated()
|
||||
{
|
||||
// No stated address ⇒ no bindable tag. Failing here, in words, is the whole point: the old path
|
||||
// committed it silently and the operator learned about it as a runtime BadNodeIdUnknown.
|
||||
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error!.ShouldContain("Temperature");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MqttTagConfigKeys.Topic, "otopcua/fixture/oven/temp")]
|
||||
[InlineData(MqttTagConfigKeys.MetricName, "Temperature")]
|
||||
public void DescribeUncommittableLeaf_accepts_either_stated_mqtt_shape(string key, string value)
|
||||
=> RawBrowseCommitMapper
|
||||
.DescribeUncommittableLeaf("Mqtt", "leaf", new Dictionary<string, string> { [key] = value })
|
||||
.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_never_blocks_a_single_reference_driver()
|
||||
{
|
||||
// Every other driver's address IS the node id — they state no fields and must stay committable.
|
||||
foreach (var driver in new[] { "OpcUaClient", "AbCip", "TwinCAT", "S7", "GalaxyMxGateway", "Modbus" })
|
||||
RawBrowseCommitMapper.DescribeUncommittableLeaf(driver, "leaf", addressFields: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLeaf_flows_the_stated_address_fields_into_the_committed_row()
|
||||
{
|
||||
var row = RawBrowseCommitMapper.MapLeaf(
|
||||
driverType: "Mqtt",
|
||||
fullName: "OtOpcUaSim/EdgeA/Filler1::Temperature",
|
||||
browseName: "Temperature",
|
||||
driverDataType: "Float32",
|
||||
defaultDataType: "Double",
|
||||
groupPrefix: null,
|
||||
folderPath: new[] { "OtOpcUaSim", "EdgeA", "Filler1" },
|
||||
createGroups: false,
|
||||
addressFields: SparkplugFields());
|
||||
|
||||
row.Tag.Name.ShouldBe("Temperature");
|
||||
row.Tag.DataType.ShouldBe("Float");
|
||||
MqttTagDefinitionFactory
|
||||
.FromSparkplugTagConfig(row.Tag.TagConfig, "Plant/Mqtt/dev1/Temperature", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.MetricName.ShouldBe("Temperature");
|
||||
}
|
||||
|
||||
// ---- CombineGroupPath -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user