diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7c942c9..44fa9e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -65,6 +65,7 @@ jobs: dotnet pack ./Email/Core/Odin.Email.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR dotnet pack ./Logging/Core/Odin.Logging.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR dotnet pack ./Messaging/RabbitMq/Odin.Messaging.RabbitMq.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR + dotnet pack ./Patterns/Commands.Abstractions/Odin.Patterns.Commands.Abstractions.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR dotnet pack ./Patterns/Commands/Odin.Patterns.Commands.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR dotnet pack ./Patterns/Queries/Odin.Patterns.Queries.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR dotnet pack ./Patterns/Notifications/Odin.Patterns.Notifications.csproj --configuration $CONFIGURATION --output $PACKAGE_DIR diff --git a/BackgroundProcessing/Abstractions/BackgroundProcessingProviders.cs b/BackgroundProcessing/Abstractions/BackgroundProcessingProviders.cs index 42bb890..e72622f 100644 --- a/BackgroundProcessing/Abstractions/BackgroundProcessingProviders.cs +++ b/BackgroundProcessing/Abstractions/BackgroundProcessingProviders.cs @@ -1,5 +1,4 @@ -using System.Collections.Specialized; -using Odin.System; +using Odin.System; namespace Odin.BackgroundProcessing { diff --git a/DesignContracts/Core/ContractException.cs b/DesignContracts/Core/ContractException.cs index d56df52..59fb70c 100644 --- a/DesignContracts/Core/ContractException.cs +++ b/DesignContracts/Core/ContractException.cs @@ -1,5 +1,3 @@ -using System.Runtime.Serialization; - namespace Odin.DesignContracts { /// diff --git a/Directory.Packages.props b/Directory.Packages.props index 62fcce5..0ba03ee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,67 +1,61 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Domain/Core/WhereIdentityIsSpecification.cs b/Domain/Core/WhereIdentityIsSpecification.cs index ce1fdc3..e49e483 100644 --- a/Domain/Core/WhereIdentityIsSpecification.cs +++ b/Domain/Core/WhereIdentityIsSpecification.cs @@ -1,4 +1,3 @@ -using System.Linq.Expressions; using System.Numerics; namespace Odin.Domain; diff --git a/Email/Office365/Office365EmailSender.cs b/Email/Office365/Office365EmailSender.cs index 2028f92..475cc37 100644 --- a/Email/Office365/Office365EmailSender.cs +++ b/Email/Office365/Office365EmailSender.cs @@ -6,7 +6,6 @@ using Odin.DesignContracts; using Odin.Logging; using Odin.System; -using Contract = Odin.DesignContracts.Contract; namespace Odin.Email; @@ -134,7 +133,7 @@ public async Task> SendEmail(IEmailMessage email) static byte[] ToByteArray(Stream inputStream) { - ArgumentNullException.ThrowIfNull(inputStream); + Precondition.RequiresNotNull(inputStream); if (inputStream.CanSeek) { diff --git a/Logging/Core/DependencyInjectionExtensions.cs b/Logging/Core/DependencyInjectionExtensions.cs index 7792ba7..0b77c36 100644 --- a/Logging/Core/DependencyInjectionExtensions.cs +++ b/Logging/Core/DependencyInjectionExtensions.cs @@ -10,12 +10,13 @@ namespace Microsoft.Extensions.DependencyInjection public static class Logger2Extensions { /// - /// Sets up ILogger2 of T in dependency injection + /// Adds the Odin ILoggerWrapper of T implementation into dependency injection /// /// /// public static void AddOdinLoggerWrapper(this IServiceCollection serviceCollection) { + serviceCollection.AddLogging(); serviceCollection.TryAddSingleton(typeof(ILoggerWrapper<>), typeof(LoggerWrapper<>)); } } diff --git a/Logging/Core/Odin.Logging.csproj b/Logging/Core/Odin.Logging.csproj index df056da..36465c8 100644 --- a/Logging/Core/Odin.Logging.csproj +++ b/Logging/Core/Odin.Logging.csproj @@ -19,6 +19,7 @@ + diff --git a/Odin.sln b/Odin.sln index 8631206..7a01577 100644 --- a/Odin.sln +++ b/Odin.sln @@ -114,6 +114,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Patterns.Notifications EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Patterns.Queries", "Patterns\Queries\Odin.Patterns.Queries.csproj", "{FA79B472-DEDA-4BEE-B151-2B553A6528C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Odin.Patterns", "Patterns\Tests\Tests.Odin.Patterns.csproj", "{1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{51D0FF83-33F9-490F-A0CB-A32DC1CC48AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Testing.NUnitUtility", "TestingUtility\Odin.Testing.NUnitUtility.csproj", "{46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Odin.Patterns.Commands.Abstractions", "Patterns\Commands.Abstractions\Odin.Patterns.Commands.Abstractions.csproj", "{7749636C-2D34-4E0B-81C9-9E89C5630858}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -260,6 +268,18 @@ Global {FA79B472-DEDA-4BEE-B151-2B553A6528C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {FA79B472-DEDA-4BEE-B151-2B553A6528C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {FA79B472-DEDA-4BEE-B151-2B553A6528C2}.Release|Any CPU.Build.0 = Release|Any CPU + {1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B}.Release|Any CPU.Build.0 = Release|Any CPU + {46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF}.Release|Any CPU.Build.0 = Release|Any CPU + {7749636C-2D34-4E0B-81C9-9E89C5630858}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7749636C-2D34-4E0B-81C9-9E89C5630858}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7749636C-2D34-4E0B-81C9-9E89C5630858}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7749636C-2D34-4E0B-81C9-9E89C5630858}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CE323D9C-635B-EFD3-5B3F-7CE371D8A86A} = {BF440C74-E223-3CBF-8FA7-83F7D164F7C3} @@ -297,5 +317,8 @@ Global {F0C4BEDA-4B20-4EC8-8265-EFF66D2B1B15} = {5B5DADD3-07F8-40E9-949A-82BEB18093CC} {36D412FB-8A69-47A4-BABF-7B851603C9A1} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} {FA79B472-DEDA-4BEE-B151-2B553A6528C2} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} + {1491A6A6-ED85-4AD0-A6EE-57F4CAF3192B} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} + {46BCDE44-FA4E-4AE0-A6C0-9AFAE1BE09DF} = {51D0FF83-33F9-490F-A0CB-A32DC1CC48AF} + {7749636C-2D34-4E0B-81C9-9E89C5630858} = {605A7674-8EA4-458D-9FEB-A86C927AC0F0} EndGlobalSection EndGlobal diff --git a/Patterns/Commands/ICommand.cs b/Patterns/Commands.Abstractions/ICommand.cs similarity index 100% rename from Patterns/Commands/ICommand.cs rename to Patterns/Commands.Abstractions/ICommand.cs diff --git a/Patterns/Commands.Abstractions/ICommandDispatcher.cs b/Patterns/Commands.Abstractions/ICommandDispatcher.cs new file mode 100644 index 0000000..824f834 --- /dev/null +++ b/Patterns/Commands.Abstractions/ICommandDispatcher.cs @@ -0,0 +1,33 @@ +namespace Odin.Patterns.Commands; + +/// +/// Dispatches commands to their registered command handlers. +/// +public interface ICommandDispatcher +{ + /// + /// Dispatches a command that does not return a result + /// to its registered command handler. + /// + /// + /// + /// + Task DispatchAsync( + TCommand command, + CancellationToken ct = default) + where TCommand : ICommand; + + /// + /// Dispatches a command that returns a result + /// to its registered command handler. + /// + /// + /// + /// + /// + /// + Task DispatchAsync( + TCommand command, + CancellationToken ct = default) + where TCommand : ICommand; +} \ No newline at end of file diff --git a/Patterns/Commands/ICommandHandler.cs b/Patterns/Commands.Abstractions/ICommandHandler.cs similarity index 100% rename from Patterns/Commands/ICommandHandler.cs rename to Patterns/Commands.Abstractions/ICommandHandler.cs diff --git a/Patterns/Commands.Abstractions/Odin.Patterns.Commands.Abstractions.csproj b/Patterns/Commands.Abstractions/Odin.Patterns.Commands.Abstractions.csproj new file mode 100644 index 0000000..d997899 --- /dev/null +++ b/Patterns/Commands.Abstractions/Odin.Patterns.Commands.Abstractions.csproj @@ -0,0 +1,19 @@ + + + net8.0;net9.0;net10.0 + true + enable + icon.png + README.md + Odin.Patterns.Commands + All abstractions for the command dispatch pattern. + + + 1591;1573; + + + + + + + diff --git a/Patterns/Commands/DependencyInjectionExtensions.cs b/Patterns/Commands/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..be5f544 --- /dev/null +++ b/Patterns/Commands/DependencyInjectionExtensions.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Odin.DesignContracts; +using Odin.Patterns.Commands; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Dependency injection methods to support Odin command dispatching. +/// +public static class DependencyInjectionExtensions +{ + /// + /// Registers the Odin implementation for ICommandDispatcher. + /// Does not auto register all ICommandHandler of TCommand implementations. + /// + /// The service collection to update. + /// The updated service collection. + public static IServiceCollection AddOdinCommandDispatcher(this IServiceCollection serviceCollection) + { + Precondition.RequiresNotNull(serviceCollection); + serviceCollection.AddOdinLoggerWrapper(); + serviceCollection.TryAddTransient(); + return serviceCollection; + } + + /// + /// Finds all implementations of ICommandHandler (of TCommand) and + /// ICommandHandler (of TCommand, TResult) in the specified assemblies, + /// and registers them as transient services. + /// + /// The service collection to update. + /// The assemblies to scan for command handler implementations. + /// The updated service collection. + public static IServiceCollection AddOdinCommandHandlers( + this IServiceCollection serviceCollection, + params Assembly[] assemblies) + { + Precondition.RequiresNotNull(serviceCollection); + Precondition.RequiresNotNull(assemblies); + + foreach (Assembly assembly in assemblies.Distinct()) + { + Precondition.RequiresNotNull(assembly); + RegisterCommandHandlers(serviceCollection, assembly); + } + + return serviceCollection; + } + + private static void RegisterCommandHandlers(IServiceCollection serviceCollection, Assembly assembly) + { + foreach (TypeInfo implementationType in assembly.DefinedTypes) + { + if (!implementationType.IsClass || implementationType.IsAbstract || implementationType.ContainsGenericParameters) + { + continue; + } + + foreach (Type handlerInterfaceType in implementationType.ImplementedInterfaces.Where(IsCommandHandlerInterface)) + { + serviceCollection.TryAddEnumerable( + ServiceDescriptor.Transient(handlerInterfaceType, implementationType.AsType())); + } + } + } + + private static bool IsCommandHandlerInterface(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + Type genericTypeDefinition = type.GetGenericTypeDefinition(); + return genericTypeDefinition == typeof(ICommandHandler<>) || + genericTypeDefinition == typeof(ICommandHandler<,>); + } +} diff --git a/Patterns/Commands/Odin.Patterns.Commands.csproj b/Patterns/Commands/Odin.Patterns.Commands.csproj index 746cd50..bddc02d 100644 --- a/Patterns/Commands/Odin.Patterns.Commands.csproj +++ b/Patterns/Commands/Odin.Patterns.Commands.csproj @@ -5,12 +5,23 @@ enable icon.png README.md - + Implementation of a command dispatch pattern. 1591;1573; + + + + + + + + + + + diff --git a/Patterns/Commands/ServiceProviderCommandDispatcher.cs b/Patterns/Commands/ServiceProviderCommandDispatcher.cs new file mode 100644 index 0000000..337a96a --- /dev/null +++ b/Patterns/Commands/ServiceProviderCommandDispatcher.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.DependencyInjection; +using Odin.DesignContracts; +using Odin.Logging; + +namespace Odin.Patterns.Commands; + +/// +/// Dispatches commands by resolving their matching command handler from an . +/// +public sealed class ServiceProviderCommandDispatcher : ICommandDispatcher +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILoggerWrapper _logger; + + /// + /// Creates a new dispatcher. + /// + /// The service provider used to resolve command handlers. + /// The logger used to record dispatch activity. + public ServiceProviderCommandDispatcher( + IServiceProvider serviceProvider, + ILoggerWrapper logger) + { + Precondition.RequiresNotNull(serviceProvider); + Precondition.RequiresNotNull(logger); + + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + public async Task DispatchAsync( + TCommand command, + CancellationToken ct = default) + where TCommand : ICommand + { + Precondition.RequiresNotNull(command); + + Type commandType = typeof(TCommand); + Type handlerInterfaceType = typeof(ICommandHandler); + + _logger.LogTrace( + "Dispatching command {CommandType} using handler interface {HandlerInterface}.", + FormatTypeName(commandType), + FormatTypeName(handlerInterfaceType)); + + ICommandHandler handler = ResolveHandler>(commandType, handlerInterfaceType); + + try + { + await handler.HandleAsync(command, ct).ConfigureAwait(false); + + _logger.LogTrace( + "Command {CommandType} completed successfully using handler {HandlerType}.", + FormatTypeName(commandType), + FormatTypeName(handler.GetType())); + } + catch (OperationCanceledException ex) when (ct.IsCancellationRequested) + { + _logger.LogDebug( + ex, + "Command {CommandType} was cancelled while executing handler {HandlerType}.", + FormatTypeName(commandType), + FormatTypeName(handler.GetType())); + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Command {CommandType} failed while executing handler {HandlerType}.", + FormatTypeName(commandType), + FormatTypeName(handler.GetType())); + throw; + } + } + + /// + public async Task DispatchAsync( + TCommand command, + CancellationToken ct = default) + where TCommand : ICommand + { + Precondition.RequiresNotNull(command); + + Type commandType = typeof(TCommand); + Type handlerInterfaceType = typeof(ICommandHandler); + + _logger.LogDebug( + "Dispatching command {CommandType} using handler interface {HandlerInterface}.", + FormatTypeName(commandType), + FormatTypeName(handlerInterfaceType)); + + ICommandHandler handler = + ResolveHandler>(commandType, handlerInterfaceType); + + try + { + TCommandResult result = await handler.HandleAsync(command, ct).ConfigureAwait(false); + + _logger.LogDebug( + "Command {CommandType} completed successfully using handler {HandlerType}.", + FormatTypeName(commandType), + FormatTypeName(handler.GetType())); + + return result; + } + catch (OperationCanceledException ex) when (ct.IsCancellationRequested) + { + _logger.LogDebug( + ex, + "Command {CommandType} was cancelled while executing handler {HandlerType}.", + FormatTypeName(commandType), + FormatTypeName(handler.GetType())); + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Command {CommandType} failed while executing handler {HandlerType}.", + FormatTypeName(commandType), + FormatTypeName(handler.GetType())); + throw; + } + } + + private THandler ResolveHandler(Type commandType, Type handlerInterfaceType) + where THandler : class + { + THandler[] handlers = _serviceProvider.GetServices().ToArray(); + + if (handlers.Length == 1) + { + return handlers[0]; + } + + string message = handlers.Length == 0 + ? $"No command handler was registered for command type '{FormatTypeName(commandType)}'. " + + $"Expected handler interface: '{FormatTypeName(handlerInterfaceType)}'." + : $"Multiple command handlers were registered for command type '{FormatTypeName(commandType)}'. " + + $"Expected handler interface: '{FormatTypeName(handlerInterfaceType)}'. Found {handlers.Length} registrations."; + + _logger.LogError(message); + throw new InvalidOperationException(message); + } + + private static string FormatTypeName(Type type) + { + return type.FullName ?? type.Name; + } +} diff --git a/Patterns/Tests/Commands/DependencyInjectionExtensionsTests.cs b/Patterns/Tests/Commands/DependencyInjectionExtensionsTests.cs new file mode 100644 index 0000000..73e20af --- /dev/null +++ b/Patterns/Tests/Commands/DependencyInjectionExtensionsTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Odin.Logging; +using Odin.Patterns.Commands; + +namespace Tests.Odin.Patterns.Commands; + +[TestFixture] +public sealed class DependencyInjectionExtensionsTests +{ + [Test] + public void AddOdinCommandDispatcher_registers_CommandDispatcher_and_LoggerWrapper() + { + ServiceCollection services = new(); + + services.AddOdinCommandDispatcher(); + + services.AssertServiceRegistration(typeof(ICommandDispatcher), ServiceLifetime.Transient, + typeof(ServiceProviderCommandDispatcher)); + services.AssertServiceRegistration(typeof(ILoggerWrapper<>), ServiceLifetime.Singleton, + typeof(LoggerWrapper<>)); + services.AssertServiceRegistration(typeof(ILogger<>), ServiceLifetime.Singleton); + } + + + [Test] + public void AddOdinCommandHandlers_registers_scanned_handlers() + { + ServiceCollection services = new(); + + services.AddOdinCommandHandlers(typeof(DependencyInjectionExtensionsTests).Assembly); + + services.AssertServiceRegistration(typeof(ICommandHandler), ServiceLifetime.Transient, + typeof(TestCommandHandler)); + + services.AssertServiceRegistration(typeof(ICommandHandler), ServiceLifetime.Transient, + typeof(TestResultCommandHandler)); + + } + + [Test] + public void AddOdinCommandHandlers_requires_specific_assemblies_to_scan() + { + ServiceCollection services = new(); + + Assert.Catch(() => services.AddOdinCommandHandlers(null!)); + } +} diff --git a/Patterns/Tests/Commands/ServiceProviderCommandDispatcherTests.cs b/Patterns/Tests/Commands/ServiceProviderCommandDispatcherTests.cs new file mode 100644 index 0000000..cbcc5fb --- /dev/null +++ b/Patterns/Tests/Commands/ServiceProviderCommandDispatcherTests.cs @@ -0,0 +1,215 @@ +using Moq; +using Odin.Logging; +using Odin.Patterns.Commands; + +namespace Tests.Odin.Patterns.Commands; + +[TestFixture] +public sealed class ServiceProviderCommandDispatcherTests +{ + [Test] + public async Task DispatchAsync_for_void_command_calls_resolved_handler_and_logs_success() + { + RecordingCommandHandler handler = new RecordingCommandHandler(); + Mock> loggerMock = CreateLoggerMock(); + ServiceProviderCommandDispatcher sut = new( + new TestServiceProvider().AddHandlers>(handler), + loggerMock.Object); + + TestCommand command = new("Arthur"); + + await sut.DispatchAsync(command); + + Assert.That(handler.ReceivedCommand, Is.SameAs(command)); + loggerMock.Verify( + x => x.LogTrace( + "Dispatching command {CommandType} using handler interface {HandlerInterface}.", + It.Is(args => + args.Length == 2 && + Equals(args[0], typeof(TestCommand).FullName))), + Times.Once); + loggerMock.Verify( + x => x.LogTrace( + "Command {CommandType} completed successfully using handler {HandlerType}.", + It.Is(args => + args.Length == 2 && + Equals(args[0], typeof(TestCommand).FullName))), + Times.Once); + } + + [Test] + public async Task DispatchAsync_for_result_command_returns_handler_result() + { + ResultCommandHandler handler = new("Trillian"); + Mock> loggerMock = CreateLoggerMock(); + ServiceProviderCommandDispatcher sut = new( + new TestServiceProvider().AddHandlers>(handler), + loggerMock.Object); + + string result = await sut.DispatchAsync(new ResultCommand(42)); + + Assert.That(result, Is.EqualTo("Trillian")); + } + + [Test] + public void DispatchAsync_when_no_handler_is_registered_throws_with_command_and_handler_details() + { + Mock> loggerMock = CreateLoggerMock(); + ServiceProviderCommandDispatcher sut = new(new TestServiceProvider(), loggerMock.Object); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await sut.DispatchAsync(new TestCommand("Ford"))); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain(typeof(TestCommand).FullName)); + Assert.That(ex.Message, Does.Contain(typeof(ICommandHandler).FullName)); + loggerMock.Verify( + x => x.LogError( + It.Is(message => + message != null && + message.Contains(typeof(TestCommand).FullName!) && + message.Contains(typeof(ICommandHandler).FullName!)), + It.IsAny()), + Times.Once); + } + + [Test] + public void DispatchAsync_when_multiple_handlers_are_registered_throws_with_command_and_handler_details() + { + Mock> loggerMock = CreateLoggerMock(); + ServiceProviderCommandDispatcher sut = new( + new TestServiceProvider().AddHandlers>( + new RecordingCommandHandler(), + new RecordingCommandHandler()), + loggerMock.Object); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await sut.DispatchAsync(new TestCommand("Ford"))); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain(typeof(TestCommand).FullName)); + Assert.That(ex.Message, Does.Contain(typeof(ICommandHandler).FullName)); + Assert.That(ex.Message, Does.Contain("Found 2 registrations")); + } + + [Test] + public void DispatchAsync_bubbles_handler_exceptions_and_logs_error() + { + InvalidOperationException expectedException = new("Kaboom"); + ThrowingCommandHandler handler = new(expectedException); + Mock> loggerMock = CreateLoggerMock(); + ServiceProviderCommandDispatcher sut = new( + new TestServiceProvider().AddHandlers>(handler), + loggerMock.Object); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await sut.DispatchAsync(new TestCommand("Zaphod"))); + + Assert.That(ex, Is.SameAs(expectedException)); + loggerMock.Verify( + x => x.LogError( + expectedException, + "Command {CommandType} failed while executing handler {HandlerType}.", + It.Is(args => + args.Length == 2 && + Equals(args[0], typeof(TestCommand).FullName))), + Times.Once); + } + + [Test] + public void DispatchAsync_passes_the_cancellation_token_to_the_handler() + { + CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + CancellableCommandHandler handler = new(); + Mock> loggerMock = CreateLoggerMock(); + ServiceProviderCommandDispatcher sut = new( + new TestServiceProvider().AddHandlers>(handler), + loggerMock.Object); + + OperationCanceledException? ex = Assert.ThrowsAsync( + async () => await sut.DispatchAsync(new TestCommand("Marvin"), cancellationTokenSource.Token)); + + Assert.That(ex, Is.Not.Null); + Assert.That(handler.ReceivedCancellationToken, Is.EqualTo(cancellationTokenSource.Token)); + } + + private static Mock> CreateLoggerMock() + { + Mock> loggerMock = new(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + return loggerMock; + } + + private sealed record TestCommand(string Value) : ICommand; + + private sealed record ResultCommand(int Value) : ICommand; + + private sealed class RecordingCommandHandler : ICommandHandler + { + public TestCommand? ReceivedCommand { get; private set; } + + public Task HandleAsync(TestCommand command, CancellationToken ct = default) + { + ReceivedCommand = command; + return Task.CompletedTask; + } + } + + private sealed class ResultCommandHandler(string valueToReturn) : ICommandHandler + { + public Task HandleAsync(ResultCommand command, CancellationToken ct = default) + { + return Task.FromResult(valueToReturn); + } + } + + private sealed class ThrowingCommandHandler(Exception exceptionToThrow) : ICommandHandler + { + public Task HandleAsync(TestCommand command, CancellationToken ct = default) + { + throw exceptionToThrow; + } + } + + private sealed class CancellableCommandHandler : ICommandHandler + { + public CancellationToken ReceivedCancellationToken { get; private set; } + + public Task HandleAsync(TestCommand command, CancellationToken ct = default) + { + ReceivedCancellationToken = ct; + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + private sealed class TestServiceProvider : IServiceProvider + { + private readonly Dictionary _services = new(); + + public TestServiceProvider AddHandlers(params THandler[] handlers) + where THandler : class + { + _services[typeof(IEnumerable)] = handlers; + return this; + } + + public object? GetService(Type serviceType) + { + if (_services.TryGetValue(serviceType, out object? service)) + { + return service; + } + + if (serviceType.IsGenericType && serviceType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + Type elementType = serviceType.GetGenericArguments()[0]; + return Array.CreateInstance(elementType, 0); + } + + return null; + } + } +} diff --git a/Patterns/Tests/Commands/TestCommands.cs b/Patterns/Tests/Commands/TestCommands.cs new file mode 100644 index 0000000..20c8063 --- /dev/null +++ b/Patterns/Tests/Commands/TestCommands.cs @@ -0,0 +1,25 @@ +using Odin.Patterns.Commands; + +namespace Tests.Odin.Patterns.Commands; + +// ReSharper disable once ClassNeverInstantiated.Local +internal sealed record TestCommand(string Value) : ICommand; + +// ReSharper disable once ClassNeverInstantiated.Local +internal sealed record TestResultCommand(int Value) : ICommand; + +internal sealed class TestCommandHandler : ICommandHandler +{ + public Task HandleAsync(TestCommand command, CancellationToken ct = default) + { + return Task.CompletedTask; + } +} + +internal sealed class TestResultCommandHandler : ICommandHandler +{ + public Task HandleAsync(TestResultCommand command, CancellationToken ct = default) + { + return Task.FromResult(command.Value.ToString()); + } +} diff --git a/Patterns/Tests/Tests.Odin.Patterns.csproj b/Patterns/Tests/Tests.Odin.Patterns.csproj new file mode 100644 index 0000000..0bb313c --- /dev/null +++ b/Patterns/Tests/Tests.Odin.Patterns.csproj @@ -0,0 +1,28 @@ + + + net8.0;net9.0;net10.0 + enable + Tests.Odin.Patterns + true + 1591; + + + + + + + + + + + + + + + + + + + + + diff --git a/System/Tests/ResultOfTMessageTests.cs b/System/Tests/ResultOfTMessageTests.cs index f280d83..989945e 100644 --- a/System/Tests/ResultOfTMessageTests.cs +++ b/System/Tests/ResultOfTMessageTests.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using NUnit.Framework; +using NUnit.Framework; using Odin.System; namespace Tests.Odin.System diff --git a/TestingUtility/Odin.Testing.NUnitUtility.csproj b/TestingUtility/Odin.Testing.NUnitUtility.csproj new file mode 100644 index 0000000..f5e96a9 --- /dev/null +++ b/TestingUtility/Odin.Testing.NUnitUtility.csproj @@ -0,0 +1,21 @@ + + + net8.0;net9.0;net10.0 + true + enable + icon.png + Odin.Testing + Assertion and other utility extension methods for NUnit based tests... + + 1591;1573; + + + + + + + + + + + \ No newline at end of file diff --git a/TestingUtility/ServiceCollectionAssertions.cs b/TestingUtility/ServiceCollectionAssertions.cs new file mode 100644 index 0000000..b7603ed --- /dev/null +++ b/TestingUtility/ServiceCollectionAssertions.cs @@ -0,0 +1,97 @@ +using NUnit.Framework; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Methods for assertion of service registration in ServiceCollection. +/// +public static class ServiceCollectionAssertions +{ + /// + /// Verifies service registration for a serviceType, lifetime and implementation type. + /// + /// + /// + /// + /// + /// + public static void AssertServiceRegistration(this ServiceCollection services, Type serviceType, + ServiceLifetime specificLifetime, Type implementationType, int registrationCount = 1 + ) + { + IReadOnlyList found = services.Where(x => + x.ServiceType == serviceType && + x.ImplementationType == implementationType && + x.Lifetime == specificLifetime).ToList(); + + Assert.That(found.Count, Is.EqualTo(registrationCount), + $"Expected {registrationCount} registration(s) for {serviceType.Name} with lifetime {specificLifetime} " + + $"and implementation {implementationType.Name} but found {found.Count}. " + + GetDescriptionOfAllServicesOfType(services, serviceType)); + + } + + /// + /// Verifies service registration for a serviceType and lifetime. + /// + /// + /// + /// + /// + public static void AssertServiceRegistration(this ServiceCollection services, Type serviceType, + ServiceLifetime specificLifetime, int registrationCount = 1 + ) + { + IReadOnlyList found = services.Where(x => + x.ServiceType == serviceType && + x.Lifetime == specificLifetime).ToList(); + + Assert.That(found.Count, Is.EqualTo(registrationCount), + $"Expected {registrationCount} registration(s) for {serviceType.Name} with lifetime {specificLifetime} " + + $"but found {found.Count}. " + + GetDescriptionOfAllServicesOfType(services, serviceType)); + + } + + /// + /// Verifies service registration for a serviceType and lifetime. + /// + /// + /// + /// + /// + public static void AssertServiceRegistration(this ServiceCollection services, Type serviceType, + Type implementationType, int registrationCount = 1 + ) + { + IReadOnlyList found = services.Where(x => + x.ServiceType == serviceType).ToList(); + + Assert.That(found.Count, Is.EqualTo(registrationCount), + $"Expected {registrationCount} registration(s) for {serviceType.Name} with any any lifetime " + + $"and implementation {implementationType.Name} but found {found.Count}. " + + GetDescriptionOfAllServicesOfType(services, serviceType)); + } + + private static string GetDescriptionOfAllServicesOfType(ServiceCollection services, Type serviceType) + { + IReadOnlyList allOfType = services + .Where(x => x.ServiceType == serviceType).ToList(); + + string serviceTypesFound = $"{(allOfType.Count == 0 ? "No other " : allOfType.Count)} registrations found for {serviceType.Name}"; + if (allOfType.Count > 0) + { + serviceTypesFound += string.Join(Environment.NewLine + " - ", allOfType.Select(x => x.ToString())); + } + return serviceTypesFound; + } + + + + internal static string FormatServiceDescriptor(ServiceDescriptor descriptor) + { + return descriptor.ToString(); + } + +} \ No newline at end of file