using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; public sealed class GalaxyDriverPageFormSerializationTests { // Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive). private static readonly JsonSerializerOptions _opts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, }; // Matches the page's _jsonOpts exactly: camelCase + case-insensitive read + UnmappedMemberHandling.Skip. private static readonly JsonSerializerOptions _pageOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, WriteIndented = false, }; /// Verifies that a round-trip serialization preserves all known fields of GalaxyDriverOptions. [Fact] public void RoundTrip_PreservesKnownFields() { var original = new GalaxyDriverOptions( Gateway: new GalaxyGatewayOptions( Endpoint: "https://gw.internal:5001", ApiKeySecretRef: "env:MY_API_KEY", UseTls: true, CaCertificatePath: "C:\\certs\\ca.pem", ConnectTimeoutSeconds: 15, DefaultCallTimeoutSeconds: 45, StreamTimeoutSeconds: 0), MxAccess: new GalaxyMxAccessOptions( ClientName: "OtOpcUa-Primary", PublishingIntervalMs: 500, WriteUserId: 1, EventPumpChannelCapacity: 100_000), Repository: new GalaxyRepositoryOptions( DiscoverPageSize: 2000, WatchDeployEvents: false), Reconnect: new GalaxyReconnectOptions( InitialBackoffMs: 1000, MaxBackoffMs: 60_000, ReplayOnSessionLost: false)) { ProbeTimeoutSeconds = 45, }; var json = JsonSerializer.Serialize(original, _opts); var back = JsonSerializer.Deserialize(json, _opts); back.ShouldNotBeNull(); back.Gateway.Endpoint.ShouldBe("https://gw.internal:5001"); back.Gateway.ApiKeySecretRef.ShouldBe("env:MY_API_KEY"); back.Gateway.UseTls.ShouldBeTrue(); back.Gateway.CaCertificatePath.ShouldBe("C:\\certs\\ca.pem"); back.Gateway.ConnectTimeoutSeconds.ShouldBe(15); back.Gateway.DefaultCallTimeoutSeconds.ShouldBe(45); back.Gateway.StreamTimeoutSeconds.ShouldBe(0); back.MxAccess.ClientName.ShouldBe("OtOpcUa-Primary"); back.MxAccess.PublishingIntervalMs.ShouldBe(500); back.MxAccess.WriteUserId.ShouldBe(1); back.MxAccess.EventPumpChannelCapacity.ShouldBe(100_000); back.Repository.DiscoverPageSize.ShouldBe(2000); back.Repository.WatchDeployEvents.ShouldBeFalse(); back.Reconnect.InitialBackoffMs.ShouldBe(1000); back.Reconnect.MaxBackoffMs.ShouldBe(60_000); back.Reconnect.ReplayOnSessionLost.ShouldBeFalse(); back.ProbeTimeoutSeconds.ShouldBe(45); } /// Verifies that deserialization silently drops unknown fields from the JSON input. [Fact] public void Deserialize_DropsUnknownFields() { // Minimal JSON that sets only ProbeTimeoutSeconds and an unknown field. // The nested records must supply their required positional args for JSON deserialization // to succeed — provide them here so the test exercises the Unknown-field skip path. var jsonWithExtra = """ { "unknownField": "old-value", "gateway": { "endpoint": "https://localhost:5001", "apiKeySecretRef": "dev:test" }, "mxAccess": { "clientName": "OtOpcUa" }, "repository": {}, "reconnect": {}, "probeTimeoutSeconds": 20 } """; var optsWithSkip = new JsonSerializerOptions(_opts) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, }; var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(20); back.Gateway.Endpoint.ShouldBe("https://localhost:5001"); } /// /// Regression test: the seed SQL stores PascalCase JSON. With /// PropertyNameCaseInsensitive = true the page must read the real values, not /// fall back to defaults. FAILS against case-sensitive opts; PASSES with the fix. /// [Fact] public void Seeded_pascalcase_config_loads_real_values() { // Exact JSON from docker-dev/seed/seed-clusters.sql (lines 130-151). var seededJson = """ { "Gateway": { "Endpoint": "http://10.100.0.48:5120", "ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY", "UseTls": false, "ConnectTimeoutSeconds": 10, "DefaultCallTimeoutSeconds": 30 }, "MxAccess": { "ClientName": "OtOpcUa-MAIN-docker-dev", "PublishingIntervalMs": 1000 }, "Repository": { "DiscoverPageSize": 5000, "WatchDeployEvents": true }, "Reconnect": { "InitialBackoffMs": 500, "MaxBackoffMs": 30000, "ReplayOnSessionLost": true } } """; // Deserialize with page-mirrored opts (camelCase + case-insensitive, as fixed). var driverOpts = JsonSerializer.Deserialize(seededJson, _pageOpts); driverOpts.ShouldNotBeNull(); var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!); // Assert REAL seeded values — not defaults. form.GatewayEndpoint.ShouldBe("http://10.100.0.48:5120"); form.GatewayApiKeySecretRef.ShouldBe("env:GALAXY_MXGW_API_KEY"); form.GatewayUseTls.ShouldBeFalse(); form.MxClientName.ShouldBe("OtOpcUa-MAIN-docker-dev"); form.RepositoryDiscoverPageSize.ShouldBe(5000); form.ReconnectInitialBackoffMs.ShouldBe(500); } /// /// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all) /// must not throw — must /// null-coalesce the missing section to its default value. /// [Fact] public void FromRecord_with_omitted_section_uses_defaults() { // Only gateway section present — Reconnect intentionally absent. var partialJson = """ { "gateway": { "endpoint": "opc://x", "apiKeySecretRef": "env:K" } } """; var driverOpts = JsonSerializer.Deserialize(partialJson, _pageOpts); driverOpts.ShouldNotBeNull(); // FromRecord must not throw even though Reconnect (and other sections) is null. var form = Should.NotThrow(() => GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!)); // Omitted Reconnect section falls back to GalaxyReconnectOptions() defaults. var defaultRc = new GalaxyReconnectOptions(); form.ReconnectInitialBackoffMs.ShouldBe(defaultRc.InitialBackoffMs); form.ReconnectMaxBackoffMs.ShouldBe(defaultRc.MaxBackoffMs); form.ReconnectReplayOnSessionLost.ShouldBe(defaultRc.ReplayOnSessionLost); } /// /// Confirms that still /// round-trips correctly when all nested records are populated (non-regressed path). /// [Fact] public void FromRecord_with_fully_populated_options_round_trips() { var original = new GalaxyDriverOptions( Gateway: new GalaxyGatewayOptions( Endpoint: "https://gw.example.com:5001", ApiKeySecretRef: "env:MY_KEY", UseTls: true, CaCertificatePath: null, ConnectTimeoutSeconds: 12, DefaultCallTimeoutSeconds: 40, StreamTimeoutSeconds: 0), MxAccess: new GalaxyMxAccessOptions( ClientName: "OtOpcUa-Test", PublishingIntervalMs: 750, WriteUserId: 2, EventPumpChannelCapacity: 25_000), Repository: new GalaxyRepositoryOptions( DiscoverPageSize: 3000, WatchDeployEvents: false), Reconnect: new GalaxyReconnectOptions( InitialBackoffMs: 800, MaxBackoffMs: 45_000, ReplayOnSessionLost: false)) { ProbeTimeoutSeconds = 20, }; var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(original); form.GatewayEndpoint.ShouldBe("https://gw.example.com:5001"); form.GatewayApiKeySecretRef.ShouldBe("env:MY_KEY"); form.GatewayUseTls.ShouldBeTrue(); form.GatewayConnectTimeoutSeconds.ShouldBe(12); form.GatewayDefaultCallTimeoutSeconds.ShouldBe(40); form.MxClientName.ShouldBe("OtOpcUa-Test"); form.MxPublishingIntervalMs.ShouldBe(750); form.MxWriteUserId.ShouldBe(2); form.MxEventPumpChannelCapacity.ShouldBe(25_000); form.RepositoryDiscoverPageSize.ShouldBe(3000); form.RepositoryWatchDeployEvents.ShouldBeFalse(); form.ReconnectInitialBackoffMs.ShouldBe(800); form.ReconnectMaxBackoffMs.ShouldBe(45_000); form.ReconnectReplayOnSessionLost.ShouldBeFalse(); form.ProbeTimeoutSeconds.ShouldBe(20); } }