diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..1903d86 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.4.3", + "commands": [ + "reportgenerator" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.gitea/workflows/test-dotnet.yaml b/.gitea/workflows/test-dotnet.yaml new file mode 100644 index 0000000..1ce7bee --- /dev/null +++ b/.gitea/workflows/test-dotnet.yaml @@ -0,0 +1,54 @@ +name: .NET Test + +on: + push: + branches: [ main ] + tags-ignore: + - '**' + paths-ignore: + - '.vscode' + - 'README.md' + - 'LICENSE' + - '.gitea/workflows/publish-*.yaml' + pull_request: + branches: [ main ] + paths-ignore: + - '.vscode' + - 'README.md' + - 'LICENSE' + - '.gitea/workflows/publish-*.yaml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: https://github.com/actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + + - name: Restore dependencies + run: dotnet restore --nologo + + - name: Build + run: dotnet build --nologo --configuration Release --no-restore + + - name: Test + run: dotnet test --nologo --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" + + - name: Restore local tools + run: dotnet tool restore + + - name: Generate coverage report + run: dotnet reportgenerator -reports:**/coverage.cobertura.xml -targetdir:./coverage -reporttypes:MarkdownSummary + + - name: Upload dotnet test results + #uses: actions/upload-artifact@v4 + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: coverage-results + path: coverage + if: ${{ always() }} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e2bb9bd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "dotnet.defaultSolution": "Just.Cqrs.sln", + "dotnetAcquisitionExtension.enableTelemetry": false +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b656c05 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + + enable + enable + + Lightweight, easy-to-use C# library designed to simplify the implementation of the Command Query Responsibility Segregation (CQRS) pattern. + JustFixMe + Copyright (c) 2025 JustFixMe + https://github.com/JustFixMe/Just.Core/ + + c#;CQRS + LICENSE + readme.md + + + diff --git a/Just.Cqrs.sln b/Just.Cqrs.sln new file mode 100644 index 0000000..2b7b003 --- /dev/null +++ b/Just.Cqrs.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cqrs.Tests", "tests\Cqrs.Tests\Cqrs.Tests.csproj", "{D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Just.Cqrs.Abstractions", "src\Just.Cqrs.Abstractions\Just.Cqrs.Abstractions.csproj", "{7CE7979E-9B98-4532-A172-6FC2BE8897F5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Just.Cqrs", "src\Just.Cqrs\Just.Cqrs.csproj", "{C474A3F6-8BF3-48A3-A542-C50024AD292E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290}.Release|Any CPU.Build.0 = Release|Any CPU + {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CE7979E-9B98-4532-A172-6FC2BE8897F5}.Release|Any CPU.Build.0 = Release|Any CPU + {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C474A3F6-8BF3-48A3-A542-C50024AD292E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D0C26007-4570-4B6D-9DE2-4DDF8ECE9290} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {7CE7979E-9B98-4532-A172-6FC2BE8897F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C474A3F6-8BF3-48A3-A542-C50024AD292E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/src/Just.Cqrs.Abstractions/ICommandDispatcher.cs b/src/Just.Cqrs.Abstractions/ICommandDispatcher.cs new file mode 100644 index 0000000..45f8ac8 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/ICommandDispatcher.cs @@ -0,0 +1,11 @@ +namespace Just.Cqrs; + +public interface ICommandDispatcher +{ + ValueTask Dispatch( + object command, + CancellationToken cancellationToken); + ValueTask Dispatch( + IKnownCommand command, + CancellationToken cancellationToken); +} diff --git a/src/Just.Cqrs.Abstractions/ICommandHandler.cs b/src/Just.Cqrs.Abstractions/ICommandHandler.cs new file mode 100644 index 0000000..6dda11c --- /dev/null +++ b/src/Just.Cqrs.Abstractions/ICommandHandler.cs @@ -0,0 +1,9 @@ +using Just.Cqrs.Internal; + +namespace Just.Cqrs; + +public interface ICommandHandler : ICommandHandlerImpl + where TCommand : notnull +{ + ValueTask Handle(TCommand command, CancellationToken cancellation); +} diff --git a/src/Just.Cqrs.Abstractions/IDispatchBehaviour.cs b/src/Just.Cqrs.Abstractions/IDispatchBehaviour.cs new file mode 100644 index 0000000..99bfb34 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/IDispatchBehaviour.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Just.Cqrs; + +/// +/// Delegate representing the rest of the pipeline +/// +/// Result type of dispatching command/query +/// Result of executing the rest of the pipeline +public delegate ValueTask DispatchFurtherDelegate(); + +/// +/// Marker interface for static type checking. Should not be used directly. +/// +public interface IDispatchBehaviour +{ + Type RequestType { get; } + Type ResponseType { get; } +} + +/// +/// Middleware analog for dispatching commands/queries +/// +/// Request type +/// Result type of dispatching command/query +public interface IDispatchBehaviour : IDispatchBehaviour + where TRequest : notnull +{ + ValueTask Handle( + TRequest request, + DispatchFurtherDelegate next, + CancellationToken cancellationToken); + + [ExcludeFromCodeCoverage] + Type IDispatchBehaviour.RequestType => typeof(TRequest); + [ExcludeFromCodeCoverage] + Type IDispatchBehaviour.ResponseType => typeof(TResponse); +} diff --git a/src/Just.Cqrs.Abstractions/IKnownCommand.cs b/src/Just.Cqrs.Abstractions/IKnownCommand.cs new file mode 100644 index 0000000..a1a61a0 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/IKnownCommand.cs @@ -0,0 +1,7 @@ +namespace Just.Cqrs; + +/// +/// Marker interface for Command type +/// +/// Result of dispatching this command +public interface IKnownCommand{} diff --git a/src/Just.Cqrs.Abstractions/IKnownQuery.cs b/src/Just.Cqrs.Abstractions/IKnownQuery.cs new file mode 100644 index 0000000..f1450b5 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/IKnownQuery.cs @@ -0,0 +1,7 @@ +namespace Just.Cqrs; + +/// +/// Marker interface for Query type +/// +/// Result of dispatching this query +public interface IKnownQuery{} diff --git a/src/Just.Cqrs.Abstractions/IQueryDispatcher.cs b/src/Just.Cqrs.Abstractions/IQueryDispatcher.cs new file mode 100644 index 0000000..30e1c73 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/IQueryDispatcher.cs @@ -0,0 +1,12 @@ +namespace Just.Cqrs; + +public interface IQueryDispatcher +{ + ValueTask Dispatch( + object query, + CancellationToken cancellationToken); + + ValueTask Dispatch( + IKnownQuery query, + CancellationToken cancellationToken); +} diff --git a/src/Just.Cqrs.Abstractions/IQueryHandler.cs b/src/Just.Cqrs.Abstractions/IQueryHandler.cs new file mode 100644 index 0000000..2d581f4 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/IQueryHandler.cs @@ -0,0 +1,9 @@ +using Just.Cqrs.Internal; + +namespace Just.Cqrs; + +public interface IQueryHandler : IQueryHandlerImpl + where TQuery : notnull +{ + ValueTask Handle(TQuery query, CancellationToken cancellation); +} diff --git a/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs b/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs new file mode 100644 index 0000000..d620b06 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/Internal/ICommandHandlerImpl.cs @@ -0,0 +1,3 @@ +namespace Just.Cqrs.Internal; + +public interface ICommandHandlerImpl { } diff --git a/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs b/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs new file mode 100644 index 0000000..e5a226b --- /dev/null +++ b/src/Just.Cqrs.Abstractions/Internal/IQueryHandlerImpl.cs @@ -0,0 +1,3 @@ +namespace Just.Cqrs.Internal; + +public interface IQueryHandlerImpl { } diff --git a/src/Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj b/src/Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj new file mode 100644 index 0000000..38450e1 --- /dev/null +++ b/src/Just.Cqrs.Abstractions/Just.Cqrs.Abstractions.csproj @@ -0,0 +1,10 @@ + + + + netstandard2.1 + latest + + Just.Cqrs + + + diff --git a/src/Just.Cqrs/CqrsServicesExtensions.cs b/src/Just.Cqrs/CqrsServicesExtensions.cs new file mode 100644 index 0000000..78e2d66 --- /dev/null +++ b/src/Just.Cqrs/CqrsServicesExtensions.cs @@ -0,0 +1,121 @@ +using Just.Cqrs; +using Just.Cqrs.Internal; +using Microsoft.Extensions.DependencyInjection.Extensions; + +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 + +public static class CqrsServicesExtensions +{ + public static IServiceCollection AddCqrs(this IServiceCollection services, Action? configure = null) + { + var options = new CqrsServicesOptions(services); + configure?.Invoke(options); + + services.TryAddKeyedSingleton(MethodsCacheServiceKey.DispatchCommand); + services.TryAddTransient(); + + services.TryAddKeyedSingleton(MethodsCacheServiceKey.DispatchQuery); + services.TryAddTransient(); + + foreach (var (service, impl, lifetime) in options.CommandHandlers) + { + services.TryAdd(new ServiceDescriptor(service, impl, lifetime)); + } + foreach (var (service, impl, lifetime) in options.QueryHandlers) + { + services.TryAdd(new ServiceDescriptor(service, impl, lifetime)); + } + foreach (var (service, impl, lifetime) in options.Behaviours) + { + services.Add(new ServiceDescriptor(service, impl, lifetime)); + } + + return services; + } + + public static CqrsServicesOptions AddCommandHandler( + this CqrsServicesOptions options, + ServiceLifetime lifetime = ServiceLifetime.Transient) + where TCommandHandler : notnull, ICommandHandlerImpl + { + var type = typeof(TCommandHandler); + var handlerInterfaces = type.FindInterfaces( + static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!, + typeof(ICommandHandler<,>)); + + foreach (var interfaceType in handlerInterfaces) + { + options.CommandHandlers.Add(( + interfaceType, + type, + lifetime)); + } + return options; + } + + public static CqrsServicesOptions AddQueryHandler( + this CqrsServicesOptions options, + ServiceLifetime lifetime = ServiceLifetime.Transient) + where TQueryHandler : notnull, IQueryHandlerImpl + { + var type = typeof(TQueryHandler); + var handlerInterfaces = type.FindInterfaces( + static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!, + typeof(IQueryHandler<,>)); + + foreach (var interfaceType in handlerInterfaces) + { + options.QueryHandlers.Add(( + interfaceType, + type, + lifetime)); + } + return options; + } + + public static CqrsServicesOptions AddOpenBehaviour(this CqrsServicesOptions options, Type behaviour, ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + var interfaces = behaviour.FindInterfaces( + static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!, + typeof(IDispatchBehaviour<,>)); + + if (interfaces.Length == 0) + { + throw new ArgumentException("Supplied type does not implement IDispatchBehaviour<,> interface.", nameof(behaviour)); + } + + if (!behaviour.ContainsGenericParameters) + { + throw new ArgumentException("Supplied type is not sutable for open behaviour.", nameof(behaviour)); + } + + options.Behaviours.Add((typeof(IDispatchBehaviour<,>), behaviour, lifetime)); + return options; + } + + public static CqrsServicesOptions AddBehaviour(this CqrsServicesOptions options, ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TBehaviour : notnull, IDispatchBehaviour + { + var type = typeof(TBehaviour); + + var interfaces = type.FindInterfaces( + static (x, t) => x.IsGenericType && x.GetGenericTypeDefinition() == (Type)t!, + typeof(IDispatchBehaviour<,>)); + + if (interfaces.Length == 0) + { + throw new InvalidOperationException("Supplied type does not implement IDispatchBehaviour<,> interface."); + } + + foreach (var interfaceType in interfaces) + { + options.Behaviours.Add(( + interfaceType, + type, + lifetime)); + } + return options; + } +} diff --git a/src/Just.Cqrs/CqrsServicesOptions.cs b/src/Just.Cqrs/CqrsServicesOptions.cs new file mode 100644 index 0000000..9b8392e --- /dev/null +++ b/src/Just.Cqrs/CqrsServicesOptions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Just.Cqrs; + +public sealed class CqrsServicesOptions(IServiceCollection services) +{ + internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> Behaviours = []; + internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> CommandHandlers = []; + internal readonly List<(Type Service, Type Impl, ServiceLifetime Lifetime)> QueryHandlers = []; + + public IServiceCollection Services { get; } = services; +} diff --git a/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs b/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs new file mode 100644 index 0000000..e8258bb --- /dev/null +++ b/src/Just.Cqrs/Internal/CommandDispatcherImpl.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Just.Cqrs.Internal; + +internal sealed class CommandDispatcherImpl( + IServiceProvider services, + [FromKeyedServices(MethodsCacheServiceKey.DispatchCommand)]IMethodsCache methodsCache +) : ICommandDispatcher +{ + [ExcludeFromCodeCoverage] + public ValueTask Dispatch(object command, CancellationToken cancellationToken) + => DispatchCommand(command, cancellationToken); + + public ValueTask Dispatch(IKnownCommand command, CancellationToken cancellationToken) + => DispatchCommand(command, cancellationToken); + + private ValueTask DispatchCommand(object command, CancellationToken cancellationToken) + { + var commandType = command.GetType(); + + var dispatchCommandMethod = methodsCache.GetOrAdd(commandType, static t => typeof(CommandDispatcherImpl) + .GetMethod(nameof(DispatchCommandImpl), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(t, typeof(TCommandResult))); + + return (ValueTask)dispatchCommandMethod + .Invoke(this, [command, cancellationToken])!; + } + + private ValueTask DispatchCommandImpl( + TCommand command, + CancellationToken cancellationToken) + where TCommand : notnull + { + var handler = services.GetRequiredService>(); + var pipeline = services.GetServices>(); + using var pipelineEnumerator = pipeline.GetEnumerator(); + + return DispatchDelegateFactory(pipelineEnumerator).Invoke(); + + DispatchFurtherDelegate DispatchDelegateFactory(IEnumerator> enumerator) => + enumerator.MoveNext() + ? (() => enumerator.Current.Handle(command, DispatchDelegateFactory(enumerator), cancellationToken)) + : (() => handler.Handle(command, cancellationToken)); + } +} diff --git a/src/Just.Cqrs/Internal/IMethodsCache.cs b/src/Just.Cqrs/Internal/IMethodsCache.cs new file mode 100644 index 0000000..3732cc7 --- /dev/null +++ b/src/Just.Cqrs/Internal/IMethodsCache.cs @@ -0,0 +1,17 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace Just.Cqrs.Internal; + +internal interface IMethodsCache +{ + MethodInfo GetOrAdd(Type key, Func valueFactory); +} + +internal static class MethodsCacheServiceKey +{ + internal const string DispatchQuery = "q"; + internal const string DispatchCommand = "c"; +} + +internal sealed class ConcurrentMethodsCache : ConcurrentDictionary, IMethodsCache; diff --git a/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs b/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs new file mode 100644 index 0000000..7ba0d09 --- /dev/null +++ b/src/Just.Cqrs/Internal/QueryDispatcherImpl.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Just.Cqrs.Internal; + +internal sealed class QueryDispatcherImpl( + IServiceProvider services, + [FromKeyedServices(MethodsCacheServiceKey.DispatchQuery)]IMethodsCache methodsCache +) : IQueryDispatcher +{ + [ExcludeFromCodeCoverage] + public ValueTask Dispatch(object query, CancellationToken cancellationToken) + => DispatchQuery(query, cancellationToken); + + public ValueTask Dispatch(IKnownQuery query, CancellationToken cancellationToken) + => DispatchQuery(query, cancellationToken); + + private ValueTask DispatchQuery(object query, CancellationToken cancellationToken) + { + var queryType = query.GetType(); + + var dispatchQueryMethod = methodsCache.GetOrAdd(queryType, static t => typeof(QueryDispatcherImpl) + .GetMethod(nameof(DispatchQueryImpl), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(t, typeof(TQueryResult))); + + return (ValueTask)dispatchQueryMethod + .Invoke(this, [query, cancellationToken])!; + } + + private ValueTask DispatchQueryImpl( + TQuery query, + CancellationToken cancellationToken) + where TQuery : notnull + { + var handler = services.GetRequiredService>(); + var pipeline = services.GetServices>(); + using var pipelineEnumerator = pipeline.GetEnumerator(); + + return DispatchDelegateFactory(pipelineEnumerator).Invoke(); + + DispatchFurtherDelegate DispatchDelegateFactory(IEnumerator> enumerator) => + enumerator.MoveNext() + ? (() => enumerator.Current.Handle(query, DispatchDelegateFactory(enumerator), cancellationToken)) + : (() => handler.Handle(query, cancellationToken)); + } +} diff --git a/src/Just.Cqrs/Just.Cqrs.csproj b/src/Just.Cqrs/Just.Cqrs.csproj new file mode 100644 index 0000000..872a7b3 --- /dev/null +++ b/src/Just.Cqrs/Just.Cqrs.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.1;net8.0;net9.0 + latest + + + + + + + + + + + + + + + + + + diff --git a/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs b/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs new file mode 100644 index 0000000..bd0d793 --- /dev/null +++ b/tests/Cqrs.Tests/CommandDispatcherImplTests/Dispatch.cs @@ -0,0 +1,188 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; + +namespace Cqrs.Tests.CommandDispatcherImplTests; + +public class Dispatch +{ + public class TestCommand : IKnownCommand {} + public class TestCommandResult {} + + [Theory] + [InlineData(ServiceLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton)] + public async Task WhenCalled_ShouldExecuteHandler(ServiceLifetime lifetime) + { + // Given + var testCommand = new TestCommand(); + var testCommandResult = new TestCommandResult(); + + var commandHandler = Substitute.For>(); + commandHandler.Handle(testCommand, CancellationToken.None).Returns(testCommandResult); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(ICommandHandler), + (IServiceProvider _) => commandHandler, + lifetime + ), + ]; + var services = serviceCollection.BuildServiceProvider(); + + var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testCommand, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testCommandResult); + await commandHandler.Received(1).Handle(testCommand, CancellationToken.None); + } + + public class TestOpenBehaviour : IDispatchBehaviour + where TRequest : notnull + { + private readonly Action _callback; + + public TestOpenBehaviour(Action callback) + { + _callback = callback; + } + + public ValueTask Handle(TRequest request, DispatchFurtherDelegate next, CancellationToken cancellationToken) + { + _callback.Invoke(request); + return next(); + } + } + + [Fact] + public async Task WhenPipelineConfigured_ShouldCallAllBehavioursInOrder() + { + // Given + var testCommand = new TestCommand(); + var testCommandResult = new TestCommandResult(); + List calls = []; + + var commandHandler = Substitute.For>(); + commandHandler.Handle(testCommand, CancellationToken.None) + .Returns(testCommandResult) + .AndDoes(_ => calls.Add("commandHandler")); + + var firstBehaviour = Substitute.For>(); + firstBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any()) + .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke()) + .AndDoes(_ => calls.Add("firstBehaviour")); + + var secondBehaviour = Substitute.For>(); + secondBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any()) + .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke()) + .AndDoes(_ => calls.Add("secondBehaviour")); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(ICommandHandler), + (IServiceProvider _) => commandHandler, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => firstBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => secondBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour<,>), + typeof(TestOpenBehaviour<,>), + ServiceLifetime.Transient + ), + ]; + serviceCollection.AddTransient>(_ => (TestCommand _) => calls.Add("thirdBehaviour")); + var services = serviceCollection.BuildServiceProvider(); + + var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testCommand, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testCommandResult); + await firstBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any()); + await secondBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any()); + await commandHandler.Received(1).Handle(testCommand, CancellationToken.None); + + calls.ShouldBe(["firstBehaviour", "secondBehaviour", "thirdBehaviour", "commandHandler"]); + } + + [Fact] + public async Task WhenNextIsNotCalled_ShouldStopExecutingPipeline() + { + // Given + var testCommand = new TestCommand(); + var testCommandResult = new TestCommandResult(); + var testCommandResultAborted = new TestCommandResult(); + List calls = []; + + var commandHandler = Substitute.For>(); + commandHandler.Handle(testCommand, CancellationToken.None) + .Returns(testCommandResult) + .AndDoes(_ => calls.Add("commandHandler")); + + var firstBehaviour = Substitute.For>(); + firstBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any()) + .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke()) + .AndDoes(_ => calls.Add("firstBehaviour")); + + var secondBehaviour = Substitute.For>(); + secondBehaviour.Handle(testCommand, Arg.Any>(), Arg.Any()) + .Returns(args => ValueTask.FromResult(testCommandResultAborted)) + .AndDoes(_ => calls.Add("secondBehaviour")); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(ICommandHandler), + (IServiceProvider _) => commandHandler, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => firstBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => secondBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour<,>), + typeof(TestOpenBehaviour<,>), + ServiceLifetime.Transient + ), + ]; + serviceCollection.AddTransient>(_ => (TestCommand _) => calls.Add("thirdBehaviour")); + var services = serviceCollection.BuildServiceProvider(); + + var sut = new CommandDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testCommand, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testCommandResultAborted); + await firstBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any()); + await secondBehaviour.Received(1).Handle(testCommand, Arg.Any>(), Arg.Any()); + await commandHandler.Received(0).Handle(testCommand, CancellationToken.None); + + calls.ShouldBe(["firstBehaviour", "secondBehaviour"]); + } +} diff --git a/tests/Cqrs.Tests/Cqrs.Tests.csproj b/tests/Cqrs.Tests/Cqrs.Tests.csproj new file mode 100644 index 0000000..3fc76ee --- /dev/null +++ b/tests/Cqrs.Tests/Cqrs.Tests.csproj @@ -0,0 +1,39 @@ + + + + net8.0;net9.0 + latest + + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddBehaviour.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddBehaviour.cs new file mode 100644 index 0000000..ba6bc88 --- /dev/null +++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddBehaviour.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Cqrs.Tests.CqrsServicesExtensionsTests; + +public class AddBehaviour +{ + public class TestCommand {} + public class TestCommandResult {} + [ExcludeFromCodeCoverage] + public class NonGenericTestOpenBehaviour : IDispatchBehaviour + { + public ValueTask Handle(TestCommand request, DispatchFurtherDelegate next, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + + [Theory] + [InlineData(ServiceLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton)] + public void WhenCalled_ShouldRegisterDispatchBehaviour(ServiceLifetime lifetime) + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt + .AddBehaviour(lifetime)); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IDispatchBehaviour) + && descriptor.ImplementationType == typeof(NonGenericTestOpenBehaviour) + && descriptor.Lifetime == lifetime, + expectedCount: 1 + ); + } + + [ExcludeFromCodeCoverage] + public class InvalidTestBehaviour : IDispatchBehaviour + { + public Type RequestType => throw new NotImplementedException(); + + public Type ResponseType => throw new NotImplementedException(); + } + + [Fact] + public void WhenCalledWithInvalidType_ShouldThrow() + { + // Given + ServiceCollection services = new(); + + // When + + // Then + Should.Throw(() => services.AddCqrs(opt => opt + .AddBehaviour()) + ); + } +} diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddCommandHandler.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddCommandHandler.cs new file mode 100644 index 0000000..942a894 --- /dev/null +++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddCommandHandler.cs @@ -0,0 +1,124 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Cqrs.Tests.CqrsServicesExtensionsTests; + +public class AddCommandHandler +{ + public class TestCommand {} + public class TestCommandResult {} + [ExcludeFromCodeCoverage] + public class TestCommandHandler : ICommandHandler + { + public ValueTask Handle(TestCommand command, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + } + + [Theory] + [InlineData(ServiceLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton)] + public void WhenCalled_ShouldRegisterCommandHandler(ServiceLifetime lifetime) + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt.AddCommandHandler(lifetime)); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandHandler) + && descriptor.ImplementationType == typeof(TestCommandHandler) + && descriptor.Lifetime == lifetime, + expectedCount: 1 + ); + } + + [Fact] + public void WhenCalledMultipleTimes_ShouldRegisterCommandHandlerOnce() + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt + .AddCommandHandler() + .AddCommandHandler() + .AddCommandHandler()); + + services.AddCqrs(opt => opt + .AddCommandHandler()); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandHandler) + && descriptor.ImplementationType == typeof(TestCommandHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + } + + public class SecondTestCommand {} + public class SecondTestCommandResult {} + [ExcludeFromCodeCoverage] + public class SecondTestCommandHandler : ICommandHandler + { + public ValueTask Handle(SecondTestCommand command, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + } + + public class ThirdTestCommand {} + public class ThirdTestCommandResult {} + [ExcludeFromCodeCoverage] + public class ThirdTestCommandHandler : ICommandHandler + { + public ValueTask Handle(ThirdTestCommand command, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void WhenCalledMultipleTimes_ShouldRegisterAllCommandHandlers() + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt + .AddCommandHandler() + .AddCommandHandler()); + services.AddCqrs(opt => opt + .AddCommandHandler()); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandHandler) + && descriptor.ImplementationType == typeof(TestCommandHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandHandler) + && descriptor.ImplementationType == typeof(SecondTestCommandHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandHandler) + && descriptor.ImplementationType == typeof(ThirdTestCommandHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + } +} diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddOpenBehaviour.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddOpenBehaviour.cs new file mode 100644 index 0000000..c994a4a --- /dev/null +++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddOpenBehaviour.cs @@ -0,0 +1,89 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Cqrs.Tests.CqrsServicesExtensionsTests; + +public class AddOpenBehaviour +{ + [ExcludeFromCodeCoverage] + public class TestOpenBehaviour : IDispatchBehaviour + where TRequest: notnull + { + public ValueTask Handle(TRequest request, DispatchFurtherDelegate next, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + + [Theory] + [InlineData(ServiceLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton)] + public void WhenCalled_ShouldRegisterOpenDispatchBehaviour(ServiceLifetime lifetime) + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt + .AddOpenBehaviour(typeof(TestOpenBehaviour<,>), lifetime)); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IDispatchBehaviour<,>) + && descriptor.ImplementationType == typeof(TestOpenBehaviour<,>) + && descriptor.Lifetime == lifetime, + expectedCount: 1 + ); + } + + [ExcludeFromCodeCoverage] + public class InvalidOpenBehaviour : IDispatchBehaviour + { + public Type RequestType => throw new NotImplementedException(); + + public Type ResponseType => throw new NotImplementedException(); + } + + [Fact] + public void WhenCalledWithInvalidType_ShouldThrow() + { + // Given + ServiceCollection services = new(); + + // When + var invalidOpenDispatchBehaviourType = typeof(InvalidOpenBehaviour); + + // Then + Should.Throw(() => services.AddCqrs(opt => opt + .AddOpenBehaviour(invalidOpenDispatchBehaviourType)) + ); + } + + public class TestCommand {} + public class TestCommandResult {} + [ExcludeFromCodeCoverage] + public class NonGenericTestOpenBehaviour : IDispatchBehaviour + { + public ValueTask Handle(TestCommand request, DispatchFurtherDelegate next, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void WhenCalledWithNonGenericType_ShouldThrow() + { + // Given + ServiceCollection services = new(); + + // When + var nonGenericOpenDispatchBehaviourType = typeof(NonGenericTestOpenBehaviour); + + // Then + Should.Throw(() => services.AddCqrs(opt => opt + .AddOpenBehaviour(nonGenericOpenDispatchBehaviourType)) + ); + } +} diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddQueryHandler.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddQueryHandler.cs new file mode 100644 index 0000000..37a797d --- /dev/null +++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddQueryHandler.cs @@ -0,0 +1,124 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Cqrs.Tests.CqrsServicesExtensionsTests; + +public class AddQueryHandler +{ + public class TestQuery {} + public class TestQueryResult {} + [ExcludeFromCodeCoverage] + public class TestQueryHandler : IQueryHandler + { + public ValueTask Handle(TestQuery Query, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + } + + [Theory] + [InlineData(ServiceLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton)] + public void WhenCalled_ShouldRegisterQueryHandler(ServiceLifetime lifetime) + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt.AddQueryHandler(lifetime)); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryHandler) + && descriptor.ImplementationType == typeof(TestQueryHandler) + && descriptor.Lifetime == lifetime, + expectedCount: 1 + ); + } + + [Fact] + public void WhenCalledMultipleTimes_ShouldRegisterQueryHandlerOnce() + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt + .AddQueryHandler() + .AddQueryHandler() + .AddQueryHandler()); + + services.AddCqrs(opt => opt + .AddQueryHandler()); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryHandler) + && descriptor.ImplementationType == typeof(TestQueryHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + } + + public class SecondTestQuery {} + public class SecondTestQueryResult {} + [ExcludeFromCodeCoverage] + public class SecondTestQueryHandler : IQueryHandler + { + public ValueTask Handle(SecondTestQuery Query, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + } + + public class ThirdTestQuery {} + public class ThirdTestQueryResult {} + [ExcludeFromCodeCoverage] + public class ThirdTestQueryHandler : IQueryHandler + { + public ValueTask Handle(ThirdTestQuery Query, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void WhenCalledMultipleTimes_ShouldRegisterAllQueryHandlers() + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(opt => opt + .AddQueryHandler() + .AddQueryHandler()); + services.AddCqrs(opt => opt + .AddQueryHandler()); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryHandler) + && descriptor.ImplementationType == typeof(TestQueryHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryHandler) + && descriptor.ImplementationType == typeof(SecondTestQueryHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryHandler) + && descriptor.ImplementationType == typeof(ThirdTestQueryHandler) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + } +} diff --git a/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddSqrs.cs b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddSqrs.cs new file mode 100644 index 0000000..ce1a7f3 --- /dev/null +++ b/tests/Cqrs.Tests/CqrsServicesExtensionsTests/AddSqrs.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Cqrs.Tests.CqrsServicesExtensionsTests; + +public class AddCqrs +{ + [Fact] + public void WhenCalled_ShouldRegisterDispatcherClasses() + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandDispatcher) + && descriptor.ImplementationType == typeof(CommandDispatcherImpl) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryDispatcher) + && descriptor.ImplementationType == typeof(QueryDispatcherImpl) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + } + + [Fact] + public void WhenCalledMultipleTimes_ShouldRegisterDispatcherClassesOnce() + { + // Given + ServiceCollection services = new(); + + // When + services.AddCqrs(); + services.AddCqrs(); + services.AddCqrs(); + services.AddCqrs(); + + // Then + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(ICommandDispatcher) + && descriptor.ImplementationType == typeof(CommandDispatcherImpl) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + + services.ShouldContain( + elementPredicate: descriptor => + descriptor.ServiceType == typeof(IQueryDispatcher) + && descriptor.ImplementationType == typeof(QueryDispatcherImpl) + && descriptor.Lifetime == ServiceLifetime.Transient, + expectedCount: 1 + ); + } +} diff --git a/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs b/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs new file mode 100644 index 0000000..5a74a90 --- /dev/null +++ b/tests/Cqrs.Tests/QueryDispatcherImplTests/Dispatch.cs @@ -0,0 +1,188 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; + +namespace Cqrs.Tests.QueryDispatcherImplTests; + +public class Dispatch +{ + public class TestQuery : IKnownQuery {} + public class TestQueryResult {} + + [Theory] + [InlineData(ServiceLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton)] + public async Task WhenCalled_ShouldExecuteHandler(ServiceLifetime lifetime) + { + // Given + var testQuery = new TestQuery(); + var testQueryResult = new TestQueryResult(); + + var queryHandler = Substitute.For>(); + queryHandler.Handle(testQuery, CancellationToken.None).Returns(testQueryResult); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(IQueryHandler), + (IServiceProvider _) => queryHandler, + lifetime + ), + ]; + var services = serviceCollection.BuildServiceProvider(); + + var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testQuery, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testQueryResult); + await queryHandler.Received(1).Handle(testQuery, CancellationToken.None); + } + + public class TestOpenBehaviour : IDispatchBehaviour + where TRequest : notnull + { + private readonly Action _callback; + + public TestOpenBehaviour(Action callback) + { + _callback = callback; + } + + public ValueTask Handle(TRequest request, DispatchFurtherDelegate next, CancellationToken cancellationToken) + { + _callback.Invoke(request); + return next(); + } + } + + [Fact] + public async Task WhenPipelineConfigured_ShouldCallAllBehavioursInOrder() + { + // Given + var testQuery = new TestQuery(); + var testQueryResult = new TestQueryResult(); + List calls = []; + + var queryHandler = Substitute.For>(); + queryHandler.Handle(testQuery, CancellationToken.None) + .Returns(testQueryResult) + .AndDoes(_ => calls.Add("queryHandler")); + + var firstBehaviour = Substitute.For>(); + firstBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any()) + .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke()) + .AndDoes(_ => calls.Add("firstBehaviour")); + + var secondBehaviour = Substitute.For>(); + secondBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any()) + .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke()) + .AndDoes(_ => calls.Add("secondBehaviour")); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(IQueryHandler), + (IServiceProvider _) => queryHandler, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => firstBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => secondBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour<,>), + typeof(TestOpenBehaviour<,>), + ServiceLifetime.Transient + ), + ]; + serviceCollection.AddTransient>(_ => (TestQuery _) => calls.Add("thirdBehaviour")); + var services = serviceCollection.BuildServiceProvider(); + + var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testQuery, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testQueryResult); + await firstBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any()); + await secondBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any()); + await queryHandler.Received(1).Handle(testQuery, CancellationToken.None); + + calls.ShouldBe(["firstBehaviour", "secondBehaviour", "thirdBehaviour", "queryHandler"]); + } + + [Fact] + public async Task WhenNextIsNotCalled_ShouldStopExecutingPipeline() + { + // Given + var testQuery = new TestQuery(); + var testQueryResult = new TestQueryResult(); + var testQueryResultAborted = new TestQueryResult(); + List calls = []; + + var queryHandler = Substitute.For>(); + queryHandler.Handle(testQuery, CancellationToken.None) + .Returns(testQueryResult) + .AndDoes(_ => calls.Add("queryHandler")); + + var firstBehaviour = Substitute.For>(); + firstBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any()) + .Returns(args => ((DispatchFurtherDelegate)args[1]).Invoke()) + .AndDoes(_ => calls.Add("firstBehaviour")); + + var secondBehaviour = Substitute.For>(); + secondBehaviour.Handle(testQuery, Arg.Any>(), Arg.Any()) + .Returns(args => ValueTask.FromResult(testQueryResultAborted)) + .AndDoes(_ => calls.Add("secondBehaviour")); + + ServiceCollection serviceCollection = + [ + new ServiceDescriptor( + typeof(IQueryHandler), + (IServiceProvider _) => queryHandler, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => firstBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour), + (IServiceProvider _) => secondBehaviour, + ServiceLifetime.Transient + ), + new ServiceDescriptor( + typeof(IDispatchBehaviour<,>), + typeof(TestOpenBehaviour<,>), + ServiceLifetime.Transient + ), + ]; + serviceCollection.AddTransient>(_ => (TestQuery _) => calls.Add("thirdBehaviour")); + var services = serviceCollection.BuildServiceProvider(); + + var sut = new QueryDispatcherImpl(services, new ConcurrentMethodsCache()); + + // When + var result = await sut.Dispatch(testQuery, CancellationToken.None); + + // Then + result.ShouldBeSameAs(testQueryResultAborted); + await firstBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any()); + await secondBehaviour.Received(1).Handle(testQuery, Arg.Any>(), Arg.Any()); + await queryHandler.Received(0).Handle(testQuery, CancellationToken.None); + + calls.ShouldBe(["firstBehaviour", "secondBehaviour"]); + } +} diff --git a/tests/Cqrs.Tests/usings.cs b/tests/Cqrs.Tests/usings.cs new file mode 100644 index 0000000..37d6c2d --- /dev/null +++ b/tests/Cqrs.Tests/usings.cs @@ -0,0 +1,3 @@ +global using Shouldly; +global using Just.Cqrs; +global using Just.Cqrs.Internal;