feat(transport): site/connection/instance bundle DTOs (M8 A2)
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip + forward-compat coverage for the M8 site/instance-scoped bundle
|
||||
/// DTOs (<see cref="SiteDto"/>, <see cref="DataConnectionDto"/>,
|
||||
/// <see cref="InstanceDto"/> and the extended <see cref="BundleContentDto"/>).
|
||||
/// </summary>
|
||||
public sealed class BundleDtoSerializationTests
|
||||
{
|
||||
// Use the production serialization contract (WhenWritingNull + enum-as-string
|
||||
// + unknown-member tolerance) so these tests exercise the real wire format.
|
||||
private static readonly JsonSerializerOptions Options = BundleJsonOptions.Default;
|
||||
|
||||
private static BundleContentDto NewFullContent() =>
|
||||
new(
|
||||
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: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>())
|
||||
{
|
||||
Sites = new[]
|
||||
{
|
||||
new SiteDto(
|
||||
SiteIdentifier: "site-a",
|
||||
Name: "Site A",
|
||||
Description: "Primary site",
|
||||
NodeAAddress: "akka.tcp://site@10.0.0.1:7000",
|
||||
NodeBAddress: "akka.tcp://site@10.0.0.2:7000",
|
||||
GrpcNodeAAddress: "10.0.0.1:8083",
|
||||
GrpcNodeBAddress: "10.0.0.2:8083"),
|
||||
},
|
||||
DataConnections = new[]
|
||||
{
|
||||
new DataConnectionDto(
|
||||
SiteIdentifier: "site-a",
|
||||
Name: "opc-main",
|
||||
Protocol: "OpcUa",
|
||||
FailoverRetryCount: 5,
|
||||
Secrets: new SecretsBlock(new Dictionary<string, string>
|
||||
{
|
||||
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://primary:4840\"}",
|
||||
["BackupConfiguration"] = "{\"endpoint\":\"opc.tcp://backup:4840\"}",
|
||||
})),
|
||||
},
|
||||
Instances = new[]
|
||||
{
|
||||
new InstanceDto(
|
||||
UniqueName: "Pump-001",
|
||||
TemplateName: "PumpTemplate",
|
||||
SiteIdentifier: "site-a",
|
||||
AreaName: "AreaWest",
|
||||
State: InstanceState.Enabled,
|
||||
AttributeOverrides: new[]
|
||||
{
|
||||
new InstanceAttributeOverrideDto(
|
||||
AttributeName: "Setpoint",
|
||||
OverrideValue: "42.5",
|
||||
ElementDataType: DataType.Double),
|
||||
},
|
||||
AlarmOverrides: new[]
|
||||
{
|
||||
new InstanceAlarmOverrideDto(
|
||||
AlarmCanonicalName: "HighTemp",
|
||||
TriggerConfigurationOverride: "{\"limit\":90}",
|
||||
PriorityLevelOverride: 800),
|
||||
},
|
||||
NativeAlarmSourceOverrides: new[]
|
||||
{
|
||||
new InstanceNativeAlarmSourceOverrideDto(
|
||||
SourceCanonicalName: "AnC.Source1",
|
||||
ConnectionNameOverride: "opc-main",
|
||||
SourceReferenceOverride: "ns=2;s=Alarm1",
|
||||
ConditionFilterOverride: "severity>=500"),
|
||||
},
|
||||
ConnectionBindings: new[]
|
||||
{
|
||||
new InstanceConnectionBindingDto(
|
||||
AttributeName: "Pressure",
|
||||
ConnectionName: "opc-main",
|
||||
DataSourceReferenceOverride: "ns=2;s=Pressure"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FullContent_RoundTrips_WithDeepEquality()
|
||||
{
|
||||
var original = NewFullContent();
|
||||
|
||||
var json = JsonSerializer.Serialize(original, Options);
|
||||
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(json, Options);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
// Record value-equality compares collection *references*, not contents, so
|
||||
// assert the structural members explicitly to get real deep equality.
|
||||
// Assert.Equal over IEnumerable compares element-by-element (each element
|
||||
// is itself a record, hence value-compared).
|
||||
Assert.Equal(original.Sites, roundTripped!.Sites);
|
||||
Assert.Equal(original.DataConnections.Count, roundTripped.DataConnections.Count);
|
||||
Assert.Equal(original.Instances.Count, roundTripped.Instances.Count);
|
||||
|
||||
var site = Assert.Single(roundTripped.Sites);
|
||||
Assert.Equal(original.Sites[0], site);
|
||||
|
||||
var conn = Assert.Single(roundTripped.DataConnections);
|
||||
Assert.Equal("site-a", conn.SiteIdentifier);
|
||||
Assert.Equal("opc-main", conn.Name);
|
||||
Assert.Equal("OpcUa", conn.Protocol);
|
||||
Assert.Equal(5, conn.FailoverRetryCount);
|
||||
Assert.NotNull(conn.Secrets);
|
||||
Assert.Equal(
|
||||
"{\"endpoint\":\"opc.tcp://primary:4840\"}",
|
||||
conn.Secrets!.Values["PrimaryConfiguration"]);
|
||||
Assert.Equal(
|
||||
"{\"endpoint\":\"opc.tcp://backup:4840\"}",
|
||||
conn.Secrets.Values["BackupConfiguration"]);
|
||||
|
||||
var inst = Assert.Single(roundTripped.Instances);
|
||||
Assert.Equal("Pump-001", inst.UniqueName);
|
||||
Assert.Equal("PumpTemplate", inst.TemplateName);
|
||||
Assert.Equal("site-a", inst.SiteIdentifier);
|
||||
Assert.Equal("AreaWest", inst.AreaName);
|
||||
Assert.Equal(InstanceState.Enabled, inst.State);
|
||||
|
||||
Assert.Equal(original.Instances[0].AttributeOverrides[0], inst.AttributeOverrides[0]);
|
||||
Assert.Equal(original.Instances[0].AlarmOverrides[0], inst.AlarmOverrides[0]);
|
||||
Assert.Equal(original.Instances[0].NativeAlarmSourceOverrides[0], inst.NativeAlarmSourceOverrides[0]);
|
||||
Assert.Equal(original.Instances[0].ConnectionBindings[0], inst.ConnectionBindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumsSerializeAsStrings_ForHumanReadableWire()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(NewFullContent(), Options);
|
||||
|
||||
// InstanceState + DataType go out as their names, not integers, because
|
||||
// the shared options register JsonStringEnumConverter.
|
||||
Assert.Contains("\"Enabled\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"Double\"", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyContent_MissingNewArrays_DeserializesToEmpty_NotNull()
|
||||
{
|
||||
// A pre-M8 bundle content blob: only the original central-config arrays
|
||||
// are present; sites/dataConnections/instances fields are entirely absent
|
||||
// from the JSON text.
|
||||
const string legacyJson = """
|
||||
{
|
||||
"TemplateFolders": [],
|
||||
"Templates": [],
|
||||
"SharedScripts": [],
|
||||
"ExternalSystems": [],
|
||||
"DatabaseConnections": [],
|
||||
"NotificationLists": [],
|
||||
"SmtpConfigs": [],
|
||||
"ApiMethods": []
|
||||
}
|
||||
""";
|
||||
|
||||
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
|
||||
|
||||
Assert.NotNull(content);
|
||||
// The forward-compat invariant: the new arrays come back EMPTY, never null.
|
||||
Assert.NotNull(content!.Sites);
|
||||
Assert.Empty(content.Sites);
|
||||
Assert.NotNull(content.DataConnections);
|
||||
Assert.Empty(content.DataConnections);
|
||||
Assert.NotNull(content.Instances);
|
||||
Assert.Empty(content.Instances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructed_NewArrays_AreEmpty_NotNull()
|
||||
{
|
||||
// Existing positional callers that never set the M8 members still get
|
||||
// empty (not null) collections, so consumers can enumerate unconditionally.
|
||||
var content = 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: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
|
||||
Assert.NotNull(content.Sites);
|
||||
Assert.Empty(content.Sites);
|
||||
Assert.NotNull(content.DataConnections);
|
||||
Assert.Empty(content.DataConnections);
|
||||
Assert.NotNull(content.Instances);
|
||||
Assert.Empty(content.Instances);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user