From bb6e883758a8f0697019a6cabfc8b68fd699cf2c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 05:54:17 -0400 Subject: [PATCH] feat(transport): serialize site/connection/instance entities<->DTOs (M8 B2) --- .../Serialization/EntitySerializer.cs | 179 ++++++++++++++- .../Serialization/EntitySerializerTests.cs | 212 ++++++++++++++++++ 2 files changed, 389 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index 88b66573..579182e9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.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; namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization; @@ -27,6 +29,13 @@ public sealed class EntitySerializer var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name); var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name); + // M8 name-link lookups. Instances/data-connections reference their owning + // site by the portable SiteIdentifier (not the EF surrogate SiteId); a + // connection binding references its DataConnection by Name. Build both + // id→name maps once so the projections below resolve in O(1). + var siteIdentifierBySiteId = aggregate.Sites.ToDictionary(s => s.Id, s => s.SiteIdentifier); + var connectionNameByConnectionId = aggregate.DataConnections.ToDictionary(c => c.Id, c => c.Name); + return new BundleContentDto( TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto( Name: f.Name, @@ -165,7 +174,74 @@ public sealed class EntitySerializer Script: m.Script, ParameterDefinitions: m.ParameterDefinitions, ReturnDefinition: m.ReturnDefinition, - TimeoutSeconds: m.TimeoutSeconds)).ToList()); + TimeoutSeconds: m.TimeoutSeconds)).ToList()) + { + // M8 site/instance-scoped payloads. These are init-only (non-positional) + // on BundleContentDto, so they're set via object-initializer here. + Sites = aggregate.Sites.Select(s => new SiteDto( + SiteIdentifier: s.SiteIdentifier, + Name: s.Name, + Description: s.Description, + NodeAAddress: s.NodeAAddress, + NodeBAddress: s.NodeBAddress, + GrpcNodeAAddress: s.GrpcNodeAAddress, + GrpcNodeBAddress: s.GrpcNodeBAddress)).ToList(), + DataConnections = aggregate.DataConnections.Select(c => + { + // The protocol-specific Primary/Backup configuration JSON typically + // embeds endpoints + credentials, so the whole blob rides inside the + // SecretsBlock (a "share without secrets" export drops it as a unit). + // Omit a key entirely when its source value is null/empty so the + // importer's TryGetValue cleanly yields "no value" rather than "". + var secretValues = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrEmpty(c.PrimaryConfiguration)) + { + secretValues["PrimaryConfiguration"] = c.PrimaryConfiguration; + } + if (!string.IsNullOrEmpty(c.BackupConfiguration)) + { + secretValues["BackupConfiguration"] = c.BackupConfiguration; + } + + return new DataConnectionDto( + SiteIdentifier: siteIdentifierBySiteId.TryGetValue(c.SiteId, out var sid) ? sid : string.Empty, + Name: c.Name, + Protocol: c.Protocol, + FailoverRetryCount: c.FailoverRetryCount, + Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null); + }).ToList(), + Instances = aggregate.Instances.Select(inst => new InstanceDto( + UniqueName: inst.UniqueName, + TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty, + SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty, + // TODO(M8): Areas don't travel in the bundle yet — EntityAggregate + // carries no Areas collection, so there's no id→name lookup. Emit + // null until area transport is added; the importer leaves AreaId null. + AreaName: null, + State: inst.State, + AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto( + AttributeName: o.AttributeName, + OverrideValue: o.OverrideValue, + ElementDataType: o.ElementDataType)).ToList(), + AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto( + AlarmCanonicalName: o.AlarmCanonicalName, + TriggerConfigurationOverride: o.TriggerConfigurationOverride, + PriorityLevelOverride: o.PriorityLevelOverride)).ToList(), + NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto( + SourceCanonicalName: o.SourceCanonicalName, + ConnectionNameOverride: o.ConnectionNameOverride, + SourceReferenceOverride: o.SourceReferenceOverride, + ConditionFilterOverride: o.ConditionFilterOverride)).ToList(), + // Connection bindings reference their DataConnection by NAME — the + // numeric DataConnectionId FK can't survive a cross-environment + // bundle, so the importer (D1) resolves ConnectionName→target FK at + // apply time. If the FK doesn't resolve in this aggregate (orphan + // row), the name comes through empty. + ConnectionBindings: inst.ConnectionBindings.Select(b => new InstanceConnectionBindingDto( + AttributeName: b.AttributeName, + ConnectionName: connectionNameByConnectionId.TryGetValue(b.DataConnectionId, out var cn) ? cn : string.Empty, + DataSourceReferenceOverride: b.DataSourceReferenceOverride)).ToList())).ToList(), + }; } /// Reconstructs a persistence-shaped from a wire-shaped . @@ -352,6 +428,100 @@ public sealed class EntitySerializer }) .ToList(); + // M8: sites first — assign synthetic ids by ordinal. Addresses are carried + // verbatim; the importer decides whether to keep or rewrite them. + var sites = content.Sites + .Select((dto, ix) => new Site(dto.Name, dto.SiteIdentifier) + { + Id = ix + 1, + Description = dto.Description, + NodeAAddress = dto.NodeAAddress, + NodeBAddress = dto.NodeBAddress, + GrpcNodeAAddress = dto.GrpcNodeAAddress, + GrpcNodeBAddress = dto.GrpcNodeBAddress, + }) + .ToList(); + var siteIdByIdentifier = sites.ToDictionary(s => s.SiteIdentifier, s => s.Id, StringComparer.Ordinal); + + // Data connections: resolve owning site by SiteIdentifier, restore the + // protocol config from the SecretsBlock (null when the key was omitted). + var dataConnections = content.DataConnections + .Select((dto, ix) => new DataConnection( + dto.Name, + dto.Protocol, + siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var sid) ? sid : 0) + { + Id = ix + 1, + FailoverRetryCount = dto.FailoverRetryCount, + PrimaryConfiguration = dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null, + BackupConfiguration = dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null, + }) + .ToList(); + + // Instances: resolve template + site by name/identifier. Areas don't travel + // (see export TODO), so AreaId stays null. Connection bindings keep their + // resolved DataConnection NAME on the wire DTO — the importer (D1) reads + // bindings from BundleContentDto.Instances[].ConnectionBindings directly and + // resolves ConnectionName→the target environment's DataConnectionId at apply + // time. FromBundleContent is NOT on the importer's path (BundleImporter walks + // the raw DTO), so here we materialize the binding entity with a placeholder + // DataConnectionId = 0; it carries AttributeName + DataSourceReferenceOverride + // so a round-trip preserves the binding shape, but the FK is intentionally + // unresolved (there is no DataConnection-name→id map valid for the target DB). + var instances = content.Instances + .Select((dto, ix) => + { + var inst = new Instance(dto.UniqueName) + { + Id = ix + 1, + State = dto.State, + TemplateId = templateIdByName.TryGetValue(dto.TemplateName, out var tid) ? tid : 0, + SiteId = siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var isid) ? isid : 0, + AreaId = null, + }; + foreach (var o in dto.AttributeOverrides) + { + inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName) + { + InstanceId = inst.Id, + OverrideValue = o.OverrideValue, + ElementDataType = o.ElementDataType, + }); + } + foreach (var o in dto.AlarmOverrides) + { + inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName) + { + InstanceId = inst.Id, + TriggerConfigurationOverride = o.TriggerConfigurationOverride, + PriorityLevelOverride = o.PriorityLevelOverride, + }); + } + foreach (var o in dto.NativeAlarmSourceOverrides) + { + inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName) + { + InstanceId = inst.Id, + ConnectionNameOverride = o.ConnectionNameOverride, + SourceReferenceOverride = o.SourceReferenceOverride, + ConditionFilterOverride = o.ConditionFilterOverride, + }); + } + foreach (var b in dto.ConnectionBindings) + { + inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName) + { + InstanceId = inst.Id, + // FK left at 0 on purpose — the importer resolves it from the + // DTO's ConnectionName against target-DB DataConnection ids. + DataConnectionId = 0, + DataSourceReferenceOverride = b.DataSourceReferenceOverride, + }); + } + return inst; + }) + .ToList(); + return new EntityAggregate( TemplateFolders: folders, Templates: templates, @@ -361,6 +531,11 @@ public sealed class EntitySerializer DatabaseConnections: databaseConnections, NotificationLists: notificationLists, SmtpConfigurations: smtpConfigurations, - ApiMethods: apiMethods); + ApiMethods: apiMethods) + { + Sites = sites, + DataConnections = dataConnections, + Instances = instances, + }; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index 51f8ab0c..975b682f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.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; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; @@ -406,4 +408,214 @@ public sealed class EntitySerializerTests Assert.Equal(DataType.Double, rtAttr.DataType); Assert.Null(rtAttr.ElementDataType); } + + // --- M8 (B2): site / data-connection / instance mapping ------------------ + + /// + /// Builds a populated aggregate: one template (so the instance's TemplateName + /// resolves), one site, one OPC UA data connection on that site, and one + /// instance with one of each child collection (attribute/alarm/native-alarm- + /// source override + a connection binding pointing at the data connection). + /// + private static EntityAggregate MakePopulatedSiteAggregate() + { + var template = new Template("Pump") { Id = 7 }; + + var site = new Site("North Plant", "north") + { + Id = 3, + Description = "primary", + NodeAAddress = "akka://sys@10.0.0.1:4053", + NodeBAddress = "akka://sys@10.0.0.2:4053", + GrpcNodeAAddress = "https://10.0.0.1:8083", + GrpcNodeBAddress = "https://10.0.0.2:8083", + }; + + var conn = new DataConnection("PlcLink", "OpcUa", siteId: 3) + { + Id = 11, + FailoverRetryCount = 5, + PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}", + BackupConfiguration = "{\"endpoint\":\"opc.tcp://plc2:4840\"}", + }; + + var inst = new Instance("Pump-001") + { + Id = 21, + TemplateId = 7, + SiteId = 3, + State = InstanceState.Enabled, + }; + inst.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint") + { + InstanceId = 21, + OverrideValue = "55", + }); + inst.AlarmOverrides.Add(new InstanceAlarmOverride("HighPressure") + { + InstanceId = 21, + TriggerConfigurationOverride = "{\"hi\":90}", + PriorityLevelOverride = 1, + }); + inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("PlcAlarms") + { + InstanceId = 21, + SourceReferenceOverride = "ns=2;s=Pump1", + }); + inst.ConnectionBindings.Add(new InstanceConnectionBinding("Pressure") + { + InstanceId = 21, + DataConnectionId = 11, + DataSourceReferenceOverride = "ns=2;s=Pump1.P", + }); + + return MakeEmptyAggregate() with + { + Templates = new[] { template }, + Sites = new[] { site }, + DataConnections = new[] { conn }, + Instances = new[] { inst }, + }; + } + + [Fact] + public void ToDto_maps_site_connection_instance_with_secrets_and_name_links() + { + var aggregate = MakePopulatedSiteAggregate(); + + var dto = new EntitySerializer().ToBundleContent(aggregate); + + // Site: straight field copy, referenced by SiteIdentifier downstream. + var dtoSite = Assert.Single(dto.Sites); + Assert.Equal("north", dtoSite.SiteIdentifier); + Assert.Equal("North Plant", dtoSite.Name); + Assert.Equal("akka://sys@10.0.0.1:4053", dtoSite.NodeAAddress); + Assert.Equal("https://10.0.0.2:8083", dtoSite.GrpcNodeBAddress); + + // Data connection: owning site resolved to SiteIdentifier; the protocol + // config lands in the SecretsBlock — NOT in any public field of the DTO. + var dtoConn = Assert.Single(dto.DataConnections); + Assert.Equal("north", dtoConn.SiteIdentifier); + Assert.Equal("PlcLink", dtoConn.Name); + Assert.Equal("OpcUa", dtoConn.Protocol); + Assert.Equal(5, dtoConn.FailoverRetryCount); + Assert.NotNull(dtoConn.Secrets); + Assert.Equal( + "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}", + dtoConn.Secrets!.Values["PrimaryConfiguration"]); + Assert.Equal( + "{\"endpoint\":\"opc.tcp://plc2:4840\"}", + dtoConn.Secrets.Values["BackupConfiguration"]); + // The secret must not leak into any non-secret string property of the DTO. + Assert.DoesNotContain("s3cret", dtoConn.SiteIdentifier); + Assert.DoesNotContain("s3cret", dtoConn.Name); + Assert.DoesNotContain("s3cret", dtoConn.Protocol); + + // Instance: template + site resolved by name/identifier (never numeric id). + var dtoInst = Assert.Single(dto.Instances); + Assert.Equal("Pump-001", dtoInst.UniqueName); + Assert.Equal("Pump", dtoInst.TemplateName); + Assert.Equal("north", dtoInst.SiteIdentifier); + Assert.Equal(InstanceState.Enabled, dtoInst.State); + // Areas don't travel yet (no Areas collection on the aggregate). + Assert.Null(dtoInst.AreaName); + + // All four child collections survive. + var dtoAttr = Assert.Single(dtoInst.AttributeOverrides); + Assert.Equal("Setpoint", dtoAttr.AttributeName); + Assert.Equal("55", dtoAttr.OverrideValue); + + var dtoAlarm = Assert.Single(dtoInst.AlarmOverrides); + Assert.Equal("HighPressure", dtoAlarm.AlarmCanonicalName); + Assert.Equal("{\"hi\":90}", dtoAlarm.TriggerConfigurationOverride); + Assert.Equal(1, dtoAlarm.PriorityLevelOverride); + + var dtoNative = Assert.Single(dtoInst.NativeAlarmSourceOverrides); + Assert.Equal("PlcAlarms", dtoNative.SourceCanonicalName); + Assert.Equal("ns=2;s=Pump1", dtoNative.SourceReferenceOverride); + + // Binding carries the RESOLVED connection NAME (not the numeric FK id). + var dtoBinding = Assert.Single(dtoInst.ConnectionBindings); + Assert.Equal("Pressure", dtoBinding.AttributeName); + Assert.Equal("PlcLink", dtoBinding.ConnectionName); + Assert.Equal("ns=2;s=Pump1.P", dtoBinding.DataSourceReferenceOverride); + } + + [Fact] + public void ToDto_omits_secret_key_when_backup_configuration_is_empty() + { + var aggregate = MakeEmptyAggregate() with + { + Sites = new[] { new Site("S", "s") { Id = 1 } }, + DataConnections = new[] + { + new DataConnection("c", "OpcUa", siteId: 1) + { + Id = 1, + PrimaryConfiguration = "{\"e\":\"x\"}", + BackupConfiguration = null, + }, + }, + }; + + var dto = new EntitySerializer().ToBundleContent(aggregate); + + var dtoConn = Assert.Single(dto.DataConnections); + Assert.NotNull(dtoConn.Secrets); + Assert.True(dtoConn.Secrets!.Values.ContainsKey("PrimaryConfiguration")); + // Null source value => key omitted entirely (not an empty string). + Assert.False(dtoConn.Secrets.Values.ContainsKey("BackupConfiguration")); + } + + [Fact] + public void Roundtrip_site_connection_instance_restores_config_from_secrets_and_binding_name() + { + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(MakePopulatedSiteAggregate()); + + var rt = sut.FromBundleContent(dto); + + // Site reconstructed with a synthetic id; identity + addresses preserved. + var rtSite = Assert.Single(rt.Sites); + Assert.Equal("north", rtSite.SiteIdentifier); + Assert.Equal("North Plant", rtSite.Name); + Assert.Equal("akka://sys@10.0.0.1:4053", rtSite.NodeAAddress); + + // Data connection: protocol config restored from the SecretsBlock; owning + // site FK resolved to the reconstructed site's synthetic id. + var rtConn = Assert.Single(rt.DataConnections); + Assert.Equal("PlcLink", rtConn.Name); + Assert.Equal("OpcUa", rtConn.Protocol); + Assert.Equal(5, rtConn.FailoverRetryCount); + Assert.Equal(rtSite.Id, rtConn.SiteId); + Assert.Equal( + "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}", + rtConn.PrimaryConfiguration); + Assert.Equal("{\"endpoint\":\"opc.tcp://plc2:4840\"}", rtConn.BackupConfiguration); + + // Instance: template + site FKs resolved to the reconstructed synthetic ids; + // Area never travels so AreaId stays null. + var rtInst = Assert.Single(rt.Instances); + Assert.Equal("Pump-001", rtInst.UniqueName); + Assert.Equal(InstanceState.Enabled, rtInst.State); + Assert.Equal(Assert.Single(rt.Templates).Id, rtInst.TemplateId); + Assert.Equal(rtSite.Id, rtInst.SiteId); + Assert.Null(rtInst.AreaId); + + // Child overrides survive the round-trip. + Assert.Equal("Setpoint", Assert.Single(rtInst.AttributeOverrides).AttributeName); + Assert.Equal("HighPressure", Assert.Single(rtInst.AlarmOverrides).AlarmCanonicalName); + Assert.Equal("PlcAlarms", Assert.Single(rtInst.NativeAlarmSourceOverrides).SourceCanonicalName); + + // The binding's resolved ConnectionName is preserved on the wire DTO so the + // importer (D1) can resolve it to a target-DB FK at apply time. The + // reconstructed entity carries AttributeName + override but leaves the + // numeric DataConnectionId at its unresolved placeholder (0). + var dtoBinding = Assert.Single(Assert.Single(dto.Instances).ConnectionBindings); + Assert.Equal("PlcLink", dtoBinding.ConnectionName); + var rtBinding = Assert.Single(rtInst.ConnectionBindings); + Assert.Equal("Pressure", rtBinding.AttributeName); + Assert.Equal("ns=2;s=Pump1.P", rtBinding.DataSourceReferenceOverride); + Assert.Equal(0, rtBinding.DataConnectionId); + } }