diff --git a/Directory.Packages.props b/Directory.Packages.props index ccfe6af..90b4e97 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,61 +4,61 @@ - - - - - - + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - + + + - - + + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + - - + + \ No newline at end of file diff --git a/FIleProcessor.Models/FileDetails.cs b/FIleProcessor.Models/FileDetails.cs index 43b87f7..9bfc668 100644 --- a/FIleProcessor.Models/FileDetails.cs +++ b/FIleProcessor.Models/FileDetails.cs @@ -82,6 +82,16 @@ public class FileDetails /// public Boolean ProcessingCompleted { get; set; } + public Boolean IsRejected { get; set; } + + public String RejectionCode { get; set; } + + public String RejectionReason { get; set; } + + public Int32 ObservedDetailRecordCount { get; set; } + + public Int32 MaximumDetailRecordCount { get; set; } + /// /// Gets or sets the processing summary. /// @@ -100,4 +110,4 @@ public class FileDetails #endregion } -} \ No newline at end of file +} diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index f27d74b..3e8ac6c 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -253,6 +253,54 @@ public async Task FileRequestHandler_ProcessUploadedFileRequest_RequestIsHandled result.IsSuccess.ShouldBeTrue(); } + [Fact] + public async Task ProcessUploadedFile_WhenFileExceedsMaximumDetailRecords_RejectsWithoutAddingLines() + { + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); + this.FileFormatHandler.FileLineCanBeIgnored(Arg.Any()).Returns((String line) => String.IsNullOrWhiteSpace(line) || line.StartsWith("H") || line.StartsWith("T")); + FileAggregate createdAggregate = TestData.GetCreatedFileAggregate(); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetEmptyFileAggregate())) + .Then().ReturnsAsync(Result.Success(createdAggregate)); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); + this.SecurityServiceClient.GetToken(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.TokenResponse())); + this.TransactionProcessorClient.GetOperators(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(TestData.OperatorList); + + StringBuilder content = new("H,20210508,\n"); + for (Int32 row = 1; row <= 1001; row++) + content.Append($"D,0777777{row:0000},{10 + ((row - 1) % 240)}\n"); + content.Append("T,1001,"); + this.FileSystem.AddFile(TestData.FilePathWithName, new MockFileData(content.ToString())); + + Result result = await this.FileProcessorDomainService.ProcessUploadedFile(TestData.ProcessUploadedFileCommand, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + createdAggregate.GetFile().IsRejected.ShouldBeTrue(); + createdAggregate.GetFile().ObservedDetailRecordCount.ShouldBe(1001); + createdAggregate.GetFile().MaximumDetailRecordCount.ShouldBe(1000); + createdAggregate.GetFile().FileLines.ShouldBeEmpty(); + this.FileSystem.File.Exists($"{TestData.FileProfileSafaricom.FailedDirectory}/{Path.GetFileName(TestData.FilePathWithName)}").ShouldBeTrue(); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("not-a-number")] + public void ResolveMaximumDetailRecordCount_WhenConfiguredValueIsInvalid_Throws(String configuredValue) + { + IConfigurationRoot invalidConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AppSettings:MaximumDetailRecordCount"] = configuredValue + }) + .Build(); + ConfigurationReader.Initialise(invalidConfiguration); + + Should.Throw(() => FileProcessorDomainService.ResolveMaximumDetailRecordCount()); + + ConfigurationReader.Initialise(new ConfigurationBuilder().AddInMemoryCollection(TestData.DefaultAppSettings).Build()); + } + [Fact] public async Task ProcessUploadedFile_WhenOperatorLookupFails_MovesAcceptedFileToFailedDirectory() { diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 17815bb..956a7f1 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -53,6 +53,9 @@ Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFil public class FileProcessorDomainService : IFileProcessorDomainService { + private const Int32 DefaultMaximumDetailRecordCount = 1000; + private const String MaximumDetailRecordsExceededCode = "MaximumDetailRecordsExceeded"; + private readonly IFileProcessorManager FileProcessorManager; private readonly IAggregateRepository FileImportLogAggregateRepository; @@ -67,6 +70,8 @@ public class FileProcessorDomainService : IFileProcessorDomainService private readonly IFileSystem FileSystem; + private readonly Int32 MaximumDetailRecordCount; + public FileProcessorDomainService(IFileProcessorManager fileProcessorManager, IAggregateRepository fileImportLogAggregateRepository, IAggregateRepository fileAggregateRepository, @@ -81,6 +86,19 @@ public FileProcessorDomainService(IFileProcessorManager fileProcessorManager, this.SecurityServiceClient = securityServiceClient; this.FileFormatHandlerResolver = fileFormatHandlerResolver; this.FileSystem = fileSystem; + this.MaximumDetailRecordCount = ResolveMaximumDetailRecordCount(); + } + + public static Int32 ResolveMaximumDetailRecordCount() + { + String configuredMaximumDetailRecordCount = ConfigurationReader.GetValue("AppSettings", "MaximumDetailRecordCount"); + if (String.IsNullOrWhiteSpace(configuredMaximumDetailRecordCount)) + return DefaultMaximumDetailRecordCount; + + if (Int32.TryParse(configuredMaximumDetailRecordCount, out Int32 maximumDetailRecordCount) == false || maximumDetailRecordCount <= 0) + throw new InvalidOperationException("AppSettings:MaximumDetailRecordCount must be a positive integer when configured"); + + return maximumDetailRecordCount; } private async Task ApplyFileUpdates(Func> action, @@ -754,8 +772,15 @@ private async Task ProcessFile(Guid fileId, inProgressFile = fileInfo; } + Boolean fileWasRejected = false; Result result = await ApplyFileUpdates(async (FileAggregate fileAggregate) => { + if (fileAggregate.GetFile().IsRejected) + { + fileWasRejected = true; + return Result.Success(); + } + String fileContent = null; //Open file for Read\Write using (Stream fs = inProgressFile.Open(FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) { @@ -769,6 +794,26 @@ private async Task ProcessFile(Guid fileId, if (String.IsNullOrEmpty(fileContent) == false) { String[] fileLines = fileContent.Split(fileProfile.LineTerminator); + Int32 detailRecordCount = fileLines.Count(fileLine => + { + String trimmedLine = fileLine.Trim(); + return String.IsNullOrEmpty(trimmedLine) == false && this.FileLineCanBeIgnored(trimmedLine, fileProfile.FileFormatHandler) == false; + }); + + if (detailRecordCount > this.MaximumDetailRecordCount) + { + Result rejectionResult = fileAggregate.RejectFile(detailRecordCount, + this.MaximumDetailRecordCount, + MaximumDetailRecordsExceededCode, + $"File contains {detailRecordCount} detail records; the maximum permitted is {this.MaximumDetailRecordCount}", + DateTime.UtcNow); + if (rejectionResult.IsFailed) + return rejectionResult; + + fileWasRejected = true; + return Result.Success(); + } + foreach (String fileLine in fileLines) { Result stateResult = fileAggregate.AddFileLine(fileLine.Trim()); if (stateResult.IsFailed) @@ -780,6 +825,13 @@ private async Task ProcessFile(Guid fileId, }, fileId, cancellationToken); if (result.IsSuccess) { + if (fileWasRejected) + { + Logger.LogWarning($"About to move rejected file {inProgressFile.Name} to [{fileProfile.FailedDirectory}]"); + inProgressFile.MoveTo($"{fileProfile.FailedDirectory}/{inProgressFile.Name}", true); + return Result.Success(); + } + Logger.LogInformation($"About to move file {inProgressFile.Name} to [{fileProfile.ProcessedDirectory}]"); // Move file now diff --git a/FileProcessor.DataTransferObjects/Responses/FileDetails.cs b/FileProcessor.DataTransferObjects/Responses/FileDetails.cs index c49d291..4b4bf47 100644 --- a/FileProcessor.DataTransferObjects/Responses/FileDetails.cs +++ b/FileProcessor.DataTransferObjects/Responses/FileDetails.cs @@ -9,6 +9,16 @@ public class FileDetails public Boolean ProcessingCompleted { get; set; } + public Boolean IsRejected { get; set; } + + public String RejectionCode { get; set; } + + public String RejectionReason { get; set; } + + public Int32 ObservedDetailRecordCount { get; set; } + + public Int32 MaximumDetailRecordCount { get; set; } + public Guid EstateId { get; set; } public Guid UserId { get; set; } @@ -31,4 +41,4 @@ public class FileDetails public FileProcessingSummary ProcessingSummary { get; set; } } -} \ No newline at end of file +} diff --git a/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs b/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs index 53fa44a..b2900a6 100644 --- a/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs +++ b/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs @@ -26,4 +26,13 @@ public record FileCreatedEvent(Guid FileId, String FileLocation, DateTime FileReceivedDateTime, Guid OperatorId) : DomainEvent(FileId, Guid.NewGuid()); + + public record FileRejectedEvent(Guid FileId, + Guid EstateId, + Guid MerchantId, + String RejectionCode, + String RejectionReason, + Int32 ObservedDetailRecordCount, + Int32 MaximumDetailRecordCount, + DateTime RejectedDateTime) : DomainEvent(FileId, Guid.NewGuid()); } diff --git a/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs b/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs index d339740..d27f96e 100644 --- a/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs +++ b/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs @@ -66,6 +66,57 @@ public void FileAggregate_CreateFile_FileAlreadyCreated_NoErrorThrown() result.IsSuccess.ShouldBeTrue(); } + [Fact] + public void FileAggregate_RejectFile_PreservesFileLevelRejectionDetails() + { + FileAggregate fileAggregate = FileAggregate.Create(TestData.FileId); + FileAggregateExtensions.CreateFileRequest createFileRequest = new(TestData.FileImportLogId, TestData.EstateId, TestData.MerchantId, TestData.UserId, TestData.FileProfileId, TestData.FileLocation, TestData.FileUploadedDateTime, TestData.SafaricomOperatorId); + fileAggregate.CreateFile(createFileRequest); + + Result result = fileAggregate.RejectFile(1001, + 1000, + "MaximumDetailRecordsExceeded", + "File contains more than the maximum permitted detail records", + TestData.FileUploadedDateTime); + + result.IsSuccess.ShouldBeTrue(); + FileDetails fileDetails = fileAggregate.GetFile(); + fileDetails.IsRejected.ShouldBeTrue(); + fileDetails.RejectionCode.ShouldBe("MaximumDetailRecordsExceeded"); + fileDetails.RejectionReason.ShouldBe("File contains more than the maximum permitted detail records"); + fileDetails.ObservedDetailRecordCount.ShouldBe(1001); + fileDetails.MaximumDetailRecordCount.ShouldBe(1000); + fileDetails.FileLines.ShouldBeEmpty(); + } + + [Fact] + public void FileAggregate_RejectFile_WhenAlreadyRejected_DoesNotAppendAnotherRejection() + { + FileAggregate fileAggregate = FileAggregate.Create(TestData.FileId); + FileAggregateExtensions.CreateFileRequest createFileRequest = new(TestData.FileImportLogId, TestData.EstateId, TestData.MerchantId, TestData.UserId, TestData.FileProfileId, TestData.FileLocation, TestData.FileUploadedDateTime, TestData.SafaricomOperatorId); + fileAggregate.CreateFile(createFileRequest); + + fileAggregate.RejectFile(1001, 1000, "MaximumDetailRecordsExceeded", "too many", TestData.FileUploadedDateTime); + Result result = fileAggregate.RejectFile(1002, 1000, "MaximumDetailRecordsExceeded", "too many", TestData.FileUploadedDateTime.AddMinutes(1)); + + result.IsSuccess.ShouldBeTrue(); + fileAggregate.GetFile().ObservedDetailRecordCount.ShouldBe(1001); + } + + [Fact] + public void FileAggregate_AddFileLine_WhenRejected_DoesNotAddLine() + { + FileAggregate fileAggregate = FileAggregate.Create(TestData.FileId); + FileAggregateExtensions.CreateFileRequest createFileRequest = new(TestData.FileImportLogId, TestData.EstateId, TestData.MerchantId, TestData.UserId, TestData.FileProfileId, TestData.FileLocation, TestData.FileUploadedDateTime, TestData.SafaricomOperatorId); + fileAggregate.CreateFile(createFileRequest); + fileAggregate.RejectFile(1001, 1000, "MaximumDetailRecordsExceeded", "too many", TestData.FileUploadedDateTime); + + Result result = fileAggregate.AddFileLine(TestData.FileLine); + + result.IsFailed.ShouldBeTrue(); + fileAggregate.GetFile().FileLines.ShouldBeEmpty(); + } + [Fact] public void FileAggregate_AddFileLine_FileLineAdded() { diff --git a/FileProcessor.FileAggregate/FileAggregate.cs b/FileProcessor.FileAggregate/FileAggregate.cs index 200a147..0cd4561 100644 --- a/FileProcessor.FileAggregate/FileAggregate.cs +++ b/FileProcessor.FileAggregate/FileAggregate.cs @@ -34,6 +34,11 @@ public static FileDetails GetFile(this FileAggregate aggregate) { FileReceivedDateTime = aggregate.FileReceivedDateTime, ProcessingCompleted = aggregate.IsCompleted, + IsRejected = aggregate.IsRejected, + RejectionCode = aggregate.RejectionCode, + RejectionReason = aggregate.RejectionReason, + ObservedDetailRecordCount = aggregate.ObservedDetailRecordCount, + MaximumDetailRecordCount = aggregate.MaximumDetailRecordCount, FileLines = aggregate.FileLines, EstateId = aggregate.EstateId, MerchantId = aggregate.MerchantId, @@ -129,12 +134,50 @@ public static Result CreateFile(this FileAggregate aggregate, CreateFileRequest return Result.Success(); } + public static void PlayEvent(this FileAggregate aggregate, FileRejectedEvent domainEvent) + { + aggregate.IsRejected = true; + aggregate.RejectionCode = domainEvent.RejectionCode; + aggregate.RejectionReason = domainEvent.RejectionReason; + aggregate.ObservedDetailRecordCount = domainEvent.ObservedDetailRecordCount; + aggregate.MaximumDetailRecordCount = domainEvent.MaximumDetailRecordCount; + aggregate.RejectedDateTime = domainEvent.RejectedDateTime; + } + + public static Result RejectFile(this FileAggregate aggregate, + Int32 observedDetailRecordCount, + Int32 maximumDetailRecordCount, + String rejectionCode, + String rejectionReason, + DateTime rejectedDateTime) + { + if (aggregate.IsCreated == false) + return Result.Invalid($"File Id {aggregate.AggregateId} has not been uploaded yet"); + + if (aggregate.IsRejected) + return Result.Success(); + + FileRejectedEvent fileRejectedEvent = new(aggregate.AggregateId, + aggregate.EstateId, + aggregate.MerchantId, + rejectionCode, + rejectionReason, + observedDetailRecordCount, + maximumDetailRecordCount, + rejectedDateTime); + aggregate.ApplyAndAppend(fileRejectedEvent); + return Result.Success(); + } + public static Result AddFileLine(this FileAggregate aggregate, String fileLine) { if (aggregate.IsCreated == false) { return Result.Invalid($"File Id {aggregate.AggregateId} has not been uploaded yet"); } + if (aggregate.IsRejected) + return Result.Invalid($"File Id {aggregate.AggregateId} has been rejected"); + Boolean lineAlreadyExists = aggregate.FileLines.Any(f => f.LineData == fileLine); // We already have this line so just return @@ -290,6 +333,18 @@ public record FileAggregate : Aggregate internal Boolean IsCompleted; + internal Boolean IsRejected; + + internal String RejectionCode; + + internal String RejectionReason; + + internal Int32 ObservedDetailRecordCount; + + internal Int32 MaximumDetailRecordCount; + + internal DateTime RejectedDateTime; + /// /// Initializes a new instance of the class. /// diff --git a/FileProcessor.IntegrationTesting.Helpers/Class1.cs b/FileProcessor.IntegrationTesting.Helpers/Class1.cs index fe5da3f..b04c017 100644 --- a/FileProcessor.IntegrationTesting.Helpers/Class1.cs +++ b/FileProcessor.IntegrationTesting.Helpers/Class1.cs @@ -16,20 +16,28 @@ public EstateDetails1(EstateDetails estateDetails) { this.EstateDetails = estateDetails; this.FileImportLogFiles = new List(); + this.UploadedFiles = new List(); } public EstateDetails EstateDetails { get; private set; } private List FileImportLogFiles; + private List UploadedFiles; + public void AddFileImportLogFile(FileImportLogFile fileImportLogFile) { this.FileImportLogFiles.Add(fileImportLogFile); } + public void AddUploadedFile(FileImportLogFile fileImportLogFile) + { + this.UploadedFiles.Add(fileImportLogFile); + } + public Guid GetFileId(String originalFileName) { - return this.FileImportLogFiles.Single(o => o.OriginalFileName == originalFileName).FileId; + return this.UploadedFiles.Single(o => o.OriginalFileName == originalFileName).FileId; } } @@ -44,4 +52,4 @@ public static class SubscriptionsHelper return subscriptions; } } -} \ No newline at end of file +} diff --git a/FileProcessor.IntegrationTesting.Helpers/FileProcessorSteps.cs b/FileProcessor.IntegrationTesting.Helpers/FileProcessorSteps.cs index 50a09de..710bf85 100644 --- a/FileProcessor.IntegrationTesting.Helpers/FileProcessorSteps.cs +++ b/FileProcessor.IntegrationTesting.Helpers/FileProcessorSteps.cs @@ -43,6 +43,12 @@ public async Task GivenIUploadThisFileForProcessing(String accessToken, St var fileId = uploadFileResult.Data; fileId.ShouldNotBe(Guid.Empty); + estateDetails.AddUploadedFile(new FileImportLogFile + { + FileId = fileId, + OriginalFileName = Path.GetFileName(filePath), + }); + await Retry.For(async () => { Result? getFileResult = @@ -51,13 +57,13 @@ await Retry.For(async () => FileDetails? fileDetails = getFileResult.Data; fileDetails.ShouldNotBeNull(); - if (!fileDetails.ProcessingCompleted) + if (!fileDetails.ProcessingCompleted && !fileDetails.IsRejected) { Console.WriteLine(this.FormatFileDetails(fileDetails)); } - fileDetails.ProcessingCompleted.ShouldBeTrue(); - }, TimeSpan.FromMinutes(5), + (fileDetails.ProcessingCompleted || fileDetails.IsRejected).ShouldBeTrue(); + }, TimeSpan.FromMinutes(15), TimeSpan.FromSeconds(30)); return fileId; @@ -257,4 +263,28 @@ await Retry.For(async () => { TimeSpan.FromMinutes(4), TimeSpan.FromSeconds(30)); } + + public async Task WhenIGetTheFileForEstateItIsRejectedForExceedingTheDetailRowLimit(String accessToken, + String fileName, + String estateName, + List estateDetailsList) + { + EstateDetails1 estate = estateDetailsList.SingleOrDefault(e => e.EstateDetails.EstateName == estateName); + estate.ShouldNotBeNull(); + + Guid fileId = estate.GetFileId(fileName); + + await Retry.For(async () => + { + var getFileResult = await this.FileProcessorClient.GetFile(accessToken, estate.EstateDetails.EstateId, fileId, CancellationToken.None); + getFileResult.IsSuccess.ShouldBeTrue(); + var fileDetails = getFileResult.Data; + fileDetails.ShouldNotBeNull(); + fileDetails.IsRejected.ShouldBeTrue(); + fileDetails.RejectionCode.ShouldBe("MaximumDetailRecordsExceeded"); + fileDetails.ObservedDetailRecordCount.ShouldBe(1001); + fileDetails.MaximumDetailRecordCount.ShouldBe(1000); + fileDetails.FileLines.ShouldBeEmpty(); + }, TimeSpan.FromMinutes(4), TimeSpan.FromSeconds(30)); + } } diff --git a/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature b/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature index 04f681e..50daf34 100644 --- a/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature +++ b/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature @@ -1,7 +1,7 @@ -@base @shared @fileprofiles +@base @shared @fileprofiles Feature: Process Topup CSV Files -Background: +Background: Given I create the following api scopes | Name | DisplayName | Description | | transactionProcessor | Transaction Processor REST Scope | A scope for Transaction Processor REST | @@ -17,8 +17,8 @@ Background: | serviceClient | Service Client | Secret1 | transactionProcessor,fileProcessor | client_credentials | Given I have a token to access the estate management and transaction processor resources - | ClientId | - | serviceClient | + | ClientId | + | serviceClient | When I create the following file profiles | Alias | FileProfileId | Name | ListeningDirectory | RequestType | OperatorName | LineTerminator | FileFormatHandler | @@ -56,7 +56,7 @@ Background: Given I create the following merchants | MerchantName | AddressLine1 | Town | Region |PostalCode | Country | ContactName | EmailAddress | EstateName | | Test Merchant 1 | Address Line 1 | TestTown | Test Region |TE57 1NG | United Kingdom | Test Contact 1 | testcontact1@merchant1.co.uk | Test Estate 1 | - + Given I have assigned the following operator to the merchants | OperatorName | MerchantName | MerchantNumber | TerminalNumber | EstateName | | Safaricom | Test Merchant 1 | 00000001 | 10000001 | Test Estate 1 | @@ -71,7 +71,7 @@ Background: | Test Estate 1 | Test Merchant 1 | Safaricom Contract | | Test Estate 1 | Test Merchant 1 | Hospital 1 Contract | - Given I make the following manual merchant deposits + Given I make the following manual merchant deposits | Reference | Amount | DateTime | MerchantName | EstateName | | Deposit1 | 300.00 | Today | Test Merchant 1 | Test Estate 1 | @@ -134,7 +134,7 @@ Scenario: Process Duplicate Safaricom Topup File with 1 detail row And I upload this file for processing | EstateName | MerchantName | FileProfileId | UserId | | Test Estate 1 | Test Merchant 1 | B2A59ABF-293D-4A6B-B81B-7007503C3476 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | - + #When As merchant "Test Merchant 1" on Estate "Test Estate 1" I get my transactions 2 transaction should be returned Given I have a file named 'SafarcomTopup2.txt' with the following contents @@ -146,7 +146,7 @@ Scenario: Process Duplicate Safaricom Topup File with 1 detail row And I upload this file for processing an error should be returned indicating the file is a duplicate | EstateName | MerchantName | FileProfileId | UserId | | Test Estate 1 | Test Merchant 1 | B2A59ABF-293D-4A6B-B81B-7007503C3476 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | - + # Wrong Format?? Scenario: Process Safaricom Topup File with Upload Date Time Given I have a file named 'SafarcomTopup.txt' with the following contents @@ -170,3 +170,28 @@ Scenario: Process Safaricom Topup File with Upload Date Time | Test Estate 1 | Test Merchant 1 | B2A59ABF-293D-4A6B-B81B-7007503C3476 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | 01/09/2021 | When I get the import log for estate 'Test Estate 1' the date on the import log is '01/09/2021' +@largefile @getfileimportdetails +Scenario: Upload a generated Safaricom Topup file with 1000 detail rows + Given I make the following manual merchant deposits + | Reference | Amount | DateTime | MerchantName | EstateName | + | Deposit1 | 600000.00 | Today | Test Merchant 1 | Test Estate 1 | + Given I have a generated Safaricom Topup file named 'LargeSafarcomTopup.txt' with 1000 detail rows + And I upload this file for processing + | EstateName | MerchantName | FileProfileId | UserId | + | Test Estate 1 | Test Merchant 1 | B2A59ABF-293D-4A6B-B81B-7007503C3476 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | + + When I get the file 'LargeSafarcomTopup.txt' for Estate 'Test Estate 1' the following file information is returned + | ProcessingCompleted | NumberOfLines | TotaLines | SuccessfulLines | IgnoredLines | FailedLines | NotProcessedLines | + | True | 1002 | 1002 | 1000 | 2 | 0 | 0 | + +@largefile @getfileimportdetails +Scenario: Reject a generated Safaricom Topup file with more than 1000 detail rows + Given I make the following manual merchant deposits + | Reference | Amount | DateTime | MerchantName | EstateName | + | Deposit1 | 600000.00 | Today | Test Merchant 1 | Test Estate 1 | + Given I have a generated Safaricom Topup file named 'OversizedSafarcomTopup.txt' with 1001 detail rows + And I upload this file for processing + | EstateName | MerchantName | FileProfileId | UserId | + | Test Estate 1 | Test Merchant 1 | B2A59ABF-293D-4A6B-B81B-7007503C3476 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | + + When I get the file 'OversizedSafarcomTopup.txt' for Estate 'Test Estate 1' it is rejected for exceeding the detail row limit diff --git a/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature.cs b/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature.cs index 9cbcd7b..de199ad 100644 --- a/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature.cs +++ b/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by Reqnroll (https://reqnroll.net/). // Reqnroll Version:3.0.0.0 @@ -13,8 +13,8 @@ using Reqnroll; namespace FileProcessor.IntegrationTests.Features { - - + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::NUnit.Framework.TestFixtureAttribute()] @@ -25,30 +25,30 @@ namespace FileProcessor.IntegrationTests.Features [global::NUnit.Framework.CategoryAttribute("fileprofiles")] public partial class ProcessTopupCSVFilesFeature { - + private global::Reqnroll.ITestRunner testRunner; - + private static string[] featureTags = new string[] { "base", "shared", "fileprofiles"}; - + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "Process Topup CSV Files", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); - + #line 1 "ProcessTopupCSV.feature" #line hidden - + [global::NUnit.Framework.OneTimeSetUpAttribute()] public static async global::System.Threading.Tasks.Task FeatureSetupAsync() { } - + [global::NUnit.Framework.OneTimeTearDownAttribute()] public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() { await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); } - + [global::NUnit.Framework.SetUpAttribute()] public async global::System.Threading.Tasks.Task TestInitializeAsync() { @@ -74,7 +74,7 @@ public partial class ProcessTopupCSVFilesFeature } } } - + [global::NUnit.Framework.TearDownAttribute()] public async global::System.Threading.Tasks.Task TestTearDownAsync() { @@ -92,87 +92,87 @@ public partial class ProcessTopupCSVFilesFeature testRunner = null; } } - + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) { testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(global::NUnit.Framework.TestContext.CurrentContext); } - + public async global::System.Threading.Tasks.Task ScenarioStartAsync() { await testRunner.OnScenarioStartAsync(); } - + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() { await testRunner.CollectScenarioErrorsAsync(); } - + public virtual async global::System.Threading.Tasks.Task FeatureBackgroundAsync() { #line 4 #line hidden - global::Reqnroll.Table table40 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { "Name", "DisplayName", "Description"}); - table40.AddRow(new string[] { + table1.AddRow(new string[] { "transactionProcessor", "Transaction Processor REST Scope", "A scope for Transaction Processor REST"}); - table40.AddRow(new string[] { + table1.AddRow(new string[] { "fileProcessor", "File Processor REST Scope", "A scope for File Processor REST"}); #line 5 - await testRunner.GivenAsync("I create the following api scopes", ((string)(null)), table40, "Given "); + await testRunner.GivenAsync("I create the following api scopes", ((string)(null)), table1, "Given "); #line hidden - global::Reqnroll.Table table41 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { "Name", "DisplayName", "Secret", "Scopes", "UserClaims"}); - table41.AddRow(new string[] { + table2.AddRow(new string[] { "transactionProcessor", "Transaction Processor REST", "Secret1", "transactionProcessor", "MerchantId, EstateId, role"}); - table41.AddRow(new string[] { + table2.AddRow(new string[] { "fileProcessor", "File Processor REST", "Secret1", "fileProcessor", ""}); #line 10 - await testRunner.GivenAsync("the following api resources exist", ((string)(null)), table41, "Given "); + await testRunner.GivenAsync("the following api resources exist", ((string)(null)), table2, "Given "); #line hidden - global::Reqnroll.Table table42 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { "ClientId", "ClientName", "Secret", "Scopes", "GrantTypes"}); - table42.AddRow(new string[] { + table3.AddRow(new string[] { "serviceClient", "Service Client", "Secret1", "transactionProcessor,fileProcessor", "client_credentials"}); #line 15 - await testRunner.GivenAsync("the following clients exist", ((string)(null)), table42, "Given "); + await testRunner.GivenAsync("the following clients exist", ((string)(null)), table3, "Given "); #line hidden - global::Reqnroll.Table table43 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table4 = new global::Reqnroll.Table(new string[] { "ClientId"}); - table43.AddRow(new string[] { + table4.AddRow(new string[] { "serviceClient"}); #line 19 await testRunner.GivenAsync("I have a token to access the estate management and transaction processor resource" + - "s", ((string)(null)), table43, "Given "); + "s", ((string)(null)), table4, "Given "); #line hidden - global::Reqnroll.Table table44 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table5 = new global::Reqnroll.Table(new string[] { "Alias", "FileProfileId", "Name", @@ -181,7 +181,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "OperatorName", "LineTerminator", "FileFormatHandler"}); - table44.AddRow(new string[] { + table5.AddRow(new string[] { "Safaricom Topup", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "Safaricom Topup", @@ -190,7 +190,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Safaricom", "1", "SafaricomFileFormatHandler"}); - table44.AddRow(new string[] { + table5.AddRow(new string[] { "Voucher Issue", "8806EDBC-3ED6-406B-9E5F-A9078356BE99", "Voucher Issue", @@ -200,61 +200,61 @@ await testRunner.GivenAsync("I have a token to access the estate management and "1", "VoucherFileFormatHandler"}); #line 23 - await testRunner.WhenAsync("I create the following file profiles", ((string)(null)), table44, "When "); + await testRunner.WhenAsync("I create the following file profiles", ((string)(null)), table5, "When "); #line hidden - global::Reqnroll.Table table45 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table6 = new global::Reqnroll.Table(new string[] { "EstateName"}); - table45.AddRow(new string[] { + table6.AddRow(new string[] { "Test Estate 1"}); #line 28 - await testRunner.GivenAsync("I have created the following estates", ((string)(null)), table45, "Given "); + await testRunner.GivenAsync("I have created the following estates", ((string)(null)), table6, "Given "); #line hidden - global::Reqnroll.Table table46 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table7 = new global::Reqnroll.Table(new string[] { "EstateName", "OperatorName", "RequireCustomMerchantNumber", "RequireCustomTerminalNumber"}); - table46.AddRow(new string[] { + table7.AddRow(new string[] { "Test Estate 1", "Safaricom", "True", "True"}); - table46.AddRow(new string[] { + table7.AddRow(new string[] { "Test Estate 1", "Voucher", "True", "True"}); #line 32 - await testRunner.GivenAsync("I have created the following operators", ((string)(null)), table46, "Given "); + await testRunner.GivenAsync("I have created the following operators", ((string)(null)), table7, "Given "); #line hidden - global::Reqnroll.Table table47 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table8 = new global::Reqnroll.Table(new string[] { "EstateName", "OperatorName"}); - table47.AddRow(new string[] { + table8.AddRow(new string[] { "Test Estate 1", "Safaricom"}); - table47.AddRow(new string[] { + table8.AddRow(new string[] { "Test Estate 1", "Voucher"}); #line 37 - await testRunner.AndAsync("I have assigned the following operators to the estates", ((string)(null)), table47, "And "); + await testRunner.AndAsync("I have assigned the following operators to the estates", ((string)(null)), table8, "And "); #line hidden - global::Reqnroll.Table table48 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table9 = new global::Reqnroll.Table(new string[] { "EstateName", "OperatorName", "ContractDescription"}); - table48.AddRow(new string[] { + table9.AddRow(new string[] { "Test Estate 1", "Safaricom", "Safaricom Contract"}); - table48.AddRow(new string[] { + table9.AddRow(new string[] { "Test Estate 1", "Voucher", "Hospital 1 Contract"}); #line 42 - await testRunner.GivenAsync("I create a contract with the following values", ((string)(null)), table48, "Given "); + await testRunner.GivenAsync("I create a contract with the following values", ((string)(null)), table9, "Given "); #line hidden - global::Reqnroll.Table table49 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table10 = new global::Reqnroll.Table(new string[] { "EstateName", "OperatorName", "ContractDescription", @@ -262,7 +262,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "DisplayText", "Value", "ProductType"}); - table49.AddRow(new string[] { + table10.AddRow(new string[] { "Test Estate 1", "Safaricom", "Safaricom Contract", @@ -270,7 +270,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Custom", "", "MobileTopup"}); - table49.AddRow(new string[] { + table10.AddRow(new string[] { "Test Estate 1", "Voucher", "Hospital 1 Contract", @@ -279,9 +279,9 @@ await testRunner.GivenAsync("I have a token to access the estate management and "", "Voucher"}); #line 47 - await testRunner.WhenAsync("I create the following Products", ((string)(null)), table49, "When "); + await testRunner.WhenAsync("I create the following Products", ((string)(null)), table10, "When "); #line hidden - global::Reqnroll.Table table50 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table11 = new global::Reqnroll.Table(new string[] { "EstateName", "OperatorName", "ContractDescription", @@ -289,7 +289,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "CalculationType", "FeeDescription", "Value"}); - table50.AddRow(new string[] { + table11.AddRow(new string[] { "Test Estate 1", "Safaricom", "Safaricom Contract", @@ -298,9 +298,9 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Merchant Commission", "2.50"}); #line 52 - await testRunner.WhenAsync("I add the following Transaction Fees", ((string)(null)), table50, "When "); + await testRunner.WhenAsync("I add the following Transaction Fees", ((string)(null)), table11, "When "); #line hidden - global::Reqnroll.Table table51 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table12 = new global::Reqnroll.Table(new string[] { "MerchantName", "AddressLine1", "Town", @@ -310,7 +310,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "ContactName", "EmailAddress", "EstateName"}); - table51.AddRow(new string[] { + table12.AddRow(new string[] { "Test Merchant 1", "Address Line 1", "TestTown", @@ -321,77 +321,77 @@ await testRunner.GivenAsync("I have a token to access the estate management and "testcontact1@merchant1.co.uk", "Test Estate 1"}); #line 56 - await testRunner.GivenAsync("I create the following merchants", ((string)(null)), table51, "Given "); + await testRunner.GivenAsync("I create the following merchants", ((string)(null)), table12, "Given "); #line hidden - global::Reqnroll.Table table52 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table13 = new global::Reqnroll.Table(new string[] { "OperatorName", "MerchantName", "MerchantNumber", "TerminalNumber", "EstateName"}); - table52.AddRow(new string[] { + table13.AddRow(new string[] { "Safaricom", "Test Merchant 1", "00000001", "10000001", "Test Estate 1"}); - table52.AddRow(new string[] { + table13.AddRow(new string[] { "Voucher", "Test Merchant 1", "00000001", "10000001", "Test Estate 1"}); #line 60 - await testRunner.GivenAsync("I have assigned the following operator to the merchants", ((string)(null)), table52, "Given "); + await testRunner.GivenAsync("I have assigned the following operator to the merchants", ((string)(null)), table13, "Given "); #line hidden - global::Reqnroll.Table table53 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table14 = new global::Reqnroll.Table(new string[] { "DeviceIdentifier", "MerchantName", "EstateName"}); - table53.AddRow(new string[] { + table14.AddRow(new string[] { "123456780", "Test Merchant 1", "Test Estate 1"}); #line 65 - await testRunner.GivenAsync("I have assigned the following devices to the merchants", ((string)(null)), table53, "Given "); + await testRunner.GivenAsync("I have assigned the following devices to the merchants", ((string)(null)), table14, "Given "); #line hidden - global::Reqnroll.Table table54 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table15 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "ContractDescription"}); - table54.AddRow(new string[] { + table15.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "Safaricom Contract"}); - table54.AddRow(new string[] { + table15.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "Hospital 1 Contract"}); #line 69 - await testRunner.WhenAsync("I add the following contracts to the following merchants", ((string)(null)), table54, "When "); + await testRunner.WhenAsync("I add the following contracts to the following merchants", ((string)(null)), table15, "When "); #line hidden - global::Reqnroll.Table table55 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table16 = new global::Reqnroll.Table(new string[] { "Reference", "Amount", "DateTime", "MerchantName", "EstateName"}); - table55.AddRow(new string[] { + table16.AddRow(new string[] { "Deposit1", "300.00", "Today", "Test Merchant 1", "Test Estate 1"}); #line 74 - await testRunner.GivenAsync("I make the following manual merchant deposits", ((string)(null)), table55, "Given "); + await testRunner.GivenAsync("I make the following manual merchant deposits", ((string)(null)), table16, "Given "); #line hidden } - + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/ProcessTopupCSV.feature.ndjson", 7); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/ProcessTopupCSV.feature.ndjson", 9); } - + [global::NUnit.Framework.TestAttribute()] [global::NUnit.Framework.DescriptionAttribute("Process Safaricom Topup File with 1 detail row")] public async global::System.Threading.Tasks.Task ProcessSafaricomTopupFileWith1DetailRow() @@ -415,42 +415,42 @@ await testRunner.GivenAsync("I have a token to access the estate management and #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table56 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table17 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table56.AddRow(new string[] { + table17.AddRow(new string[] { "H", "20210508", ""}); - table56.AddRow(new string[] { + table17.AddRow(new string[] { "D", "07777777775", "100"}); - table56.AddRow(new string[] { + table17.AddRow(new string[] { "T", "1", ""}); #line 80 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup.txt\' with the following contents", ((string)(null)), table56, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup.txt\' with the following contents", ((string)(null)), table17, "Given "); #line hidden - global::Reqnroll.Table table57 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table18 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId"}); - table57.AddRow(new string[] { + table18.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); #line 85 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table57, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table18, "And "); #line hidden } await this.ScenarioCleanupAsync(); } - + [global::NUnit.Framework.TestAttribute()] [global::NUnit.Framework.DescriptionAttribute("Process Safaricom Topup File with 2 detail rows")] public async global::System.Threading.Tasks.Task ProcessSafaricomTopupFileWith2DetailRows() @@ -474,46 +474,46 @@ await testRunner.GivenAsync("I have a token to access the estate management and #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table58 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table19 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table58.AddRow(new string[] { + table19.AddRow(new string[] { "H", "20210508", ""}); - table58.AddRow(new string[] { + table19.AddRow(new string[] { "D", "07777777775", "100"}); - table58.AddRow(new string[] { + table19.AddRow(new string[] { "D", "07777777776", "200"}); - table58.AddRow(new string[] { + table19.AddRow(new string[] { "T", "2", ""}); #line 92 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup.txt\' with the following contents", ((string)(null)), table58, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup.txt\' with the following contents", ((string)(null)), table19, "Given "); #line hidden - global::Reqnroll.Table table59 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table20 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId"}); - table59.AddRow(new string[] { + table20.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); #line 98 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table59, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table20, "And "); #line hidden } await this.ScenarioCleanupAsync(); } - + [global::NUnit.Framework.TestAttribute()] [global::NUnit.Framework.DescriptionAttribute("Process 2 Safaricom Topup Files")] public async global::System.Threading.Tasks.Task Process2SafaricomTopupFiles() @@ -537,78 +537,78 @@ await testRunner.GivenAsync("I have a token to access the estate management and #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table60 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table21 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table60.AddRow(new string[] { + table21.AddRow(new string[] { "H", "20210508", ""}); - table60.AddRow(new string[] { + table21.AddRow(new string[] { "D", "07777777775", "100"}); - table60.AddRow(new string[] { + table21.AddRow(new string[] { "T", "1", ""}); #line 105 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup1.txt\' with the following contents", ((string)(null)), table60, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup1.txt\' with the following contents", ((string)(null)), table21, "Given "); #line hidden - global::Reqnroll.Table table61 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table22 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId"}); - table61.AddRow(new string[] { + table22.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); #line 110 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table61, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table22, "And "); #line hidden - global::Reqnroll.Table table62 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table23 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table62.AddRow(new string[] { + table23.AddRow(new string[] { "H", "20210508", ""}); - table62.AddRow(new string[] { + table23.AddRow(new string[] { "D", "07777777776", "150"}); - table62.AddRow(new string[] { + table23.AddRow(new string[] { "D", "07777777777", "50"}); - table62.AddRow(new string[] { + table23.AddRow(new string[] { "T", "2", ""}); #line 114 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup2.txt\' with the following contents", ((string)(null)), table62, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup2.txt\' with the following contents", ((string)(null)), table23, "Given "); #line hidden - global::Reqnroll.Table table63 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table24 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId"}); - table63.AddRow(new string[] { + table24.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); #line 120 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table63, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table24, "And "); #line hidden } await this.ScenarioCleanupAsync(); } - + [global::NUnit.Framework.TestAttribute()] [global::NUnit.Framework.DescriptionAttribute("Process Duplicate Safaricom Topup File with 1 detail row")] [global::NUnit.Framework.CategoryAttribute("PRTest")] @@ -634,83 +634,83 @@ await testRunner.GivenAsync("I have a token to access the estate management and #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table64 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table25 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table64.AddRow(new string[] { + table25.AddRow(new string[] { "H", "20210508", ""}); - table64.AddRow(new string[] { + table25.AddRow(new string[] { "D", "07777777775", "100"}); - table64.AddRow(new string[] { + table25.AddRow(new string[] { "D", "07777777776", "200"}); - table64.AddRow(new string[] { + table25.AddRow(new string[] { "T", "1", ""}); #line 128 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup1.txt\' with the following contents", ((string)(null)), table64, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup1.txt\' with the following contents", ((string)(null)), table25, "Given "); #line hidden - global::Reqnroll.Table table65 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table26 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId"}); - table65.AddRow(new string[] { + table26.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); #line 134 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table65, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table26, "And "); #line hidden - global::Reqnroll.Table table66 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table27 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table66.AddRow(new string[] { + table27.AddRow(new string[] { "H", "20210508", ""}); - table66.AddRow(new string[] { + table27.AddRow(new string[] { "D", "07777777775", "100"}); - table66.AddRow(new string[] { + table27.AddRow(new string[] { "D", "07777777776", "200"}); - table66.AddRow(new string[] { + table27.AddRow(new string[] { "T", "1", ""}); #line 140 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup2.txt\' with the following contents", ((string)(null)), table66, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup2.txt\' with the following contents", ((string)(null)), table27, "Given "); #line hidden - global::Reqnroll.Table table67 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table28 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId"}); - table67.AddRow(new string[] { + table28.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); #line 146 await testRunner.AndAsync("I upload this file for processing an error should be returned indicating the file" + - " is a duplicate", ((string)(null)), table67, "And "); + " is a duplicate", ((string)(null)), table28, "And "); #line hidden } await this.ScenarioCleanupAsync(); } - + [global::NUnit.Framework.TestAttribute()] [global::NUnit.Framework.DescriptionAttribute("Process Safaricom Topup File with Upload Date Time")] public async global::System.Threading.Tasks.Task ProcessSafaricomTopupFileWithUploadDateTime() @@ -734,81 +734,231 @@ await testRunner.GivenAsync("I have a token to access the estate management and #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table68 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table29 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table68.AddRow(new string[] { + table29.AddRow(new string[] { "H", "20210508", ""}); - table68.AddRow(new string[] { + table29.AddRow(new string[] { "D", "07777777775", "100"}); - table68.AddRow(new string[] { + table29.AddRow(new string[] { "T", "1", ""}); #line 152 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup.txt\' with the following contents", ((string)(null)), table68, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup.txt\' with the following contents", ((string)(null)), table29, "Given "); #line hidden - global::Reqnroll.Table table69 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table30 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId", "UploadDateTime"}); - table69.AddRow(new string[] { + table30.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476", "Today"}); #line 157 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table69, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table30, "And "); #line hidden #line 161 await testRunner.WhenAsync("I get the import log for estate \'Test Estate 1\' the date on the import log is \'To" + "day\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden - global::Reqnroll.Table table70 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table31 = new global::Reqnroll.Table(new string[] { "Column1", "Column2", "Column3"}); - table70.AddRow(new string[] { + table31.AddRow(new string[] { "H", "20210508", ""}); - table70.AddRow(new string[] { + table31.AddRow(new string[] { "D", "07777777775", "200"}); - table70.AddRow(new string[] { + table31.AddRow(new string[] { "T", "1", ""}); #line 163 - await testRunner.GivenAsync("I have a file named \'SafarcomTopup1.txt\' with the following contents", ((string)(null)), table70, "Given "); + await testRunner.GivenAsync("I have a file named \'SafarcomTopup1.txt\' with the following contents", ((string)(null)), table31, "Given "); #line hidden - global::Reqnroll.Table table71 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table32 = new global::Reqnroll.Table(new string[] { "EstateName", "MerchantName", "FileProfileId", "UserId", "UploadDateTime"}); - table71.AddRow(new string[] { + table32.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", "B2A59ABF-293D-4A6B-B81B-7007503C3476", "ABA59ABF-293D-4A6B-B81B-7007503C3476", "01/09/2021"}); #line 168 - await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table71, "And "); + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table32, "And "); #line hidden #line 172 await testRunner.WhenAsync("I get the import log for estate \'Test Estate 1\' the date on the import log is \'01" + "/09/2021\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Upload a generated Safaricom Topup file with 1000 detail rows")] + [global::NUnit.Framework.CategoryAttribute("largefile")] + [global::NUnit.Framework.CategoryAttribute("getfileimportdetails")] + public async global::System.Threading.Tasks.Task UploadAGeneratedSafaricomTopupFileWith1000DetailRows() + { + string[] tagsOfScenario = new string[] { + "largefile", + "getfileimportdetails"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "5"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Upload a generated Safaricom Topup file with 1000 detail rows", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 174 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table33 = new global::Reqnroll.Table(new string[] { + "Reference", + "Amount", + "DateTime", + "MerchantName", + "EstateName"}); + table33.AddRow(new string[] { + "Deposit1", + "600000.00", + "Today", + "Test Merchant 1", + "Test Estate 1"}); +#line 175 + await testRunner.GivenAsync("I make the following manual merchant deposits", ((string)(null)), table33, "Given "); +#line hidden +#line 178 + await testRunner.GivenAsync("I have a generated Safaricom Topup file named \'LargeSafarcomTopup.txt\' with 1000 " + + "detail rows", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden + global::Reqnroll.Table table34 = new global::Reqnroll.Table(new string[] { + "EstateName", + "MerchantName", + "FileProfileId", + "UserId"}); + table34.AddRow(new string[] { + "Test Estate 1", + "Test Merchant 1", + "B2A59ABF-293D-4A6B-B81B-7007503C3476", + "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); +#line 179 + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table34, "And "); +#line hidden + global::Reqnroll.Table table35 = new global::Reqnroll.Table(new string[] { + "ProcessingCompleted", + "NumberOfLines", + "TotaLines", + "SuccessfulLines", + "IgnoredLines", + "FailedLines", + "NotProcessedLines"}); + table35.AddRow(new string[] { + "True", + "1002", + "1002", + "1000", + "2", + "0", + "0"}); +#line 183 + await testRunner.WhenAsync("I get the file \'LargeSafarcomTopup.txt\' for Estate \'Test Estate 1\' the following " + + "file information is returned", ((string)(null)), table35, "When "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Reject a generated Safaricom Topup file with more than 1000 detail rows")] + [global::NUnit.Framework.CategoryAttribute("largefile")] + [global::NUnit.Framework.CategoryAttribute("getfileimportdetails")] + public async global::System.Threading.Tasks.Task RejectAGeneratedSafaricomTopupFileWithMoreThan1000DetailRows() + { + string[] tagsOfScenario = new string[] { + "largefile", + "getfileimportdetails"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "6"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Reject a generated Safaricom Topup file with more than 1000 detail rows", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 188 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table36 = new global::Reqnroll.Table(new string[] { + "Reference", + "Amount", + "DateTime", + "MerchantName", + "EstateName"}); + table36.AddRow(new string[] { + "Deposit1", + "600000.00", + "Today", + "Test Merchant 1", + "Test Estate 1"}); +#line 189 + await testRunner.GivenAsync("I make the following manual merchant deposits", ((string)(null)), table36, "Given "); +#line hidden +#line 192 + await testRunner.GivenAsync("I have a generated Safaricom Topup file named \'OversizedSafarcomTopup.txt\' with 1" + + "001 detail rows", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden + global::Reqnroll.Table table37 = new global::Reqnroll.Table(new string[] { + "EstateName", + "MerchantName", + "FileProfileId", + "UserId"}); + table37.AddRow(new string[] { + "Test Estate 1", + "Test Merchant 1", + "B2A59ABF-293D-4A6B-B81B-7007503C3476", + "ABA59ABF-293D-4A6B-B81B-7007503C3476"}); +#line 193 + await testRunner.AndAsync("I upload this file for processing", ((string)(null)), table37, "And "); +#line hidden +#line 197 + await testRunner.WhenAsync("I get the file \'OversizedSafarcomTopup.txt\' for Estate \'Test Estate 1\' it is reje" + + "cted for exceeding the detail row limit", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/FileProcessor.IntegrationTests/Steps/SharedSteps.cs b/FileProcessor.IntegrationTests/Steps/SharedSteps.cs index 093a725..c236be3 100644 --- a/FileProcessor.IntegrationTests/Steps/SharedSteps.cs +++ b/FileProcessor.IntegrationTests/Steps/SharedSteps.cs @@ -1,6 +1,7 @@ using SecurityService.DataTransferObjects; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using TransactionProcessor.DataTransferObjects.Requests.Contract; @@ -74,6 +75,39 @@ public void GivenIHaveAFileNamedWithTheFollowingContents(string fileName, DataTa this.TestingContext.UploadFile = this.GetFileProcessorSteps().WriteDileToDisk(fileName, fileData); } + [Given(@"I have a generated Safaricom Topup file named '(.*)' with (.*) detail rows")] + public void GivenIHaveAGeneratedSafaricomTopupFileNamedWithDetailRows(string fileName, int detailRows) + { + detailRows.ShouldBeGreaterThan(0); + + string directory = "/home/runner/specflow"; + Directory.CreateDirectory(directory); + string filePath = Path.Combine(directory, fileName); + + using (StreamWriter writer = new StreamWriter(filePath, false)) + { + writer.WriteLine("H,20210508,"); + for (int row = 1; row <= detailRows; row++) + { + int amount = 10 + ((row - 1) % 240); + string customerAccountNumber = $"0777777{row:0000}"; + writer.WriteLine($"D,{customerAccountNumber},{amount}"); + } + + writer.Write($"T,{detailRows},"); + } + + long fileBytes = new FileInfo(filePath).Length; + this.TestingContext.UploadFile = filePath; + this.ScenarioContext["LargeFileProbe"] = new LargeFileProbe(detailRows, + fileBytes, + Process.GetCurrentProcess().WorkingSet64, + GC.GetTotalMemory(forceFullCollection: false), + Stopwatch.GetTimestamp()); + + this.TestingContext.Logger.LogInformation($"Large file generated: rows={detailRows}, bytes={fileBytes}"); + } + [Given(@"I upload this file for processing")] public async Task GivenIUploadThisFileForProcessing(DataTable table) { @@ -82,6 +116,16 @@ public async Task GivenIUploadThisFileForProcessing(DataTable table) (EstateDetails1, UploadFileRequest) uploadFileRequest = table.Rows.ToUploadFileRequest(this.TestingContext.Estates, fileData); await this.GetFileProcessorSteps().GivenIUploadThisFileForProcessing(this.TestingContext.AccessToken, filePath, fileData, uploadFileRequest.Item1, uploadFileRequest.Item2); + + if (this.ScenarioContext.TryGetValue("LargeFileProbe", out LargeFileProbe probe)) + { + TimeSpan elapsed = Stopwatch.GetElapsedTime(probe.StartTimestamp); + long workingSetDelta = Process.GetCurrentProcess().WorkingSet64 - probe.StartingWorkingSet; + long managedMemoryDelta = GC.GetTotalMemory(forceFullCollection: false) - probe.StartingManagedMemory; + this.TestingContext.Logger.LogInformation($"Large file completed: rows={probe.DetailRows}, bytes={probe.FileBytes}, " + + $"elapsed={elapsed}, workingSetDeltaBytes={workingSetDelta}, " + + $"managedMemoryDeltaBytes={managedMemoryDelta}"); + } } [Given(@"I upload this file for processing an error should be returned indicating the file is a duplicate")] @@ -98,6 +142,15 @@ public async Task WhenIGetTheImportLogForEstateTheDateOnTheImportLogIs(string es await this.GetFileProcessorSteps().WhenIGetTheImportLogForEstateTheDateOnTheImportLogIs(this.TestingContext.AccessToken, estateName, expectedDateString, this.TestingContext.Estates); } + [When(@"I get the file '([^']*)' for Estate '([^']*)' it is rejected for exceeding the detail row limit")] + public async Task WhenIGetTheFileForEstateItIsRejectedForExceedingTheDetailRowLimit(String fileName, String estateName) + { + await this.GetFileProcessorSteps().WhenIGetTheFileForEstateItIsRejectedForExceedingTheDetailRowLimit(this.TestingContext.AccessToken, + fileName, + estateName, + this.TestingContext.Estates); + } + //[When(@"As merchant ""(.*)"" on Estate ""(.*)"" I get my transactions (.*) transaction should be returned")] //public async Task WhenAsMerchantOnEstateIGetMyTransactionsTransactionShouldBeReturned(string merchantName, string estateName, int numberOfTransactions) //{ @@ -350,6 +403,12 @@ await Retry.For(async () => { } } + internal sealed record LargeFileProbe(int DetailRows, + long FileBytes, + long StartingWorkingSet, + long StartingManagedMemory, + long StartTimestamp); + [ExcludeFromCodeCoverage] public record Merchant(string Id, string Name, int numberOfEventsProcessed, decimal balance); diff --git a/FileProcessor.Testing/TestData.cs b/FileProcessor.Testing/TestData.cs index 0660d43..9943594 100644 --- a/FileProcessor.Testing/TestData.cs +++ b/FileProcessor.Testing/TestData.cs @@ -344,6 +344,7 @@ public static FileAggregate GetFileAggregateWithLinesAlreadyProcessed() ["AppSettings:ClientSecret"] = "clientSecret", ["AppSettings:TemporaryFileLocation"] = "C:\\Temp", ["AppSettings:FileProfilePollingWindowInSeconds"] = "30", + ["AppSettings:MaximumDetailRecordCount"] = "1000", ["ConnectionStrings:HealthCheck"] = "HeathCheckConnString", ["ConnectionStrings:TransactionProcessorReadModel"] = "EstateReportingReadModel", ["SecurityConfiguration:Authority"] = "https://127.0.0.1", diff --git a/FileProcessor/Bootstrapper/MiscRegistry.cs b/FileProcessor/Bootstrapper/MiscRegistry.cs index 05b79e6..b9e5d81 100644 --- a/FileProcessor/Bootstrapper/MiscRegistry.cs +++ b/FileProcessor/Bootstrapper/MiscRegistry.cs @@ -22,6 +22,7 @@ public class MiscRegistry : ServiceRegistry /// public MiscRegistry() { + _ = FileProcessorDomainService.ResolveMaximumDetailRecordCount(); this.AddSingleton(); this.AddSingleton(); this.AddSingleton(); diff --git a/FileProcessor/Common/ModelFactory.cs b/FileProcessor/Common/ModelFactory.cs index a8bae8a..e3e9e3b 100644 --- a/FileProcessor/Common/ModelFactory.cs +++ b/FileProcessor/Common/ModelFactory.cs @@ -69,6 +69,11 @@ public static FileDetailsResponse ConvertFrom(Models.FileDetails fileDetails) FileProfileId = fileDetails.FileProfileId, FileLocation = fileDetails.FileLocation, ProcessingCompleted = fileDetails.ProcessingCompleted, + IsRejected = fileDetails.IsRejected, + RejectionCode = fileDetails.RejectionCode, + RejectionReason = fileDetails.RejectionReason, + ObservedDetailRecordCount = fileDetails.ObservedDetailRecordCount, + MaximumDetailRecordCount = fileDetails.MaximumDetailRecordCount, FileProfileName = fileDetails.FileProfileName, MerchantName = fileDetails.MerchantName, UserEmailAddress = fileDetails.UserEmailAddress, @@ -148,4 +153,4 @@ public static FileImportLogFile ConvertFrom(ImportLogFile importLogFile) #endregion } -} \ No newline at end of file +} diff --git a/FileProcessor/appsettings.json b/FileProcessor/appsettings.json index 6af69b8..f386279 100644 --- a/FileProcessor/appsettings.json +++ b/FileProcessor/appsettings.json @@ -28,7 +28,8 @@ "FileLineAddedEvent" ] }, - "TemporaryFileLocation": "/home/txnproc/bulkfiles/temporary" + "TemporaryFileLocation": "/home/txnproc/bulkfiles/temporary", + "MaximumDetailRecordCount": 1000 }, "HealthMonitoring": { "Service": { diff --git a/docs/superpowers/specs/2026-10-16-file-rejection-event-design.md b/docs/superpowers/specs/2026-10-16-file-rejection-event-design.md new file mode 100644 index 0000000..6add84e --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-file-rejection-event-design.md @@ -0,0 +1,81 @@ +# File size rejection event design + +## Status + +Draft for review + +## Intent + +The file processor should accept files with up to 1,000 detail records and reject larger files in a way that is observable and auditable. A rejected file must not produce one `FileLineAddedEvent` per input line, because the current aggregate is rehydrated as each line event is processed and the cost grows badly for very large files. + +The rejection should be represented in the file aggregate by a `FileRejectedEvent`, rather than by silently failing before an aggregate exists. This preserves a durable record of the file, its rejection reason, and the configured limit without creating a large or otherwise empty line collection. + +## Context + +The current processing flow creates the file aggregate and then adds each parsed file line. The 100,000-row experiment demonstrated that this causes aggregate rehydration to become the bottleneck. The 1,000-row test is the intended normal operating boundary and currently represents 1,002 physical lines: one header, 1,000 detail lines, and one trailer, with no trailing blank line. + +## Proposed domain model + +Add `FileRejectedEvent` to the file domain events. The event should contain: + +- `FileId`, `EstateId`, and `MerchantId`, following the existing file event convention. +- A stable rejection code, initially `MaximumDetailRecordsExceeded`. +- A human-readable rejection reason. +- The observed detail-record count. +- The configured maximum detail-record count. +- The rejection timestamp. + +The event is terminal for file processing. The aggregate should expose rejection state and details through `FileDetails`, including the rejection code/reason and the observed/configured counts. Existing line-processing summaries remain line-based; a size-rejected file has zero `FileLine` entries and is distinguished by its file-level rejection state. + +The exact property names should follow the repository's existing model naming conventions when implemented. The stable code is the contract; the message is for diagnostics and user-facing explanation. + +## Processing flow + +1. `ProcessUploadedFile` creates the file aggregate as it does today, so the file identity and upload metadata are persisted. +2. Before calling `AddFileLine` for any input line, the service determines the number of detail records according to the selected file profile. Header and trailer records do not count toward the 1,000-detail-record limit. A trailing blank line is not treated as a detail record. +3. If the detail count is at most 1,000, processing continues unchanged. The existing 1,000-row scenario remains the boundary acceptance test. +4. If the detail count is greater than 1,000, the aggregate appends exactly one `FileRejectedEvent` and no `FileLineAddedEvent`s. Processing ends as a terminal rejection. +5. The file-management flow moves the physical file to the existing failed/rejected location using the same failure-path mechanism used for processing failures. The move must happen after the rejection event has been persisted, and retry/recovery must not append another rejection event. +6. A subsequent `GetFile` call returns the file-level rejection state and reason. It must not report the oversized file as successfully processed or as a partially processed 1,000-line file. + +This intentionally does not attempt to solve the broader 100,000-row streaming or event-sourcing scalability problem. That can be a separate piece of work if the 1,000-row operational limit is later revisited. + +## Configuration + +Use a named setting with a default of `1000` detail records. The limit should be injected into the processing service rather than hard-coded in the parser or aggregate. Configuration validation should reject zero or negative values during application startup/options binding. + +The service should pass the configured value into the rejection event so the historical decision remains understandable if the configured limit changes later. + +## Idempotency and failure handling + +- A file already marked rejected must not be parsed or have line events appended on a retry. +- If persistence fails while appending `FileRejectedEvent`, the physical file must remain recoverable in its current processing location and the operation must return the failure. +- If the event is persisted but the physical-file move fails, directory recovery must be able to find the file's terminal rejected state and complete or retry the move without creating a second rejection event. +- The existing successfully processed and line-level rejected paths must retain their current behavior. + +## Tests + +Add focused aggregate tests that verify: + +- `FileRejectedEvent` rehydrates the aggregate as rejected with the recorded code, reason, observed count, and configured limit. +- A rejection cannot be appended before the file is created. +- A rejected aggregate cannot accept file lines or be marked processing-completed. +- Replaying the event preserves the same rejection data. + +Add service tests that verify: + +- 1,000 detail records proceed to normal line processing. +- 1,001 detail records append `FileCreatedEvent` followed by one `FileRejectedEvent` and append no line events. +- The physical file follows the failed/rejected path after the event is persisted. +- A retry of an already rejected file does not append duplicate events. + +Extend the Reqnroll coverage with an opt-in scenario for 1,001 detail rows that asserts the file is rejected and exposes the configured limit/reason. Keep the existing 1,000-row scenario as the normal acceptance test. The generated fixture must continue to use unique complete detail lines, account numbers derived from the row number, amounts between 10 and 249, and no trailing blank line. + +## Acceptance criteria + +- A 1,000-detail-row file is accepted and processed as it is today. +- A 1,001-detail-row file is rejected before any line event is created. +- The rejection is represented by a durable `FileRejectedEvent` with a stable code and useful diagnostics. +- The file does not become an unobservable empty aggregate: its creation and rejection are both visible in the event stream and API response. +- The event stream for an oversized file is bounded and independent of the number of excess lines. +- Existing file-processing behavior and tests remain green.