From 7472d741a18106b1b6602305dfcf4b3c63dbcc09 Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 3 Jun 2025 14:10:53 +0100 Subject: [PATCH 1/8] feat: fileParser validation handling, FileParsingExcpetion class created, Filetransform try catch added, tests and test data files added,, --- .../NbssAppointmentEvents/FileParser.cs | 19 +- .../NbssAppointmentEvents/FileTransformer.cs | 58 +++- .../Models/FileParsingException.cs | 21 ++ .../Functions/FileExtractFunction.cs | 2 +- .../Data/Models/MeshFileType.cs | 3 +- .../FileTransformerTest.cs | 259 ++++++++++++++++++ .../NbssAppointmentEvents/FileParserTests.cs | 121 +++++++- .../TestData/DataBeforeFields.dat | 4 + .../TestData/EmptyRecordType.dat | 4 + .../TestData/FieldsOnly.dat | 1 + .../TestData/NullRecordType.dat | 4 + 11 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Models/FileParsingException.cs create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTransformerTest.cs create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/DataBeforeFields.dat create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/EmptyRecordType.dat create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/FieldsOnly.dat create mode 100644 tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/NullRecordType.dat diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs index 7dbedb52..ec783cfe 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs @@ -1,6 +1,7 @@ using CsvHelper; using CsvHelper.Configuration; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; using System.Globalization; using System.Text; @@ -31,6 +32,7 @@ public ParsedFile Parse(Stream stream) var rowNumber = 0; var fields = new List(); + var fieldsFound = false; while (csv.Read()) { @@ -44,9 +46,15 @@ public ParsedFile Parse(Stream stream) case FieldsIdentifier: fields = ParseFields(csv); + fieldsFound = true; break; case DataIdentifier: + if (!fieldsFound) + { + throw new FileParsingException( + ErrorCodes.MissingFieldHeadings, "Field headings are missing"); + } rowNumber++; result.DataRecords.Add(ParseDataRecord(csv, fields, rowNumber)); break; @@ -56,7 +64,10 @@ public ParsedFile Parse(Stream stream) break; default: - throw new InvalidOperationException($"Unknown record identifier: {recordIdentifier}"); + recordIdentifier = recordIdentifier ?? "No Record Identifier found"; + throw new FileParsingException( + ErrorCodes.UnknownRecordTypeIdentifier, + $"Unknown Record Identifier {recordIdentifier}"); } } @@ -96,11 +107,6 @@ private static CsvReader CreateCsvReader(StreamReader reader) private static FileDataRecord ParseDataRecord(CsvReader csv, List columnHeadings, int rowNumber) { - if (columnHeadings.Count == 0) - { - throw new InvalidOperationException("Field headers (NBSSAPPT_FLDS) must appear before data records."); - } - const int dataFieldStartIndex = 1; var record = new FileDataRecord { RowNumber = rowNumber }; @@ -140,4 +146,3 @@ public FileHeaderRecordMap() } } } - diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs index ce2897af..b0e46948 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using ServiceLayer.Data.Models; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; @@ -6,22 +7,65 @@ namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents; public class FileTransformer( IFileParser fileParser, IValidationRunner validationRunner, - IStagingPersister stagingPersister) + IStagingPersister stagingPersister, + ILogger logger) : FileTransformerBase { protected override MeshFileType HandlesFileType => MeshFileType.NbssAppointmentEvents; public override async Task> TransformFileAsync(Stream stream, MeshFile metaData) { - // TODO - wrap this parsing in a try-catch and return a List in case of any unforeseen parsing issues (file is totally unlike anything we expect) - var parsed = fileParser.Parse(stream); + try + { + var parsed = fileParser.Parse(stream); + var validationErrors = validationRunner.Validate(parsed); + + if (!validationErrors.Any()) + { + await stagingPersister.WriteStagedData(parsed, metaData); + } - var validationErrors = validationRunner.Validate(parsed); - if (!validationErrors.Any()) + return validationErrors; + } + catch (FileParsingException ex) { - await stagingPersister.WriteStagedData(parsed, metaData); + return HandleFileParsingException(ex); } + catch (Exception ex) + { + return HandleUnexpectedException(ex, metaData); + } + } + + private List HandleFileParsingException(FileParsingException ex) + { + logger.LogError("File parsing failed with validation error. Code: {ErrorCode}, Message: {ErrorMessage}", + ex.Code, ex.ErrorMessage); + + return + [ + new ValidationError + { + Code = ex.Code, + Error = ex.ErrorMessage, + Scope = ValidationErrorScope.File + } + ]; + } + + private IList HandleUnexpectedException(Exception ex, MeshFile metaData) + { + logger.LogError(ex, "System error occurred while parsing NBSS appointment file. File: {FileName}", + metaData?.FileId ?? "Unknown"); - return validationErrors; + return + [ + new ValidationError + { + Code = ErrorCodes.UnableToParseFile, + Error = "Unable to parse file", + Scope = ValidationErrorScope.File + } + ]; } } diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Models/FileParsingException.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Models/FileParsingException.cs new file mode 100644 index 00000000..14522cc5 --- /dev/null +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Models/FileParsingException.cs @@ -0,0 +1,21 @@ +namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents; + +public class FileParsingException : Exception +{ + public string Code { get; } + public string ErrorMessage { get; } + + public FileParsingException(string code, string errorMessage) + : base(errorMessage) + { + Code = code; + ErrorMessage = errorMessage; + } + + public FileParsingException(string code, string errorMessage, Exception innerException) + : base(errorMessage, innerException) + { + Code = code; + ErrorMessage = errorMessage; + } +} diff --git a/src/ServiceLayer.Mesh/Functions/FileExtractFunction.cs b/src/ServiceLayer.Mesh/Functions/FileExtractFunction.cs index f58c70bd..a8c62de0 100644 --- a/src/ServiceLayer.Mesh/Functions/FileExtractFunction.cs +++ b/src/ServiceLayer.Mesh/Functions/FileExtractFunction.cs @@ -109,7 +109,7 @@ private async Task ProcessFileExtraction(MeshFile file) private async Task HandleExtractionError(MeshFile file, FileExtractQueueMessage message, Exception ex) { - logger.LogError(ex, "An exception occurred during file extraction for fileId: {fileId}", message.FileId); + logger.LogError(ex, "An exception occurred during file extraction for fileId: {FileId}", message.FileId); file.Status = MeshFileStatus.FailedExtract; file.LastUpdatedUtc = DateTime.UtcNow; await serviceLayerDbContext.SaveChangesAsync(); diff --git a/src/ServiceLayer.Shared/Data/Models/MeshFileType.cs b/src/ServiceLayer.Shared/Data/Models/MeshFileType.cs index 32a8c197..7f842a49 100644 --- a/src/ServiceLayer.Shared/Data/Models/MeshFileType.cs +++ b/src/ServiceLayer.Shared/Data/Models/MeshFileType.cs @@ -2,5 +2,6 @@ namespace ServiceLayer.Data.Models; public enum MeshFileType { - NbssAppointmentEvents + NbssAppointmentEvents, + Unknown } diff --git a/tests/ServiceLayer.Mesh.Tests/FileTransformerTest.cs b/tests/ServiceLayer.Mesh.Tests/FileTransformerTest.cs new file mode 100644 index 00000000..67d754bd --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTransformerTest.cs @@ -0,0 +1,259 @@ +using Microsoft.Extensions.Logging; +using Moq; +using ServiceLayer.Data.Models; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; +using ServiceLayer.TestUtilities; + +namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents; + +public class FileTransformerTests +{ + private readonly Mock _fileParserMock = new(); + private readonly Mock _validationRunnerMock = new(); + private readonly Mock _stagingPersisterMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly FileTransformer _fileTransformer; + private readonly MeshFile _testMeshFile; + private readonly Stream _testStream; + private readonly ParsedFile parsedFile = new(); + + public FileTransformerTests() + { + _fileTransformer = new FileTransformer( + _fileParserMock.Object, + _validationRunnerMock.Object, + _stagingPersisterMock.Object, + _loggerMock.Object); + + _testMeshFile = new MeshFile + { + FileId = "test-file-123", + FileType = MeshFileType.NbssAppointmentEvents, + MailboxId = "testMailboxId", + Status = MeshFileStatus.Extracted + }; + + _testStream = new MemoryStream(); + } + + [Fact] + public void CanHandle_NbssAppointmentEventsFileType_ReturnsTrue() + { + // Act + var result = _fileTransformer.CanHandle(MeshFileType.NbssAppointmentEvents); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanHandle_OtherFileType_ReturnsFalse() + { + // Act + var result = _fileTransformer.CanHandle(MeshFileType.Unknown); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task TransformFileAsync_ValidFileWithNoValidationErrors_ParsesValidatesAndPersists() + { + // Arrange + var validationErrors = new List(); + + _fileParserMock.Setup(p => p.Parse(_testStream)).Returns(parsedFile); + _validationRunnerMock.Setup(v => v.Validate(parsedFile)).Returns(validationErrors); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Empty(result); + _fileParserMock.Verify(p => p.Parse(_testStream), Times.Once); + _validationRunnerMock.Verify(v => v.Validate(parsedFile), Times.Once); + _stagingPersisterMock.Verify(s => s.WriteStagedData(parsedFile, _testMeshFile), Times.Once); + _loggerMock.VerifyNoLogs(LogLevel.Error); + } + + [Fact] + public async Task TransformFileAsync_ValidFileWithValidationErrors_DoesNotPersistData() + { + // Arrange + var validationErrors = new List + { + new() { Code = "TEST001", Error = "Test validation error", Scope = ValidationErrorScope.Record, RowNumber = 1 } + }; + + _fileParserMock.Setup(p => p.Parse(_testStream)).Returns(parsedFile); + _validationRunnerMock.Setup(v => v.Validate(parsedFile)).Returns(validationErrors); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Equal(validationErrors, result); + _fileParserMock.Verify(p => p.Parse(_testStream), Times.Once); + _validationRunnerMock.Verify(v => v.Validate(parsedFile), Times.Once); + _stagingPersisterMock.Verify(s => s.WriteStagedData(It.IsAny(), It.IsAny()), Times.Never); + _loggerMock.VerifyNoLogs(LogLevel.Error); + } + + [Fact] + public async Task TransformFileAsync_FileParsingExceptionThrown_ReturnsFileValidationError() + { + // Arrange + var fileParsingException = new FileParsingException(ErrorCodes.UnknownRecordTypeIdentifier, "Unknown record type identifier 'INVALID_TYPE'"); + + _fileParserMock.Setup(p => p.Parse(_testStream)).Throws(fileParsingException); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Single(result); + var validationError = result[0]; + Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, validationError.Code); + Assert.Equal("Unknown record type identifier 'INVALID_TYPE'", validationError.Error); + Assert.Equal(ValidationErrorScope.File, validationError.Scope); + + _fileParserMock.Verify(p => p.Parse(_testStream), Times.Once); + _validationRunnerMock.Verify(v => v.Validate(It.IsAny()), Times.Never); + _stagingPersisterMock.Verify(s => s.WriteStagedData(It.IsAny(), It.IsAny()), Times.Never); + + _loggerMock.VerifyLogger(LogLevel.Error, + $"File parsing failed with validation error. Code: {ErrorCodes.UnknownRecordTypeIdentifier}, Message: Unknown record type identifier 'INVALID_TYPE'"); + } + + [Fact] + public async Task TransformFileAsync_UnexpectedExceptionThrown_ReturnsSystemValidationError() + { + // Arrange + var unexpectedException = new InvalidOperationException("Something went wrong"); + _fileParserMock.Setup(p => p.Parse(_testStream)).Throws(unexpectedException); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Single(result); + var validationError = result[0]; + Assert.Equal(ErrorCodes.UnableToParseFile, validationError.Code); + Assert.Equal("Unable to parse file", validationError.Error); + Assert.Equal(ValidationErrorScope.File, validationError.Scope); + + _fileParserMock.Verify(p => p.Parse(_testStream), Times.Once); + _validationRunnerMock.Verify(v => v.Validate(It.IsAny()), Times.Never); + _stagingPersisterMock.Verify(s => s.WriteStagedData(It.IsAny(), It.IsAny()), Times.Never); + + _loggerMock.VerifyLogger(LogLevel.Error, + $"System error occurred while parsing NBSS appointment file. File: {_testMeshFile.FileId}", + ex => ex == unexpectedException); + } + + [Fact] + public async Task TransformFileAsync_UnexpectedExceptionWithNullMetaData_LogsUnknownFileName() + { + // Arrange + var unexpectedException = new InvalidOperationException("Something went wrong"); + _fileParserMock.Setup(p => p.Parse(_testStream)).Throws(unexpectedException); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, null); + + // Assert + Assert.Single(result); + var validationError = result[0]; + Assert.Equal(ErrorCodes.UnableToParseFile, validationError.Code); + Assert.Equal("Unable to parse file", validationError.Error); + Assert.Equal(ValidationErrorScope.File, validationError.Scope); + + _loggerMock.VerifyLogger(LogLevel.Error, + "System error occurred while parsing NBSS appointment file. File: Unknown", + ex => ex == unexpectedException); + } + + [Fact] + public async Task TransformFileAsync_ValidationRunnerThrowsException_ReturnsSystemValidationError() + { + // Arrange + var validationException = new InvalidOperationException("Validation failed"); + + _fileParserMock.Setup(p => p.Parse(_testStream)).Returns(parsedFile); + _validationRunnerMock.Setup(v => v.Validate(parsedFile)).Throws(validationException); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Single(result); + var validationError = result[0]; + Assert.Equal(ErrorCodes.UnableToParseFile, validationError.Code); + Assert.Equal("Unable to parse file", validationError.Error); + Assert.Equal(ValidationErrorScope.File, validationError.Scope); + + _fileParserMock.Verify(p => p.Parse(_testStream), Times.Once); + _validationRunnerMock.Verify(v => v.Validate(parsedFile), Times.Once); + _stagingPersisterMock.Verify(s => s.WriteStagedData(It.IsAny(), It.IsAny()), Times.Never); + + _loggerMock.VerifyLogger(LogLevel.Error, + $"System error occurred while parsing NBSS appointment file. File: {_testMeshFile.FileId}", + ex => ex == validationException); + } + + [Fact] + public async Task TransformFileAsync_StagingPersisterThrowsException_ReturnsSystemValidationError() + { + // Arrange + var validationErrors = new List(); + var persistException = new InvalidOperationException("Database error"); + + _fileParserMock.Setup(p => p.Parse(_testStream)).Returns(parsedFile); + _validationRunnerMock.Setup(v => v.Validate(parsedFile)).Returns(validationErrors); + _stagingPersisterMock.Setup(s => s.WriteStagedData(parsedFile, _testMeshFile)).ThrowsAsync(persistException); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Single(result); + var validationError = result[0]; + Assert.Equal(ErrorCodes.UnableToParseFile, validationError.Code); + Assert.Equal("Unable to parse file", validationError.Error); + Assert.Equal(ValidationErrorScope.File, validationError.Scope); + + _fileParserMock.Verify(p => p.Parse(_testStream), Times.Once); + _validationRunnerMock.Verify(v => v.Validate(parsedFile), Times.Once); + _stagingPersisterMock.Verify(s => s.WriteStagedData(parsedFile, _testMeshFile), Times.Once); + + _loggerMock.VerifyLogger(LogLevel.Error, + $"System error occurred while parsing NBSS appointment file. File: {_testMeshFile.FileId}", + ex => ex == persistException); + } + + [Theory] + [InlineData(ErrorCodes.MissingFieldHeadings, "Field headings are missing")] + [InlineData(ErrorCodes.UnknownRecordTypeIdentifier, "Unknown record type 'INVALID'")] + [InlineData("CUSTOM001", "Custom validation error")] + public async Task TransformFileAsync_DifferentFileParsingExceptions_ReturnsCorrectValidationErrors(string errorCode, string errorMessage) + { + // Arrange + var fileParsingException = new FileParsingException(errorCode, errorMessage); + _fileParserMock.Setup(p => p.Parse(_testStream)).Throws(fileParsingException); + + // Act + var result = await _fileTransformer.TransformFileAsync(_testStream, _testMeshFile); + + // Assert + Assert.Single(result); + var validationError = result[0]; + Assert.Equal(errorCode, validationError.Code); + Assert.Equal(errorMessage, validationError.Error); + Assert.Equal(ValidationErrorScope.File, validationError.Scope); + + _loggerMock.VerifyLogger(LogLevel.Error, + $"File parsing failed with validation error. Code: {errorCode}, Message: {errorMessage}"); + } +} diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs index d5b5e4b9..db9409ee 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs @@ -4,6 +4,7 @@ using CsvHelper.Configuration; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents; using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models; +using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation; using static ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.FileParser; namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents; @@ -144,27 +145,29 @@ public void Parse_CompleteDataset_ParsesAllFieldsCorrectly() } [Fact] - public void Parse_MissingFieldsRecord_ThrowsInvalidOperationException() + public void Parse_MissingFieldsRecord_ThrowsFileParsingException() { // Arrange using var fileStream = GetTestFileStream("MissingFields.dat"); // Act & Assert - var exception = Assert.Throws(() => _fileParser.Parse(fileStream)); + var exception = Assert.Throws(() => _fileParser.Parse(fileStream)); - Assert.Equal("Field headers (NBSSAPPT_FLDS) must appear before data records.", exception.Message); + Assert.Equal(ErrorCodes.MissingFieldHeadings, exception.Code); + Assert.Equal("Field headings are missing", exception.ErrorMessage); } [Fact] - public void Parse_UnknownRecordType_ThrowsInvalidOperationException() + public void Parse_UnknownRecordType_ThrowsFileParsingException() { // Arrange using var fileStream = GetTestFileStream("UnknownRecord.dat"); // Act & Assert - var exception = Assert.Throws(() => _fileParser.Parse(fileStream)); + var exception = Assert.Throws(() => _fileParser.Parse(fileStream)); - Assert.Equal("Unknown record identifier: UNKNOWN_TYPE", exception.Message); + Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, exception.Code); + Assert.Equal("Unknown Record Identifier UNKNOWN_TYPE", exception.ErrorMessage); } [Fact] @@ -319,6 +322,112 @@ public void VerifyFileTrailerRecordMap_MapsCorrectly() Assert.Equal("000002", result.RecordCount); } + + [Fact] + public void Parse_DataRecordBeforeFields_ThrowsFileParsingExceptionWithCorrectCode() + { + // Arrange + using var stream = GetTestFileStream("DataBeforeFields.dat"); + + // Act & Assert + var exception = Assert.Throws(() => _fileParser.Parse(stream)); + + Assert.Equal(ErrorCodes.MissingFieldHeadings, exception.Code); + Assert.Equal("Field headings are missing", exception.ErrorMessage); + } + + [Fact] + public void Parse_UnknownRecordTypeWithNullIdentifier_ThrowsFileParsingExceptionWithNull() + { + // Arrange + using var stream = GetTestFileStream("NullRecordType.dat"); + + // Act & Assert + var exception = Assert.Throws(() => _fileParser.Parse(stream)); + + Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, exception.Code); + Assert.Equal("Unknown Record Identifier ", exception.ErrorMessage); + } + + [Fact] + public void Parse_UnknownRecordTypeWithEmptyString_ThrowsFileParsingExceptionWithNull() + { + // Arrange + using var stream = GetTestFileStream("EmptyRecordType.dat"); + + // Act & Assert + var exception = Assert.Throws(() => _fileParser.Parse(stream)); + + Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, exception.Code); + Assert.Equal("Unknown Record Identifier ", exception.ErrorMessage); + } + + [Fact] + public void Parse_FileParsingExceptionWithInnerException_PreservesInnerException() + { + // Arrange + var innerException = new InvalidOperationException("Inner error"); + + // Act + var exception = new FileParsingException("TEST001", "Test error", innerException); + + // Assert + Assert.Equal("TEST001", exception.Code); + Assert.Equal("Test error", exception.ErrorMessage); + Assert.Equal(innerException, exception.InnerException); + Assert.Equal("Test error", exception.Message); + } + + [Fact] + public void Parse_FileWithOnlyHeader_CompletesSuccessfully() + { + // Arrange + using var stream = GetTestFileStream("HeaderMapping.dat"); + + // Act + var result = _fileParser.Parse(stream); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.FileHeader); + Assert.Equal("NBSSAPPT_HDR", result.FileHeader.RecordTypeIdentifier); + Assert.Null(result.FileTrailer); + Assert.Empty(result.DataRecords); + } + + [Fact] + public void Parse_FileWithOnlyTrailer_CompletesSuccessfully() + { + // Arrange + using var stream = GetTestFileStream("TrailerMapping.dat"); + + // Act + var result = _fileParser.Parse(stream); + + // Assert + Assert.NotNull(result); + Assert.Null(result.FileHeader); + Assert.NotNull(result.FileTrailer); + Assert.Equal("NBSSAPPT_END", result.FileTrailer.RecordTypeIdentifier); + Assert.Empty(result.DataRecords); + } + + [Fact] + public void Parse_FileWithOnlyFields_CompletesSuccessfully() + { + // Arrange + using var stream = GetTestFileStream("FieldsOnly.dat"); + + // Act + var result = _fileParser.Parse(stream); + + // Assert + Assert.NotNull(result); + Assert.Null(result.FileHeader); + Assert.Null(result.FileTrailer); + Assert.Empty(result.DataRecords); + } + // Helper methods private CsvReader CreateConfiguredCsvReader(string fileName) { diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/DataBeforeFields.dat b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/DataBeforeFields.dat new file mode 100644 index 00000000..28950308 --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/DataBeforeFields.dat @@ -0,0 +1,4 @@ +"NBSSAPPT_HDR"|"00000107"|"20250317"|"133128"|"000001" +"NBSSAPPT_DATA"|"000001"|"KMK"|"B"|"BU003"|"B" +"NBSSAPPT_FLDS"|"Sequence"|"BSO"|"Action"|"Clinic Code"|"Status" +"NBSSAPPT_END"|"00000107"|"20250317"|"133129"|"000001" diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/EmptyRecordType.dat b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/EmptyRecordType.dat new file mode 100644 index 00000000..519f9d17 --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/EmptyRecordType.dat @@ -0,0 +1,4 @@ +"NBSSAPPT_HDR"|"00000107"|"20250317"|"133128"|"000001" +"NBSSAPPT_FLDS"|"Sequence"|"BSO"|"Action"|"Clinic Code"|"Status" +""|"000001"|"KMK"|"B"|"BU003"|"B" +"NBSSAPPT_END"|"00000107"|"20250317"|"133129"|"000001" diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/FieldsOnly.dat b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/FieldsOnly.dat new file mode 100644 index 00000000..bd864250 --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/FieldsOnly.dat @@ -0,0 +1 @@ +"NBSSAPPT_FLDS"|"Sequence"|"BSO"|"Action"|"Clinic Code"|"Status" diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/NullRecordType.dat b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/NullRecordType.dat new file mode 100644 index 00000000..519f9d17 --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestData/NullRecordType.dat @@ -0,0 +1,4 @@ +"NBSSAPPT_HDR"|"00000107"|"20250317"|"133128"|"000001" +"NBSSAPPT_FLDS"|"Sequence"|"BSO"|"Action"|"Clinic Code"|"Status" +""|"000001"|"KMK"|"B"|"BU003"|"B" +"NBSSAPPT_END"|"00000107"|"20250317"|"133129"|"000001" From d1b44d2f23eb28b0068fa45f258a858fa70c10ed Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 3 Jun 2025 14:31:09 +0100 Subject: [PATCH 2/8] fix: renamed filename --- .../{FileTransformerTest.cs => FileTransformerTests.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/ServiceLayer.Mesh.Tests/{FileTransformerTest.cs => FileTransformerTests.cs} (100%) diff --git a/tests/ServiceLayer.Mesh.Tests/FileTransformerTest.cs b/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs similarity index 100% rename from tests/ServiceLayer.Mesh.Tests/FileTransformerTest.cs rename to tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs From 7449d83d55f067d87e39dbf2fe4408bfba3b8c05 Mon Sep 17 00:00:00 2001 From: warren Date: Tue, 3 Jun 2025 14:42:04 +0100 Subject: [PATCH 3/8] fix: null handling for sonarQube --- tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs index 67d754bd..edd2babb 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs @@ -161,7 +161,7 @@ public async Task TransformFileAsync_UnexpectedExceptionWithNullMetaData_LogsUnk _fileParserMock.Setup(p => p.Parse(_testStream)).Throws(unexpectedException); // Act - var result = await _fileTransformer.TransformFileAsync(_testStream, null); + var result = await _fileTransformer.TransformFileAsync(_testStream, null!); // Assert Assert.Single(result); From d183c356a69230e1fcab79c544a65cb306abd9d5 Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 10 Jun 2025 09:36:57 +0100 Subject: [PATCH 4/8] Move FileParsingException --- .../NbssAppointmentEvents/{Models => }/FileParsingException.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/{Models => }/FileParsingException.cs (100%) diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Models/FileParsingException.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParsingException.cs similarity index 100% rename from src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Models/FileParsingException.cs rename to src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParsingException.cs From d3e1707ceccb264b6babccaa098c9c589667cdfe Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 10 Jun 2025 09:53:30 +0100 Subject: [PATCH 5/8] Remove redundant ErrorMessage property --- .../NbssAppointmentEvents/FileParsingException.cs | 12 +++++------- .../NbssAppointmentEvents/FileTransformer.cs | 4 ++-- .../NbssAppointmentEvents/FileParserTests.cs | 14 +++++++------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParsingException.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParsingException.cs index 14522cc5..88864a5f 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParsingException.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParsingException.cs @@ -3,19 +3,17 @@ namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents; public class FileParsingException : Exception { public string Code { get; } - public string ErrorMessage { get; } - public FileParsingException(string code, string errorMessage) - : base(errorMessage) + public FileParsingException(string code, string message) + : base(message) { Code = code; - ErrorMessage = errorMessage; } - public FileParsingException(string code, string errorMessage, Exception innerException) - : base(errorMessage, innerException) + public FileParsingException(string code, string message, Exception innerException) + : base(message, innerException) { Code = code; - ErrorMessage = errorMessage; } } + diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs index b0e46948..9eabc980 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs @@ -40,14 +40,14 @@ public override async Task> TransformFileAsync(Stream str private List HandleFileParsingException(FileParsingException ex) { logger.LogError("File parsing failed with validation error. Code: {ErrorCode}, Message: {ErrorMessage}", - ex.Code, ex.ErrorMessage); + ex.Code, ex.Message); return [ new ValidationError { Code = ex.Code, - Error = ex.ErrorMessage, + Error = ex.Message, Scope = ValidationErrorScope.File } ]; diff --git a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs index db9409ee..7248ea4f 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/FileParserTests.cs @@ -22,7 +22,7 @@ public FileParserTests() private FileStream GetTestFileStream(string fileName) { - string filePath = Path.Combine(_testDataPath, fileName); + var filePath = Path.Combine(_testDataPath, fileName); return File.OpenRead(filePath); } @@ -154,7 +154,7 @@ public void Parse_MissingFieldsRecord_ThrowsFileParsingException() var exception = Assert.Throws(() => _fileParser.Parse(fileStream)); Assert.Equal(ErrorCodes.MissingFieldHeadings, exception.Code); - Assert.Equal("Field headings are missing", exception.ErrorMessage); + Assert.Equal("Field headings are missing", exception.Message); } [Fact] @@ -167,7 +167,7 @@ public void Parse_UnknownRecordType_ThrowsFileParsingException() var exception = Assert.Throws(() => _fileParser.Parse(fileStream)); Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, exception.Code); - Assert.Equal("Unknown Record Identifier UNKNOWN_TYPE", exception.ErrorMessage); + Assert.Equal("Unknown Record Identifier UNKNOWN_TYPE", exception.Message); } [Fact] @@ -333,7 +333,7 @@ public void Parse_DataRecordBeforeFields_ThrowsFileParsingExceptionWithCorrectCo var exception = Assert.Throws(() => _fileParser.Parse(stream)); Assert.Equal(ErrorCodes.MissingFieldHeadings, exception.Code); - Assert.Equal("Field headings are missing", exception.ErrorMessage); + Assert.Equal("Field headings are missing", exception.Message); } [Fact] @@ -346,7 +346,7 @@ public void Parse_UnknownRecordTypeWithNullIdentifier_ThrowsFileParsingException var exception = Assert.Throws(() => _fileParser.Parse(stream)); Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, exception.Code); - Assert.Equal("Unknown Record Identifier ", exception.ErrorMessage); + Assert.Equal("Unknown Record Identifier ", exception.Message); } [Fact] @@ -359,7 +359,7 @@ public void Parse_UnknownRecordTypeWithEmptyString_ThrowsFileParsingExceptionWit var exception = Assert.Throws(() => _fileParser.Parse(stream)); Assert.Equal(ErrorCodes.UnknownRecordTypeIdentifier, exception.Code); - Assert.Equal("Unknown Record Identifier ", exception.ErrorMessage); + Assert.Equal("Unknown Record Identifier ", exception.Message); } [Fact] @@ -373,7 +373,7 @@ public void Parse_FileParsingExceptionWithInnerException_PreservesInnerException // Assert Assert.Equal("TEST001", exception.Code); - Assert.Equal("Test error", exception.ErrorMessage); + Assert.Equal("Test error", exception.Message); Assert.Equal(innerException, exception.InnerException); Assert.Equal("Test error", exception.Message); } From 313f8ea80138993c2b55b1b3654d1c2e3236f65d Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 10 Jun 2025 10:03:28 +0100 Subject: [PATCH 6/8] Remove unnecessary null handling --- .../NbssAppointmentEvents/FileTransformer.cs | 2 +- .../FileTransformerTests.cs | 22 ------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs index 9eabc980..35a4a4fe 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileTransformer.cs @@ -56,7 +56,7 @@ private List HandleFileParsingException(FileParsingException ex private IList HandleUnexpectedException(Exception ex, MeshFile metaData) { logger.LogError(ex, "System error occurred while parsing NBSS appointment file. File: {FileName}", - metaData?.FileId ?? "Unknown"); + metaData.FileId); return [ diff --git a/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs b/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs index edd2babb..27079e1b 100644 --- a/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs +++ b/tests/ServiceLayer.Mesh.Tests/FileTransformerTests.cs @@ -153,28 +153,6 @@ public async Task TransformFileAsync_UnexpectedExceptionThrown_ReturnsSystemVali ex => ex == unexpectedException); } - [Fact] - public async Task TransformFileAsync_UnexpectedExceptionWithNullMetaData_LogsUnknownFileName() - { - // Arrange - var unexpectedException = new InvalidOperationException("Something went wrong"); - _fileParserMock.Setup(p => p.Parse(_testStream)).Throws(unexpectedException); - - // Act - var result = await _fileTransformer.TransformFileAsync(_testStream, null!); - - // Assert - Assert.Single(result); - var validationError = result[0]; - Assert.Equal(ErrorCodes.UnableToParseFile, validationError.Code); - Assert.Equal("Unable to parse file", validationError.Error); - Assert.Equal(ValidationErrorScope.File, validationError.Scope); - - _loggerMock.VerifyLogger(LogLevel.Error, - "System error occurred while parsing NBSS appointment file. File: Unknown", - ex => ex == unexpectedException); - } - [Fact] public async Task TransformFileAsync_ValidationRunnerThrowsException_ReturnsSystemValidationError() { From a897966f9d7169dc8d29a76e1c335b95e164b0f7 Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 10 Jun 2025 10:05:01 +0100 Subject: [PATCH 7/8] compound assignment --- .../FileTypes/NbssAppointmentEvents/FileParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs index ec783cfe..c9ae4995 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs @@ -64,7 +64,7 @@ public ParsedFile Parse(Stream stream) break; default: - recordIdentifier = recordIdentifier ?? "No Record Identifier found"; + recordIdentifier ??= "No Record Identifier found"; throw new FileParsingException( ErrorCodes.UnknownRecordTypeIdentifier, $"Unknown Record Identifier {recordIdentifier}"); From b43490e46a2d5500202e46bc80165db135c881ff Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Tue, 10 Jun 2025 10:08:21 +0100 Subject: [PATCH 8/8] tweak, remove redundant variable --- .../FileTypes/NbssAppointmentEvents/FileParser.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs index c9ae4995..9dbb2de7 100644 --- a/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs +++ b/src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs @@ -32,7 +32,6 @@ public ParsedFile Parse(Stream stream) var rowNumber = 0; var fields = new List(); - var fieldsFound = false; while (csv.Read()) { @@ -46,15 +45,9 @@ public ParsedFile Parse(Stream stream) case FieldsIdentifier: fields = ParseFields(csv); - fieldsFound = true; break; case DataIdentifier: - if (!fieldsFound) - { - throw new FileParsingException( - ErrorCodes.MissingFieldHeadings, "Field headings are missing"); - } rowNumber++; result.DataRecords.Add(ParseDataRecord(csv, fields, rowNumber)); break; @@ -107,6 +100,11 @@ private static CsvReader CreateCsvReader(StreamReader reader) private static FileDataRecord ParseDataRecord(CsvReader csv, List columnHeadings, int rowNumber) { + if (columnHeadings.Count == 0) + { + throw new FileParsingException(ErrorCodes.MissingFieldHeadings, "Field headings are missing"); + } + const int dataFieldStartIndex = 1; var record = new FileDataRecord { RowNumber = rowNumber };