using ScadaLink.Commons.Types.DataConnections; using ScadaLink.Commons.Types.Flattening; using ScadaLink.Commons.Validators; namespace ScadaLink.Commons.Tests.Validators; public class OpcUaEndpointConfigValidatorTests { private static OpcUaEndpointConfig Valid() => new() { EndpointUrl = "opc.tcp://plant-a:4840", // Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10). }; [Fact] public void Validate_DefaultsWithValidUrl_IsValid() { var result = OpcUaEndpointConfigValidator.Validate(Valid()); Assert.True(result.IsValid); Assert.Empty(result.Errors); } [Fact] public void Validate_MissingEndpointUrl_Fails() { var c = Valid(); c.EndpointUrl = ""; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.False(r.IsValid); Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl" && e.Category == ValidationCategory.ConnectionConfig && e.Message.Contains("required", StringComparison.OrdinalIgnoreCase)); } [Theory] [InlineData("http://x")] [InlineData("opc.tcp://")] [InlineData("not a url")] public void Validate_BadEndpointUrl_Fails(string url) { var c = Valid(); c.EndpointUrl = url; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.False(r.IsValid); Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl"); } [Fact] public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails() { var c = Valid(); c.KeepAliveCount = 10; c.LifetimeCount = 29; // 3*10 = 30; 29 < 30 → invalid var r = OpcUaEndpointConfigValidator.Validate(c); Assert.False(r.IsValid); Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount"); } [Theory] [InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))] [InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))] [InlineData(nameof(OpcUaEndpointConfig.PublishingIntervalMs))] [InlineData(nameof(OpcUaEndpointConfig.SamplingIntervalMs))] public void Validate_NonPositiveTiming_Fails(string field) { var c = Valid(); typeof(OpcUaEndpointConfig).GetProperty(field)!.SetValue(c, 0); var r = OpcUaEndpointConfigValidator.Validate(c); Assert.False(r.IsValid); Assert.Contains(r.Errors, e => e.EntityName == field); } [Fact] public void Validate_QueueSizeZero_Fails() { var c = Valid(); c.QueueSize = 0; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "QueueSize"); } [Fact] public void Validate_HeartbeatEnabledNoTagPath_Fails() { var c = Valid(); c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "", MaxSilenceSeconds = 30 }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath"); } [Fact] public void Validate_HeartbeatNonPositiveSilence_Fails() { var c = Valid(); c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 0 }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds"); } [Fact] public void Validate_FieldPrefix_AppliedToEveryError() { var c = Valid(); c.EndpointUrl = ""; c.QueueSize = 0; var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary."); Assert.All(r.Errors, e => Assert.StartsWith("Primary.", e.EntityName!)); Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl"); Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize"); } // ── Layer B extensions: auth, deadband, subscription display name ── [Fact] public void Validate_EmptySubscriptionDisplayName_Fails() { var c = Valid(); c.SubscriptionDisplayName = ""; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "SubscriptionDisplayName"); } [Fact] public void Validate_UserIdentityAnonymous_NoExtraFieldsRequired() { var c = Valid(); c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.Anonymous }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.True(r.IsValid); } [Fact] public void Validate_UsernamePasswordWithoutUsername_Fails() { var c = Valid(); c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.UsernamePassword, Username = "", Password = "secret" }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.Username"); } [Fact] public void Validate_X509WithoutCertificatePath_Fails() { var c = Valid(); c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.X509Certificate, CertificatePath = "" }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.CertificatePath"); } [Fact] public void Validate_UsernamePasswordWithUsername_Passes() { var c = Valid(); c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.UsernamePassword, Username = "alice", Password = "" }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.True(r.IsValid); } [Fact] public void Validate_DeadbandWithNonPositiveValue_Fails() { var c = Valid(); c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Absolute, Value = 0 }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.Contains(r.Errors, e => e.EntityName == "Deadband.Value"); } [Fact] public void Validate_DeadbandWithPositiveValue_Passes() { var c = Valid(); c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }; var r = OpcUaEndpointConfigValidator.Validate(c); Assert.True(r.IsValid); } [Fact] public void Validate_UserIdentityErrorsPrefixed_WithFieldPrefix() { var c = Valid(); c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.UsernamePassword, Username = "" }; var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary."); Assert.Contains(r.Errors, e => e.EntityName == "Primary.UserIdentity.Username"); } }