50a77a8170
TimeoutSeconds is the per-system HTTP timeout ExternalSystemClient reads at call time and is deployed to sites (proto field 6), but Transport bundles silently dropped it: the DTO never carried it, export never wrote it, the importer's Add path reset it to 0, the Overwrite path left the target's stale value untouched, and ArtifactDiff never compared it (so a bundle whose only change was a tuned timeout previewed as Identical). Adds TimeoutSeconds as an additive-optional trailing field on ExternalSystemDto (schemaVersion stays 1.1, bundleFormatVersion unchanged) and threads it through EntitySerializer (export + import), BundleImporter (Add and Overwrite), and ArtifactDiff.CompareExternalSystem. Old-bundle semantics pinned: an absent field (null) falls back to the entity default on Add and leaves the target's existing value untouched on Overwrite/diff. Beefed up the RoundTripEquivalenceTests external-system fixture with a non-default TimeoutSeconds so the reflection round-trip guard actually exercises the field instead of comparing default against default. Swept the rest of the ExternalSystemDto shape against ExternalSystemDefinition field-by-field; no other operative field is missing.
1110 lines
46 KiB
C#
1110 lines
46 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 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;
|
|
|
|
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_preserves_LockedInDerived_on_attribute_alarm_script()
|
|
{
|
|
var basic = new Template("Basic") { Id = 1 };
|
|
basic.Attributes.Add(new TemplateAttribute("Pressure")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
DataType = DataType.Double,
|
|
LockedInDerived = true,
|
|
});
|
|
basic.Alarms.Add(new TemplateAlarm("High")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
LockedInDerived = true,
|
|
});
|
|
basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
LockedInDerived = true,
|
|
});
|
|
|
|
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
// Export carries the flag on all three child DTOs.
|
|
var dtoBasic = Assert.Single(dto.Templates);
|
|
Assert.True(Assert.Single(dtoBasic.Attributes).LockedInDerived);
|
|
Assert.True(Assert.Single(dtoBasic.Alarms).LockedInDerived);
|
|
Assert.True(Assert.Single(dtoBasic.Scripts).LockedInDerived);
|
|
|
|
// FromBundleContent restores it onto the entities.
|
|
var rtBasic = Assert.Single(sut.FromBundleContent(dto).Templates);
|
|
Assert.True(Assert.Single(rtBasic.Attributes).LockedInDerived);
|
|
Assert.True(Assert.Single(rtBasic.Alarms).LockedInDerived);
|
|
Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_template_preserves_NativeAlarmSources()
|
|
{
|
|
var basic = new Template("Basic") { Id = 1 };
|
|
basic.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
Description = "boiler alarms",
|
|
ConnectionName = "OpcUaPrimary",
|
|
SourceReference = "ns=3;s=Boiler.Alarm",
|
|
ConditionFilter = "severity>500",
|
|
IsLocked = true,
|
|
IsInherited = true,
|
|
LockedInDerived = true,
|
|
});
|
|
|
|
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
var dtoSrc = Assert.Single(Assert.Single(dto.Templates).NativeAlarmSources);
|
|
Assert.Equal("Boiler", dtoSrc.Name);
|
|
Assert.Equal("OpcUaPrimary", dtoSrc.ConnectionName);
|
|
Assert.Equal("ns=3;s=Boiler.Alarm", dtoSrc.SourceReference);
|
|
Assert.Equal("severity>500", dtoSrc.ConditionFilter);
|
|
Assert.True(dtoSrc.IsLocked);
|
|
Assert.True(dtoSrc.IsInherited);
|
|
Assert.True(dtoSrc.LockedInDerived);
|
|
|
|
var rtSrc = Assert.Single(Assert.Single(sut.FromBundleContent(dto).Templates).NativeAlarmSources);
|
|
Assert.Equal("Boiler", rtSrc.Name);
|
|
Assert.Equal("boiler alarms", rtSrc.Description);
|
|
Assert.Equal("OpcUaPrimary", rtSrc.ConnectionName);
|
|
Assert.Equal("ns=3;s=Boiler.Alarm", rtSrc.SourceReference);
|
|
Assert.Equal("severity>500", rtSrc.ConditionFilter);
|
|
Assert.True(rtSrc.IsLocked);
|
|
Assert.True(rtSrc.IsInherited);
|
|
Assert.True(rtSrc.LockedInDerived);
|
|
}
|
|
|
|
[Fact]
|
|
public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
|
|
{
|
|
// A bundle written before LockedInDerived existed: the property is absent
|
|
// from every template-child object. It must deserialize to false, not throw.
|
|
// PascalCase keys + string enum names — the on-wire form BundleJsonOptions
|
|
// produces (no naming policy, JsonStringEnumConverter, case-sensitive). All
|
|
// required collections present; LockedInDerived absent on every child.
|
|
const string json = """
|
|
{
|
|
"TemplateFolders": [],
|
|
"Templates": [
|
|
{
|
|
"Name": "Legacy",
|
|
"FolderName": null,
|
|
"BaseTemplateName": null,
|
|
"Description": null,
|
|
"Attributes": [
|
|
{ "Name": "A", "Value": "1", "DataType": "Double", "IsLocked": false,
|
|
"Description": null, "DataSourceReference": null }
|
|
],
|
|
"Alarms": [
|
|
{ "Name": "Alm", "Description": null, "PriorityLevel": 1,
|
|
"TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false,
|
|
"OnTriggerScriptName": null }
|
|
],
|
|
"Scripts": [
|
|
{ "Name": "S", "Code": "return 1;", "TriggerType": null,
|
|
"TriggerConfiguration": null, "ParameterDefinitions": null,
|
|
"ReturnDefinition": null, "IsLocked": false }
|
|
],
|
|
"Compositions": []
|
|
}
|
|
],
|
|
"SharedScripts": [],
|
|
"ExternalSystems": [],
|
|
"DatabaseConnections": [],
|
|
"NotificationLists": [],
|
|
"SmtpConfigs": [],
|
|
"ApiMethods": []
|
|
}
|
|
""";
|
|
|
|
var content = JsonSerializer.Deserialize<BundleContentDto>(json, BundleJsonOptions.Default)!;
|
|
var template = Assert.Single(content.Templates);
|
|
Assert.False(Assert.Single(template.Attributes).LockedInDerived);
|
|
Assert.False(Assert.Single(template.Alarms).LockedInDerived);
|
|
Assert.False(Assert.Single(template.Scripts).LockedInDerived);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// arch-review 2026-08-19 P7-1 (row-27 shape): <c>TimeoutSeconds</c> is operative at
|
|
/// runtime (the per-system HTTP timeout) but was silently dropped by the export/import
|
|
/// DTO — same defect class as the earlier <c>MaxRetries</c>/<c>RetryDelay</c> holes this
|
|
/// test's sibling covers.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Roundtrip_external_system_preserves_timeout_seconds()
|
|
{
|
|
var sys = new ExternalSystemDefinition("billing", "https://billing/api", "Basic")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 120,
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
Assert.Equal(120, Assert.Single(dto.ExternalSystems).TimeoutSeconds);
|
|
|
|
var roundTripped = sut.FromBundleContent(dto);
|
|
|
|
var rtSys = Assert.Single(roundTripped.ExternalSystems);
|
|
Assert.Equal("billing", rtSys.Name);
|
|
Assert.Equal(120, rtSys.TimeoutSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the old-bundle semantics: a DTO with no <c>TimeoutSeconds</c> at all (the
|
|
/// property default, as an old bundle deserializes it) falls back to the entity's own
|
|
/// "0 = unset" default on import — it must not throw or coerce to some other sentinel.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromDto_with_null_TimeoutSeconds_yields_entity_default_zero()
|
|
{
|
|
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.Equal(0, sys.TimeoutSeconds);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// --- S10: SMS recipient PhoneNumber + SmsConfiguration round-trip ---------
|
|
|
|
[Fact]
|
|
public void ToDto_carries_sms_recipient_phone_number()
|
|
{
|
|
var list = new NotificationList("on-call") { Id = 1, Type = NotificationType.Sms };
|
|
list.Recipients.Add(NotificationRecipient.ForSms("Pager", "+15551234567"));
|
|
var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };
|
|
|
|
var dto = new EntitySerializer().ToBundleContent(aggregate);
|
|
|
|
var dtoList = Assert.Single(dto.NotificationLists);
|
|
Assert.Equal(NotificationType.Sms, dtoList.Type);
|
|
var dtoRecipient = Assert.Single(dtoList.Recipients);
|
|
Assert.Equal("Pager", dtoRecipient.Name);
|
|
Assert.Equal("+15551234567", dtoRecipient.PhoneNumber);
|
|
// SMS-only recipient: no email is carried (the old email-only filter would
|
|
// have dropped this recipient entirely).
|
|
Assert.Null(dtoRecipient.EmailAddress);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_sms_recipient_preserves_phone_number()
|
|
{
|
|
var list = new NotificationList("on-call") { Id = 1, Type = NotificationType.Sms };
|
|
list.Recipients.Add(NotificationRecipient.ForSms("Pager", "+15551234567"));
|
|
var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate));
|
|
|
|
var rtList = Assert.Single(rt.NotificationLists);
|
|
Assert.Equal(NotificationType.Sms, rtList.Type);
|
|
var rtRecipient = Assert.Single(rtList.Recipients);
|
|
Assert.Equal("Pager", rtRecipient.Name);
|
|
Assert.Equal("+15551234567", rtRecipient.PhoneNumber);
|
|
Assert.Null(rtRecipient.EmailAddress);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_email_recipient_preserves_email_and_leaves_phone_null()
|
|
{
|
|
var list = new NotificationList("alerts") { Id = 1, Type = NotificationType.Email };
|
|
list.Recipients.Add(NotificationRecipient.ForEmail("Ops", "ops@example.com"));
|
|
var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
var rt = sut.FromBundleContent(dto);
|
|
|
|
var dtoRecipient = Assert.Single(Assert.Single(dto.NotificationLists).Recipients);
|
|
Assert.Equal("ops@example.com", dtoRecipient.EmailAddress);
|
|
Assert.Null(dtoRecipient.PhoneNumber);
|
|
|
|
var rtRecipient = Assert.Single(Assert.Single(rt.NotificationLists).Recipients);
|
|
Assert.Equal("Ops", rtRecipient.Name);
|
|
Assert.Equal("ops@example.com", rtRecipient.EmailAddress);
|
|
Assert.Null(rtRecipient.PhoneNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_recipient_with_both_contacts_preserves_both()
|
|
{
|
|
var list = new NotificationList("dual") { Id = 1, Type = NotificationType.Sms };
|
|
// A recipient carrying BOTH an email and a phone number must round-trip
|
|
// both contacts (neither is lost on import).
|
|
list.Recipients.Add(new NotificationRecipient("Both", "both@example.com")
|
|
{
|
|
PhoneNumber = "+15559876543",
|
|
});
|
|
var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate));
|
|
|
|
var rtRecipient = Assert.Single(Assert.Single(rt.NotificationLists).Recipients);
|
|
Assert.Equal("both@example.com", rtRecipient.EmailAddress);
|
|
Assert.Equal("+15559876543", rtRecipient.PhoneNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_recipient_without_phone_number_yields_null_phone()
|
|
{
|
|
// Backward-compat at the DTO level: a recipient DTO built without a phone
|
|
// number (old bundles never carried one) imports with PhoneNumber == null.
|
|
var dto = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: new[]
|
|
{
|
|
new NotificationListDto(
|
|
"alerts",
|
|
NotificationType.Email,
|
|
new[] { new NotificationRecipientDto("Ops", "ops@example.com") }),
|
|
},
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var recipient = Assert.Single(Assert.Single(aggregate.NotificationLists).Recipients);
|
|
Assert.Equal("ops@example.com", recipient.EmailAddress);
|
|
Assert.Null(recipient.PhoneNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDto_carves_sms_auth_token_into_secrets_block()
|
|
{
|
|
var sms = new SmsConfiguration("AC123", "+15550001111")
|
|
{
|
|
Id = 1,
|
|
AuthToken = "twilio-super-secret",
|
|
MessagingServiceSid = "MG999",
|
|
ApiBaseUrl = "https://api.twilio.com",
|
|
ConnectionTimeoutSeconds = 20,
|
|
MaxRetries = 7,
|
|
RetryDelay = TimeSpan.FromSeconds(45),
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } };
|
|
|
|
var dto = new EntitySerializer().ToBundleContent(aggregate);
|
|
|
|
var dtoSms = Assert.Single(dto.SmsConfigs);
|
|
Assert.Equal("AC123", dtoSms.AccountSid);
|
|
Assert.Equal("+15550001111", dtoSms.FromNumber);
|
|
Assert.Equal("MG999", dtoSms.MessagingServiceSid);
|
|
// The auth token lands in the SecretsBlock — NOT in any public field.
|
|
Assert.NotNull(dtoSms.Secrets);
|
|
Assert.Equal("twilio-super-secret", dtoSms.Secrets!.Values["AuthToken"]);
|
|
Assert.DoesNotContain("twilio-super-secret", dtoSms.AccountSid);
|
|
Assert.DoesNotContain("twilio-super-secret", dtoSms.FromNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_sms_config_restores_auth_token_from_secrets_block()
|
|
{
|
|
var sms = new SmsConfiguration("AC123", "+15550001111")
|
|
{
|
|
Id = 1,
|
|
AuthToken = "twilio-super-secret",
|
|
MessagingServiceSid = "MG999",
|
|
ApiBaseUrl = "https://api.twilio.com",
|
|
ConnectionTimeoutSeconds = 20,
|
|
MaxRetries = 7,
|
|
RetryDelay = TimeSpan.FromSeconds(45),
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate));
|
|
|
|
var rtSms = Assert.Single(rt.SmsConfigurations);
|
|
Assert.Equal("AC123", rtSms.AccountSid);
|
|
Assert.Equal("+15550001111", rtSms.FromNumber);
|
|
Assert.Equal("MG999", rtSms.MessagingServiceSid);
|
|
Assert.Equal("https://api.twilio.com", rtSms.ApiBaseUrl);
|
|
Assert.Equal(20, rtSms.ConnectionTimeoutSeconds);
|
|
Assert.Equal(7, rtSms.MaxRetries);
|
|
Assert.Equal(TimeSpan.FromSeconds(45), rtSms.RetryDelay);
|
|
// Secret decrypted back out of the SecretsBlock.
|
|
Assert.Equal("twilio-super-secret", rtSms.AuthToken);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDto_omits_sms_secret_key_when_auth_token_is_null()
|
|
{
|
|
var sms = new SmsConfiguration("AC123", "+15550001111") { Id = 1, AuthToken = null };
|
|
var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } };
|
|
|
|
var dto = new EntitySerializer().ToBundleContent(aggregate);
|
|
|
|
var dtoSms = Assert.Single(dto.SmsConfigs);
|
|
// No token => no SecretsBlock at all (mirrors the SMTP "empty credentials"
|
|
// behaviour where the whole block stays null).
|
|
Assert.Null(dtoSms.Secrets);
|
|
|
|
// And it round-trips to a null token without throwing.
|
|
var rt = new EntitySerializer().FromBundleContent(dto);
|
|
Assert.Null(Assert.Single(rt.SmsConfigurations).AuthToken);
|
|
}
|
|
|
|
// --- EWS: Transport + the pre-existing OAuth2 authority/scope drop ---------
|
|
|
|
[Fact]
|
|
public void Roundtrip_smtp_config_preserves_transport_and_oauth2_fields()
|
|
{
|
|
// Transport is the EWS selector; OAuth2Authority/OAuth2Scope predate it and
|
|
// were silently dropped by the bundle (they were never on the DTO). All
|
|
// three are non-sensitive endpoint metadata, so they ride as plain DTO
|
|
// fields — only Credentials belongs in the SecretsBlock.
|
|
var smtp = new SmtpConfiguration("https://mail.example.test/EWS/Exchange.asmx", "Basic", "noreply@example.com")
|
|
{
|
|
Id = 1,
|
|
Port = 587,
|
|
Credentials = "user:p@ssw0rd",
|
|
TlsMode = "StartTLS",
|
|
ConnectionTimeoutSeconds = 25,
|
|
MaxConcurrentConnections = 3,
|
|
MaxRetries = 4,
|
|
RetryDelay = TimeSpan.FromSeconds(90),
|
|
Transport = "Ews",
|
|
OAuth2Authority = "https://login.example.test/token",
|
|
OAuth2Scope = "https://mail.example.test/.default",
|
|
};
|
|
var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
|
|
|
|
var sut = new EntitySerializer();
|
|
var dto = sut.ToBundleContent(aggregate);
|
|
|
|
var dtoSmtp = Assert.Single(dto.SmtpConfigs);
|
|
Assert.Equal("Ews", dtoSmtp.Transport);
|
|
Assert.Equal("https://login.example.test/token", dtoSmtp.OAuth2Authority);
|
|
Assert.Equal("https://mail.example.test/.default", dtoSmtp.OAuth2Scope);
|
|
|
|
var rt = sut.FromBundleContent(dto);
|
|
|
|
var rtSmtp = Assert.Single(rt.SmtpConfigurations);
|
|
Assert.Equal("Ews", rtSmtp.Transport);
|
|
Assert.Equal("https://login.example.test/token", rtSmtp.OAuth2Authority);
|
|
Assert.Equal("https://mail.example.test/.default", rtSmtp.OAuth2Scope);
|
|
// The pre-existing fields still survive alongside the new ones.
|
|
Assert.Equal("https://mail.example.test/EWS/Exchange.asmx", rtSmtp.Host);
|
|
Assert.Equal(587, rtSmtp.Port);
|
|
Assert.Equal("StartTLS", rtSmtp.TlsMode);
|
|
Assert.Equal("user:p@ssw0rd", rtSmtp.Credentials);
|
|
Assert.Equal(25, rtSmtp.ConnectionTimeoutSeconds);
|
|
Assert.Equal(3, rtSmtp.MaxConcurrentConnections);
|
|
Assert.Equal(4, rtSmtp.MaxRetries);
|
|
Assert.Equal(TimeSpan.FromSeconds(90), rtSmtp.RetryDelay);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_smtp_config_without_transport_or_oauth2_fields_yields_nulls()
|
|
{
|
|
// Backward-compat at the DTO level (mirrors the PhoneNumber precedent): an
|
|
// SMTP DTO built without the three trailing params — as an old bundle's JSON
|
|
// deserializes, since those properties are simply absent — imports with all
|
|
// three null, i.e. legacy SMTP delivery and provider-default OAuth2 endpoints.
|
|
var dto = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: new[]
|
|
{
|
|
new SmtpConfigDto(
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
AuthType: "Basic",
|
|
FromAddress: "noreply@example.com",
|
|
TlsMode: "StartTLS",
|
|
ConnectionTimeoutSeconds: 30,
|
|
MaxConcurrentConnections: 2,
|
|
MaxRetries: 3,
|
|
RetryDelay: TimeSpan.FromSeconds(60),
|
|
Secrets: null),
|
|
},
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
|
|
|
var smtp = Assert.Single(aggregate.SmtpConfigurations);
|
|
Assert.Null(smtp.Transport);
|
|
Assert.Null(smtp.OAuth2Authority);
|
|
Assert.Null(smtp.OAuth2Scope);
|
|
Assert.Equal("smtp.example.com", smtp.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public void SmtpConfigDto_omits_null_transport_and_oauth2_fields_from_json()
|
|
{
|
|
// WhenWritingNull keeps the three new properties out of a plain-SMTP
|
|
// bundle's JSON entirely, so the wire shape is unchanged for existing
|
|
// exports (additive evolution — no bundleFormatVersion bump).
|
|
var dto = new SmtpConfigDto(
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
AuthType: "Basic",
|
|
FromAddress: "noreply@example.com",
|
|
TlsMode: "StartTLS",
|
|
ConnectionTimeoutSeconds: 30,
|
|
MaxConcurrentConnections: 2,
|
|
MaxRetries: 3,
|
|
RetryDelay: TimeSpan.FromSeconds(60),
|
|
Secrets: null);
|
|
|
|
var json = JsonSerializer.Serialize(dto, BundleJsonOptions.Default);
|
|
|
|
Assert.DoesNotContain("Transport", json, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("OAuth2Authority", json, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("OAuth2Scope", json, StringComparison.Ordinal);
|
|
}
|
|
}
|