diff --git a/src/ServiceLayer.Mesh/Configuration/AppConfiguration.cs b/src/ServiceLayer.Mesh/Configuration/AppConfiguration.cs index 15219c25..82081a9f 100644 --- a/src/ServiceLayer.Mesh/Configuration/AppConfiguration.cs +++ b/src/ServiceLayer.Mesh/Configuration/AppConfiguration.cs @@ -6,7 +6,8 @@ public class AppConfiguration : IFileTransformQueueClientConfiguration, IFileTransformFunctionConfiguration, IFileRetryFunctionConfiguration, - IMeshHandshakeFunctionConfiguration + IMeshHandshakeFunctionConfiguration, + IValidationRunnerConfiguration { public string NbssMeshMailboxId => GetRequired("NbssMailboxId"); @@ -14,24 +15,12 @@ public class AppConfiguration : public string FileTransformQueueName => GetRequired("FileTransformQueueName"); - public int StaleHours => GetRequiredInt("StaleHours"); + public int MaximumValidationErrors => GetOptionalInt("MaximumValidationErrors", 100); - private static string GetRequired(string key) - { - var value = EnvironmentVariables.GetRequired(key); + public int StaleHours => GetOptionalInt("StaleHours", 12); - return value; - } - - private static int GetRequiredInt(string key) - { - var value = GetRequired(key); - - if (!int.TryParse(value, out var intValue)) - { - throw new InvalidOperationException($"Environment variable '{key}' is not a valid integer"); - } - - return intValue; - } + private static string GetRequired(string key) => + EnvironmentVariables.GetRequired(key); + private static int GetOptionalInt(string key, int defaultValue) => + EnvironmentVariables.GetOptionalInt(key, defaultValue); } diff --git a/src/ServiceLayer.Mesh/Configuration/IValidationRunnerConfiguration.cs b/src/ServiceLayer.Mesh/Configuration/IValidationRunnerConfiguration.cs new file mode 100644 index 00000000..14608814 --- /dev/null +++ b/src/ServiceLayer.Mesh/Configuration/IValidationRunnerConfiguration.cs @@ -0,0 +1,6 @@ +namespace ServiceLayer.Mesh.Configuration; + +public interface IValidationRunnerConfiguration +{ + int MaximumValidationErrors { get; } +} diff --git a/src/ServiceLayer.Mesh/Configuration/ServiceCollectionExtensions.cs b/src/ServiceLayer.Mesh/Configuration/ServiceCollectionExtensions.cs index 8361f313..c20b5bec 100644 --- a/src/ServiceLayer.Mesh/Configuration/ServiceCollectionExtensions.cs +++ b/src/ServiceLayer.Mesh/Configuration/ServiceCollectionExtensions.cs @@ -6,12 +6,16 @@ internal static class ServiceCollectionExtensions { internal static IServiceCollection AddApplicationConfiguration(this IServiceCollection services) { - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + var implementationType = typeof(AppConfiguration); + + var interfaces = implementationType + .GetInterfaces() + .Where(i => i.Namespace == implementationType.Namespace); + + foreach (var serviceType in interfaces) + { + services.AddTransient(serviceType, implementationType); + } return services; } diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ErrorCodes.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ErrorCodes.cs index a0473b3c..e1f84d17 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ErrorCodes.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ErrorCodes.cs @@ -71,4 +71,5 @@ public static class ErrorCodes public const string MissingActionTimestamp = "NBSSAPPT067"; public const string InvalidActionTimestamp = "NBSSAPPT068"; public const string UnknownRecordTypeIdentifier = "NBSSAPPT069"; + public const string ValidationAborted = "NBSSAPPT999"; } diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidationRunner.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidationRunner.cs index 351c24c1..eccd190c 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidationRunner.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidationRunner.cs @@ -1,8 +1,10 @@ +using ServiceLayer.Mesh.Configuration; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; public class ValidationRunner( + IValidationRunnerConfiguration configuration, IEnumerable fileValidators, IEnumerable recordValidators) : IValidationRunner @@ -11,18 +13,61 @@ public IList Validate(ParsedFile file) { var errors = new List(); + RunFileValidators(file, errors); + if (errors.Count >= configuration.MaximumValidationErrors) + { + return FinalizeEarly(errors); + } + + RunRecordValidators(file, errors); + if (errors.Count >= configuration.MaximumValidationErrors) + { + return FinalizeEarly(errors); + } + + return errors; + } + + private void RunFileValidators(ParsedFile file, List errors) + { foreach (var validator in fileValidators) { - errors.AddRange(validator.Validate(file)); + var results = validator.Validate(file); + AddErrorsWithCap(results, errors); + if (errors.Count >= configuration.MaximumValidationErrors) return; } + } - foreach (var dataRecord in file.DataRecords) + private void RunRecordValidators(ParsedFile file, List errors) + { + foreach (var record in file.DataRecords) { - foreach (var recordValidator in recordValidators) + foreach (var validator in recordValidators) { - errors.AddRange(recordValidator.Validate(dataRecord)); + var results = validator.Validate(record); + AddErrorsWithCap(results, errors); + if (errors.Count >= configuration.MaximumValidationErrors) return; } } + } + + private void AddErrorsWithCap(IEnumerable newErrors, List existingErrors) + { + foreach (var error in newErrors) + { + if (existingErrors.Count >= configuration.MaximumValidationErrors) break; + existingErrors.Add(error); + } + } + + private List FinalizeEarly(List errors) + { + errors.Add(new ValidationError + { + Code = ErrorCodes.ValidationAborted, + Error = $"Validation aborted after {configuration.MaximumValidationErrors} errors encountered", + Scope = ValidationErrorScope.File + }); return errors; } diff --git a/src/ServiceLayer.Mesh/ValidationError.cs b/src/ServiceLayer.Mesh/ValidationError.cs index 2f6cd722..702daaa0 100644 --- a/src/ServiceLayer.Mesh/ValidationError.cs +++ b/src/ServiceLayer.Mesh/ValidationError.cs @@ -4,7 +4,7 @@ public class ValidationError { public int? RowNumber { get; set; } - public required string Field { get; set; } + public string? Field { get; set; } public required string Code { get; set; } diff --git a/src/ServiceLayer.Shared/EnvironmentVariables.cs b/src/ServiceLayer.Shared/EnvironmentVariables.cs index 1137b060..fdf10d70 100644 --- a/src/ServiceLayer.Shared/EnvironmentVariables.cs +++ b/src/ServiceLayer.Shared/EnvironmentVariables.cs @@ -19,4 +19,33 @@ public static string GetRequired(string key) return value; } + + public static int GetRequiredInt(string key) + { + var value = GetRequired(key); + + if (!int.TryParse(value, out var intValue)) + { + throw new InvalidOperationException($"Environment variable '{key}' is not a valid integer"); + } + + return intValue; + } + + public static int GetOptionalInt(string key, int defaultValue) + { + var value = Environment.GetEnvironmentVariable(key); + + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (!int.TryParse(value, out var intValue)) + { + throw new InvalidOperationException($"Environment variable '{key}' is not a valid integer"); + } + + return intValue; + } } diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationRunnerTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationRunnerTests.cs index 329e020a..90ee4b2d 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationRunnerTests.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationRunnerTests.cs @@ -1,4 +1,5 @@ using Moq; +using ServiceLayer.Mesh.Configuration; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; @@ -6,6 +7,13 @@ namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents.Validation; public class ValidationRunnerTests { + private readonly Mock _configurationMock = new(); + + public ValidationRunnerTests() + { + _configurationMock.Setup(c => c.MaximumValidationErrors).Returns(100); + } + [Fact] public void Validate_FileAndRecordValidatorsReturnErrors_ReturnsAllErrors() { @@ -50,6 +58,7 @@ public void Validate_FileAndRecordValidatorsReturnErrors_ReturnsAllErrors() .Returns([recordValidationError2, recordValidationError3]); var runner = new ValidationRunner( + _configurationMock.Object, [fileValidator1.Object, fileValidator2.Object], [recordValidator1.Object, recordValidator2.Object]); @@ -100,6 +109,7 @@ public void Validate_OnlyRecordValidatorsReturnErrors_ReturnsAllErrors() .Returns([recordValidationError2, recordValidationError3]); var runner = new ValidationRunner( + _configurationMock.Object, [fileValidator1.Object, fileValidator2.Object], [recordValidator1.Object, recordValidator2.Object]); @@ -149,6 +159,7 @@ public void Validate_OnlyFileValidatorsReturnErrors_ReturnsAllErrors() .Returns([]); var runner = new ValidationRunner( + _configurationMock.Object, [fileValidator1.Object, fileValidator2.Object], [recordValidator1.Object, recordValidator2.Object]); @@ -189,6 +200,7 @@ public void Validate_FileAndRecordValidatorsReturnNoErrors_ReturnsNoErrors() .Returns([]); var runner = new ValidationRunner( + _configurationMock.Object, [fileValidator1.Object, fileValidator2.Object], [recordValidator1.Object, recordValidator2.Object]); @@ -199,6 +211,162 @@ public void Validate_FileAndRecordValidatorsReturnNoErrors_ReturnsNoErrors() Assert.Empty(results); } + [Theory] + [InlineData(19, 10, 10, true, 20)] + [InlineData(20, 10, 10, true, 21)] + [InlineData(21, 10, 10, false, 20)] + [InlineData(100, 49, 50, false, 99)] + [InlineData(100, 100, 0, true, 101)] + [InlineData(100, 0, 100, true, 101)] + [InlineData(100, 100, 100, true, 101)] + [InlineData(100, 200, 200, true, 101)] + public void Validate_TooManyFileValidationErrors_ReturnsFirstErrorsPlusIndicationOfEarlyTermination( + int maximumErrorCount, int validator1ErrorCount, int validator2ErrorCount, bool expectAborted, + int expectedErrorCount) + { + // Arrange + var file = new ParsedFile + { + DataRecords = [new FileDataRecord(), new FileDataRecord()] + }; + + var fileValidator1 = new Mock(); + fileValidator1 + .Setup(v => v.Validate(file)) + .Returns(BuildValidationErrors(ValidationErrorScope.File, validator1ErrorCount)); + var fileValidator2 = new Mock(); + fileValidator2 + .Setup(v => v.Validate(file)) + .Returns(BuildValidationErrors(ValidationErrorScope.File, validator2ErrorCount)); + + _configurationMock.Setup(c => c.MaximumValidationErrors) + .Returns(maximumErrorCount); + + var runner = new ValidationRunner( + _configurationMock.Object, + [fileValidator1.Object, fileValidator2.Object], []); + + // Act + var results = runner.Validate(file); + + // Assert + Assert.Equal(expectedErrorCount, results.Count); + if (expectAborted) + { + AssertContainsValidationAbortedError(results, maximumErrorCount); + } + else + { + AssertDoesNotContainValidationAbortedError(results, maximumErrorCount); + } + } + + [Theory] + [InlineData(39, 10, 10, true, 40)] + [InlineData(40, 10, 10, true, 41)] + [InlineData(41, 10, 10, false, 40)] + [InlineData(100, 24, 25, false, 98)] + [InlineData(100, 25, 25, true, 101)] + [InlineData(100, 50, 0, true, 101)] + [InlineData(100, 0, 50, true, 101)] + [InlineData(100, 50, 50, true, 101)] + [InlineData(100, 100, 100, true, 101)] + public void Validate_TooManyRecordValidatorErrors_ReturnsFirstErrorsPlusIndicationOfEarlyTermination( + int maximumErrorCount, int validator1ErrorCount, int validator2ErrorCount, bool expectAborted, + int expectedErrorCount) + { + // Arrange + var file = new ParsedFile + { + DataRecords = [new FileDataRecord(), new FileDataRecord()] + }; + + var recordValidator1 = new Mock(); + recordValidator1 + .Setup(v => v.Validate(It.IsAny())) + .Returns(BuildValidationErrors(ValidationErrorScope.File, validator1ErrorCount)); + var recordValidator2 = new Mock(); + recordValidator2 + .Setup(v => v.Validate(It.IsAny())) + .Returns(BuildValidationErrors(ValidationErrorScope.File, validator2ErrorCount)); + + _configurationMock.Setup(c => c.MaximumValidationErrors) + .Returns(maximumErrorCount); + + var runner = new ValidationRunner(_configurationMock.Object, + [], [recordValidator1.Object, recordValidator2.Object]); + + // Act + var results = runner.Validate(file); + + // Assert + Assert.Equal(expectedErrorCount, results.Count); + if (expectAborted) + { + AssertContainsValidationAbortedError(results, maximumErrorCount); + } + else + { + AssertDoesNotContainValidationAbortedError(results, maximumErrorCount); + } + } + + [Theory] + [InlineData(29, 10, 10, true, 30)] + [InlineData(30, 10, 10, true, 31)] + [InlineData(31, 10, 10, false, 30)] + [InlineData(100, 49, 25, false, 99)] + [InlineData(100, 50, 25, true, 101)] + [InlineData(100, 100, 0, true, 101)] + [InlineData(100, 0, 50, true, 101)] + [InlineData(100, 100, 50, true, 101)] + [InlineData(100, 200, 100, true, 101)] + public void Validate_TooManyValidatorErrors_ReturnsFirstErrorsPlusIndicationOfEarlyTermination( + int maximumErrorCount, int fileValidatorErrorCount, int recordValidatorErrorCount, bool expectAborted, + int expectedErrorCount) + { + // Arrange + var file = new ParsedFile + { + DataRecords = [new FileDataRecord(), new FileDataRecord()] + }; + + var fileValidator = new Mock(); + fileValidator + .Setup(v => v.Validate(file)) + .Returns(BuildValidationErrors(ValidationErrorScope.File, fileValidatorErrorCount)); + var recordValidator = new Mock(); + recordValidator + .Setup(v => v.Validate(It.IsAny())) + .Returns(BuildValidationErrors(ValidationErrorScope.File, recordValidatorErrorCount)); + + _configurationMock.Setup(c => c.MaximumValidationErrors) + .Returns(maximumErrorCount); + + var runner = new ValidationRunner(_configurationMock.Object, + [fileValidator.Object], [recordValidator.Object]); + + // Act + var results = runner.Validate(file); + + // Assert + Assert.Equal(expectedErrorCount, results.Count); + if (expectAborted) + { + AssertContainsValidationAbortedError(results, maximumErrorCount); + } + else + { + AssertDoesNotContainValidationAbortedError(results, maximumErrorCount); + } + } + + private static IEnumerable BuildValidationErrors(ValidationErrorScope scope, int count) + { + return Enumerable.Range(0, count) + .Select(_ => BuildValidationError(scope)); + } + private static ValidationError BuildValidationError(ValidationErrorScope scope) { var validationError = new ValidationError @@ -216,4 +384,26 @@ private static ValidationError BuildValidationError(ValidationErrorScope scope) return validationError; } + + private static void AssertContainsValidationAbortedError(IList errors, int maximumErrors) + { + Assert.Contains(errors, BuildValidationAbortedPredicate(maximumErrors)); + } + + private static void AssertDoesNotContainValidationAbortedError(IList errors, int maximumErrors) + { + Assert.DoesNotContain(errors, BuildValidationAbortedPredicate(maximumErrors)); + } + + private static Predicate BuildValidationAbortedPredicate(int maximumErrors) + { + var errorMessage = $"Validation aborted after {maximumErrors} errors encountered"; + + return e => + e.Code == ErrorCodes.ValidationAborted && + e.Error == errorMessage && + e.Scope == ValidationErrorScope.File && + e.Field is null && + e.RowNumber is null; + } } diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationTestBase.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationTestBase.cs index f9c4385b..9780924c 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationTestBase.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationTestBase.cs @@ -1,3 +1,5 @@ +using Moq; +using ServiceLayer.Mesh.Configuration; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; @@ -5,6 +7,8 @@ namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents.Validation; public abstract class ValidationTestBase { + private readonly Mock _configurationMock = new(); + protected readonly ValidationRunner SystemUnderTest; protected ValidationTestBase() @@ -12,7 +16,9 @@ protected ValidationTestBase() var recordValidators = ValidatorRegistry.GetAllRecordValidators(); var fileValidators = ValidatorRegistry.GetAllFileValidators(); - SystemUnderTest = new ValidationRunner(fileValidators, recordValidators); + _configurationMock.Setup(c => c.MaximumValidationErrors).Returns(100); + + SystemUnderTest = new ValidationRunner(_configurationMock.Object, fileValidators, recordValidators); } protected static ParsedFile ValidParsedFile =>