refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the inbound-API bearer credential is stored as a
|
||||
/// deterministic keyed hash (HMAC-SHA256 with a server-side pepper) rather than
|
||||
/// plaintext. These tests pin the hasher contract that the entity, the validator,
|
||||
/// and the management create-path all depend on.
|
||||
/// </summary>
|
||||
public class ApiKeyHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var first = hasher.Hash("some-api-key-value");
|
||||
var second = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DoesNotEqualPlaintext()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var hash = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.DoesNotContain("some-api-key-value", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentInputs_ProduceDifferentHashes()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.NotEqual(hasher.Hash("key-one"), hasher.Hash("key-two"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentPeppers_ProduceDifferentHashes()
|
||||
{
|
||||
var a = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
var b = new ApiKeyHasher("a-different-but-equally-long-pepper-val");
|
||||
|
||||
// The pepper binds the hash to the server: a stolen DB dump is useless
|
||||
// without the pepper because the same key hashes differently under it.
|
||||
Assert.NotEqual(a.Hash("same-api-key"), b.Hash("same-api-key"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("too-short")]
|
||||
public void Constructor_MissingOrWeakPepper_FailsFast(string? pepper)
|
||||
{
|
||||
// The pepper must be present and of meaningful length; a missing or weak
|
||||
// pepper is a deployment misconfiguration and must fail loudly.
|
||||
Assert.Throws<ArgumentException>(() => new ApiKeyHasher(pepper!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_NullInput_Throws()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => hasher.Hash(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsUsableWithoutAPepper()
|
||||
{
|
||||
// The unpeppered default exists for tests and non-production wiring; it is
|
||||
// still a one-way HMAC-SHA256, just without the server-binding pepper.
|
||||
var hash = ApiKeyHasher.Default.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("some-api-key-value"), hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
|
||||
/// used by the ManagementService + CentralUI audit endpoints and the
|
||||
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
|
||||
/// repeated value independently, silently drop unparseable/blank elements, and
|
||||
/// collapse an empty result to <c>null</c>.
|
||||
/// </summary>
|
||||
public class AuditQueryParamParsersTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseEnumList_NullInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_EmptyInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_AllValuesValid_ParsesEverything()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||
new[] { "ApiOutbound", "DbOutbound" });
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_IsCaseInsensitive()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(new[] { "apioutbound" });
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||
new[] { "ApiOutbound", "NotAChannel", "Notification" });
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(new[] { "Bogus", "" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_NullInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseStringList(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_TrimsValuesAndDropsBlanks()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseStringList(
|
||||
new[] { " site-1 ", "", " ", "site-2", null });
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_AllBlank_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseStringList(new[] { "", " ", null }));
|
||||
}
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
|
||||
|
||||
public class OpcUaEndpointConfigSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_TypedRoundtrip_PreservesAllFields()
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://plant-a:4840",
|
||||
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
||||
AutoAcceptUntrustedCerts = false,
|
||||
SessionTimeoutMs = 90000,
|
||||
OperationTimeoutMs = 20000,
|
||||
PublishingIntervalMs = 500,
|
||||
SamplingIntervalMs = 250,
|
||||
QueueSize = 50,
|
||||
KeepAliveCount = 5,
|
||||
LifetimeCount = 15,
|
||||
MaxNotificationsPerPublish = 200,
|
||||
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Sensors.HB", MaxSilenceSeconds = 60 }
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.False(isLegacy);
|
||||
Assert.Equal(original.EndpointUrl, round.EndpointUrl);
|
||||
Assert.Equal(original.SecurityMode, round.SecurityMode);
|
||||
Assert.Equal(original.AutoAcceptUntrustedCerts, round.AutoAcceptUntrustedCerts);
|
||||
Assert.Equal(original.SessionTimeoutMs, round.SessionTimeoutMs);
|
||||
Assert.Equal(original.OperationTimeoutMs, round.OperationTimeoutMs);
|
||||
Assert.Equal(original.PublishingIntervalMs, round.PublishingIntervalMs);
|
||||
Assert.Equal(original.SamplingIntervalMs, round.SamplingIntervalMs);
|
||||
Assert.Equal(original.QueueSize, round.QueueSize);
|
||||
Assert.Equal(original.KeepAliveCount, round.KeepAliveCount);
|
||||
Assert.Equal(original.LifetimeCount, round.LifetimeCount);
|
||||
Assert.Equal(original.MaxNotificationsPerPublish, round.MaxNotificationsPerPublish);
|
||||
Assert.NotNull(round.Heartbeat);
|
||||
Assert.Equal("Sensors.HB", round.Heartbeat!.TagPath);
|
||||
Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_UsesCamelCase()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(config);
|
||||
Assert.Contains("\"endpointUrl\"", json);
|
||||
Assert.DoesNotContain("\"EndpointUrl\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_SecurityModeAsCamelCaseString()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { SecurityMode = OpcUaSecurityMode.SignAndEncrypt };
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(config);
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
Assert.Equal("signAndEncrypt", doc.RootElement.GetProperty("securityMode").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Deserialize_NullOrEmpty_ReturnsDefaults(string? input)
|
||||
{
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(input);
|
||||
|
||||
Assert.False(isLegacy);
|
||||
Assert.Equal("", config.EndpointUrl);
|
||||
Assert.Equal(60000, config.SessionTimeoutMs);
|
||||
Assert.Null(config.Heartbeat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
|
||||
{
|
||||
var legacyJson = """
|
||||
{
|
||||
"endpoint": "opc.tcp://legacy:4840",
|
||||
"SessionTimeoutMs": "45000",
|
||||
"SamplingIntervalMs": "500",
|
||||
"SecurityMode": "Sign",
|
||||
"AutoAcceptUntrustedCerts": "false",
|
||||
"HeartbeatTagPath": "Old.Heartbeat",
|
||||
"HeartbeatMaxSilence": "20"
|
||||
}
|
||||
""";
|
||||
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
|
||||
|
||||
Assert.True(isLegacy);
|
||||
Assert.Equal("opc.tcp://legacy:4840", config.EndpointUrl);
|
||||
Assert.Equal(45000, config.SessionTimeoutMs);
|
||||
Assert.Equal(500, config.SamplingIntervalMs);
|
||||
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
|
||||
Assert.False(config.AutoAcceptUntrustedCerts);
|
||||
Assert.NotNull(config.Heartbeat);
|
||||
Assert.Equal("Old.Heartbeat", config.Heartbeat!.TagPath);
|
||||
Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyWithEndpointUrlPascalKey_Works()
|
||||
{
|
||||
var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}""";
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
|
||||
Assert.True(isLegacy);
|
||||
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyWithMixedTypeValues_PreservesAllFields()
|
||||
{
|
||||
// Real-world legacy producer emitted mixed JSON value types:
|
||||
// string for endpoint, number for publishInterval, etc.
|
||||
var legacyJson = """
|
||||
{"endpoint":"opc.tcp://scadabridge-opcua:50000","securityMode":"None","publishInterval":1000,"AutoAcceptUntrustedCerts":false,"SessionTimeoutMs":45000}
|
||||
""";
|
||||
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
|
||||
|
||||
Assert.True(isLegacy);
|
||||
Assert.Equal("opc.tcp://scadabridge-opcua:50000", config.EndpointUrl);
|
||||
Assert.Equal(OpcUaSecurityMode.None, config.SecurityMode);
|
||||
Assert.False(config.AutoAcceptUntrustedCerts);
|
||||
Assert.Equal(45000, config.SessionTimeoutMs);
|
||||
// publishInterval is not a recognized key (the real key is PublishingIntervalMs);
|
||||
// FromFlatDict ignores unknown keys, so PublishingIntervalMs stays at its POCO default of 1000.
|
||||
Assert.Equal(1000, config.PublishingIntervalMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyParsed_StatusIsLegacy()
|
||||
{
|
||||
var (_, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
|
||||
Assert.True(isLegacy);
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
|
||||
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("\"just a string\"")]
|
||||
public void Deserialize_Malformed_ReportsMalformedNotLegacy(string input)
|
||||
{
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(input);
|
||||
|
||||
// Genuinely unparseable input must NOT be reported as a recoverable legacy row.
|
||||
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
|
||||
Assert.False(result.IsLegacy);
|
||||
Assert.Equal("", result.Config.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy()
|
||||
{
|
||||
// A flat object with unrecognized keys is still a parseable legacy row, not malformed.
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize("{\"foo\":123}");
|
||||
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
|
||||
Assert.True(result.IsLegacy);
|
||||
}
|
||||
|
||||
// ── Commons-014 regression: a corrupt typed row must not be mislabelled Legacy ──
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy()
|
||||
{
|
||||
// The row IS the current typed shape (it has endpointUrl) but an enum-valued
|
||||
// field holds an unrecognised string. Typed deserialization throws JsonException;
|
||||
// it must NOT fall through to the legacy path and be reported as Legacy.
|
||||
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"NotARealMode"}""";
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
|
||||
Assert.False(result.IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy()
|
||||
{
|
||||
// endpointUrl present (typed shape) but a numeric field holds a non-numeric token.
|
||||
var json = """{"endpointUrl":"opc.tcp://x:4840","sessionTimeoutMs":"not-a-number"}""";
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
|
||||
Assert.False(result.IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ValidTypedRow_StillReportsTyped()
|
||||
{
|
||||
// Guard: a clean typed row is still classified Typed after the Commons-014 fix.
|
||||
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"sign"}""";
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(OpcUaConfigParseStatus.Typed, result.Status);
|
||||
Assert.Equal("opc.tcp://x:4840", result.Config.EndpointUrl);
|
||||
Assert.Equal(OpcUaSecurityMode.Sign, result.Config.SecurityMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TwoElementDeconstruction_StillWorks()
|
||||
{
|
||||
// Backward-compat: existing callers deconstruct into (Config, IsLegacy).
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(
|
||||
"""{"endpointUrl":"opc.tcp://x:4840"}""");
|
||||
Assert.False(isLegacy);
|
||||
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_OmitsNullHeartbeat()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("opc.tcp://x:4840", dict["endpoint"]);
|
||||
Assert.Equal("60000", dict["SessionTimeoutMs"]);
|
||||
Assert.Equal("None", dict["SecurityMode"]);
|
||||
Assert.Equal("True", dict["AutoAcceptUntrustedCerts"]);
|
||||
Assert.False(dict.ContainsKey("HeartbeatTagPath"));
|
||||
Assert.False(dict.ContainsKey("HeartbeatMaxSilence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesHeartbeat_WhenSet()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "HB.Tag", MaxSilenceSeconds = 45 }
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("HB.Tag", dict["HeartbeatTagPath"]);
|
||||
Assert.Equal("45", dict["HeartbeatMaxSilence"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_RoundTripsAllKeys()
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["SessionTimeoutMs"] = "30000",
|
||||
["SecurityMode"] = "Sign",
|
||||
["HeartbeatTagPath"] = "Hb",
|
||||
["HeartbeatMaxSilence"] = "15"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
|
||||
Assert.Equal(30000, config.SessionTimeoutMs);
|
||||
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
|
||||
Assert.NotNull(config.Heartbeat);
|
||||
Assert.Equal("Hb", config.Heartbeat!.TagPath);
|
||||
}
|
||||
|
||||
// ── Layer A/B extensions: subscription tuning, auth, deadband ──
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RoundtripsNewSubscriptionScalars()
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
DiscardOldest = false,
|
||||
SubscriptionPriority = 200,
|
||||
SubscriptionDisplayName = "ScadaBridge-Primary",
|
||||
TimestampsToReturn = OpcUaTimestampsToReturn.Both
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.False(isLegacy);
|
||||
Assert.False(round.DiscardOldest);
|
||||
Assert.Equal((byte)200, round.SubscriptionPriority);
|
||||
Assert.Equal("ScadaBridge-Primary", round.SubscriptionDisplayName);
|
||||
Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RoundtripsDeadband()
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
Deadband = new OpcUaDeadbandConfig
|
||||
{
|
||||
Type = OpcUaDeadbandType.Percent,
|
||||
Value = 2.5
|
||||
}
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.NotNull(round.Deadband);
|
||||
Assert.Equal(OpcUaDeadbandType.Percent, round.Deadband!.Type);
|
||||
Assert.Equal(2.5, round.Deadband.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")]
|
||||
[InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")]
|
||||
[InlineData(OpcUaUserTokenType.Anonymous, "", "", "", "")]
|
||||
public void Serialize_RoundtripsUserIdentity(
|
||||
OpcUaUserTokenType tokenType, string user, string pass, string certPath, string certPass)
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = tokenType,
|
||||
Username = user,
|
||||
Password = pass,
|
||||
CertificatePath = certPath,
|
||||
CertificatePassword = certPass
|
||||
}
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.NotNull(round.UserIdentity);
|
||||
Assert.Equal(tokenType, round.UserIdentity!.TokenType);
|
||||
Assert.Equal(user, round.UserIdentity.Username);
|
||||
Assert.Equal(pass, round.UserIdentity.Password);
|
||||
Assert.Equal(certPath, round.UserIdentity.CertificatePath);
|
||||
Assert.Equal(certPass, round.UserIdentity.CertificatePassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson()
|
||||
{
|
||||
// Default config: UserIdentity and Deadband are null. Roundtrip should
|
||||
// preserve nulls (anonymous = no auth needed in flattened JSON either).
|
||||
var original = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Null(round.UserIdentity);
|
||||
Assert.Null(round.Deadband);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesNewScalars()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
DiscardOldest = false,
|
||||
SubscriptionPriority = 50,
|
||||
SubscriptionDisplayName = "ScadaBridge-Edge",
|
||||
TimestampsToReturn = OpcUaTimestampsToReturn.Server
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("False", dict["DiscardOldest"]);
|
||||
Assert.Equal("50", dict["SubscriptionPriority"]);
|
||||
Assert.Equal("ScadaBridge-Edge", dict["SubscriptionDisplayName"]);
|
||||
Assert.Equal("Server", dict["TimestampsToReturn"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_OmitsNullUserIdentityAndDeadband()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.False(dict.ContainsKey("UserIdentity.TokenType"));
|
||||
Assert.False(dict.ContainsKey("UserIdentity.Username"));
|
||||
Assert.False(dict.ContainsKey("Deadband.Type"));
|
||||
Assert.False(dict.ContainsKey("Deadband.Value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesUserIdentity_WhenSet()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = OpcUaUserTokenType.UsernamePassword,
|
||||
Username = "alice",
|
||||
Password = "secret"
|
||||
}
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("UsernamePassword", dict["UserIdentity.TokenType"]);
|
||||
Assert.Equal("alice", dict["UserIdentity.Username"]);
|
||||
Assert.Equal("secret", dict["UserIdentity.Password"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesDeadband_WhenSet()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("Percent", dict["Deadband.Type"]);
|
||||
Assert.Equal("1.5", dict["Deadband.Value"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_MaterializesUserIdentity()
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["UserIdentity.TokenType"] = "UsernamePassword",
|
||||
["UserIdentity.Username"] = "bob",
|
||||
["UserIdentity.Password"] = "hunter2"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.NotNull(config.UserIdentity);
|
||||
Assert.Equal(OpcUaUserTokenType.UsernamePassword, config.UserIdentity!.TokenType);
|
||||
Assert.Equal("bob", config.UserIdentity.Username);
|
||||
Assert.Equal("hunter2", config.UserIdentity.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_MaterializesDeadband()
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["Deadband.Type"] = "Absolute",
|
||||
["Deadband.Value"] = "0.25"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.NotNull(config.Deadband);
|
||||
Assert.Equal(OpcUaDeadbandType.Absolute, config.Deadband!.Type);
|
||||
Assert.Equal(0.25, config.Deadband.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity()
|
||||
{
|
||||
// Explicit Anonymous TokenType (different from "missing") materializes the sub-object.
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["UserIdentity.TokenType"] = "Anonymous"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.NotNull(config.UserIdentity);
|
||||
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DynamicJsonElement"/>, including the Commons-002 regression:
|
||||
/// a wrapped element must remain valid for deferred (script-time) access even after
|
||||
/// the <see cref="JsonDocument"/> it was parsed from has been disposed.
|
||||
/// </summary>
|
||||
public class DynamicJsonElementTests
|
||||
{
|
||||
private static DynamicJsonElement Wrap(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return new DynamicJsonElement(doc.RootElement);
|
||||
// doc is disposed here — a wrapper that retained a non-cloned element would
|
||||
// now throw ObjectDisposedException on the first member access.
|
||||
}
|
||||
|
||||
// ── Commons-002 regression: lifetime independence from the source document ──
|
||||
|
||||
[Fact]
|
||||
public void MemberAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "name": "pump", "id": 7 }""");
|
||||
|
||||
Assert.Equal("pump", (string)obj.name);
|
||||
Assert.Equal(7, (int)obj.id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
|
||||
Assert.Equal("b", obj.items[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "outer": { "inner": { "value": 42 } } }""");
|
||||
|
||||
Assert.Equal(42, (int)obj.outer.inner.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "label": "site-1" }""");
|
||||
|
||||
Assert.Equal("site-1", obj.label.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Access_SurvivesGarbageCollection_OfSourceDocument()
|
||||
{
|
||||
// No reference to the source document is held anywhere; force collection
|
||||
// and finalization to prove the wrapper does not depend on it.
|
||||
var obj = MakeWrapperAndDropDocument();
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
dynamic d = obj;
|
||||
Assert.Equal("ok", (string)d.status);
|
||||
}
|
||||
|
||||
private static DynamicJsonElement MakeWrapperAndDropDocument()
|
||||
{
|
||||
var doc = JsonDocument.Parse("""{ "status": "ok" }""");
|
||||
var wrapper = new DynamicJsonElement(doc.RootElement);
|
||||
doc.Dispose();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ── Basic conversion / access behavior ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Convert_NumberToInt()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "n": 123 }""");
|
||||
Assert.Equal(123, (int)obj.n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_BoolFromJson()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "flag": true }""");
|
||||
Assert.True((bool)obj.flag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingMember_Throws()
|
||||
{
|
||||
// TryGetMember returns false for an absent property, so the dynamic binder
|
||||
// surfaces a RuntimeBinderException — the standard DynamicObject contract.
|
||||
dynamic obj = Wrap("""{ "a": 1 }""");
|
||||
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
|
||||
() => { var _ = obj.doesNotExist; });
|
||||
}
|
||||
|
||||
// ── Commons-006 regression: TryConvert(object) must never null out a present value ──
|
||||
|
||||
[Fact]
|
||||
public void TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull()
|
||||
{
|
||||
// Directly exercise the DynamicObject.TryConvert contract for an `object`
|
||||
// target: a present JSON object/array/string must not convert to null.
|
||||
using var objDoc = JsonDocument.Parse("""{ "x": 1 }""");
|
||||
var objWrapper = new DynamicJsonElement(objDoc.RootElement);
|
||||
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
|
||||
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
|
||||
|
||||
Assert.True(objWrapper.TryConvert(convBinder, out var result));
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConvert_ObjectTarget_OnJsonNull_ReturnsNull()
|
||||
{
|
||||
// Only a genuinely null JSON value converts to a null object.
|
||||
using var doc = JsonDocument.Parse("""{ "v": null }""");
|
||||
var nullWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("v"));
|
||||
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
|
||||
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
|
||||
|
||||
Assert.True(nullWrapper.TryConvert(convBinder, out var result));
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure()
|
||||
{
|
||||
// Requesting int from a JSON string is genuinely unconvertible: TryConvert
|
||||
// must report false rather than a null success.
|
||||
using var doc = JsonDocument.Parse("""{ "s": "not-a-number" }""");
|
||||
var strWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("s"));
|
||||
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
|
||||
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(int), typeof(DynamicJsonElementTests));
|
||||
|
||||
Assert.False(strWrapper.TryConvert(convBinder, out var result));
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ── Commons-013 regression: integral index values other than int must work ──
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WithLongIndex_Works()
|
||||
{
|
||||
// DynamicJsonElement.Wrap surfaces JSON numbers as long; an index computed
|
||||
// from a wrapped JSON number (obj.items[obj.count - 1]) arrives as a long.
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
long idx = 1L;
|
||||
|
||||
Assert.Equal("b", obj.items[idx]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WithIndexDerivedFromWrappedJsonNumber_Works()
|
||||
{
|
||||
// The exact failing case from Commons-013: count is a wrapped JSON number
|
||||
// (unwrapped as long), so count - 1 is a long.
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ], "count": 3 }""");
|
||||
|
||||
Assert.Equal("c", obj.items[obj.count - 1]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0, "a")]
|
||||
[InlineData((short)1, "b")]
|
||||
public void IndexAccess_WithWideningIntegralIndex_Works(object index, string expected)
|
||||
{
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
|
||||
Assert.Equal(expected, obj.items[index]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WithLongIndexOutOfRange_Throws()
|
||||
{
|
||||
// An out-of-range long index is still rejected (binder surfaces the error).
|
||||
dynamic obj = Wrap("""{ "items": [ "a" ] }""");
|
||||
long idx = 5L;
|
||||
|
||||
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
|
||||
() => { var _ = obj.items[idx]; });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class EnumTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary" })]
|
||||
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
|
||||
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
|
||||
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
|
||||
[InlineData(typeof(AlarmLevel), new[] { "None", "Low", "LowLow", "High", "HighHigh" })]
|
||||
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo", "Expression" })]
|
||||
[InlineData(typeof(ConnectionHealth), new[] { "Connected", "Disconnected", "Connecting", "Error" })]
|
||||
[InlineData(typeof(NotificationStatus), new[] { "Pending", "Retrying", "Delivered", "Parked", "Discarded" })]
|
||||
[InlineData(typeof(NotificationType), new[] { "Email" })]
|
||||
public void Enum_ShouldHaveExpectedValues(Type enumType, string[] expectedNames)
|
||||
{
|
||||
var actualNames = Enum.GetNames(enumType);
|
||||
Assert.Equal(expectedNames, actualNames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(DataType))]
|
||||
[InlineData(typeof(InstanceState))]
|
||||
[InlineData(typeof(DeploymentStatus))]
|
||||
[InlineData(typeof(AlarmState))]
|
||||
[InlineData(typeof(AlarmLevel))]
|
||||
[InlineData(typeof(AlarmTriggerType))]
|
||||
[InlineData(typeof(ConnectionHealth))]
|
||||
[InlineData(typeof(NotificationStatus))]
|
||||
[InlineData(typeof(NotificationType))]
|
||||
public void Enum_ShouldBeSingularNamed(Type enumType)
|
||||
{
|
||||
// Singular names should not end with 's' (except 'Status' which is singular)
|
||||
var name = enumType.Name;
|
||||
Assert.False(name.EndsWith("es") && !name.EndsWith("Status"),
|
||||
$"Enum {name} appears to be plural (ends with 'es').");
|
||||
Assert.False(name.EndsWith("s") && !name.EndsWith("ss") && !name.EndsWith("us"),
|
||||
$"Enum {name} appears to be plural (ends with 's').");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the exact member sets of the Audit Log (#23) enums.
|
||||
/// Lock-in tests; any addition/removal/rename is a deliberate design change
|
||||
/// that must come with a corresponding update to alog.md §4.
|
||||
/// </summary>
|
||||
public class AuditEnumTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuditChannel_HasExactlyExpectedMembers()
|
||||
{
|
||||
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" };
|
||||
var actual = Enum.GetValues(typeof(AuditChannel))
|
||||
.Cast<AuditChannel>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected.Length, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditKind_HasExactlyTenExpectedMembers()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached",
|
||||
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
|
||||
"CachedSubmit", "CachedResolve",
|
||||
};
|
||||
var actual = Enum.GetValues(typeof(AuditKind))
|
||||
.Cast<AuditKind>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(10, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditStatus_HasExactlyEightExpectedMembers()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered",
|
||||
"Failed", "Parked", "Discarded", "Skipped",
|
||||
};
|
||||
var actual = Enum.GetValues(typeof(AuditStatus))
|
||||
.Cast<AuditStatus>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(8, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditForwardState_HasExactlyExpectedMembers()
|
||||
{
|
||||
var expected = new[] { "Pending", "Forwarded", "Reconciled" };
|
||||
var actual = Enum.GetValues(typeof(AuditForwardState))
|
||||
.Cast<AuditForwardState>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected.Length, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-010: coverage for the small computed-property logic on
|
||||
/// <see cref="ConfigurationDiff"/> and <see cref="ScriptScope"/>.
|
||||
/// </summary>
|
||||
public class FlatteningAndScriptScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigurationDiff_NoChanges_HasChangesIsFalse()
|
||||
{
|
||||
var diff = new ConfigurationDiff { InstanceUniqueName = "inst-1" };
|
||||
|
||||
Assert.False(diff.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationDiff_WithAttributeChange_HasChangesIsTrue()
|
||||
{
|
||||
var diff = new ConfigurationDiff
|
||||
{
|
||||
InstanceUniqueName = "inst-1",
|
||||
AlarmChanges = new[]
|
||||
{
|
||||
new DiffEntry<ResolvedAlarm> { CanonicalName = "HiAlarm", ChangeType = DiffChangeType.Added }
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptScope_Root_HasNoParent()
|
||||
{
|
||||
Assert.False(ScriptScope.Root.HasParent);
|
||||
Assert.Null(ScriptScope.Root.ParentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptScope_WithParentPath_HasParentIsTrue()
|
||||
{
|
||||
var scope = new ScriptScope("Pump1.Motor", "Pump1");
|
||||
|
||||
Assert.True(scope.HasParent);
|
||||
Assert.Equal("Pump1", scope.ParentPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class ResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_ShouldCreateSuccessfulResult()
|
||||
{
|
||||
var result = Result<int>.Success(42);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.IsFailure);
|
||||
Assert.Equal(42, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ShouldCreateFailedResult()
|
||||
{
|
||||
var result = Result<int>.Failure("something went wrong");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("something went wrong", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_OnFailure_ShouldThrow()
|
||||
{
|
||||
var result = Result<int>.Failure("error");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_OnSuccess_ShouldThrow()
|
||||
{
|
||||
var result = Result<int>.Success(42);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_OnSuccess_ShouldCallOnSuccess()
|
||||
{
|
||||
var result = Result<int>.Success(42);
|
||||
|
||||
var output = result.Match(
|
||||
v => $"value={v}",
|
||||
e => $"error={e}");
|
||||
|
||||
Assert.Equal("value=42", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_OnFailure_ShouldCallOnFailure()
|
||||
{
|
||||
var result = Result<int>.Failure("bad");
|
||||
|
||||
var output = result.Match(
|
||||
v => $"value={v}",
|
||||
e => $"error={e}");
|
||||
|
||||
Assert.Equal("error=bad", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_WithNullableReferenceType_ShouldWork()
|
||||
{
|
||||
var result = Result<string>.Success("hello");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("hello", result.Value);
|
||||
}
|
||||
|
||||
// ── Commons-011 regression: a failed Result must always carry a message ──
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithNullError_ShouldThrow()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => Result<int>.Failure(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Failure_WithBlankError_ShouldThrow(string error)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Result<int>.Failure(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class RetryPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetryPolicy_ShouldBeImmutableRecord()
|
||||
{
|
||||
var policy = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(3, policy.MaxRetries);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), policy.Delay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryPolicy_WithExpression_ShouldCreateNewInstance()
|
||||
{
|
||||
var original = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
var modified = original with { MaxRetries = 5 };
|
||||
|
||||
Assert.Equal(3, original.MaxRetries);
|
||||
Assert.Equal(5, modified.MaxRetries);
|
||||
Assert.Equal(original.Delay, modified.Delay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryPolicy_EqualValues_ShouldBeEqual()
|
||||
{
|
||||
var a = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
var b = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-010: coverage for <see cref="ScriptArgs.Normalize"/> — the script-call
|
||||
/// parameter normalizer (dictionary / anonymous-object / primitive-rejection paths).
|
||||
/// </summary>
|
||||
public class ScriptArgsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsNull()
|
||||
{
|
||||
Assert.Null(ScriptArgs.Normalize(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ReadOnlyDictionary_ReturnedAsIs()
|
||||
{
|
||||
IReadOnlyDictionary<string, object?> input =
|
||||
new Dictionary<string, object?> { ["a"] = 1 };
|
||||
|
||||
var result = ScriptArgs.Normalize(input);
|
||||
|
||||
Assert.Same(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PlainDictionary_ReturnedAsIs()
|
||||
{
|
||||
// Dictionary<string,object?> implements IReadOnlyDictionary, so it matches the
|
||||
// first switch arm and is returned by reference (no defensive copy).
|
||||
var input = new Dictionary<string, object?> { ["a"] = 1 };
|
||||
|
||||
var result = ScriptArgs.Normalize(input);
|
||||
|
||||
Assert.Same(input, result);
|
||||
Assert.Equal(1, result!["a"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NonGenericDictionary_KeysStringified()
|
||||
{
|
||||
IDictionary raw = new Hashtable { [42] = "answer" };
|
||||
|
||||
var result = ScriptArgs.Normalize(raw);
|
||||
|
||||
Assert.Equal("answer", result!["42"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AnonymousObject_PropertiesBecomeEntries()
|
||||
{
|
||||
var result = ScriptArgs.Normalize(new { name = "Bob", count = 3 });
|
||||
|
||||
Assert.Equal("Bob", result!["name"]);
|
||||
Assert.Equal(3, result["count"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(42)]
|
||||
[InlineData(true)]
|
||||
[InlineData(3.14)]
|
||||
public void Normalize_Primitive_Throws(object primitive)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(primitive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_String_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Decimal_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(9.99m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class ScriptParametersTests
|
||||
{
|
||||
// ── Non-nullable scalar Get<T> ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_ExactTypeMatch()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42 });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_FromLong()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42L });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_FromString()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "42" });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_MissingKey_Throws()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("Parameter 'x' not found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_NullValue_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = null });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("Parameter 'x' value is null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_Unparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "abc" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_DirectReturn()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = "hello" });
|
||||
Assert.Equal("hello", p.Get<string>("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_NullValue_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = null });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<string>("s"));
|
||||
Assert.Contains("value is null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_FromInt()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = 42 });
|
||||
Assert.Equal("42", p.Get<string>("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Bool_True()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = true });
|
||||
Assert.True(p.Get<bool>("b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromLong()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 42L });
|
||||
Assert.Equal(42.0, p.Get<double>("d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromFloat()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 3.14f });
|
||||
Assert.Equal(3.14, p.Get<double>("d"), 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Long_FromInt()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["l"] = 42 });
|
||||
Assert.Equal(42L, p.Get<long>("l"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Float_FromDouble()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["f"] = 3.14 });
|
||||
Assert.Equal(3.14f, p.Get<float>("f"), 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_DateTime_FromString()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "2026-03-22T10:30:00Z" });
|
||||
var result = p.Get<DateTime>("dt");
|
||||
Assert.Equal(new DateTime(2026, 3, 22, 10, 30, 0, DateTimeKind.Utc), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_DateTime_ExactType()
|
||||
{
|
||||
var dt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = dt });
|
||||
Assert.Equal(dt, p.Get<DateTime>("dt"));
|
||||
}
|
||||
|
||||
// ── Nullable scalar Get<T?> ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_MissingKey_ReturnsNull()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
Assert.Null(p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_NullValue_ReturnsNull()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = null });
|
||||
Assert.Null(p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_ValidValue_ReturnsValue()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42L });
|
||||
Assert.Equal(42, p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
// Commons-003: a parameter that is *present but unconvertible* is a caller/script
|
||||
// bug and must throw — not be silently mapped to null (which a script would
|
||||
// misread as "not supplied"). Genuinely absent/null still returns null.
|
||||
[Fact]
|
||||
public void Get_NullableInt_PresentButUnparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "banana" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_PresentButOverflowing_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableDateTime_PresentButUnparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "not-a-date" });
|
||||
Assert.Throws<ScriptParameterException>(() => p.Get<DateTime?>("dt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableDouble_ValidValue()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 3.14 });
|
||||
Assert.Equal(3.14, p.Get<double?>("d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableBool_ValidValue()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = true });
|
||||
Assert.True(p.Get<bool?>("b"));
|
||||
}
|
||||
|
||||
// ── Array Get<T[]> ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_FromListOfLongs()
|
||||
{
|
||||
var list = new List<object?> { 1L, 2L, 3L };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
Assert.Equal(new[] { 1, 2, 3 }, p.Get<int[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_EmptyList()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = new List<object?>() });
|
||||
Assert.Empty(p.Get<int[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_NonList_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = "not a list" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("is not a list or array", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_UnparsableElement_ThrowsWithIndex()
|
||||
{
|
||||
var list = new List<object?> { 1L, 2L, "bad" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("element at index 2", ex.Message);
|
||||
Assert.Contains("'bad'", ex.Message);
|
||||
Assert.Contains("Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_NullElement_Throws()
|
||||
{
|
||||
var list = new List<object?> { 1L, null, 3L };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("element at index 1 is null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_StringArray_FromListOfStrings()
|
||||
{
|
||||
var list = new List<object?> { "a", "b", "c" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
Assert.Equal(new[] { "a", "b", "c" }, p.Get<string[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_DoubleArray_FromListOfNumbers()
|
||||
{
|
||||
var list = new List<object?> { 1.1, 2.2, 3.3 };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
Assert.Equal(new[] { 1.1, 2.2, 3.3 }, p.Get<double[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_MissingKey_Throws()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("not found", ex.Message);
|
||||
}
|
||||
|
||||
// ── List<T> Get<List<T>> ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_ListInt_FromListOfLongs()
|
||||
{
|
||||
var list = new List<object?> { 10L, 20L, 30L };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
var result = p.Get<List<int>>("items");
|
||||
Assert.Equal(new List<int> { 10, 20, 30 }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListString_FromListOfStrings()
|
||||
{
|
||||
var list = new List<object?> { "x", "y" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
Assert.Equal(new List<string> { "x", "y" }, p.Get<List<string>>("items"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListInt_UnparsableElement_ThrowsWithIndex()
|
||||
{
|
||||
var list = new List<object?> { 1L, "oops" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<List<int>>("items"));
|
||||
Assert.Contains("element at index 1", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListDouble_FromMixedNumbers()
|
||||
{
|
||||
var list = new List<object?> { 1L, 2.5, 3 };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
var result = p.Get<List<double>>("items");
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(1.0, result[0]);
|
||||
Assert.Equal(2.5, result[1]);
|
||||
Assert.Equal(3.0, result[2]);
|
||||
}
|
||||
|
||||
// ── Backward compatibility ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Indexer_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = "val" });
|
||||
Assert.Equal("val", p["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsKey_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = 1 });
|
||||
Assert.True(p.ContainsKey("key"));
|
||||
Assert.False(p.ContainsKey("missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = 42 });
|
||||
Assert.True(p.TryGetValue("key", out var val));
|
||||
Assert.Equal(42, val);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 });
|
||||
Assert.Equal(2, p.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enumeration_Works()
|
||||
{
|
||||
var dict = new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 };
|
||||
var p = new ScriptParameters(dict);
|
||||
var keys = p.Select(kv => kv.Key).OrderBy(k => k).ToList();
|
||||
Assert.Equal(new[] { "a", "b" }, keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyConstructor_ProducesEmptyDictionary()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
Assert.Empty(p);
|
||||
Assert.False(p.ContainsKey("anything"));
|
||||
}
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromInt()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 42 });
|
||||
Assert.Equal(42.0, p.Get<double>("d"));
|
||||
}
|
||||
|
||||
// ── JsonElement values (from JSON deserialization) ─────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"x\": 42}");
|
||||
var element = doc.RootElement.GetProperty("x").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = element });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_FromJsonElementList()
|
||||
{
|
||||
// Simulates what JsonSerializer.Deserialize<List<object?>> produces
|
||||
using var doc = JsonDocument.Parse("[10, 20, 30]");
|
||||
var list = JsonSerializer.Deserialize<List<object?>>(doc.RootElement.GetRawText())!;
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
Assert.Equal(new[] { 10, 20, 30 }, p.Get<int[]>("items"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListInt_FromJsonElementList()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("[1, 2, 3]");
|
||||
var list = JsonSerializer.Deserialize<List<object?>>(doc.RootElement.GetRawText())!;
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
Assert.Equal(new List<int> { 1, 2, 3 }, p.Get<List<int>>("items"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"s\": \"hello\"}");
|
||||
var element = doc.RootElement.GetProperty("s").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = element });
|
||||
Assert.Equal("hello", p.Get<string>("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"d\": 3.14}");
|
||||
var element = doc.RootElement.GetProperty("d").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = element });
|
||||
Assert.Equal(3.14, p.Get<double>("d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Bool_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"b\": true}");
|
||||
var element = doc.RootElement.GetProperty("b").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = element });
|
||||
Assert.True(p.Get<bool>("b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_OverflowFromLong_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="SiteCallOperational"/> — the positional record carried on
|
||||
/// the combined <c>CachedCallTelemetry</c> packet — round-trips the SourceNode
|
||||
/// field through positional construction (where the parameter sits between
|
||||
/// <c>SourceSite</c> and <c>Status</c>, mirroring the central <c>SiteCalls</c>
|
||||
/// table column order).
|
||||
/// </summary>
|
||||
public class SiteCallOperationalTests
|
||||
{
|
||||
[Fact]
|
||||
public void SiteCallOperational_carries_SourceNode()
|
||||
{
|
||||
// SourceNode identifies the cluster node that emitted the cached call
|
||||
// (site node-a/node-b or central-a/central-b). Nullable — callsites
|
||||
// pass null until INodeIdentityProvider stamping arrives in Task 14.
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var defaulted = new SiteCallOperational(
|
||||
TrackedOperationId: trackedId,
|
||||
Channel: "ApiOutbound",
|
||||
Target: "ERP.GetOrder",
|
||||
SourceSite: "site-01",
|
||||
SourceNode: null,
|
||||
Status: "Submitted",
|
||||
RetryCount: 0,
|
||||
LastError: null,
|
||||
HttpStatus: null,
|
||||
CreatedAtUtc: nowUtc,
|
||||
UpdatedAtUtc: nowUtc,
|
||||
TerminalAtUtc: null);
|
||||
Assert.Null(defaulted.SourceNode);
|
||||
|
||||
var stamped = defaulted with { SourceNode = "node-a" };
|
||||
Assert.Equal("node-a", stamped.SourceNode);
|
||||
Assert.Null(defaulted.SourceNode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class SiteNotificationKpiSnapshotTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_AssignsAllMembers()
|
||||
{
|
||||
var snapshot = new SiteNotificationKpiSnapshot(
|
||||
SourceSiteId: "plant-a",
|
||||
QueueDepth: 5,
|
||||
StuckCount: 2,
|
||||
ParkedCount: 1,
|
||||
DeliveredLastInterval: 40,
|
||||
OldestPendingAge: TimeSpan.FromMinutes(12));
|
||||
|
||||
Assert.Equal("plant-a", snapshot.SourceSiteId);
|
||||
Assert.Equal(5, snapshot.QueueDepth);
|
||||
Assert.Equal(2, snapshot.StuckCount);
|
||||
Assert.Equal(1, snapshot.ParkedCount);
|
||||
Assert.Equal(40, snapshot.DeliveredLastInterval);
|
||||
Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestPendingAge_IsNullableForSitesWithNoBacklog()
|
||||
{
|
||||
var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null);
|
||||
Assert.Null(snapshot.OldestPendingAge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Commons-001: the check-then-act race between the timer
|
||||
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
|
||||
///
|
||||
/// The original implementation guarded firing with a single <c>volatile bool</c> that
|
||||
/// was both read by the callback and reset by the caller threads. Because the
|
||||
/// check-then-set was not atomic with the timer reschedule, a callback that had
|
||||
/// already entered could raise <c>Stale</c> after the period it was scheduled for
|
||||
/// had been cancelled or restarted — a spurious staleness signal that, for a
|
||||
/// connection-health monitor, triggers an unnecessary reconnect.
|
||||
///
|
||||
/// These tests use the internal <c>CallbackEnteredHook</c> seam to deterministically
|
||||
/// interleave a caller-thread operation with an in-flight callback.
|
||||
/// </summary>
|
||||
public class StaleTagMonitorRaceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A value arrives (<c>OnValueReceived</c>) while a previous-period timer callback
|
||||
/// is in flight, before that callback decides whether to fire. The old period has
|
||||
/// been superseded, so the in-flight callback must not raise <c>Stale</c>
|
||||
/// immediately; <c>Stale</c> may only fire later, for the fresh period, after a
|
||||
/// full <c>MaxSilence</c> with no further values.
|
||||
///
|
||||
/// With the original single-volatile-bool guard the in-flight callback fired
|
||||
/// <c>Stale</c> right after the value arrived (a spurious, wrong-moment signal);
|
||||
/// this test detects that by checking how soon the fire lands after the value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_DoesNotFirePromptly_WhenValueArrivesWhileCallbackInFlight()
|
||||
{
|
||||
var maxSilence = TimeSpan.FromMilliseconds(60);
|
||||
using var monitor = new StaleTagMonitor(maxSilence);
|
||||
|
||||
DateTime? valueArrivedAt = null;
|
||||
DateTime? staleFiredAt = null;
|
||||
monitor.Stale += () => staleFiredAt ??= DateTime.UtcNow;
|
||||
|
||||
// When the (old-period) callback is entered, simulate a fresh value arriving
|
||||
// on another thread before the callback's fire decision.
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null; // only intercept the first callback
|
||||
valueArrivedAt = DateTime.UtcNow;
|
||||
monitor.OnValueReceived();
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
|
||||
// Wait well past the intercepted callback and the fresh period's deadline.
|
||||
Thread.Sleep(300);
|
||||
monitor.Stop();
|
||||
|
||||
// The fresh period legitimately goes stale, so a fire is expected — but it
|
||||
// must land roughly MaxSilence after the value, not immediately. A spurious
|
||||
// wrong-moment fire from the superseded callback would land within a few ms.
|
||||
Assert.NotNull(valueArrivedAt);
|
||||
Assert.NotNull(staleFiredAt);
|
||||
var delay = staleFiredAt.Value - valueArrivedAt.Value;
|
||||
Assert.True(delay >= maxSilence * 0.5,
|
||||
$"Stale fired only {delay.TotalMilliseconds:F0}ms after the value arrived; " +
|
||||
$"expected at least {maxSilence.TotalMilliseconds * 0.5:F0}ms — the in-flight " +
|
||||
"callback fired spuriously for the superseded period.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Stop</c> races an in-flight timer callback. Once monitoring is stopped no
|
||||
/// <c>Stale</c> signal may be delivered, even for a callback that had already
|
||||
/// been entered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_DoesNotFire_WhenStopRacesInFlightCallback()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null;
|
||||
monitor.Stop();
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
Thread.Sleep(200);
|
||||
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Start</c> (a restart) races an in-flight callback from the prior run. The
|
||||
/// old callback belongs to a superseded period and must not fire; the new period
|
||||
/// fires exactly once on its own deadline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_FiresOnceForNewPeriod_WhenRestartRacesInFlightCallback()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null;
|
||||
monitor.Start(); // restart — supersedes the in-flight callback's period
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
|
||||
// Old callback must be suppressed; the restarted period fires exactly once.
|
||||
Thread.Sleep(250);
|
||||
monitor.Stop();
|
||||
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class StaleTagMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ZeroTimeSpan_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeTimeSpan_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_FiresAfterMaxSilence()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
await Task.Delay(300);
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_FiresOnlyOnce()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
await Task.Delay(300);
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValueReceived_ResetsTimer()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(200));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
// Keep resetting before the 200ms deadline
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
monitor.OnValueReceived();
|
||||
}
|
||||
|
||||
// Should not have gone stale
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValueReceived_AllowsStaleAfterSilence()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
// Reset once
|
||||
await Task.Delay(50);
|
||||
monitor.OnValueReceived();
|
||||
|
||||
// Then go silent
|
||||
await Task.Delay(250);
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
// Wait for first stale
|
||||
await Task.Delay(250);
|
||||
Assert.Equal(1, staleCount);
|
||||
|
||||
// Reset — should allow second stale fire
|
||||
monitor.OnValueReceived();
|
||||
await Task.Delay(250);
|
||||
Assert.Equal(2, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_PreventsStale()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
monitor.Stop();
|
||||
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_PreventsStale()
|
||||
{
|
||||
var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
monitor.Dispose();
|
||||
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxSilence_ReturnsConfiguredValue()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromSeconds(42));
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), monitor.MaxSilence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
|
||||
/// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and
|
||||
/// surfaced to scripts via <c>Tracking.Status(id)</c>.
|
||||
/// </summary>
|
||||
public class TrackedOperationIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void New_ProducesUniqueIds()
|
||||
{
|
||||
var a = TrackedOperationId.New();
|
||||
var b = TrackedOperationId.New();
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.NotEqual(Guid.Empty, a.Value);
|
||||
Assert.NotEqual(Guid.Empty, b.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = TrackedOperationId.New();
|
||||
var serialized = original.ToString();
|
||||
|
||||
var parsed = TrackedOperationId.Parse(serialized);
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(original.Value, parsed.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_InvalidInput_ReturnsFalse()
|
||||
{
|
||||
Assert.False(TrackedOperationId.TryParse("not-a-guid", out var result));
|
||||
Assert.Equal(default, result);
|
||||
|
||||
Assert.False(TrackedOperationId.TryParse(null, out var nullResult));
|
||||
Assert.Equal(default, nullResult);
|
||||
|
||||
Assert.False(TrackedOperationId.TryParse(string.Empty, out var emptyResult));
|
||||
Assert.Equal(default, emptyResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ValidInput_ReturnsTrueAndId()
|
||||
{
|
||||
var original = TrackedOperationId.New();
|
||||
var serialized = original.ToString();
|
||||
|
||||
Assert.True(TrackedOperationId.TryParse(serialized, out var parsed));
|
||||
Assert.Equal(original, parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_BasedOnValue()
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
var a = new TrackedOperationId(guid);
|
||||
var b = new TrackedOperationId(guid);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.True(a == b);
|
||||
Assert.False(a != b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_StandardGuidFormat()
|
||||
{
|
||||
var guid = Guid.Parse("12345678-1234-1234-1234-1234567890ab");
|
||||
var id = new TrackedOperationId(guid);
|
||||
|
||||
// "D" format: 32 hex digits separated by hyphens (8-4-4-4-12).
|
||||
Assert.Equal("12345678-1234-1234-1234-1234567890ab", id.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at
|
||||
/// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap
|
||||
/// iteration counts, null salt/IV). Valid construction must round-trip the fields.
|
||||
/// </summary>
|
||||
public sealed class EncryptionMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithDocumentedValues_Succeeds()
|
||||
{
|
||||
// 600_000 is the design-doc production value; "abc"/"def" are placeholder
|
||||
// Base64 strings, kept short for test legibility.
|
||||
var meta = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def");
|
||||
|
||||
Assert.Equal("AES-256-GCM", meta.Algorithm);
|
||||
Assert.Equal("PBKDF2-SHA256", meta.Kdf);
|
||||
Assert.Equal(600_000, meta.Iterations);
|
||||
Assert.Equal("abc", meta.SaltB64);
|
||||
Assert.Equal("def", meta.IvB64);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AES-128-CBC")] // weaker algorithm
|
||||
[InlineData("AES-256-CBC")] // unauthenticated mode
|
||||
[InlineData("aes-256-gcm")] // case must match exactly
|
||||
[InlineData("")]
|
||||
[InlineData("FOO")]
|
||||
public void Constructor_UnknownAlgorithm_Throws(string algorithm)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
|
||||
Algorithm: algorithm,
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def"));
|
||||
|
||||
Assert.Equal("Algorithm", ex.ParamName);
|
||||
Assert.Contains("AES-256-GCM", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PBKDF2-SHA1")] // weaker hash
|
||||
[InlineData("argon2id")] // unsupported KDF
|
||||
[InlineData("pbkdf2-sha256")] // case must match
|
||||
[InlineData("")]
|
||||
public void Constructor_UnknownKdf_Throws(string kdf)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: kdf,
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def"));
|
||||
|
||||
Assert.Equal("Kdf", ex.ParamName);
|
||||
Assert.Contains("PBKDF2-SHA256", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(1)]
|
||||
[InlineData(99_999)] // one below the floor
|
||||
[InlineData(10_000_001)] // one above the ceiling
|
||||
[InlineData(int.MaxValue)]
|
||||
public void Constructor_IterationsOutOfRange_Throws(int iterations)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: iterations,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def"));
|
||||
|
||||
Assert.Equal("Iterations", ex.ParamName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100_000)] // OWASP minimum (exact)
|
||||
[InlineData(600_000)] // design-doc production value
|
||||
[InlineData(10_000_000)] // ceiling (exact)
|
||||
public void Constructor_IterationsAtBoundary_Succeeds(int iterations)
|
||||
{
|
||||
// Exercises the inclusive boundary check on both ends.
|
||||
var meta = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: iterations,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def");
|
||||
|
||||
Assert.Equal(iterations, meta.Iterations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullSalt_Throws()
|
||||
{
|
||||
// null is rejected; empty is permitted (the seed pattern used by BundleSerializer.Pack).
|
||||
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: null!,
|
||||
IvB64: "def"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullIv_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern()
|
||||
{
|
||||
// BundleSerializer.Pack re-stamps salt/iv from the ciphertext it actually
|
||||
// writes, so callers (BundleExporter) construct a seed instance with empty
|
||||
// placeholders. Validation must therefore accept empty here.
|
||||
var seed = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: string.Empty,
|
||||
IvB64: string.Empty);
|
||||
|
||||
Assert.Equal(string.Empty, seed.SaltB64);
|
||||
Assert.Equal(string.Empty, seed.IvB64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
|
||||
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
|
||||
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
|
||||
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
|
||||
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
|
||||
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
|
||||
/// (Commons-015) and is reused here only to populate manifest fixtures.
|
||||
/// </summary>
|
||||
public sealed class TransportRecordsTests
|
||||
{
|
||||
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// BundleManifest
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BundleManifest_Constructor_RoundTripsAllFields()
|
||||
{
|
||||
var summary = new BundleSummary(
|
||||
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
|
||||
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
|
||||
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
|
||||
var contents = new List<ManifestContentEntry>
|
||||
{
|
||||
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
|
||||
new("Template", "Valve", 2, Array.Empty<string>()),
|
||||
};
|
||||
|
||||
var manifest = new BundleManifest(
|
||||
BundleFormatVersion: 1,
|
||||
SchemaVersion: "1.0",
|
||||
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
|
||||
SourceEnvironment: "cli",
|
||||
ExportedBy: "alice",
|
||||
ScadaBridgeVersion: "0.9.0",
|
||||
ContentHash: "sha256:deadbeef",
|
||||
Encryption: null,
|
||||
Summary: summary,
|
||||
Contents: contents);
|
||||
|
||||
Assert.Equal(1, manifest.BundleFormatVersion);
|
||||
Assert.Equal("1.0", manifest.SchemaVersion);
|
||||
Assert.Equal("cli", manifest.SourceEnvironment);
|
||||
Assert.Equal("alice", manifest.ExportedBy);
|
||||
Assert.Equal("0.9.0", manifest.ScadaBridgeVersion);
|
||||
Assert.Equal("sha256:deadbeef", manifest.ContentHash);
|
||||
Assert.Null(manifest.Encryption);
|
||||
Assert.Equal(summary, manifest.Summary);
|
||||
Assert.Equal(2, manifest.Contents.Count);
|
||||
Assert.Equal("Pump", manifest.Contents[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
|
||||
{
|
||||
var encryption = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "c2FsdA==",
|
||||
IvB64: "aXY=");
|
||||
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
var manifest = new BundleManifest(
|
||||
BundleFormatVersion: 1,
|
||||
SchemaVersion: "1.0",
|
||||
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
|
||||
SourceEnvironment: "ui",
|
||||
ExportedBy: "bob",
|
||||
ScadaBridgeVersion: "0.9.0",
|
||||
ContentHash: "sha256:abc",
|
||||
Encryption: encryption,
|
||||
Summary: summary,
|
||||
Contents: new List<ManifestContentEntry>
|
||||
{
|
||||
new("Template", "Pump", 7, new List<string> { "dep-a" }),
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<BundleManifest>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(manifest.SourceEnvironment, rt!.SourceEnvironment);
|
||||
Assert.Equal(manifest.ContentHash, rt.ContentHash);
|
||||
Assert.Equal(manifest.Summary, rt.Summary);
|
||||
Assert.Single(rt.Contents);
|
||||
Assert.Equal("Pump", rt.Contents[0].Name);
|
||||
Assert.Equal(7, rt.Contents[0].Version);
|
||||
Assert.NotNull(rt.Encryption);
|
||||
Assert.Equal("AES-256-GCM", rt.Encryption!.Algorithm);
|
||||
Assert.Equal(600_000, rt.Encryption.Iterations);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ExportSelection
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ExportSelection_Constructor_PreservesAllIdLists()
|
||||
{
|
||||
var sel = new ExportSelection(
|
||||
TemplateIds: new[] { 1, 2, 3 },
|
||||
SharedScriptIds: new[] { 10 },
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: new[] { 20, 21 },
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: new[] { 30 },
|
||||
ApiKeyIds: new[] { 40, 41 },
|
||||
ApiMethodIds: new[] { 50 },
|
||||
IncludeDependencies: true);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, sel.TemplateIds);
|
||||
Assert.Single(sel.SharedScriptIds);
|
||||
Assert.Empty(sel.ExternalSystemIds);
|
||||
Assert.Equal(2, sel.DatabaseConnectionIds.Count);
|
||||
Assert.True(sel.IncludeDependencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
|
||||
{
|
||||
var sel = new ExportSelection(
|
||||
TemplateIds: new[] { 1, 2 },
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: new[] { 5 },
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiKeyIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false);
|
||||
|
||||
var json = JsonSerializer.Serialize(sel, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ExportSelection>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(sel.TemplateIds, rt!.TemplateIds);
|
||||
Assert.Equal(sel.ExternalSystemIds, rt.ExternalSystemIds);
|
||||
Assert.False(rt.IncludeDependencies);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ImportPreview
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImportPreview_Constructor_AllowsAllConflictKinds()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var items = new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "Pump", ExistingVersion: 1, IncomingVersion: 1, Kind: ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null),
|
||||
new("Template", "Valve", ExistingVersion: 1, IncomingVersion: 2, Kind: ConflictKind.Modified, FieldDiffJson: "{\"name\":\"Valve\"}", BlockerReason: null),
|
||||
new("Template", "New", ExistingVersion: null, IncomingVersion: 1, Kind: ConflictKind.New, FieldDiffJson: null, BlockerReason: null),
|
||||
new("Template", "Bad", ExistingVersion: 1, IncomingVersion: 5, Kind: ConflictKind.Blocker, FieldDiffJson: null, BlockerReason: "Parameters property mismatch"),
|
||||
};
|
||||
|
||||
var preview = new ImportPreview(sessionId, items);
|
||||
|
||||
Assert.Equal(sessionId, preview.SessionId);
|
||||
Assert.Equal(4, preview.Items.Count);
|
||||
Assert.Equal(ConflictKind.Identical, preview.Items[0].Kind);
|
||||
Assert.Equal(ConflictKind.Modified, preview.Items[1].Kind);
|
||||
Assert.Equal(ConflictKind.New, preview.Items[2].Kind);
|
||||
Assert.Equal(ConflictKind.Blocker, preview.Items[3].Kind);
|
||||
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
|
||||
{
|
||||
var preview = new ImportPreview(
|
||||
SessionId: Guid.NewGuid(),
|
||||
Items: new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "X", 1, 2, ConflictKind.Modified, "{}", null),
|
||||
new("Template", "Y", null, 1, ConflictKind.New, null, null),
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(preview, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ImportPreview>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(preview.SessionId, rt!.SessionId);
|
||||
Assert.Equal(2, rt.Items.Count);
|
||||
Assert.Equal(ConflictKind.Modified, rt.Items[0].Kind);
|
||||
Assert.Equal(ConflictKind.New, rt.Items[1].Kind);
|
||||
Assert.Null(rt.Items[1].ExistingVersion);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ImportResolution
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(ResolutionAction.Add, null)]
|
||||
[InlineData(ResolutionAction.Overwrite, null)]
|
||||
[InlineData(ResolutionAction.Skip, null)]
|
||||
[InlineData(ResolutionAction.Rename, "NewName")]
|
||||
public void ImportResolution_Constructor_PreservesAllActions(ResolutionAction action, string? renameTo)
|
||||
{
|
||||
var res = new ImportResolution("Template", "Pump", action, renameTo);
|
||||
Assert.Equal("Template", res.EntityType);
|
||||
Assert.Equal("Pump", res.Name);
|
||||
Assert.Equal(action, res.Action);
|
||||
Assert.Equal(renameTo, res.RenameTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
|
||||
{
|
||||
var res = new ImportResolution("Template", "Pump", ResolutionAction.Rename, "Pump_v2");
|
||||
|
||||
var json = JsonSerializer.Serialize(res, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ImportResolution>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(ResolutionAction.Rename, rt!.Action);
|
||||
Assert.Equal("Pump_v2", rt.RenameTo);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ImportResult
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
|
||||
{
|
||||
var bundleImportId = Guid.NewGuid();
|
||||
var result = new ImportResult(
|
||||
BundleImportId: bundleImportId,
|
||||
Added: 3,
|
||||
Overwritten: 1,
|
||||
Skipped: 2,
|
||||
Renamed: 1,
|
||||
StaleInstanceIds: new List<int> { 100, 200, 300 },
|
||||
AuditEventCorrelation: "audit-corr-001");
|
||||
|
||||
Assert.Equal(bundleImportId, result.BundleImportId);
|
||||
Assert.Equal(3, result.Added);
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
Assert.Equal(2, result.Skipped);
|
||||
Assert.Equal(1, result.Renamed);
|
||||
Assert.Equal(new[] { 100, 200, 300 }, result.StaleInstanceIds);
|
||||
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
|
||||
{
|
||||
var result = new ImportResult(
|
||||
BundleImportId: Guid.NewGuid(),
|
||||
Added: 5,
|
||||
Overwritten: 0,
|
||||
Skipped: 0,
|
||||
Renamed: 0,
|
||||
StaleInstanceIds: Array.Empty<int>(),
|
||||
AuditEventCorrelation: "audit-corr-xyz");
|
||||
|
||||
var json = JsonSerializer.Serialize(result, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ImportResult>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(result.BundleImportId, rt!.BundleImportId);
|
||||
Assert.Equal(5, rt.Added);
|
||||
Assert.Empty(rt.StaleInstanceIds);
|
||||
Assert.Equal("audit-corr-xyz", rt.AuditEventCorrelation);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Record equality sanity (catches positional/tuple slip)
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TransportRecords_RecordValueEquality()
|
||||
{
|
||||
var a = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
|
||||
var b = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var c = a with { Action = ResolutionAction.Overwrite };
|
||||
Assert.NotEqual(a, c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ValueFormatter"/>. Includes the Commons-012 regression:
|
||||
/// formatting must be culture-invariant because the formatter feeds non-UI contexts
|
||||
/// (gRPC stream events, logs) where locale-dependent output would be inconsistent.
|
||||
/// </summary>
|
||||
public class ValueFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void FormatDisplayValue_Null_ReturnsEmptyString()
|
||||
{
|
||||
Assert.Equal("", ValueFormatter.FormatDisplayValue(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_String_ReturnsValueUnchanged()
|
||||
{
|
||||
Assert.Equal("hello", ValueFormatter.FormatDisplayValue("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_Collection_JoinsWithComma()
|
||||
{
|
||||
Assert.Equal("1,2,3", ValueFormatter.FormatDisplayValue(new[] { 1, 2, 3 }));
|
||||
}
|
||||
|
||||
// ── Commons-012 regression: culture-invariant numeric/date formatting ──
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_Double_UsesInvariantCulture_RegardlessOfThreadCulture()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
// German uses a comma as the decimal separator; invariant uses a dot.
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("3.14", ValueFormatter.FormatDisplayValue(3.14));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_DateTime_UsesInvariantCulture_RegardlessOfThreadCulture()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
var dt = new DateTime(2026, 5, 16, 0, 0, 0, DateTimeKind.Utc);
|
||||
var invariant = dt.ToString(CultureInfo.InvariantCulture);
|
||||
Assert.Equal(invariant, ValueFormatter.FormatDisplayValue(dt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_CollectionOfDoubles_UsesInvariantCulture()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("1.5,2.5", ValueFormatter.FormatDisplayValue(new[] { 1.5, 2.5 }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user