diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index cf80088c..226c1e1c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -1,7 +1,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; @@ -24,7 +26,15 @@ public sealed record EntityAggregate( IReadOnlyList DatabaseConnections, IReadOnlyList NotificationLists, IReadOnlyList SmtpConfigurations, - IReadOnlyList ApiMethods); + IReadOnlyList ApiMethods) +{ + // M8: site/instance-scoped entities. Init-only with empty-array defaults so + // existing positional `new EntityAggregate(...)` callers keep compiling and + // never see a null collection; new callers opt in via object-initializer. + public IReadOnlyList Sites { get; init; } = Array.Empty(); + public IReadOnlyList DataConnections { get; init; } = Array.Empty(); + public IReadOnlyList Instances { get; init; } = Array.Empty(); +} /// /// Top-level serializable bundle payload. Lists are sequenced in dependency @@ -50,7 +60,24 @@ public sealed record BundleContentDto( IReadOnlyList NotificationLists, IReadOnlyList SmtpConfigs, IReadOnlyList ApiMethods, - IReadOnlyList? ApiKeys = null); + IReadOnlyList? ApiKeys = null) +{ + // M8: site/instance-scoped payloads. Modeled as init-only properties with + // empty-array defaults (rather than additional positional ctor params) for + // two reasons: + // 1. Forward-compat: deserializing an OLDER bundle whose content blob has + // no sites/dataConnections/instances fields leaves each property at its + // Array.Empty<>() initializer — so consumers always see an empty list, + // never null. (Contrast ApiKeys, which is a nullable legacy field.) + // 2. Source-compat: every existing `new BundleContentDto(...)` positional + // caller keeps compiling unchanged; new producers opt in via an + // object-initializer (`new BundleContentDto(...) { Sites = ... }`). + // These are written by the serializer (WhenWritingNull does not apply — they + // are non-null), so new bundles always carry the three arrays explicitly. + public IReadOnlyList Sites { get; init; } = Array.Empty(); + public IReadOnlyList DataConnections { get; init; } = Array.Empty(); + public IReadOnlyList Instances { get; init; } = Array.Empty(); +} /// /// Carved-off secret values for an entity. The outer DTO carries all non- @@ -175,3 +202,71 @@ public sealed record ApiMethodDto( string? ParameterDefinitions, string? ReturnDefinition, int TimeoutSeconds); + +// --- M8: site/instance-scoped transport DTOs -------------------------------- +// These travel alongside the original central-config DTOs above. Sites and +// instances are referenced by their stable string identities (SiteIdentifier, +// UniqueName, TemplateName) rather than database ids so a bundle resolves +// correctly against the *target* environment's own surrogate keys. + +/// +/// A site definition. Addresses are carried verbatim; the importer decides +/// whether to keep or rewrite them for the target environment. +/// +public sealed record SiteDto( + string SiteIdentifier, + string Name, + string? Description, + string? NodeAAddress, + string? NodeBAddress, + string? GrpcNodeAAddress, + string? GrpcNodeBAddress); + +/// +/// A site-scoped protocol connection (the Sites.DataConnection entity — +/// NOT the External-System ). The protocol- +/// specific Primary/Backup configuration JSON rides inside +/// so a "share without secrets" export can drop it as a unit. +/// +public sealed record DataConnectionDto( + string SiteIdentifier, + string Name, + string Protocol, + int FailoverRetryCount, + SecretsBlock? Secrets); + +public sealed record InstanceAttributeOverrideDto( + string AttributeName, + string? OverrideValue, + DataType? ElementDataType); + +public sealed record InstanceAlarmOverrideDto( + string AlarmCanonicalName, + string? TriggerConfigurationOverride, + int? PriorityLevelOverride); + +public sealed record InstanceNativeAlarmSourceOverrideDto( + string SourceCanonicalName, + string? ConnectionNameOverride, + string? SourceReferenceOverride, + string? ConditionFilterOverride); + +public sealed record InstanceConnectionBindingDto( + string AttributeName, + string ConnectionName, + string? DataSourceReferenceOverride); + +/// +/// A deployable instance with all of its per-instance overrides and bindings. +/// References its template and site by name/identifier (not id). +/// +public sealed record InstanceDto( + string UniqueName, + string TemplateName, + string SiteIdentifier, + string? AreaName, + InstanceState State, + IReadOnlyList AttributeOverrides, + IReadOnlyList AlarmOverrides, + IReadOnlyList NativeAlarmSourceOverrides, + IReadOnlyList ConnectionBindings); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs new file mode 100644 index 00000000..73ed1206 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs @@ -0,0 +1,203 @@ +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); + } +}