Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs
T

410 lines
16 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.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
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);
}
}