diff --git a/Directory.Packages.props b/Directory.Packages.props index 593e495..c585016 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + diff --git a/FIleProcessor.Models/FileLine.cs b/FIleProcessor.Models/FileLine.cs index 2e5a4b3..db06d06 100644 --- a/FIleProcessor.Models/FileLine.cs +++ b/FIleProcessor.Models/FileLine.cs @@ -3,58 +3,22 @@ using System; using System.Collections.Generic; - /// - /// - /// public class FileLine { - #region Properties - - /// - /// Gets or sets the line data. - /// - /// - /// The line data. - /// public String LineData { get; set; } - /// - /// Gets or sets the line number. - /// - /// - /// The line number. - /// public Int32 LineNumber { get; set; } - /// - /// Gets or sets a value indicating whether this is ignored. - /// - /// - /// true if ignored; otherwise, false. - /// public ProcessingResult ProcessingResult { get; set; } - /// - /// Gets or sets the transaction identifier. - /// - /// - /// The transaction identifier. - /// 1011934 - /// public Guid TransactionId { get; set; } - /// - /// Gets or sets the rejected reason. - /// - /// - /// The rejected reason. - /// + public Guid EventId { get; set; } + public String RejectedReason { get; set; } public Int32 DispatchAttemptCount { get; set; } public List FailedDispatchAttempts { get; set; } = new(); - - #endregion } } diff --git a/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs b/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs index 74f49e0..ab7de3c 100644 --- a/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs @@ -24,6 +24,8 @@ public async Task ProcessUploadedFile(FileCommands.ProcessUploadedFileCo public async Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command, CancellationToken cancellationToken) => Result.Success(); + + public async Task ReplayFileLine(FileCommands.ReplayFileLineCommand command, CancellationToken cancellationToken) => Result.Success(); } public class DummyFileProcessorManager : IFileProcessorManager { diff --git a/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs b/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs new file mode 100644 index 0000000..d021bd6 --- /dev/null +++ b/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FileProcessor.BusinessLogic.Requests; +using FileProcessor.Handlers; +using Imposter.Abstractions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Shouldly; +using SimpleResults; +using FileProcessor.Testing; +using Xunit; + +namespace FileProcessor.BusinessLogic.Tests; + +public class FileHandlersTests +{ + [Fact] + public async Task ReplayLine_SendsReplayCommand_AndReturnsOk() + { + IMediatorImposter mediator = new(); + mediator.Send(Arg>.Any(), Arg.Any()).ReturnsAsync(Result.Success()); + + IResult result = await FileHandlers.ReplayLine( + mediator.Instance(), TestData.FileId, TestData.EstateId, TestData.LineNumber, CancellationToken.None); + + StatusCodeHttpResult response = result.ShouldBeOfType(); + response.StatusCode.ShouldBe(StatusCodes.Status204NoContent); + mediator.Send(Arg>.Any(), Arg.Any()).Called(Count.Once()); + } +} diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index aff8156..021ba88 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -1092,6 +1092,67 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa message.Contains(TestData.FileId.ToString())); fileAggregate.GetFile().FileLines.Single().DispatchAttemptCount.ShouldBe(1); } + + [Fact] + public async Task FileRequestHandler_ReplayFileLineRequest_UnprocessedLineIsProcessed() + { + FileAggregate fileAggregate = TestData.GetFileAggregateWithLines(); + FileLine fileLine = fileAggregate.GetFile().FileLines.Single(); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(fileAggregate)); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); + this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(TestData.GetMerchantResponseWithOperator); + this.TransactionProcessorClient.GetMerchantContracts(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetMerchantContractsResponse())); + this.SecurityServiceClient.GetToken(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.TokenResponse())); + this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(TestData.ClientSaleTransactionResponse); + this.FileFormatHandler.FileLineCanBeIgnored(Arg.Any()).Returns(false); + this.FileFormatHandler.ParseFileLine(Arg.Any()).Returns(TestData.TransactionMetadata); + + FileCommands.ReplayFileLineCommand command = new(TestData.FileId, TestData.EstateId, fileLine.LineNumber); + + Result result = await this.FileProcessorDomainService.ReplayFileLine(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + fileLine.ProcessingResult.ShouldBe(ProcessingResult.Successful); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Twice()); + } + + [Fact] + public async Task FileRequestHandler_ReplayFileLineRequest_AlreadyProcessedLineIsIgnored() + { + FileAggregate fileAggregate = TestData.GetFileAggregateWithLinesAlreadyProcessed(); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(fileAggregate)); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); + + Result result = await this.FileProcessorDomainService.ReplayFileLine( + new FileCommands.ReplayFileLineCommand(TestData.FileId, TestData.EstateId, 1), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Once()); + } + + [Fact] + public async Task FileRequestHandler_ReplayFileLineRequest_LineNotFound_ReturnsFailure() + { + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + + Result result = await this.FileProcessorDomainService.ReplayFileLine( + new FileCommands.ReplayFileLineCommand(TestData.FileId, TestData.EstateId, TestData.NotFoundLineNumber), CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task FileRequestHandler_ReplayFileLineRequest_FileAggregateNotFound_ReturnsNotFound() + { + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.NotFound()); + + Result result = await this.FileProcessorDomainService.ReplayFileLine( + new FileCommands.ReplayFileLineCommand(TestData.FileId, TestData.EstateId, TestData.LineNumber), CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.NotFound); + } private void VerifyFileProcessing(String filePath) diff --git a/FileProcessor.BusinessLogic.Tests/FileRequestHandlerTests.cs b/FileProcessor.BusinessLogic.Tests/FileRequestHandlerTests.cs index 6e4851d..6249dc5 100644 --- a/FileProcessor.BusinessLogic.Tests/FileRequestHandlerTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileRequestHandlerTests.cs @@ -4,15 +4,18 @@ using System.Threading.Tasks; using FileProcessor.BusinessLogic.Managers; using MediatR; +using SimpleResults; namespace FileProcessor.BusinessLogic.Tests { using System.Threading; using Imposter.Abstractions; using RequestHandlers; + using Requests; using Services; using Shouldly; using Testing; + using Xunit; public class FileRequestHandlerTests { @@ -45,5 +48,18 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Reques await this.FileRequestHandler.Handle(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); }); } + + [Fact] + public async Task FileRequestHandler_ReplayFileLineRequest_RequestIsDelegated() + { + FileCommands.ReplayFileLineCommand command = new(TestData.FileId, TestData.EstateId, TestData.LineNumber); + this.FileProcessorDomainService.ReplayFileLine(Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success()); + + Result result = await this.FileRequestHandler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + this.FileProcessorDomainService.ReplayFileLine(command, CancellationToken.None).Called(Count.Once()); + } } } diff --git a/FileProcessor.BusinessLogic/RequestHandlers/FileRequestHandler.cs b/FileProcessor.BusinessLogic/RequestHandlers/FileRequestHandler.cs index 08704d5..a7a29fe 100644 --- a/FileProcessor.BusinessLogic/RequestHandlers/FileRequestHandler.cs +++ b/FileProcessor.BusinessLogic/RequestHandlers/FileRequestHandler.cs @@ -17,7 +17,8 @@ public class FileRequestHandler : IRequestHandler, IRequestHandler>, IRequestHandler>>, - IRequestHandler> + IRequestHandler>, + IRequestHandler { private readonly IFileProcessorDomainService DomainService; private readonly IFileProcessorManager Manager; @@ -56,5 +57,10 @@ public async Task> Handle(FileQueries.GetFileQuery query, CancellationToken cancellationToken) { return await this.Manager.GetFileImportLog(query.FileImportLogId, query.EstateId, query.MerchantId, cancellationToken); } + + public async Task Handle(FileCommands.ReplayFileLineCommand request, CancellationToken cancellationToken) + { + return await this.DomainService.ReplayFileLine(request, cancellationToken); + } } } diff --git a/FileProcessor.BusinessLogic/Requests/FileCommands.cs b/FileProcessor.BusinessLogic/Requests/FileCommands.cs index 500ae85..da4364f 100644 --- a/FileProcessor.BusinessLogic/Requests/FileCommands.cs +++ b/FileProcessor.BusinessLogic/Requests/FileCommands.cs @@ -26,4 +26,8 @@ public record UploadFileCommand(Guid FileId, String FilePath, Guid FileProfileId, DateTime FileUploadedDateTime) : IRequest; + + public record ReplayFileLineCommand(Guid FileId, + Guid EstateId, + Int32 LineNumber) : IRequest; } \ No newline at end of file diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 00190a5..4fdc0ec 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -47,6 +47,8 @@ Task ProcessUploadedFile(FileCommands.ProcessUploadedFileCommand command Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command, CancellationToken cancellationToken); + + Task ReplayFileLine(FileCommands.ReplayFileLineCommand command, CancellationToken cancellationToken); } public class FileProcessorDomainService : IFileProcessorDomainService @@ -298,6 +300,34 @@ private async Task> GetOperatorIdForFileProfile(Guid estateId, Guid return Result.Success(@operator.OperatorId); } + public async Task ReplayFileLine(FileCommands.ReplayFileLineCommand command, CancellationToken cancellationToken) + { + Result result = await ApplyFileUpdates(async (FileAggregate fileAggregate) => + { + FileDetails fileDetails = fileAggregate.GetFile(); + // Now find the line to be resumbitted + FileLine fileLine = fileDetails.FileLines.SingleOrDefault(l => l.LineNumber == command.LineNumber); + if (fileLine == null) + { + return Result.NotFound($"Line number {command.LineNumber} not found in file Id {command.FileId}"); + } + + if (fileLine.ProcessingResult != ProcessingResult.NotProcessed) + { + return Result.Success(); + } + + // Build the command to reprocess the line + FileCommands.ProcessTransactionForFileLineCommand processTransactionForFileLineCommand = new FileCommands.ProcessTransactionForFileLineCommand(command.FileId, fileLine.EventId, + command.LineNumber, fileLine.LineData); + Result result = await this.ProcessTransactionForFileLine(processTransactionForFileLineCommand, cancellationToken); + return result; + }, command.FileId, cancellationToken); + + return result; + } + + public async Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command, CancellationToken cancellationToken) { FileLineProcessingContext processingContext = new(command); diff --git a/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs b/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs index 9bce7f5..d339740 100644 --- a/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs +++ b/FileProcessor.FileAggregate.Tests/FileAggregateTests.cs @@ -5,6 +5,7 @@ namespace FileProcessor.FileAggregate.Tests { using System.Linq; + using File.DomainEvents; using FileProcessor.Models; using Shared.Exceptions; using Shouldly; @@ -88,6 +89,17 @@ public void FileAggregate_AddFileLine_FileLineAdded() fileDetails.ProcessingSummary.IgnoredLines.ShouldBe(0); } + [Fact] + public void FileAggregate_PlayFileLineAddedEvent_PreservesEventIdOnFileLine() + { + FileAggregate fileAggregate = FileAggregate.Create(TestData.FileId); + FileLineAddedEvent domainEvent = TestData.FileLineAddedEvent; + + fileAggregate.PlayEvent(domainEvent); + + fileAggregate.GetFile().FileLines.Single().EventId.ShouldBe(domainEvent.EventId); + } + [Fact] public void FileAggregate_AddFileLine_AddDuplicateLine_FileLineIsNotAddedAdded() { diff --git a/FileProcessor.FileAggregate/FileAggregate.cs b/FileProcessor.FileAggregate/FileAggregate.cs index 8373a5f..200a147 100644 --- a/FileProcessor.FileAggregate/FileAggregate.cs +++ b/FileProcessor.FileAggregate/FileAggregate.cs @@ -60,7 +60,8 @@ public static void PlayEvent(this FileAggregate aggregate, FileLineAddedEvent do { LineData = domainEvent.FileLine, LineNumber = domainEvent.LineNumber, - FailedDispatchAttempts = new List() + FailedDispatchAttempts = new List(), + EventId= domainEvent.EventId }); } diff --git a/FileProcessor/Endpoints/FileEndpoints.cs b/FileProcessor/Endpoints/FileEndpoints.cs index 63b3d32..a160268 100644 --- a/FileProcessor/Endpoints/FileEndpoints.cs +++ b/FileProcessor/Endpoints/FileEndpoints.cs @@ -16,12 +16,15 @@ public static void MapFileEndpoints(this IEndpointRouteBuilder app) .WithTags("Files") .RequireAuthorization(); - group.MapPost("/", FileHandlers.UploadFileAsync) + group.MapPost("/", FileHandlers.UploadFile) .DisableAntiforgery() .Accepts("multipart/form-data") .WithName("UploadFile"); - group.MapGet("/{fileId:guid}", FileHandlers.GetFileAsync) + group.MapGet("/{fileId:guid}", FileHandlers.GetFile) .WithName("GetFile"); + + group.MapPost("/{fileId:guid}/lines/{lineNumber:int}/retry", FileHandlers.ReplayLine) + .WithName("ReplayLine"); } -} \ No newline at end of file +} diff --git a/FileProcessor/Handlers/FileHandlers.cs b/FileProcessor/Handlers/FileHandlers.cs index 272af2b..b5a640c 100644 --- a/FileProcessor/Handlers/FileHandlers.cs +++ b/FileProcessor/Handlers/FileHandlers.cs @@ -20,7 +20,7 @@ namespace FileProcessor.Handlers; public static class FileHandlers { - public static async Task UploadFileAsync(IMediator mediator, + public static async Task UploadFile(IMediator mediator, [FromForm] UploadFileRequest request, [FromForm] IFormCollection formCollection, CancellationToken cancellationToken) @@ -56,7 +56,7 @@ public static async Task UploadFileAsync(IMediator mediator, return ResponseFactory.FromResult(result, r => r); } - public static async Task GetFileAsync(IMediator mediator, + public static async Task GetFile(IMediator mediator, [FromRoute] Guid fileId, [FromQuery] Guid estateId, CancellationToken cancellationToken) @@ -66,4 +66,16 @@ public static async Task GetFileAsync(IMediator mediator, return ResponseFactory.FromResult(result, ModelFactory.ConvertFrom); } + + public static async Task ReplayLine(IMediator mediator, + [FromRoute] Guid fileId, + [FromQuery] Guid estateId, + [FromRoute] Int32 lineNumber, + CancellationToken cancellationToken) + { + FileCommands.ReplayFileLineCommand command = new(fileId, estateId, lineNumber); + Result result = await mediator.Send(command, cancellationToken); + + return ResponseFactory.FromResult(result); + } } \ No newline at end of file