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
211 changes: 211 additions & 0 deletions FileProcessor.Tests/DomainEventHandlersTests.cs
Original file line number Diff line number Diff line change
@@ -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<NullReferenceException>(() => 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<KeyNotFoundException>(() => DomainEventHandlers.HandleDomainEvent(
context.Request,
TestData.FileLineAddedEvent,
new Resolver(Result.Success(new List<IDomainEventHandler>())),
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<Ok>();
}

[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<IDomainEventHandler> { new Handler(Result.Success()) })),
CancellationToken.None);

result.ShouldBeOfType<Ok>();
}

[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<IDomainEventHandler>
{
new Handler(Result.Failure("Processing failed"))
})),
CancellationToken.None);

ProblemHttpResult problem = result.ShouldBeOfType<ProblemHttpResult>();
problem.StatusCode.ShouldBe(500);
problem.ProblemDetails.Title.ShouldBe("One or more event handlers failed");
problem.ProblemDetails.Extensions["eventId"].ShouldBeOfType<Guid>();
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<IDomainEventHandler>
{
new Handler(exception: new InvalidOperationException("Handler failed"))
})),
CancellationToken.None);

result.ShouldBeOfType<ProblemHttpResult>();
}

[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<IDomainEventHandler>
{
new Handler(task: Task.FromException<Result>(new InvalidOperationException("Handler failed")))
})),
CancellationToken.None);

result.ShouldBeOfType<ProblemHttpResult>();
}

[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<IDomainEventHandler>
{
new Handler(task: Task.FromCanceled<Result>(new CancellationToken(true)))
})),
CancellationToken.None);

result.ShouldBeOfType<ProblemHttpResult>();
}

[Fact]
public async Task HandleDomainEvent_WhenCancellationIsAlreadyRequested_ThrowsOperationCanceledException()
{
DefaultHttpContext context = CreateRequest(EventType);
using CancellationTokenSource cancellation = new();
cancellation.Cancel();

await Should.ThrowAsync<OperationCanceledException>(() => DomainEventHandlers.HandleDomainEvent(
context.Request,
TestData.FileLineAddedEvent,
new Resolver(Result.Success(new List<IDomainEventHandler>())),
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<List<IDomainEventHandler>> FailedHandlersResult()
{
return new Result<List<IDomainEventHandler>>
{
Status = ResultStatus.Failure,
Message = "No handlers"
};
}

private sealed class Resolver(Result<List<IDomainEventHandler>> result) : IDomainEventHandlerResolver
{
public Result<List<IDomainEventHandler>> GetDomainEventHandlers(Shared.DomainDrivenDesign.EventSourcing.IDomainEvent domainEvent) => result;
}

private sealed class Handler : IDomainEventHandler
{
private readonly Task<Result> task;
private readonly Exception exception;

public Handler(Result result = null, Task<Result> task = null, Exception exception = null)
{
this.task = task ?? Task.FromResult(result ?? Result.Success());
this.exception = exception;
}

public Task<Result> Handle(Shared.DomainDrivenDesign.EventSourcing.IDomainEvent domainEvent, CancellationToken cancellationToken)
{
if (this.exception != null)
throw this.exception;

return this.task;
}
}
}
75 changes: 67 additions & 8 deletions FileProcessor/Handlers/DomainEventHandlers.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
{
Expand All @@ -38,9 +39,31 @@ public static async Task<IResult> HandleDomainEvent(HttpRequest request,
return Results.Ok();
}

var eventHandlers = eventHandlersResult.Data;
var tasks = eventHandlers.Select(h => h.Handle(domainEvent, cancellationToken));
await Task.WhenAll(tasks);
List<HandlerExecution> executions = eventHandlersResult.Data
.Select(domainEventHandler => ExecuteHandler(domainEventHandler, domainEvent, cancellationToken))
.ToList();

try
{
await Task.WhenAll(executions.Select(execution => execution.Task));
}
catch (Exception ex)
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
{
Logger.LogError(new Exception($"One or more handlers threw while processing event [{domainEvent.EventId}]", ex));
}

List<HandlerFailure> failures = executions.SelectMany(GetFailures).ToList();
if (failures.Any())
{
return Results.Problem(title: "One or more event handlers failed",
statusCode: 500,
extensions: new Dictionary<String, Object>
{
["eventId"] = domainEvent.EventId,
["eventType"] = domainEvent.GetType().Name,
["failures"] = failures
});
}

Logger.LogInformation("Finished processing event - ID [{domainEvent.EventId}]");

Expand All @@ -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<Result>(ex));
}
}

private sealed record HandlerExecution(IDomainEventHandler Handler, Task<Result> Task);

private sealed record HandlerFailure(String Handler, String Error);

private static IEnumerable<HandlerFailure> GetFailures(HandlerExecution execution)
{
if (execution.Task.IsFaulted)
{
String error = String.Join("; ", execution.Task.Exception?.Flatten().InnerExceptions
.SelectMany(exception => exception.GetExceptionMessages()) ?? Enumerable.Empty<String>());
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
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<IDomainEvent> GetDomainEvent(HttpRequest request, object domainEvent)
{
string eventType = request.Headers["eventType"].ToString();
Expand Down
Loading