using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization; /// /// Round-trip + forward-compat coverage for the M8 site/instance-scoped bundle /// DTOs (, , /// and the extended ). /// public sealed class BundleDtoSerializationTests { // Use the production serialization contract (WhenWritingNull + enum-as-string // + unknown-member tolerance) so these tests exercise the real wire format. private static readonly JsonSerializerOptions Options = BundleJsonOptions.Default; private static BundleContentDto NewFullContent() => new( TemplateFolders: Array.Empty(), Templates: Array.Empty(), SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()) { Sites = new[] { new SiteDto( SiteIdentifier: "site-a", Name: "Site A", Description: "Primary site", NodeAAddress: "akka.tcp://site@10.0.0.1:7000", NodeBAddress: "akka.tcp://site@10.0.0.2:7000", GrpcNodeAAddress: "10.0.0.1:8083", GrpcNodeBAddress: "10.0.0.2:8083"), }, DataConnections = new[] { new DataConnectionDto( SiteIdentifier: "site-a", Name: "opc-main", Protocol: "OpcUa", FailoverRetryCount: 5, Secrets: new SecretsBlock(new Dictionary { ["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://primary:4840\"}", ["BackupConfiguration"] = "{\"endpoint\":\"opc.tcp://backup:4840\"}", })), }, Instances = new[] { new InstanceDto( UniqueName: "Pump-001", TemplateName: "PumpTemplate", SiteIdentifier: "site-a", AreaName: "AreaWest", State: InstanceState.Enabled, AttributeOverrides: new[] { new InstanceAttributeOverrideDto( AttributeName: "Setpoint", OverrideValue: "42.5", ElementDataType: DataType.Double), }, AlarmOverrides: new[] { new InstanceAlarmOverrideDto( AlarmCanonicalName: "HighTemp", TriggerConfigurationOverride: "{\"limit\":90}", PriorityLevelOverride: 800), }, NativeAlarmSourceOverrides: new[] { new InstanceNativeAlarmSourceOverrideDto( SourceCanonicalName: "AnC.Source1", ConnectionNameOverride: "opc-main", SourceReferenceOverride: "ns=2;s=Alarm1", ConditionFilterOverride: "severity>=500"), }, ConnectionBindings: new[] { new InstanceConnectionBindingDto( AttributeName: "Pressure", ConnectionName: "opc-main", DataSourceReferenceOverride: "ns=2;s=Pressure"), }), }, }; [Fact] public void FullContent_RoundTrips_WithDeepEquality() { var original = NewFullContent(); var json = JsonSerializer.Serialize(original, Options); var roundTripped = JsonSerializer.Deserialize(json, Options); Assert.NotNull(roundTripped); // Record value-equality compares collection *references*, not contents, so // assert the structural members explicitly to get real deep equality. // Assert.Equal over IEnumerable compares element-by-element (each element // is itself a record, hence value-compared). Assert.Equal(original.Sites, roundTripped!.Sites); Assert.Equal(original.DataConnections.Count, roundTripped.DataConnections.Count); Assert.Equal(original.Instances.Count, roundTripped.Instances.Count); var site = Assert.Single(roundTripped.Sites); Assert.Equal(original.Sites[0], site); var conn = Assert.Single(roundTripped.DataConnections); Assert.Equal("site-a", conn.SiteIdentifier); Assert.Equal("opc-main", conn.Name); Assert.Equal("OpcUa", conn.Protocol); Assert.Equal(5, conn.FailoverRetryCount); Assert.NotNull(conn.Secrets); Assert.Equal( "{\"endpoint\":\"opc.tcp://primary:4840\"}", conn.Secrets!.Values["PrimaryConfiguration"]); Assert.Equal( "{\"endpoint\":\"opc.tcp://backup:4840\"}", conn.Secrets.Values["BackupConfiguration"]); var inst = Assert.Single(roundTripped.Instances); Assert.Equal("Pump-001", inst.UniqueName); Assert.Equal("PumpTemplate", inst.TemplateName); Assert.Equal("site-a", inst.SiteIdentifier); Assert.Equal("AreaWest", inst.AreaName); Assert.Equal(InstanceState.Enabled, inst.State); Assert.Equal(original.Instances[0].AttributeOverrides[0], inst.AttributeOverrides[0]); Assert.Equal(original.Instances[0].AlarmOverrides[0], inst.AlarmOverrides[0]); Assert.Equal(original.Instances[0].NativeAlarmSourceOverrides[0], inst.NativeAlarmSourceOverrides[0]); Assert.Equal(original.Instances[0].ConnectionBindings[0], inst.ConnectionBindings[0]); } [Fact] public void EnumsSerializeAsStrings_ForHumanReadableWire() { var json = JsonSerializer.Serialize(NewFullContent(), Options); // InstanceState + DataType go out as their names, not integers, because // the shared options register JsonStringEnumConverter. Assert.Contains("\"Enabled\"", json, StringComparison.Ordinal); Assert.Contains("\"Double\"", json, StringComparison.Ordinal); } [Fact] public void LegacyContent_MissingNewArrays_DeserializesToEmpty_NotNull() { // A pre-M8 bundle content blob: only the original central-config arrays // are present; sites/dataConnections/instances fields are entirely absent // from the JSON text. const string legacyJson = """ { "TemplateFolders": [], "Templates": [], "SharedScripts": [], "ExternalSystems": [], "DatabaseConnections": [], "NotificationLists": [], "SmtpConfigs": [], "ApiMethods": [] } """; var content = JsonSerializer.Deserialize(legacyJson, Options); Assert.NotNull(content); // The forward-compat invariant: the new arrays come back EMPTY, never null. Assert.NotNull(content!.Sites); Assert.Empty(content.Sites); Assert.NotNull(content.DataConnections); Assert.Empty(content.DataConnections); Assert.NotNull(content.Instances); Assert.Empty(content.Instances); } [Fact] public void DefaultConstructed_NewArrays_AreEmpty_NotNull() { // Existing positional callers that never set the M8 members still get // empty (not null) collections, so consumers can enumerate unconditionally. var content = new BundleContentDto( TemplateFolders: Array.Empty(), Templates: Array.Empty(), SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()); Assert.NotNull(content.Sites); Assert.Empty(content.Sites); Assert.NotNull(content.DataConnections); Assert.Empty(content.DataConnections); Assert.NotNull(content.Instances); Assert.Empty(content.Instances); } }