feat(transport): site/connection/instance bundle DTOs (M8 A2)
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;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -24,7 +26,15 @@ public sealed record EntityAggregate(
|
|||||||
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
|
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
|
||||||
IReadOnlyList<NotificationList> NotificationLists,
|
IReadOnlyList<NotificationList> NotificationLists,
|
||||||
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
|
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
|
||||||
IReadOnlyList<ApiMethod> ApiMethods);
|
IReadOnlyList<ApiMethod> ApiMethods)
|
||||||
|
{
|
||||||
|
// M8: site/instance-scoped entities. Init-only with empty-array defaults so
|
||||||
|
// existing positional `new EntityAggregate(...)` callers keep compiling and
|
||||||
|
// never see a null collection; new callers opt in via object-initializer.
|
||||||
|
public IReadOnlyList<Site> Sites { get; init; } = Array.Empty<Site>();
|
||||||
|
public IReadOnlyList<DataConnection> DataConnections { get; init; } = Array.Empty<DataConnection>();
|
||||||
|
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Top-level serializable bundle payload. Lists are sequenced in dependency
|
/// Top-level serializable bundle payload. Lists are sequenced in dependency
|
||||||
@@ -50,7 +60,24 @@ public sealed record BundleContentDto(
|
|||||||
IReadOnlyList<NotificationListDto> NotificationLists,
|
IReadOnlyList<NotificationListDto> NotificationLists,
|
||||||
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
|
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
|
||||||
IReadOnlyList<ApiMethodDto> ApiMethods,
|
IReadOnlyList<ApiMethodDto> ApiMethods,
|
||||||
IReadOnlyList<ApiKeyDto>? ApiKeys = null);
|
IReadOnlyList<ApiKeyDto>? ApiKeys = null)
|
||||||
|
{
|
||||||
|
// M8: site/instance-scoped payloads. Modeled as init-only properties with
|
||||||
|
// empty-array defaults (rather than additional positional ctor params) for
|
||||||
|
// two reasons:
|
||||||
|
// 1. Forward-compat: deserializing an OLDER bundle whose content blob has
|
||||||
|
// no sites/dataConnections/instances fields leaves each property at its
|
||||||
|
// Array.Empty<>() initializer — so consumers always see an empty list,
|
||||||
|
// never null. (Contrast ApiKeys, which is a nullable legacy field.)
|
||||||
|
// 2. Source-compat: every existing `new BundleContentDto(...)` positional
|
||||||
|
// caller keeps compiling unchanged; new producers opt in via an
|
||||||
|
// object-initializer (`new BundleContentDto(...) { Sites = ... }`).
|
||||||
|
// These are written by the serializer (WhenWritingNull does not apply — they
|
||||||
|
// are non-null), so new bundles always carry the three arrays explicitly.
|
||||||
|
public IReadOnlyList<SiteDto> Sites { get; init; } = Array.Empty<SiteDto>();
|
||||||
|
public IReadOnlyList<DataConnectionDto> DataConnections { get; init; } = Array.Empty<DataConnectionDto>();
|
||||||
|
public IReadOnlyList<InstanceDto> Instances { get; init; } = Array.Empty<InstanceDto>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Carved-off secret values for an entity. The outer DTO carries all non-
|
/// Carved-off secret values for an entity. The outer DTO carries all non-
|
||||||
@@ -175,3 +202,71 @@ public sealed record ApiMethodDto(
|
|||||||
string? ParameterDefinitions,
|
string? ParameterDefinitions,
|
||||||
string? ReturnDefinition,
|
string? ReturnDefinition,
|
||||||
int TimeoutSeconds);
|
int TimeoutSeconds);
|
||||||
|
|
||||||
|
// --- M8: site/instance-scoped transport DTOs --------------------------------
|
||||||
|
// These travel alongside the original central-config DTOs above. Sites and
|
||||||
|
// instances are referenced by their stable string identities (SiteIdentifier,
|
||||||
|
// UniqueName, TemplateName) rather than database ids so a bundle resolves
|
||||||
|
// correctly against the *target* environment's own surrogate keys.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A site definition. Addresses are carried verbatim; the importer decides
|
||||||
|
/// whether to keep or rewrite them for the target environment.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteDto(
|
||||||
|
string SiteIdentifier,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string? NodeAAddress,
|
||||||
|
string? NodeBAddress,
|
||||||
|
string? GrpcNodeAAddress,
|
||||||
|
string? GrpcNodeBAddress);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A site-scoped protocol connection (the <c>Sites.DataConnection</c> entity —
|
||||||
|
/// NOT the External-System <see cref="DatabaseConnectionDto"/>). The protocol-
|
||||||
|
/// specific Primary/Backup configuration JSON rides inside <see cref="Secrets"/>
|
||||||
|
/// so a "share without secrets" export can drop it as a unit.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DataConnectionDto(
|
||||||
|
string SiteIdentifier,
|
||||||
|
string Name,
|
||||||
|
string Protocol,
|
||||||
|
int FailoverRetryCount,
|
||||||
|
SecretsBlock? Secrets);
|
||||||
|
|
||||||
|
public sealed record InstanceAttributeOverrideDto(
|
||||||
|
string AttributeName,
|
||||||
|
string? OverrideValue,
|
||||||
|
DataType? ElementDataType);
|
||||||
|
|
||||||
|
public sealed record InstanceAlarmOverrideDto(
|
||||||
|
string AlarmCanonicalName,
|
||||||
|
string? TriggerConfigurationOverride,
|
||||||
|
int? PriorityLevelOverride);
|
||||||
|
|
||||||
|
public sealed record InstanceNativeAlarmSourceOverrideDto(
|
||||||
|
string SourceCanonicalName,
|
||||||
|
string? ConnectionNameOverride,
|
||||||
|
string? SourceReferenceOverride,
|
||||||
|
string? ConditionFilterOverride);
|
||||||
|
|
||||||
|
public sealed record InstanceConnectionBindingDto(
|
||||||
|
string AttributeName,
|
||||||
|
string ConnectionName,
|
||||||
|
string? DataSourceReferenceOverride);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deployable instance with all of its per-instance overrides and bindings.
|
||||||
|
/// References its template and site by name/identifier (not id).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InstanceDto(
|
||||||
|
string UniqueName,
|
||||||
|
string TemplateName,
|
||||||
|
string SiteIdentifier,
|
||||||
|
string? AreaName,
|
||||||
|
InstanceState State,
|
||||||
|
IReadOnlyList<InstanceAttributeOverrideDto> AttributeOverrides,
|
||||||
|
IReadOnlyList<InstanceAlarmOverrideDto> AlarmOverrides,
|
||||||
|
IReadOnlyList<InstanceNativeAlarmSourceOverrideDto> NativeAlarmSourceOverrides,
|
||||||
|
IReadOnlyList<InstanceConnectionBindingDto> ConnectionBindings);
|
||||||
|
|||||||
+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