diff --git a/FileProcessor.Tests/DomainEventHandlersTests.cs b/FileProcessor.Tests/DomainEventHandlersTests.cs new file mode 100644 index 0000000..ffbacc1 --- /dev/null +++ b/FileProcessor.Tests/DomainEventHandlersTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FileProcessor.File.DomainEvents; +using FileProcessor.Handlers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Shared.EventStore.EventHandling; +using Shared.Exceptions; +using Shared.General; +using Shared.Logger; +using Shared.Serialisation; +using SimpleResults; +using Shouldly; +using FileProcessor.Testing; +using Xunit; + +namespace FileProcessor.Tests; + +public class DomainEventHandlersTests +{ + private static readonly string EventType = nameof(FileLineAddedEvent); + + public DomainEventHandlersTests() + { + StringSerialiser.Initialise(new SystemTextJsonSerializer(SystemTextJsonSerializer.GetDefaultJsonSerializerOptions())); + Logger.Initialise(new NullLogger()); + } + + [Fact] + public async Task HandleDomainEvent_WhenMappedTypeIsNotADomainEvent_ThrowsNullReferenceException() + { + DefaultHttpContext context = CreateRequest("non-domain-event"); + TypeMap.AddType(typeof(string), "non-domain-event"); + + await Should.ThrowAsync(() => DomainEventHandlers.HandleDomainEvent( + context.Request, + "not a domain event", + new Resolver(FailedHandlersResult()), + CancellationToken.None)); + } + + [Fact] + public async Task HandleDomainEvent_WhenEventTypeIsNotRegistered_ThrowsLookupException() + { + DefaultHttpContext context = CreateRequest("missing-event-type"); + + await Should.ThrowAsync(() => DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List())), + CancellationToken.None)); + } + + [Fact] + public async Task HandleDomainEvent_WhenNoHandlersAreConfigured_ReturnsOk() + { + DefaultHttpContext context = CreateRequest(EventType); + + IResult result = await DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(FailedHandlersResult()), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task HandleDomainEvent_WhenHandlerSucceeds_ReturnsOk() + { + DefaultHttpContext context = CreateRequest(EventType); + + IResult result = await DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List { new Handler(Result.Success()) })), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task HandleDomainEvent_WhenHandlerReturnsFailure_ReturnsProblemWithFailureDetails() + { + DefaultHttpContext context = CreateRequest(EventType); + + IResult result = await DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List + { + new Handler(Result.Failure("Processing failed")) + })), + CancellationToken.None); + + ProblemHttpResult problem = result.ShouldBeOfType(); + problem.StatusCode.ShouldBe(500); + problem.ProblemDetails.Title.ShouldBe("One or more event handlers failed"); + problem.ProblemDetails.Extensions["eventId"].ShouldBeOfType(); + problem.ProblemDetails.Extensions["eventType"].ShouldBe(EventType); + } + + [Fact] + public async Task HandleDomainEvent_WhenHandlerThrowsSynchronously_ReturnsProblem() + { + DefaultHttpContext context = CreateRequest(EventType); + + IResult result = await DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List + { + new Handler(exception: new InvalidOperationException("Handler failed")) + })), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task HandleDomainEvent_WhenHandlerFaultsAsynchronously_ReturnsProblem() + { + DefaultHttpContext context = CreateRequest(EventType); + + IResult result = await DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List + { + new Handler(task: Task.FromException(new InvalidOperationException("Handler failed"))) + })), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task HandleDomainEvent_WhenHandlerIsCancelled_ReturnsProblem() + { + DefaultHttpContext context = CreateRequest(EventType); + + IResult result = await DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List + { + new Handler(task: Task.FromCanceled(new CancellationToken(true))) + })), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task HandleDomainEvent_WhenCancellationIsAlreadyRequested_ThrowsOperationCanceledException() + { + DefaultHttpContext context = CreateRequest(EventType); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + await Should.ThrowAsync(() => DomainEventHandlers.HandleDomainEvent( + context.Request, + TestData.FileLineAddedEvent, + new Resolver(Result.Success(new List())), + cancellation.Token)); + } + + private static DefaultHttpContext CreateRequest(string eventType) + { + DefaultHttpContext context = new(); + context.Request.Headers["eventType"] = eventType; + TypeMap.AddType(typeof(FileLineAddedEvent), EventType); + return context; + } + + private static Result> FailedHandlersResult() + { + return new Result> + { + Status = ResultStatus.Failure, + Message = "No handlers" + }; + } + + private sealed class Resolver(Result> result) : IDomainEventHandlerResolver + { + public Result> GetDomainEventHandlers(Shared.DomainDrivenDesign.EventSourcing.IDomainEvent domainEvent) => result; + } + + private sealed class Handler : IDomainEventHandler + { + private readonly Task task; + private readonly Exception exception; + + public Handler(Result result = null, Task task = null, Exception exception = null) + { + this.task = task ?? Task.FromResult(result ?? Result.Success()); + this.exception = exception; + } + + public Task Handle(Shared.DomainDrivenDesign.EventSourcing.IDomainEvent domainEvent, CancellationToken cancellationToken) + { + if (this.exception != null) + throw this.exception; + + return this.task; + } + } +} diff --git a/FileProcessor/Handlers/DomainEventHandlers.cs b/FileProcessor/Handlers/DomainEventHandlers.cs index ca7a5e2..5273441 100644 --- a/FileProcessor/Handlers/DomainEventHandlers.cs +++ b/FileProcessor/Handlers/DomainEventHandlers.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; using Shared.DomainDrivenDesign.EventSourcing; using Shared.EventStore.Aggregate; @@ -12,6 +8,11 @@ using Shared.Logger; using Shared.Serialisation; using SimpleResults; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace FileProcessor.Handlers { @@ -38,9 +39,31 @@ public static async Task HandleDomainEvent(HttpRequest request, return Results.Ok(); } - var eventHandlers = eventHandlersResult.Data; - var tasks = eventHandlers.Select(h => h.Handle(domainEvent, cancellationToken)); - await Task.WhenAll(tasks); + List executions = eventHandlersResult.Data + .Select(domainEventHandler => ExecuteHandler(domainEventHandler, domainEvent, cancellationToken)) + .ToList(); + + try + { + await Task.WhenAll(executions.Select(execution => execution.Task)); + } + catch (Exception ex) + { + Logger.LogError(new Exception($"One or more handlers threw while processing event [{domainEvent.EventId}]", ex)); + } + + List failures = executions.SelectMany(GetFailures).ToList(); + if (failures.Any()) + { + return Results.Problem(title: "One or more event handlers failed", + statusCode: 500, + extensions: new Dictionary + { + ["eventId"] = domainEvent.EventId, + ["eventType"] = domainEvent.GetType().Name, + ["failures"] = failures + }); + } Logger.LogInformation("Finished processing event - ID [{domainEvent.EventId}]"); @@ -63,6 +86,42 @@ private static void Callback(CancellationToken cancellationToken, Guid eventId) } } + private static HandlerExecution ExecuteHandler(IDomainEventHandler handler, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + try + { + return new HandlerExecution(handler, handler.Handle(domainEvent, cancellationToken)); + } + catch (Exception ex) + { + return new HandlerExecution(handler, Task.FromException(ex)); + } + } + + private sealed record HandlerExecution(IDomainEventHandler Handler, Task Task); + + private sealed record HandlerFailure(String Handler, String Error); + + private static IEnumerable GetFailures(HandlerExecution execution) + { + if (execution.Task.IsFaulted) + { + String error = String.Join("; ", execution.Task.Exception?.Flatten().InnerExceptions + .SelectMany(exception => exception.GetExceptionMessages()) ?? Enumerable.Empty()); + yield return new HandlerFailure(execution.Handler.GetType().Name, error); + } + else if (execution.Task.IsCanceled) + { + yield return new HandlerFailure(execution.Handler.GetType().Name, "Handler execution was cancelled"); + } + else if (execution.Task.Result.IsFailed) + { + yield return new HandlerFailure(execution.Handler.GetType().Name, execution.Task.Result.Message); + } + } + private static async Task GetDomainEvent(HttpRequest request, object domainEvent) { string eventType = request.Headers["eventType"].ToString();