Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.Uris" Version="9.0.0" />
<PackageVersion Include="ClientProxyBase" Version="2026.9.4" />
<PackageVersion Include="HealthMonitoring.Client" Version="2026.9.2.30" />
<PackageVersion Include="HealthMonitoring.Client" Version="2026.9.3.34" />
<PackageVersion Include="OpenIddict.Validation.AspNetCore" Version="7.7.0" />
<PackageVersion Include="OpenIddict.Validation.SystemNetHttp" Version="7.7.0" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
Expand Down
40 changes: 2 additions & 38 deletions FIleProcessor.Models/FileLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,22 @@
using System;
using System.Collections.Generic;

/// <summary>
///
/// </summary>
public class FileLine
{
#region Properties

/// <summary>
/// Gets or sets the line data.
/// </summary>
/// <value>
/// The line data.
/// </value>
public String LineData { get; set; }

/// <summary>
/// Gets or sets the line number.
/// </summary>
/// <value>
/// The line number.
/// </value>
public Int32 LineNumber { get; set; }

/// <summary>
/// Gets or sets a value indicating whether this <see cref="FileLine"/> is ignored.
/// </summary>
/// <value>
/// <c>true</c> if ignored; otherwise, <c>false</c>.
/// </value>
public ProcessingResult ProcessingResult { get; set; }

/// <summary>
/// Gets or sets the transaction identifier.
/// </summary>
/// <value>
/// The transaction identifier.
/// 1011934
/// </value>
public Guid TransactionId { get; set; }

/// <summary>
/// Gets or sets the rejected reason.
/// </summary>
/// <value>
/// The rejected reason.
/// </value>
public Guid EventId { get; set; }

public String RejectedReason { get; set; }

public Int32 DispatchAttemptCount { get; set; }

public List<TransactionDispatchAttempt> FailedDispatchAttempts { get; set; } = new();

#endregion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public async Task<Result> ProcessUploadedFile(FileCommands.ProcessUploadedFileCo

public async Task<Result> ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command,
CancellationToken cancellationToken) => Result.Success();

public async Task<Result> ReplayFileLine(FileCommands.ReplayFileLineCommand command, CancellationToken cancellationToken) => Result.Success();
}

