feat(transport): serialize site/connection/instance entities<->DTOs (M8 B2)
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
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.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
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.Entities.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
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 folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
|
||||||
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.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(
|
return new BundleContentDto(
|
||||||
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
|
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
|
||||||
Name: f.Name,
|
Name: f.Name,
|
||||||
@@ -165,7 +174,74 @@ public sealed class EntitySerializer
|
|||||||
Script: m.Script,
|
Script: m.Script,
|
||||||
ParameterDefinitions: m.ParameterDefinitions,
|
ParameterDefinitions: m.ParameterDefinitions,
|
||||||
ReturnDefinition: m.ReturnDefinition,
|
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<string, string>(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(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
|
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
|
||||||
@@ -352,6 +428,100 @@ public sealed class EntitySerializer
|
|||||||
})
|
})
|
||||||
.ToList();
|
.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(
|
return new EntityAggregate(
|
||||||
TemplateFolders: folders,
|
TemplateFolders: folders,
|
||||||
Templates: templates,
|
Templates: templates,
|
||||||
@@ -361,6 +531,11 @@ public sealed class EntitySerializer
|
|||||||
DatabaseConnections: databaseConnections,
|
DatabaseConnections: databaseConnections,
|
||||||
NotificationLists: notificationLists,
|
NotificationLists: notificationLists,
|
||||||
SmtpConfigurations: smtpConfigurations,
|
SmtpConfigurations: smtpConfigurations,
|
||||||
ApiMethods: apiMethods);
|
ApiMethods: apiMethods)
|
||||||
|
{
|
||||||
|
Sites = sites,
|
||||||
|
DataConnections = dataConnections,
|
||||||
|
Instances = instances,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
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.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
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.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
@@ -406,4 +408,214 @@ public sealed class EntitySerializerTests
|
|||||||
Assert.Equal(DataType.Double, rtAttr.DataType);
|
Assert.Equal(DataType.Double, rtAttr.DataType);
|
||||||
Assert.Null(rtAttr.ElementDataType);
|
Assert.Null(rtAttr.ElementDataType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- M8 (B2): site / data-connection / instance mapping ------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user