622 lines
26 KiB
C#
622 lines
26 KiB
C#
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;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
|
|
|
|
public sealed class EntitySerializerTests
|
|
{
|
|
private static EntityAggregate MakeEmptyAggregate() => new(
|
|
TemplateFolders: Array.Empty<TemplateFolder>(),
|
|
Templates: Array.Empty<Template>(),
|
|
SharedScripts: Array.Empty<SharedScript>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
|
|
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
|
|
NotificationLists: Array.Empty<NotificationList>(),
|
|
SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
|
|
ApiMethods: Array.Empty<ApiMethod>());
|
|
|
|
[Fact]
|
|
public void ToDto_carves_external_system_credentials_into_secrets_block()
|
|
{
|
|
var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
|
|
{
|
|
Id = 1,
|
|
AuthConfiguration = "{\"apiKey\":\"super-secret\"}",
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
var dtoSys = Assert.Single(dto.ExternalSystems);
|
|
Assert.NotNull(dtoSys.Secrets);
|
|
Assert.True(dtoSys.Secrets!.Values.ContainsKey("AuthConfiguration"));
|
|
Assert.Equal("{\"apiKey\":\"super-secret\"}", dtoSys.Secrets.Values["AuthConfiguration"]);
|
|
// Public part does not carry the secret.
|
|
Assert.Equal("erp", dtoSys.Name);
|
|
Assert.Equal("https://erp/api", dtoSys.BaseUrl);
|
|
Assert.Equal("ApiKey", dtoSys.AuthType);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDto_carves_smtp_password_into_secrets_block()
|
|
{
|
|
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
|
|
{
|
|
Id = 1,
|
|
Port = 587,
|
|
Credentials = "user:p@ssw0rd",
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
|
|
|
|
var dto = new EntitySerializer().ToBundleContent(aggregate);
|
|
|
|
var dtoSmtp = Assert.Single(dto.SmtpConfigs);
|
|
Assert.NotNull(dtoSmtp.Secrets);
|
|
Assert.Equal("user:p@ssw0rd", dtoSmtp.Secrets!.Values["Credentials"]);
|
|
Assert.Equal("smtp.example.com", dtoSmtp.Host);
|
|
Assert.Equal(587, dtoSmtp.Port);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_template_preserves_attributes_alarms_scripts_composition()
|
|
{
|
|
var folder = new TemplateFolder("root") { Id = 1, SortOrder = 0 };
|
|
var basic = new Template("Basic") { Id = 1, FolderId = 1, Description = "base" };
|
|
basic.Attributes.Add(new TemplateAttribute("Pressure")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
DataType = DataType.Double,
|
|
Value = "0",
|
|
IsLocked = true,
|
|
Description = "PSI",
|
|
});
|
|
basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
TriggerType = "Periodic",
|
|
ParameterDefinitions = "[]",
|
|
ReturnDefinition = "void",
|
|
IsLocked = false,
|
|
MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
|
|
});
|
|
basic.Alarms.Add(new TemplateAlarm("High")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
PriorityLevel = 2,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = "{\"threshold\":100}",
|
|
IsLocked = false,
|
|
// FU-B / #37 — alarm fires "OnUpdate" script when triggered. The FK is
|
|
// an in-aggregate script id; the DTO carries the name and the
|
|
// importer resolves the FK on the way back in.
|
|
OnTriggerScriptId = 1,
|
|
});
|
|
|
|
var assembly = new Template("Assembly") { Id = 2, FolderId = 1 };
|
|
assembly.Compositions.Add(new TemplateComposition("MotorA")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 2,
|
|
ComposedTemplateId = 1, // refers to "Basic".
|
|
});
|
|
|
|
var aggregate = MakeEmptyAggregate() with
|
|
{
|
|
TemplateFolders = new[] { folder },
|
|
Templates = new[] { basic, assembly },
|
|
};
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
// FU-B / #37 — verify the DTO carries OnTriggerScriptName by NAME (not id).
|
|
// The bundle is portable across environments so a script-id FK can't
|
|
// survive a round-trip; resolution back to a script id is the importer's
|
|
// job once the parent template's scripts have been re-persisted.
|
|
var dtoBasic = Assert.Single(dto.Templates, t => t.Name == "Basic");
|
|
var dtoAlarm = Assert.Single(dtoBasic.Alarms);
|
|
Assert.Equal("OnUpdate", dtoAlarm.OnTriggerScriptName);
|
|
|
|
var roundTripped = sut.FromBundleContent(dto);
|
|
|
|
var rtBasic = Assert.Single(roundTripped.Templates, t => t.Name == "Basic");
|
|
var rtAttr = Assert.Single(rtBasic.Attributes);
|
|
Assert.Equal("Pressure", rtAttr.Name);
|
|
Assert.Equal(DataType.Double, rtAttr.DataType);
|
|
Assert.Equal("0", rtAttr.Value);
|
|
Assert.True(rtAttr.IsLocked);
|
|
|
|
var rtAlarm = Assert.Single(rtBasic.Alarms);
|
|
Assert.Equal("High", rtAlarm.Name);
|
|
Assert.Equal(AlarmTriggerType.RangeViolation, rtAlarm.TriggerType);
|
|
Assert.Equal("{\"threshold\":100}", rtAlarm.TriggerConfiguration);
|
|
Assert.Equal(2, rtAlarm.PriorityLevel);
|
|
// FromBundleContent leaves OnTriggerScriptId null — the importer
|
|
// resolves it post-persist when target-DB script ids are known.
|
|
Assert.Null(rtAlarm.OnTriggerScriptId);
|
|
|
|
var rtScript = Assert.Single(rtBasic.Scripts);
|
|
Assert.Equal("OnUpdate", rtScript.Name);
|
|
Assert.Equal("return 1;", rtScript.Code);
|
|
Assert.Equal("Periodic", rtScript.TriggerType);
|
|
Assert.Equal(TimeSpan.FromSeconds(30), rtScript.MinTimeBetweenRuns);
|
|
|
|
var rtAssembly = Assert.Single(roundTripped.Templates, t => t.Name == "Assembly");
|
|
var rtComp = Assert.Single(rtAssembly.Compositions);
|
|
Assert.Equal("MotorA", rtComp.InstanceName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_template_folder_preserves_hierarchy()
|
|
{
|
|
var root = new TemplateFolder("Root") { Id = 1, SortOrder = 0 };
|
|
var child = new TemplateFolder("Pumps") { Id = 2, ParentFolderId = 1, SortOrder = 1 };
|
|
|
|
var aggregate = MakeEmptyAggregate() with
|
|
{
|
|
TemplateFolders = new[] { root, child },
|
|
};
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
var rt = sut.FromBundleContent(dto);
|
|
|
|
Assert.Equal(2, rt.TemplateFolders.Count);
|
|
var rtRoot = Assert.Single(rt.TemplateFolders, f => f.Name == "Root");
|
|
var rtChild = Assert.Single(rt.TemplateFolders, f => f.Name == "Pumps");
|
|
Assert.Null(rtRoot.ParentFolderId);
|
|
// Hierarchy is preserved by name reference; new local ids get assigned but
|
|
// the child's parent must still point at the row whose name is "Root".
|
|
Assert.NotNull(rtChild.ParentFolderId);
|
|
Assert.Equal(rtRoot.Id, rtChild.ParentFolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_external_system_preserves_retry_config()
|
|
{
|
|
var sys = new ExternalSystemDefinition("billing", "https://billing/api", "Basic")
|
|
{
|
|
Id = 1,
|
|
MaxRetries = 5,
|
|
RetryDelay = TimeSpan.FromSeconds(15),
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
var roundTripped = sut.FromBundleContent(dto);
|
|
|
|
var rtSys = Assert.Single(roundTripped.ExternalSystems);
|
|
Assert.Equal("billing", rtSys.Name);
|
|
Assert.Equal(5, rtSys.MaxRetries);
|
|
Assert.Equal(TimeSpan.FromSeconds(15), rtSys.RetryDelay);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets()
|
|
{
|
|
var dto = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: new[]
|
|
{
|
|
new ExternalSystemDto("erp", "https://x", "None", MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Array.Empty<ExternalSystemMethodDto>(), Secrets: null),
|
|
},
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var sys = Assert.Single(aggregate.ExternalSystems);
|
|
Assert.Null(sys.AuthConfiguration);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_List_attribute_preserves_ElementDataType()
|
|
{
|
|
// A template with a DataType.List attribute whose element type is String.
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Attributes.Add(new TemplateAttribute("Tags")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
DataType = DataType.List,
|
|
ElementDataType = DataType.String,
|
|
Value = "[\"a\",\"b\"]",
|
|
IsLocked = false,
|
|
});
|
|
|
|
var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
// Export side: DTO must carry ElementDataType.
|
|
var dtoTemplate = Assert.Single(dto.Templates);
|
|
var dtoAttr = Assert.Single(dtoTemplate.Attributes);
|
|
Assert.Equal(DataType.List, dtoAttr.DataType);
|
|
Assert.Equal(DataType.String, dtoAttr.ElementDataType);
|
|
|
|
// Import side: entity reconstructed from DTO preserves the value.
|
|
var roundTripped = sut.FromBundleContent(dto);
|
|
var rtTemplate = Assert.Single(roundTripped.Templates);
|
|
var rtAttr = Assert.Single(rtTemplate.Attributes);
|
|
Assert.Equal(DataType.List, rtAttr.DataType);
|
|
Assert.Equal(DataType.String, rtAttr.ElementDataType);
|
|
Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
|
|
}
|
|
|
|
private static BundleContentDto MakeContentWithListAttribute(
|
|
string value, DataType elementType)
|
|
{
|
|
var template = new TemplateDto(
|
|
Name: "Pump",
|
|
FolderName: null,
|
|
BaseTemplateName: null,
|
|
Description: null,
|
|
Attributes: new[]
|
|
{
|
|
new TemplateAttributeDto(
|
|
Name: "Tags",
|
|
Value: value,
|
|
DataType: DataType.List,
|
|
IsLocked: false,
|
|
Description: null,
|
|
DataSourceReference: null,
|
|
ElementDataType: elementType),
|
|
},
|
|
Alarms: Array.Empty<TemplateAlarmDto>(),
|
|
Scripts: Array.Empty<TemplateScriptDto>(),
|
|
Compositions: Array.Empty<TemplateCompositionDto>());
|
|
|
|
return new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: new[] { template },
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Import_normalizes_old_form_Int32_list_value_to_native_json()
|
|
{
|
|
// Pre-native bundle: quoted Int32 list elements.
|
|
var dto = MakeContentWithListAttribute("[\"10\",\"20\"]", DataType.Int32);
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
|
Assert.Equal(DataType.List, attr.DataType);
|
|
Assert.Equal(DataType.Int32, attr.ElementDataType);
|
|
// Imported native: numbers unquoted.
|
|
Assert.Equal("[10,20]", attr.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Import_leaves_string_list_value_quoted()
|
|
{
|
|
var dto = MakeContentWithListAttribute("[\"a\",\"b\"]", DataType.String);
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
|
Assert.Equal(DataType.List, attr.DataType);
|
|
Assert.Equal(DataType.String, attr.ElementDataType);
|
|
// Strings stay quoted in native form.
|
|
Assert.Equal("[\"a\",\"b\"]", attr.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Import_leaves_malformed_list_value_unchanged_without_throwing()
|
|
{
|
|
// Truncated JSON array — Decode throws FormatException; the import must
|
|
// still succeed and carry the value through verbatim (DB normalizer is
|
|
// the backstop).
|
|
var dto = MakeContentWithListAttribute("[\"a\"", DataType.String);
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
|
Assert.Equal("[\"a\"", attr.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Import_leaves_scalar_attribute_value_unchanged()
|
|
{
|
|
var template = new TemplateDto(
|
|
Name: "Sensor",
|
|
FolderName: null,
|
|
BaseTemplateName: null,
|
|
Description: null,
|
|
Attributes: new[]
|
|
{
|
|
new TemplateAttributeDto(
|
|
Name: "Pressure",
|
|
Value: "42.0",
|
|
DataType: DataType.Double,
|
|
IsLocked: false,
|
|
Description: null,
|
|
DataSourceReference: null,
|
|
ElementDataType: null),
|
|
},
|
|
Alarms: Array.Empty<TemplateAlarmDto>(),
|
|
Scripts: Array.Empty<TemplateScriptDto>(),
|
|
Compositions: Array.Empty<TemplateCompositionDto>());
|
|
var dto = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: new[] { template },
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
|
Assert.Equal(DataType.Double, attr.DataType);
|
|
Assert.Equal("42.0", attr.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
|
|
{
|
|
// Backward-compat: an old bundle DTO with null ElementDataType must not throw
|
|
// and must produce a scalar attribute with null ElementDataType.
|
|
var template = new Template("Sensor") { Id = 1 };
|
|
template.Attributes.Add(new TemplateAttribute("Pressure")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
DataType = DataType.Double,
|
|
ElementDataType = null,
|
|
Value = "42.0",
|
|
IsLocked = false,
|
|
});
|
|
|
|
var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
var dtoTemplate = Assert.Single(dto.Templates);
|
|
var dtoAttr = Assert.Single(dtoTemplate.Attributes);
|
|
Assert.Equal(DataType.Double, dtoAttr.DataType);
|
|
Assert.Null(dtoAttr.ElementDataType);
|
|
|
|
var roundTripped = sut.FromBundleContent(dto);
|
|
var rtAttr = Assert.Single(Assert.Single(roundTripped.Templates).Attributes);
|
|
Assert.Equal(DataType.Double, rtAttr.DataType);
|
|
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);
|
|
}
|
|
}
|