public class DummyFileProcessorManager : IFileProcessorManager {
Expand Down
32 changes: 32 additions & 0 deletions FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs
Original file line number Diff line number Diff line change
@@ -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<IRequest<Result>>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(Result.Success());

IResult result = await FileHandlers.ReplayLine(
mediator.Instance(), TestData.FileId, TestData.EstateId, TestData.LineNumber, CancellationToken.None);

StatusCodeHttpResult response = result.ShouldBeOfType<StatusCodeHttpResult>();
response.StatusCode.ShouldBe(StatusCodes.Status204NoContent);
mediator.Send(Arg<IRequest<Result>>.Any(), Arg<CancellationToken>.Any()).Called(Count.Once());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Guid>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(Result.Success(fileAggregate));
this.FileAggregateRepository.SaveChanges(Arg<FileAggregate>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(Result.Success());
this.FileProcessorManager.GetFileProfile(Arg<Guid>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(TestData.FileProfileSafaricom);
this.TransactionProcessorClient.GetMerchant(Arg<String>.Any(), Arg<Guid>.Any(), Arg<Guid>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(TestData.GetMerchantResponseWithOperator);
this.TransactionProcessorClient.GetMerchantContracts(Arg<String>.Any(), Arg<Guid>.Any(), Arg<Guid>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(Result.Success(TestData.GetMerchantContractsResponse()));
this.SecurityServiceClient.GetToken(Arg<String>.Any(), Arg<String>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(Result.Success(TestData.TokenResponse()));
this.TransactionProcessorClient.PerformTransaction(Arg<String>.Any(), Arg<SaleTransactionRequest>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(TestData.ClientSaleTransactionResponse);
this.FileFormatHandler.FileLineCanBeIgnored(Arg<String>.Any()).Returns(false);
this.FileFormatHandler.ParseFileLine(Arg<String>.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<FileAggregate>.Any(), Arg<CancellationToken>.Any()).Called(Count.Twice());
}

[Fact]
public async Task FileRequestHandler_ReplayFileLineRequest_AlreadyProcessedLineIsIgnored()
{
FileAggregate fileAggregate = TestData.GetFileAggregateWithLinesAlreadyProcessed();
this.FileAggregateRepository.GetLatestVersion(Arg<Guid>.Any(), Arg<CancellationToken>.Any()).ReturnsAsync(Result.Success(fileAggregate));
this.FileAggregateRepository.SaveChanges(Arg<FileAggregate>.Any(), Arg<CancellationToken>.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<FileAggregate>.Any(), Arg<CancellationToken>.Any()).Called(Count.Once());
}

[Fact]
public async Task FileRequestHandler_ReplayFileLineRequest_LineNotFound_ReturnsFailure()
{
this.FileAggregateRepository.GetLatestVersion(Arg<Guid>.Any(), Arg<CancellationToken>.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<Guid>.Any(), Arg<CancellationToken>.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)
Expand Down
16 changes: 16 additions & 0 deletions FileProcessor.BusinessLogic.Tests/FileRequestHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<FileCommands.ReplayFileLineCommand>.Any(), Arg<CancellationToken>.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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public class FileRequestHandler : IRequestHandler<FileCommands.ProcessTransactio
IRequestHandler<FileCommands.UploadFileCommand, Result>,
IRequestHandler<FileQueries.GetFileQuery, Result<FileDetails>>,
IRequestHandler<FileQueries.GetImportLogsQuery, Result<List<Models.FileImportLog>>>,
IRequestHandler<FileQueries.GetImportLogQuery, Result<Models.FileImportLog>>
IRequestHandler<FileQueries.GetImportLogQuery, Result<Models.FileImportLog>>,
IRequestHandler<FileCommands.ReplayFileLineCommand, Result>
{
private readonly IFileProcessorDomainService DomainService;
private readonly IFileProcessorManager Manager;
Expand Down Expand Up @@ -56,5 +57,10 @@ public async Task<Result<FileDetails>> Handle(FileQueries.GetFileQuery query,
CancellationToken cancellationToken) {
return await this.Manager.GetFileImportLog(query.FileImportLogId, query.EstateId, query.MerchantId, cancellationToken);
}

public async Task<Result> Handle(FileCommands.ReplayFileLineCommand request, CancellationToken cancellationToken)
{
return await this.DomainService.ReplayFileLine(request, cancellationToken);
}
}
}
4 changes: 4 additions & 0 deletions FileProcessor.BusinessLogic/Requests/FileCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ public record UploadFileCommand(Guid FileId,
String FilePath,
Guid FileProfileId,
DateTime FileUploadedDateTime) : IRequest<Result>;

public record ReplayFileLineCommand(Guid FileId,
Guid EstateId,
Int32 LineNumber) : IRequest<Result>;
}
30 changes: 30 additions & 0 deletions FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Task<Result> ProcessUploadedFile(FileCommands.ProcessUploadedFileCommand command

Task<Result> ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command,
CancellationToken cancellationToken);

Task<Result> ReplayFileLine(FileCommands.ReplayFileLineCommand command, CancellationToken cancellationToken);
}

public class FileProcessorDomainService : IFileProcessorDomainService
Expand Down Expand Up @@ -298,6 +300,34 @@ private async Task<Result<Guid>> GetOperatorIdForFileProfile(Guid estateId, Guid
return Result.Success(@operator.OperatorId);
}

public async Task<Result> 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<Result> ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command,
CancellationToken cancellationToken) {
FileLineProcessingContext processingContext = new(command);
Expand Down
12 changes: 12 additions & 0 deletions FileProcessor.FileAggregate.Tests/FileAggregateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace FileProcessor.FileAggregate.Tests
{
using System.Linq;
using File.DomainEvents;
using FileProcessor.Models;
using Shared.Exceptions;
using Shouldly;
Expand Down Expand Up @@ -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()
{
Expand Down
3 changes: 2 additions & 1 deletion FileProcessor.FileAggregate/FileAggregate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ public static void PlayEvent(this FileAggregate aggregate, FileLineAddedEvent do
{
LineData = domainEvent.FileLine,
LineNumber = domainEvent.LineNumber,
FailedDispatchAttempts = new List<TransactionDispatchAttempt>()
FailedDispatchAttempts = new List<TransactionDispatchAttempt>(),
EventId= domainEvent.EventId
});
}

Expand Down
9 changes: 6 additions & 3 deletions FileProcessor/Endpoints/FileEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ public static void MapFileEndpoints(this IEndpointRouteBuilder app)
.WithTags("Files")
.RequireAuthorization();

group.MapPost("/", FileHandlers.UploadFileAsync)
group.MapPost("/", FileHandlers.UploadFile)
.DisableAntiforgery()
.Accepts<IFormFile>("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");
}
}
}
16 changes: 14 additions & 2 deletions FileProcessor/Handlers/FileHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace FileProcessor.Handlers;

public static class FileHandlers
{
public static async Task<IResult> UploadFileAsync(IMediator mediator,
public static async Task<IResult> UploadFile(IMediator mediator,
[FromForm] UploadFileRequest request,
[FromForm] IFormCollection formCollection,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -56,7 +56,7 @@ public static async Task<IResult> UploadFileAsync(IMediator mediator,
return ResponseFactory.FromResult(result, r => r);
}

public static async Task<IResult> GetFileAsync(IMediator mediator,
public static async Task<IResult> GetFile(IMediator mediator,
[FromRoute] Guid fileId,
[FromQuery] Guid estateId,
CancellationToken cancellationToken)
Expand All @@ -66,4 +66,16 @@ public static async Task<IResult> GetFileAsync(IMediator mediator,

return ResponseFactory.FromResult(result, ModelFactory.ConvertFrom);
}

public static async Task<IResult> 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);
}
}
Loading