From a5f0c200864a5c108524bdcd78e8c3e09f49e273 Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Mon, 2 Jun 2025 16:53:57 +0100 Subject: [PATCH 1/3] feat: DTOSS-9159 file-level validation of NBSS Appointment event files --- .../Validation/FileValidator.cs | 122 ++++++++++ .../Validation/HeaderFieldRegexValidator.cs | 43 ++++ .../Validation/ValidatorRegistry.cs | 3 +- src/ServiceLayer.Mesh/ValidationError.cs | 2 +- .../NbssAppointmentEvents/TestDataBuilder.cs | 4 +- .../Validation/FileValidatorTests.cs | 211 ++++++++++++++++++ .../Validation/ValidationErrorAssertions.cs | 2 +- 7 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs create mode 100644 src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidator.cs create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/FileValidatorTests.cs diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs new file mode 100644 index 00000000..702cba78 --- /dev/null +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs @@ -0,0 +1,122 @@ +using System.Text.RegularExpressions; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; + +namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; + +public partial class FileValidator : IFileValidator +{ + private readonly HeaderFieldRegexValidator _headerExtractIdValidator = new( + x => x.ExtractId, "Extract ID", ExtractIdRegex(), + ErrorCodes.MissingExtractId, ErrorCodes.InvalidExtractId); + + private readonly HeaderFieldRegexValidator _headerIdRecordCountValidator = new( + x => x.RecordCount, "Record count", RecordCountRegex(), + ErrorCodes.MissingRecordCount, ErrorCodes.InvalidRecordCount); + + public IEnumerable Validate(ParsedFile file) + { + foreach (var error in ValidateHeaderPresence(file)) + { + yield return error; + } + + foreach (var error in ValidateTrailerPresence(file)) + { + yield return error; + } + + foreach (var error in ValidateExtractId(file)) + { + yield return error; + } + + foreach (var error in ValidateRecordCount(file)) + { + yield return error; + } + } + + private static IEnumerable ValidateHeaderPresence(ParsedFile file) + { + if (file.FileHeader == null) + { + yield return new ValidationError + { + Code = ErrorCodes.MissingHeader, + Error = "Header is missing", + Scope = ValidationErrorScope.File + }; + } + } + + private static IEnumerable ValidateTrailerPresence(ParsedFile file) + { + if (file.FileTrailer == null) + { + yield return new ValidationError + { + Code = ErrorCodes.MissingTrailer, + Error = "Trailer is missing", + Scope = ValidationErrorScope.File + }; + } + } + + private IEnumerable ValidateExtractId(ParsedFile file) + { + if (file.FileHeader == null) yield break; + + foreach (var error in _headerExtractIdValidator.Validate(file)) + { + yield return error; + } + + if (file.FileTrailer != null && file.FileHeader.ExtractId != file.FileTrailer.ExtractId) + { + yield return new ValidationError + { + Field = "Extract ID", + Code = ErrorCodes.InconsistentExtractId, + Error = "Extract ID does not match value in header", + Scope = ValidationErrorScope.Trailer + }; + } + } + + private IEnumerable ValidateRecordCount(ParsedFile file) + { + if (file.FileHeader == null) yield break; + + var headerRecordCountErrors = _headerIdRecordCountValidator.Validate(file).ToList(); + + foreach (var error in headerRecordCountErrors) + { + yield return error; + } + + if (file.FileTrailer != null && file.FileHeader.RecordCount != file.FileTrailer.RecordCount) + { + yield return new ValidationError + { + Field = "Record count", + Code = ErrorCodes.InconsistentRecordCount, + Error = "Record count does not match value in header", + Scope = ValidationErrorScope.Trailer + }; + } else if (headerRecordCountErrors.Count == 0 && file.DataRecords.Count != int.Parse(file.FileHeader.RecordCount!)) + { + yield return new ValidationError + { + Code = ErrorCodes.UnexpectedRecordCount, + Error = "Record count does not match value in header and trailer", + Scope = ValidationErrorScope.File + }; + } + } + + [GeneratedRegex(@"^\d{8}$")] + private static partial Regex ExtractIdRegex(); + + [GeneratedRegex(@"^(?!000000)\d{6}$")] + private static partial Regex RecordCountRegex(); +} diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidator.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidator.cs new file mode 100644 index 00000000..de3bce52 --- /dev/null +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidator.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Text.RegularExpressions; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; + +namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; + +public class HeaderFieldRegexValidator( + Expression> fieldSelector, + string fieldName, + Regex pattern, + string errorCodeMissing, + string errorCodeInvalidFormat) + : IFileValidator +{ + public IEnumerable Validate(ParsedFile file) + { + var header = file.FileHeader!; + var value = fieldSelector.Compile().Invoke(header); + + if (value == null) + { + yield return new ValidationError + { + Scope = ValidationErrorScope.Header, + Field = fieldName, + Error = $"{fieldName} is missing", + Code = errorCodeMissing, + }; + yield break; + } + + if (!pattern.IsMatch(value)) + { + yield return new ValidationError + { + Scope = ValidationErrorScope.Header, + Field = fieldName, + Error = $"{fieldName} is in an invalid format", + Code = errorCodeInvalidFormat, + }; + } + } +} diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidatorRegistry.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidatorRegistry.cs index 0847dc00..4c8bed53 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidatorRegistry.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidatorRegistry.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices.JavaScript; using System.Text.RegularExpressions; namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; @@ -69,7 +68,7 @@ public static IEnumerable GetAllRecordValidators() public static IEnumerable GetAllFileValidators() { - return []; + return [new FileValidator()]; } [GeneratedRegex(@"^[BCU]$", RegexOptions.Compiled)] 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/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestDataBuilder.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestDataBuilder.cs index ecbc236a..b91eaa4e 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestDataBuilder.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestDataBuilder.cs @@ -12,7 +12,7 @@ public static ParsedFile BuildValidParsedFile(int numberOfRecords = 3) ExtractId = "00000107", TransferStartDate = "20250519", TransferStartTime = "153806", - RecordCount = numberOfRecords.ToString() + RecordCount = numberOfRecords.ToString("D6") }; var trailer = new FileTrailerRecord @@ -21,7 +21,7 @@ public static ParsedFile BuildValidParsedFile(int numberOfRecords = 3) ExtractId = "00000107", TransferEndDate = "20250519", TransferEndTime = "153957", - RecordCount = numberOfRecords.ToString() + RecordCount = numberOfRecords.ToString("D6") }; var dataRecords = Enumerable.Range(1, numberOfRecords) diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/FileValidatorTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/FileValidatorTests.cs new file mode 100644 index 00000000..e0868466 --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/FileValidatorTests.cs @@ -0,0 +1,211 @@ +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; + +namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents.Validation; + +public class FileValidatorTests : ValidationTestBase +{ + [Fact] + public void Validate_HeaderMissing_ReturnsValidationError() + { + // Arrange + var file = ValidParsedFile; + file.FileHeader = null; + + // Act + var validationErrors = Validate(file); + + // Assert + validationErrors.ShouldContainValidationError( + null, + "Header is missing", + ErrorCodes.MissingHeader, + ValidationErrorScope.File + ); + } + + [Fact] + public void Validate_TrailerMissing_ReturnsValidationError() + { + // Arrange + var file = ValidParsedFile; + file.FileTrailer = null; + + // Act + var validationErrors = Validate(file); + + // Assert + validationErrors.ShouldContainValidationError( + null, + "Trailer is missing", + ErrorCodes.MissingTrailer, + ValidationErrorScope.File + ); + } + + [Fact] + public void Validate_HeaderExtractIdMissing_ReturnsValidationError() + { + // Arrange + var file = ValidParsedFile; + file.FileHeader!.ExtractId = null; + + // Act + var validationErrors = Validate(file); + + // Assert + validationErrors.ShouldContainValidationError( + "Extract ID", + "Extract ID is missing", + ErrorCodes.MissingExtractId, + ValidationErrorScope.Header + ); + } + + [Theory] + [InlineData("1")] // Missing leading zeroes + [InlineData("100000000")] // Too large + [InlineData("")] // Blank + [InlineData("asdf")] // NaN + public void Validate_HeaderExtractIdInvalidFormat_ReturnsValidationError(string value) + { + // Arrange + var file = ValidParsedFile; + file.FileHeader!.ExtractId = value; + + // Act + var validationErrors = Validate(file).ToList(); + + // Assert + validationErrors.ShouldContainValidationError( + "Extract ID", + "Extract ID is in an invalid format", + ErrorCodes.InvalidExtractId, + ValidationErrorScope.Header + ); + } + + [Theory] + [InlineData("00000000")] + [InlineData("00000001")] + [InlineData("99999999")] + public void Validate_HeaderExtractIdValidFormat_NoValidationErrorsReturned(string value) + { + // Arrange + var file = ValidParsedFile; + file.FileHeader!.ExtractId = value; + file.FileTrailer!.ExtractId = value; + + // Act + var validationErrors = Validate(file).ToList(); + + // Assert + Assert.Empty(validationErrors); + } + + [Fact] + public void Validate_HeaderRecordCountMissing_ReturnsValidationError() + { + // Arrange + var file = ValidParsedFile; + file.FileHeader!.RecordCount = null; + + // Act + var validationErrors = Validate(file); + + // Assert + validationErrors.ShouldContainValidationError( + "Record count", + "Record count is missing", + ErrorCodes.MissingRecordCount, + ValidationErrorScope.Header + ); + } + + [Theory] + [InlineData("1")] // Missing leading zeroes + [InlineData("000000")] // All zeroes + [InlineData("1000000")] // Too large + [InlineData("")] // Blank + [InlineData("asdf")] // NaN + public void Validate_HeaderRecordCountInvalidFormat_ReturnsValidationError(string value) + { + // Arrange + var file = ValidParsedFile; + file.FileHeader!.RecordCount = value; + + // Act + var validationErrors = Validate(file).ToList(); + + // Assert + validationErrors.ShouldContainValidationError( + "Record count", + "Record count is in an invalid format", + ErrorCodes.InvalidRecordCount, + ValidationErrorScope.Header + ); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("00000108")] + public void Validate_TrailerExtractIdMismatch_ReturnsValidationError(string? value) + { + // Arrange + var file = ValidParsedFile; + file.FileTrailer!.ExtractId = value; + + // Act + var validationErrors = Validate(file).ToList(); + + // Assert + validationErrors.ShouldContainValidationError( + "Extract ID", + "Extract ID does not match value in header", + ErrorCodes.InconsistentExtractId, + ValidationErrorScope.Trailer + ); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("000002")] + [InlineData("000004")] + public void Validate_TrailerRecordCountMismatch_ReturnsValidationError(string? value) + { + // Arrange + var file = ValidParsedFile; + file.FileTrailer!.RecordCount = value; + + // Act + var validationErrors = Validate(file).ToList(); + + // Assert + validationErrors.ShouldContainValidationError( + "Record count", + "Record count does not match value in header", + ErrorCodes.InconsistentRecordCount, + ValidationErrorScope.Trailer + ); + } + + [Fact] + public void Validate_UnexpectedRecordCount_ReturnsValidationError() + { + // Arrange + var file = ValidParsedFile; + file.DataRecords.RemoveAt(0); + + // Act + var validationErrors = Validate(file).ToList(); + + // Assert + validationErrors.ShouldContainValidationError( + null, + "Record count does not match value in header and trailer", + ErrorCodes.UnexpectedRecordCount, + ValidationErrorScope.File + ); + } +} diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationErrorAssertions.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationErrorAssertions.cs index 3d125a74..21ab6aa6 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationErrorAssertions.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/ValidationErrorAssertions.cs @@ -4,7 +4,7 @@ public static class ValidationErrorAssertions { public static void ShouldContainValidationError( this IEnumerable errors, - string expectedField, + string? expectedField, string expectedError, string expectedCode, ValidationErrorScope expectedScope = ValidationErrorScope.Record, From 0da3775d54f702ff677e0f6fcd353913544985bf Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 3 Jun 2025 08:58:04 +0100 Subject: [PATCH 2/3] Refactoring --- .../Validation/FileValidator.cs | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs index 702cba78..c8189144 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/FileValidator.cs @@ -15,25 +15,10 @@ public partial class FileValidator : IFileValidator public IEnumerable Validate(ParsedFile file) { - foreach (var error in ValidateHeaderPresence(file)) - { - yield return error; - } - - foreach (var error in ValidateTrailerPresence(file)) - { - yield return error; - } - - foreach (var error in ValidateExtractId(file)) - { - yield return error; - } - - foreach (var error in ValidateRecordCount(file)) - { - yield return error; - } + return ValidateHeaderPresence(file) + .Concat(ValidateTrailerPresence(file)) + .Concat(ValidateExtractId(file)) + .Concat(ValidateRecordCount(file)); } private static IEnumerable ValidateHeaderPresence(ParsedFile file) @@ -103,7 +88,9 @@ private IEnumerable ValidateRecordCount(ParsedFile file) Error = "Record count does not match value in header", Scope = ValidationErrorScope.Trailer }; - } else if (headerRecordCountErrors.Count == 0 && file.DataRecords.Count != int.Parse(file.FileHeader.RecordCount!)) + } + else if (headerRecordCountErrors.Count == 0 && + file.DataRecords.Count != int.Parse(file.FileHeader.RecordCount!)) { yield return new ValidationError { From 38444b59f04042beee2464216cb3769df03b921f Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 3 Jun 2025 09:29:01 +0100 Subject: [PATCH 3/3] Added unit tests for HeaderFieldRegexValidator --- .../HeaderFieldRegexValidatorTests.cs | 67 +++++++++++++++++++ .../Validation/RegexValidatorTests.cs | 3 + 2 files changed, 70 insertions(+) create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidatorTests.cs diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidatorTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidatorTests.cs new file mode 100644 index 00000000..1e2875cc --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/HeaderFieldRegexValidatorTests.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; + +namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents.Validation; + +public partial class HeaderFieldRegexValidatorTests +{ + private const string FieldName = "TestField"; + private const string MissingCode = "ERR001"; + private const string InvalidFormatCode = "ERR002"; + private readonly Regex _pattern = TestRegex(); + + [Fact] + public void Validate_NullValue_ShouldReturnMissingError() + { + // Arrange + var file = TestDataBuilder.BuildValidParsedFile(); + file.FileHeader!.ExtractId = null; + + var validator = new HeaderFieldRegexValidator(x => x.ExtractId, FieldName, _pattern, MissingCode, InvalidFormatCode); + + // Act + var errors = validator.Validate(file).ToList(); + + // Assert + errors.ShouldContainValidationError(FieldName, $"{FieldName} is missing", MissingCode, ValidationErrorScope.Header); + } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + public void Validate_ValueNotMatchingPattern_ShouldReturnInvalidFormatError(string invalidValue) + { + // Arrange + var file = TestDataBuilder.BuildValidParsedFile(); + file.FileHeader!.ExtractId = invalidValue; + + var validator = new HeaderFieldRegexValidator(x => x.ExtractId, FieldName, _pattern, MissingCode, InvalidFormatCode); + + // Act + var errors = validator.Validate(file).ToList(); + + // Assert + errors.ShouldContainValidationError(FieldName, $"{FieldName} is in an invalid format", InvalidFormatCode,ValidationErrorScope.Header); + } + + [Theory] + [InlineData("AB12")] + [InlineData("CD34")] + public void Validate_ValueMatchingPattern_ShouldReturnNoErrors(string validValue) + { + // Arrange + var file = TestDataBuilder.BuildValidParsedFile(); + file.FileHeader!.ExtractId = validValue; + + var validator = new HeaderFieldRegexValidator(x => x.ExtractId, FieldName, _pattern, MissingCode, InvalidFormatCode); + + // Act + var errors = validator.Validate(file).ToList(); + + // Assert + Assert.Empty(errors); + } + + [GeneratedRegex(@"^[A-Z]{2}\d{2}$", RegexOptions.Compiled)] + private static partial Regex TestRegex(); +} diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/RegexValidatorTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/RegexValidatorTests.cs index b9beb027..cc93f88b 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/RegexValidatorTests.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/Validation/RegexValidatorTests.cs @@ -56,6 +56,7 @@ public void Validate_ValueNotMatchingPattern_ShouldReturnInvalidFormatError(stri [InlineData("CD34")] public void Validate_ValueMatchingPattern_ShouldReturnNoErrors(string validValue) { + // Arrange var record = new FileDataRecord { RowNumber = 3 @@ -64,8 +65,10 @@ public void Validate_ValueMatchingPattern_ShouldReturnNoErrors(string validValue var validator = new RegexValidator(FieldName, _pattern, MissingCode, InvalidFormatCode); + // Act var errors = validator.Validate(record).ToList(); + // Assert Assert.Empty(errors); }