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;
///
/// Round-trip + forward-compat coverage for the M8 site/instance-scoped bundle
/// DTOs (, ,
/// and the extended ).
///
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(),
Templates: Array.Empty(),
SharedScripts: Array.Empty(),
ExternalSystems: Array.Empty(),
DatabaseConnections: Array.Empty(),
NotificationLists: Array.Empty(),
SmtpConfigs: Array.Empty(),
ApiMethods: Array.Empty())
{
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
{
["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(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(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(),
Templates: Array.Empty(),
SharedScripts: Array.Empty(),
ExternalSystems: Array.Empty(),
DatabaseConnections: Array.Empty(),
NotificationLists: Array.Empty(),
SmtpConfigs: Array.Empty(),
ApiMethods: Array.Empty());
Assert.NotNull(content.Sites);
Assert.Empty(content.Sites);
Assert.NotNull(content.DataConnections);
Assert.Empty(content.DataConnections);
Assert.NotNull(content.Instances);
Assert.Empty(content.Instances);
}
// --- S10: SMS recipient PhoneNumber + SmsConfig wire format ---------------
[Fact]
public void LegacyRecipient_MissingPhoneNumber_DeserializesToNull()
{
// A pre-SMS bundle: notification-list recipients carry only Name + Email,
// with no PhoneNumber field present in the JSON text at all.
const string legacyJson = """
{
"TemplateFolders": [],
"Templates": [],
"SharedScripts": [],
"ExternalSystems": [],
"DatabaseConnections": [],
"NotificationLists": [
{
"Name": "alerts",
"Type": "Email",
"Recipients": [ { "Name": "Ops", "EmailAddress": "ops@example.com" } ]
}
],
"SmtpConfigs": [],
"ApiMethods": []
}
""";
var content = JsonSerializer.Deserialize(legacyJson, Options);
Assert.NotNull(content);
var recipient = Assert.Single(Assert.Single(content!.NotificationLists).Recipients);
Assert.Equal("Ops", recipient.Name);
Assert.Equal("ops@example.com", recipient.EmailAddress);
// The backward-compat invariant: an absent PhoneNumber comes back null.
Assert.Null(recipient.PhoneNumber);
}
[Fact]
public void LegacyContent_MissingSmsConfigsArray_DeserializesToEmpty_NotNull()
{
// A pre-SMS bundle has no smsConfigs field; the importer must see an empty
// (never null) collection — same forward-compat invariant as the M8 arrays.
const string legacyJson = """
{
"TemplateFolders": [],
"Templates": [],
"SharedScripts": [],
"ExternalSystems": [],
"DatabaseConnections": [],
"NotificationLists": [],
"SmtpConfigs": [],
"ApiMethods": []
}
""";
var content = JsonSerializer.Deserialize(legacyJson, Options);
Assert.NotNull(content);
Assert.NotNull(content!.SmsConfigs);
Assert.Empty(content.SmsConfigs);
}
[Fact]
public void DefaultConstructed_SmsConfigs_IsEmpty_NotNull()
{
var content = new BundleContentDto(
TemplateFolders: Array.Empty(),
Templates: Array.Empty(),
SharedScripts: Array.Empty(),
ExternalSystems: Array.Empty(),
DatabaseConnections: Array.Empty(),
NotificationLists: Array.Empty(),
SmtpConfigs: Array.Empty(),
ApiMethods: Array.Empty());
Assert.NotNull(content.SmsConfigs);
Assert.Empty(content.SmsConfigs);
}
[Fact]
public void SmsConfig_AuthToken_RidesSecretsBlock_NotCleartextPublicFields()
{
// The auth token must travel only inside the SecretsBlock, exactly like the
// SMTP credentials test pattern — it must NOT leak into any public DTO field.
var content = new BundleContentDto(
TemplateFolders: Array.Empty(),
Templates: Array.Empty(),
SharedScripts: Array.Empty(),
ExternalSystems: Array.Empty(),
DatabaseConnections: Array.Empty(),
NotificationLists: Array.Empty(),
SmtpConfigs: Array.Empty(),
ApiMethods: Array.Empty())
{
SmsConfigs = new[]
{
new SmsConfigDto(
AccountSid: "AC123",
FromNumber: "+15550001111",
MessagingServiceSid: "MG999",
ApiBaseUrl: "https://api.twilio.com",
ConnectionTimeoutSeconds: 20,
MaxRetries: 7,
RetryDelay: TimeSpan.FromSeconds(45),
Secrets: new SecretsBlock(new Dictionary
{
["AuthToken"] = "twilio-super-secret",
})),
},
};
var json = JsonSerializer.Serialize(content, Options);
// The secret appears exactly once — inside the SecretsBlock — and the
// round-trip restores it.
Assert.Contains("twilio-super-secret", json, StringComparison.Ordinal);
Assert.Contains("\"AuthToken\"", json, StringComparison.Ordinal);
var roundTripped = JsonSerializer.Deserialize(json, Options);
Assert.NotNull(roundTripped);
var sms = Assert.Single(roundTripped!.SmsConfigs);
Assert.Equal("AC123", sms.AccountSid);
Assert.Equal("+15550001111", sms.FromNumber);
Assert.NotNull(sms.Secrets);
Assert.Equal("twilio-super-secret", sms.Secrets!.Values["AuthToken"]);
}
}