diff --git a/CQRS.slnx b/CQRS.slnx new file mode 100644 index 0000000..b4c46ba --- /dev/null +++ b/CQRS.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/lib/.gitkeep b/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/Logitar.CQRS/CommandBus.cs b/lib/Logitar.CQRS/CommandBus.cs new file mode 100644 index 0000000..98166a7 --- /dev/null +++ b/lib/Logitar.CQRS/CommandBus.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Logitar.CQRS; + +/// +/// Represents an in-memory bus in which sent commands are executed synchronously. +/// +public class CommandBus : ICommandBus +{ + /// + /// The name of the command handler method. + /// + protected const string HandlerName = nameof(ICommandHandler<,>.HandleAsync); + + /// + /// Gets the logger. + /// + protected virtual ILogger? Logger { get; } + /// + /// Gets the pseudo-random number generator. + /// + protected virtual Random Random { get; } = new(); + /// + /// Gets the service provider. + /// + protected virtual IServiceProvider ServiceProvider { get; } + /// + /// Gets the retry settings. + /// + protected virtual RetrySettings Settings { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + public CommandBus(IServiceProvider serviceProvider) + { + Logger = serviceProvider.GetService>(); + ServiceProvider = serviceProvider; + Settings = serviceProvider.GetService() ?? new(); + } + + /// + /// Executes the specified command. + /// + /// The type of the command result. + /// The command to execute. + /// The cancellation token. + /// The command result. + /// The handler did not define the handle method, or it did not return a task. + public virtual async Task ExecuteAsync(ICommand command, CancellationToken cancellationToken) + { + Settings.Validate(); + + object handler = await GetHandlerAsync(command, cancellationToken); + + Type handlerType = handler.GetType(); + Type commandType = command.GetType(); + Type[] parameterTypes = [commandType, typeof(CancellationToken)]; + MethodInfo handle = handlerType.GetMethod(HandlerName, parameterTypes) + ?? throw new InvalidOperationException($"The handler {handlerType} must define a '{HandlerName}' method."); + + object[] parameters = [command, cancellationToken]; + Exception? innerException; + int attempt = 0; + while (true) + { + attempt++; + try + { + object? result = handle.Invoke(handler, parameters); + if (result is not Task task) + { + throw new InvalidOperationException($"The handler {handlerType} {HandlerName} method must return a {nameof(Task)}."); + } + return await task; + } + catch (Exception exception) + { + if (!ShouldRetry(command, exception)) + { + throw; + } + innerException = exception; + + int millisecondsDelay = CalculateMillisecondsDelay(command, exception, attempt); + if (millisecondsDelay < 0) + { + throw new InvalidOperationException($"The retry delay '{millisecondsDelay}' should be greater than or equal to 0ms."); + } + + if (Settings.Algorithm == RetryAlgorithm.None + || (Settings.MaximumRetries > 0 && attempt > Settings.MaximumRetries) + || (Settings.MaximumDelay > 0 && millisecondsDelay > Settings.MaximumDelay)) + { + break; + } + + if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning(exception, "Command '{Command}' execution failed at attempt {Attempt}, will retry in {Delay}ms.", commandType, attempt, millisecondsDelay); + } + + if (millisecondsDelay > 0) + { + await Task.Delay(millisecondsDelay, cancellationToken); + } + } + } + + throw new InvalidOperationException($"Command '{commandType}' execution failed after {attempt} attempts. See inner exception for more detail.", innerException); + } + + /// + /// Finds the handler for the specified command. + /// + /// The type of the command result. + /// The command. + /// The cancellation token. + /// The command handler. + /// There is no handler or many handlers for the specified command. + protected virtual async Task GetHandlerAsync(ICommand command, CancellationToken cancellationToken) + { + Type commandType = command.GetType(); + IEnumerable handlers = ServiceProvider.GetServices(typeof(ICommandHandler<,>).MakeGenericType(commandType, typeof(TResult))) + .Where(handler => handler is not null) + .Select(handler => handler!); + int count = handlers.Count(); + if (count != 1) + { + StringBuilder message = new StringBuilder("Exactly one handler was expected for command of type '").Append(commandType).Append("', but "); + if (count < 1) + { + message.Append("none was found."); + } + else + { + message.Append(count).Append(" were found."); + } + throw new InvalidOperationException(message.ToString()); + } + return handlers.Single(); + } + + /// + /// Determines if the command execution should be retried or not. + /// + /// The type of the command result. + /// The command to execute. + /// The exception. + /// A value indicating whether or not the command execution should be retried. + protected virtual bool ShouldRetry(ICommand command, Exception exception) + { + return true; + } + + /// + /// Calculates the delay, in milliseconds, to wait before retrying the execution of a command after a failure. + /// The delay is computed according to the retry algorithm and configuration defined in . + /// + /// The type of the command result. + /// The command to execute. + /// The exception. + /// The current retry attempt number, starting at 1. + /// The number of milliseconds to wait before retrying. Returns 0 when retrying should occur immediately or when the configured delay or algorithm does not produce a positive value. + protected virtual int CalculateMillisecondsDelay(ICommand command, Exception exception, int attempt) + { + if (Settings.Delay > 0) + { + switch (Settings.Algorithm) + { + case RetryAlgorithm.Exponential: + if (Settings.ExponentialBase > 1) + { + return (int)Math.Pow(Settings.ExponentialBase, attempt - 1) * Settings.Delay; + } + break; + case RetryAlgorithm.Fixed: + return Settings.Delay; + case RetryAlgorithm.Linear: + return attempt * Settings.Delay; + case RetryAlgorithm.Random: + if (Settings.RandomVariation > 0 && Settings.RandomVariation < Settings.Delay) + { + int minimum = Settings.Delay - Settings.RandomVariation; + int maximum = Settings.Delay + Settings.RandomVariation; + return Random.Next(minimum, maximum + 1); + } + break; + } + } + return 0; + } +} diff --git a/lib/Logitar.CQRS/DependencyInjectionExtensions.cs b/lib/Logitar.CQRS/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..ccbd448 --- /dev/null +++ b/lib/Logitar.CQRS/DependencyInjectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.CQRS; + +/// +/// Provides extension methods for registering the Logitar CQRS pattern into a dependency injection container. +/// +public static class DependencyInjectionExtensions +{ + /// + /// Registers the command and query buses, along with their supporting configuration such as , into the service collection. + /// This enables Logitar's CQRS handling throughout the application. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddLogitarCQRS(this IServiceCollection services) + { + return services + .AddSingleton(serviceProvider => RetrySettings.Initialize(serviceProvider.GetRequiredService())) + .AddTransient() + .AddTransient(); + } +} diff --git a/lib/Logitar.CQRS/ICommand.cs b/lib/Logitar.CQRS/ICommand.cs new file mode 100644 index 0000000..62b4369 --- /dev/null +++ b/lib/Logitar.CQRS/ICommand.cs @@ -0,0 +1,12 @@ +namespace Logitar.CQRS; + +/// +/// Represents a command without result. +/// +public interface ICommand : ICommand; + +/// +/// Represents a command returning a result. +/// +/// The type of the result. +public interface ICommand; diff --git a/lib/Logitar.CQRS/ICommandBus.cs b/lib/Logitar.CQRS/ICommandBus.cs new file mode 100644 index 0000000..1b97aaf --- /dev/null +++ b/lib/Logitar.CQRS/ICommandBus.cs @@ -0,0 +1,16 @@ +namespace Logitar.CQRS; + +/// +/// Represents a bus in which commands are sent. +/// +public interface ICommandBus +{ + /// + /// Executes the specified command. + /// + /// The type of the command result. + /// The command to execute. + /// The cancellation token. + /// The command result. + Task ExecuteAsync(ICommand command, CancellationToken cancellationToken = default); +} diff --git a/lib/Logitar.CQRS/ICommandHandler.cs b/lib/Logitar.CQRS/ICommandHandler.cs new file mode 100644 index 0000000..e4abeff --- /dev/null +++ b/lib/Logitar.CQRS/ICommandHandler.cs @@ -0,0 +1,17 @@ +namespace Logitar.CQRS; + +/// +/// Represents a handler for a specific command. +/// +/// The type of the command. +/// The type of the command result. +public interface ICommandHandler where TCommand : ICommand +{ + /// + /// Handles the specified command and returns its result. + /// + /// The command to handle. + /// The cancellation token. + /// The command result. + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default); +} diff --git a/lib/Logitar.CQRS/IQuery.cs b/lib/Logitar.CQRS/IQuery.cs new file mode 100644 index 0000000..d10a1f4 --- /dev/null +++ b/lib/Logitar.CQRS/IQuery.cs @@ -0,0 +1,7 @@ +namespace Logitar.CQRS; + +/// +/// Represents a query returning a result. +/// +/// The type of the result. +public interface IQuery; diff --git a/lib/Logitar.CQRS/IQueryBus.cs b/lib/Logitar.CQRS/IQueryBus.cs new file mode 100644 index 0000000..715f52b --- /dev/null +++ b/lib/Logitar.CQRS/IQueryBus.cs @@ -0,0 +1,16 @@ +namespace Logitar.CQRS; + +/// +/// Represents a bus in which queries are sent. +/// +public interface IQueryBus +{ + /// + /// Executes the specified query. + /// + /// The type of the query result. + /// The query to execute. + /// The cancellation token. + /// The query result. + Task ExecuteAsync(IQuery query, CancellationToken cancellationToken = default); +} diff --git a/lib/Logitar.CQRS/IQueryHandler.cs b/lib/Logitar.CQRS/IQueryHandler.cs new file mode 100644 index 0000000..cfcdec6 --- /dev/null +++ b/lib/Logitar.CQRS/IQueryHandler.cs @@ -0,0 +1,17 @@ +namespace Logitar.CQRS; + +/// +/// Represents a handler for a specific query. +/// +/// The type of the query. +/// The type of the query result. +public interface IQueryHandler where TQuery : IQuery +{ + /// + /// Handles the specified query and returns its result. + /// + /// The query to handle. + /// The cancellation token. + /// The query result. + Task HandleAsync(TQuery query, CancellationToken cancellationToken = default); +} diff --git a/lib/Logitar.CQRS/LICENSE b/lib/Logitar.CQRS/LICENSE new file mode 100644 index 0000000..70fdfae --- /dev/null +++ b/lib/Logitar.CQRS/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Logitar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/Logitar.CQRS/Logitar.CQRS.csproj b/lib/Logitar.CQRS/Logitar.CQRS.csproj new file mode 100644 index 0000000..03c0236 --- /dev/null +++ b/lib/Logitar.CQRS/Logitar.CQRS.csproj @@ -0,0 +1,65 @@ + + + + net10.0 + enable + enable + True + Logitar.CQRS + Francis Pion + Logitar.NET + Provides an implementation of the Command Query Responsibility Segregation pattern. + © 2025 Logitar All Rights Reserved. + logitar.png + README.md + git + https://github.com/Logitar/CQRS + 10.0.0.0 + $(AssemblyVersion) + LICENSE + True + 10.0.0 + en-CA + True + Initial release. + logitar net framework cqrs command query responsibility segregation + https://github.com/Logitar/CQRS/tree/main/lib/Logitar.CQRS + + + + True + + + + True + + + + + + + + + + + + \ + True + + + \ + True + + + \ + True + + + + + + + + + + diff --git a/lib/Logitar.CQRS/QueryBus.cs b/lib/Logitar.CQRS/QueryBus.cs new file mode 100644 index 0000000..5354b28 --- /dev/null +++ b/lib/Logitar.CQRS/QueryBus.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Logitar.CQRS; + +/// +/// Represents an in-memory bus in which sent querys are executed synchronously. +/// +public class QueryBus : IQueryBus +{ + /// + /// The name of the query handler method. + /// + protected const string HandlerName = nameof(IQueryHandler<,>.HandleAsync); + + /// + /// Gets the logger. + /// + protected virtual ILogger? Logger { get; } + /// + /// Gets the pseudo-random number generator. + /// + protected virtual Random Random { get; } = new(); + /// + /// Gets the service provider. + /// + protected virtual IServiceProvider ServiceProvider { get; } + /// + /// Gets the retry settings. + /// + protected virtual RetrySettings Settings { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + public QueryBus(IServiceProvider serviceProvider) + { + Logger = serviceProvider.GetService>(); + ServiceProvider = serviceProvider; + Settings = serviceProvider.GetService() ?? new(); + } + + /// + /// Executes the specified query. + /// + /// The type of the query result. + /// The query to execute. + /// The cancellation token. + /// The query result. + /// The handler did not define the handle method, or it did not return a task. + public virtual async Task ExecuteAsync(IQuery query, CancellationToken cancellationToken) + { + Settings.Validate(); + + object handler = await GetHandlerAsync(query, cancellationToken); + + Type handlerType = handler.GetType(); + Type queryType = query.GetType(); + Type[] parameterTypes = [queryType, typeof(CancellationToken)]; + MethodInfo handle = handlerType.GetMethod(HandlerName, parameterTypes) + ?? throw new InvalidOperationException($"The handler {handlerType} must define a '{HandlerName}' method."); + + object[] parameters = [query, cancellationToken]; + Exception? innerException; + int attempt = 0; + while (true) + { + attempt++; + try + { + object? result = handle.Invoke(handler, parameters); + if (result is not Task task) + { + throw new InvalidOperationException($"The handler {handlerType} {HandlerName} method must return a {nameof(Task)}."); + } + return await task; + } + catch (Exception exception) + { + if (!ShouldRetry(query, exception)) + { + throw; + } + innerException = exception; + + int millisecondsDelay = CalculateMillisecondsDelay(query, exception, attempt); + if (millisecondsDelay < 0) + { + throw new InvalidOperationException($"The retry delay '{millisecondsDelay}' should be greater than or equal to 0ms."); + } + + if (Settings.Algorithm == RetryAlgorithm.None + || (Settings.MaximumRetries > 0 && attempt > Settings.MaximumRetries) + || (Settings.MaximumDelay > 0 && millisecondsDelay > Settings.MaximumDelay)) + { + break; + } + + if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning(exception, "Query '{Query}' execution failed at attempt {Attempt}, will retry in {Delay}ms.", queryType, attempt, millisecondsDelay); + } + + if (millisecondsDelay > 0) + { + await Task.Delay(millisecondsDelay, cancellationToken); + } + } + } + + throw new InvalidOperationException($"Query '{queryType}' execution failed after {attempt} attempts. See inner exception for more detail.", innerException); + } + + /// + /// Finds the handler for the specified query. + /// + /// The type of the query result. + /// The query. + /// The cancellation token. + /// The query handler. + /// There is no handler or many handlers for the specified query. + protected virtual async Task GetHandlerAsync(IQuery query, CancellationToken cancellationToken) + { + Type queryType = query.GetType(); + IEnumerable handlers = ServiceProvider.GetServices(typeof(IQueryHandler<,>).MakeGenericType(queryType, typeof(TResult))) + .Where(handler => handler is not null) + .Select(handler => handler!); + int count = handlers.Count(); + if (count != 1) + { + StringBuilder message = new StringBuilder("Exactly one handler was expected for query of type '").Append(queryType).Append("', but "); + if (count < 1) + { + message.Append("none was found."); + } + else + { + message.Append(count).Append(" were found."); + } + throw new InvalidOperationException(message.ToString()); + } + return handlers.Single(); + } + + /// + /// Determines if the query execution should be retried or not. + /// + /// The type of the query result. + /// The query to execute. + /// The exception. + /// A value indicating whether or not the query execution should be retried. + protected virtual bool ShouldRetry(IQuery query, Exception exception) + { + return true; + } + + /// + /// Calculates the delay, in milliseconds, to wait before retrying the execution of a query after a failure. + /// The delay is computed according to the retry algorithm and configuration defined in . + /// + /// The type of the query result. + /// The query to execute. + /// The exception. + /// The current retry attempt number, starting at 1. + /// The number of milliseconds to wait before retrying. Returns 0 when retrying should occur immediately or when the configured delay or algorithm does not produce a positive value. + protected virtual int CalculateMillisecondsDelay(IQuery query, Exception exception, int attempt) + { + if (Settings.Delay > 0) + { + switch (Settings.Algorithm) + { + case RetryAlgorithm.Exponential: + if (Settings.ExponentialBase > 1) + { + return (int)Math.Pow(Settings.ExponentialBase, attempt - 1) * Settings.Delay; + } + break; + case RetryAlgorithm.Fixed: + return Settings.Delay; + case RetryAlgorithm.Linear: + return attempt * Settings.Delay; + case RetryAlgorithm.Random: + if (Settings.RandomVariation > 0 && Settings.RandomVariation < Settings.Delay) + { + int minimum = Settings.Delay - Settings.RandomVariation; + int maximum = Settings.Delay + Settings.RandomVariation; + return Random.Next(minimum, maximum + 1); + } + break; + } + } + return 0; + } +} diff --git a/lib/Logitar.CQRS/README.md b/lib/Logitar.CQRS/README.md new file mode 100644 index 0000000..94b8c36 --- /dev/null +++ b/lib/Logitar.CQRS/README.md @@ -0,0 +1,3 @@ +# Logitar.CQRS + +Provides an implementation of the Command Query Responsibility Segregation pattern. diff --git a/lib/Logitar.CQRS/RetryAlgorithm.cs b/lib/Logitar.CQRS/RetryAlgorithm.cs new file mode 100644 index 0000000..4d92d11 --- /dev/null +++ b/lib/Logitar.CQRS/RetryAlgorithm.cs @@ -0,0 +1,36 @@ +namespace Logitar.CQRS; + +/// +/// Defines retry scheduling strategies used to determine when subsequent attempts are executed. +/// +public enum RetryAlgorithm +{ + /// + /// No retry is performed. The operation fails immediately on the first error. + /// + None = 0, + + /// + /// Each retry delay increases exponentially, typically doubling after every failed attempt + /// (e.g., 1s, 2s, 4s, 8s). Useful for reducing load on congested systems. + /// + Exponential = 1, + + /// + /// A constant delay is applied between every retry attempt (e.g., always 5s). + /// Suitable for predictable and steady retry pacing. + /// + Fixed = 2, + + /// + /// The delay grows linearly with each retry attempt (e.g., 1s, 2s, 3s, 4s). + /// Provides gradual backoff without the rapid escalation of exponential strategies. + /// + Linear = 3, + + /// + /// Each retry delay is selected randomly within a defined range. + /// Helps reduce synchronized retry storms across multiple clients. + /// + Random = 4 +} diff --git a/lib/Logitar.CQRS/RetrySettings.cs b/lib/Logitar.CQRS/RetrySettings.cs new file mode 100644 index 0000000..3a035bc --- /dev/null +++ b/lib/Logitar.CQRS/RetrySettings.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Configuration; + +namespace Logitar.CQRS; + +/// +/// Represents configuration options for retry behaviour, including timing strategy, base delays, and operational limits. +/// +public record RetrySettings +{ + /// + /// The configuration section key used to bind retry settings. + /// + public const string SectionKey = "Retry"; + + /// + /// The algorithm determining how retry delays are calculated. + /// + public RetryAlgorithm Algorithm { get; set; } + /// + /// The base delay, in milliseconds, applied before the first retry or used as the fixed interval depending on the algorithm. + /// + public int Delay { get; set; } + /// + /// The numeric base used when applying exponential backoff. For example, a base of 2 doubles the delay on each retry attempt. + /// + public int ExponentialBase { get; set; } + /// + /// The maximum random variation, in milliseconds, added or subtracted when using randomised retry delays. + /// + public int RandomVariation { get; set; } + + /// + /// The maximum number of retry attempts allowed before the operation is considered failed. A value of 0 typically means no retries. + /// + public int MaximumRetries { get; set; } + /// + /// The maximum delay, in milliseconds, permitted between retry attempts. This acts as a safety cap for exponential or linear backoff strategies. + /// + public int MaximumDelay { get; set; } + + /// + /// Initializes by binding the configuration section and applying environment variable overrides when present. + /// + /// The configuration. + /// The initialized settings. + public static RetrySettings Initialize(IConfiguration configuration) + { + RetrySettings settings = configuration.GetSection(SectionKey).Get() ?? new(); + + settings.Algorithm = EnvironmentHelper.GetEnum("RETRY_ALGORITHM", settings.Algorithm); + settings.Delay = EnvironmentHelper.GetInt32("RETRY_DELAY", settings.Delay); + settings.ExponentialBase = EnvironmentHelper.GetInt32("RETRY_EXPONENTIAL_BASE", settings.ExponentialBase); + settings.RandomVariation = EnvironmentHelper.GetInt32("RETRY_RANDOM_VARIATION", settings.RandomVariation); + + settings.MaximumRetries = EnvironmentHelper.GetInt32("RETRY_MAXIMUM_RETRIES", settings.MaximumRetries); + settings.MaximumDelay = EnvironmentHelper.GetInt32("RETRY_MAXIMUM_DELAY", settings.MaximumDelay); + + return settings; + } + + /// + /// Validates the retry settings. + /// + public void Validate() + { + List errors = new(capacity: 7); + + if (Delay < 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than or equal to 0."); + } + if (MaximumDelay < 0) + { + errors.Add($"'{nameof(MaximumDelay)}' must be greater than or equal to 0."); + } + + switch (Algorithm) + { + case RetryAlgorithm.Exponential: + if (Delay <= 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than 0."); + } + if (ExponentialBase <= 1) + { + errors.Add($"'{nameof(ExponentialBase)}' must be greater than 1."); + } + break; + case RetryAlgorithm.Fixed: + if (MaximumDelay > 0) + { + errors.Add($"'{nameof(MaximumDelay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); + } + break; + case RetryAlgorithm.Linear: + if (Delay <= 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than 0."); + } + break; + case RetryAlgorithm.Random: + if (Delay <= 0) + { + errors.Add($"'{nameof(Delay)}' must be greater than 0."); + } + if (RandomVariation <= 0) + { + errors.Add($"'{nameof(RandomVariation)}' must be greater than 0."); + } + if (RandomVariation > Delay) + { + errors.Add($"'{nameof(RandomVariation)}' must be less than or equal to '{nameof(Delay)}'."); + } + if (MaximumDelay > 0) + { + errors.Add($"'{nameof(MaximumDelay)}' must be 0 when '{nameof(Algorithm)}' is {Algorithm}."); + } + break; + case RetryAlgorithm.None: + break; + default: + errors.Add($"'{nameof(Algorithm)}' is not a valid retry algorithm."); + break; + } + + if (MaximumRetries < 0) + { + errors.Add($"'{nameof(MaximumRetries)}' must be greater than or equal to 0."); + } + + if (errors.Count > 0) + { + StringBuilder message = new("Validation failed."); + foreach (string error in errors) + { + message.AppendLine().Append(" - ").Append(error); + } + throw new InvalidOperationException(message.ToString()); + } + } +} diff --git a/lib/Logitar.CQRS/Unit.cs b/lib/Logitar.CQRS/Unit.cs new file mode 100644 index 0000000..37bebd0 --- /dev/null +++ b/lib/Logitar.CQRS/Unit.cs @@ -0,0 +1,49 @@ +namespace Logitar.CQRS; + +/// +/// Represents a void type, since is not a valid return type in C#. +/// +public readonly struct Unit +{ + /// + /// Gets the default and only value of a unit. + /// + public static readonly Unit Value = new(); + + /// + /// Gets a completed task from the default and only unit value. + /// + public static Task CompletedTask => Task.FromResult(Value); + + /// + /// Returns a value indicating whether or not the specified units are equal. + /// + /// The first unit to compare. + /// The other unit to compare. + /// True if the units are equal. + public static bool operator ==(Unit left, Unit right) => left.Equals(right); + /// + /// Returns a value indicating whether or not the specified units are different. + /// + /// The first unit to compare. + /// The other unit to compare. + /// True if the units are different. + public static bool operator !=(Unit left, Unit right) => !(left == right); + + /// + /// Returns a value indicating whether or not the specified object is equal to the unit. + /// + /// The object to be compared to. + /// True if the object is equal to the unit. + public override bool Equals([NotNullWhen(true)] object? obj) => obj is Unit; + /// + /// Returns the hash code of the current unit. + /// + /// The hash code. + public override int GetHashCode() => 0; + /// + /// Returns a string representation of the unit. + /// + /// The string representation. + public override string ToString() => "()"; +} diff --git a/lib/Logitar.CQRS/logitar.png b/lib/Logitar.CQRS/logitar.png new file mode 100644 index 0000000..5b031f4 Binary files /dev/null and b/lib/Logitar.CQRS/logitar.png differ diff --git a/tests/Logitar.CQRS.Tests/Categories.cs b/tests/Logitar.CQRS.Tests/Categories.cs new file mode 100644 index 0000000..81f6887 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Categories.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal static class Categories +{ + public const string Unit = "Unit"; +} diff --git a/tests/Logitar.CQRS.Tests/Command.cs b/tests/Logitar.CQRS.Tests/Command.cs new file mode 100644 index 0000000..64f3c63 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Command.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +public record Command : ICommand; diff --git a/tests/Logitar.CQRS.Tests/CommandBusTests.cs b/tests/Logitar.CQRS.Tests/CommandBusTests.cs new file mode 100644 index 0000000..c616e36 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/CommandBusTests.cs @@ -0,0 +1,442 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Logitar.CQRS.Tests; + +[Trait(Traits.Category, Categories.Unit)] +public class CommandBusTests +{ + private readonly CancellationToken _cancellationToken = default; + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the algorithm is None.")] + public void Given_AlgorithmIsNone_When_CalculateMillisecondsDelay_Then_ZeroReturned() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.None, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the delay is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay) + { + Assert.True(delay <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = delay + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the exponential base is less than 2.")] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Given_ExponentialBaseLessThanTwo_When_CalculateMillisecondsDelay_Then_ZeroReturned(int exponentialBase) + { + Assert.True(exponentialBase < 2); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is greater than or equal to the delay.")] + [InlineData(100, 100)] + [InlineData(100, 1000)] + public void Given_RandomVariationGreaterThanOrEqualToDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay, int randomVariation) + { + Assert.True(delay > 0); + Assert.True(randomVariation > 0); + Assert.True(delay <= randomVariation); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeRandomVariation_When_CalculateMillisecondsDelay_Then_ZeroReturned(int randomVariation) + { + Assert.True(randomVariation <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return the correct exponential delay.")] + [InlineData(100, 10, 2, 1000)] + [InlineData(100, 2, 5, 1600)] + public void Given_Exponential_When_CalculateMillisecondsDelay_Then_CorrectDelay(int delay, int exponentialBase, int attempt, int millisecondsDelay) + { + Assert.True(delay > 0); + Assert.True(exponentialBase > 1); + Assert.True(attempt > 0); + Assert.True(millisecondsDelay > 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = delay, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + Assert.Equal(millisecondsDelay, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct fixed delay.")] + public void Given_Fixed_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 10; + Assert.Equal(settings.Delay, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct linear delay.")] + public void Given_Linear_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Linear, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 5; + Assert.Equal(settings.Delay * attempt, commandBus.CalculateMillisecondsDelay(command, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct random delay.")] + public void Given_Random_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 500, + RandomVariation = 450 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Exception exception = new(); + int attempt = 5; + int delay = commandBus.CalculateMillisecondsDelay(command, exception, attempt); + + int minimum = settings.Delay - settings.RandomVariation; + int maximum = settings.Delay + settings.RandomVariation; + Assert.True(minimum <= delay && delay <= maximum); + } + + [Fact(DisplayName = "ExecuteAsync: it should log a warning when an execution is retried.")] + public async Task Given_Retry_When_ExecuteAsync_Then_WarningLogged() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = 2 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.AtLeastOnce()); + } + + [Fact(DisplayName = "ExecuteAsync: it should rethrow the exception when it should not be retried.")] + public async Task Given_ExceptionNotRetried_When_ExecuteAsync_Then_ExceptionRethrown() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.IsType(exception.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should retry the execution given a maximum delay.")] + public async Task Given_MaximumDelay_When_ExecuteAsync_Then_RetriedUntilReached() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = 2, + MaximumDelay = 1000 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(4)); // NOTE(fpion): 100, 200, 400, 800 + } + + [Theory(DisplayName = "ExecuteAsync: it should retry the execution given maximum retries.")] + [InlineData(1)] + [InlineData(5)] + public async Task Given_MaximumRetries_When_ExecuteAsync_Then_RetriedUntilReached(int maximumRetries) + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = maximumRetries + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(maximumRetries)); + } + + [Fact(DisplayName = "ExecuteAsync: it should return the execution result when it succeeded.")] + public async Task Given_Success_When_ExecuteAsync_Then_ExecutionResult() + { + ServiceCollection services = new(); + services.AddSingleton, CommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + Unit result = await commandBus.ExecuteAsync(command, _cancellationToken); + Assert.Equal(Unit.Value, result); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the execution failed.")] + public async Task Given_ExecutionFailed_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedCommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.Equal($"Command '{command.GetType()}' execution failed after 1 attempts. See inner exception for more detail.", exception.Message); + Assert.IsType(exception.InnerException); + Assert.IsType(exception.InnerException.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not have a handle.")] + public async Task Given_HandlerWithoutHandle_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidCommandBus commandBus = new(serviceProvider, new HandlelessCommandHandler()); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.Equal($"The handler {typeof(HandlelessCommandHandler)} must define a 'HandleAsync' method.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not return a task.")] + public async Task Given_HandlerDoesNotReturnTask_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidCommandBus commandBus = new(serviceProvider, new InvalidReturnCommandHandler()); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.IsType(exception.InnerException); + Assert.Equal($"The handler {typeof(InvalidReturnCommandHandler)} HandleAsync method must return a Task.", exception.InnerException.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the milliseconds delay is negative.")] + public async Task Given_NegativeDelay_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidCommandBus commandBus = new(serviceProvider, new NotImplementedCommandHandler(), millisecondsDelay: -1); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + Assert.Equal("The retry delay '-1' should be greater than or equal to 0ms.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the settings are not valid.")] + public async Task Given_InvalidSettings_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton(new RetrySettings + { + Delay = -1 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + CommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.ExecuteAsync(command, _cancellationToken)); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than or equal to 0.", lines[1]); + } + + [Fact(DisplayName = "GetHandlerAsync: it should return the handler found.")] + public async Task Given_SingleHandler_When_GetHandlerAsync_Then_HandlerReturned() + { + ServiceCollection services = new(); + services.AddSingleton, CommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + object handler = await commandBus.GetHandlerAsync(command, _cancellationToken); + Assert.IsType(handler); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when many handlers were found.")] + public async Task Given_ManyHandlers_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, CommandHandler>(); + services.AddSingleton, CommandHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.GetHandlerAsync(command, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for command of type '{command.GetType()}', but 2 were found.", exception.Message); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when no handler was found.")] + public async Task Given_NoHandler_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeCommandBus commandBus = new(serviceProvider); + + Command command = new(); + var exception = await Assert.ThrowsAsync(async () => await commandBus.GetHandlerAsync(command, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for command of type '{command.GetType()}', but none was found.", exception.Message); + } +} diff --git a/tests/Logitar.CQRS.Tests/CommandHandler.cs b/tests/Logitar.CQRS.Tests/CommandHandler.cs new file mode 100644 index 0000000..07f42b2 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/CommandHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class CommandHandler : ICommandHandler +{ + public Task HandleAsync(Command command, CancellationToken cancellationToken) => Unit.CompletedTask; +} diff --git a/tests/Logitar.CQRS.Tests/FakeCommandBus.cs b/tests/Logitar.CQRS.Tests/FakeCommandBus.cs new file mode 100644 index 0000000..8060f9d --- /dev/null +++ b/tests/Logitar.CQRS.Tests/FakeCommandBus.cs @@ -0,0 +1,27 @@ +namespace Logitar.CQRS.Tests; + +internal class FakeCommandBus : CommandBus +{ + public FakeCommandBus(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + protected override bool ShouldRetry(ICommand command, Exception exception) + { + if (exception is TargetInvocationException targetInvocation && targetInvocation.InnerException is not null) + { + exception = targetInvocation.InnerException; + } + return exception is not NotImplementedException; + } + + public new async Task GetHandlerAsync(ICommand command, CancellationToken cancellationToken) + { + return await base.GetHandlerAsync(command, cancellationToken); + } + + public new int CalculateMillisecondsDelay(ICommand command, Exception exception, int attempt) + { + return base.CalculateMillisecondsDelay(command, exception, attempt); + } +} diff --git a/tests/Logitar.CQRS.Tests/FakeQueryBus.cs b/tests/Logitar.CQRS.Tests/FakeQueryBus.cs new file mode 100644 index 0000000..adc089a --- /dev/null +++ b/tests/Logitar.CQRS.Tests/FakeQueryBus.cs @@ -0,0 +1,27 @@ +namespace Logitar.CQRS.Tests; + +internal class FakeQueryBus : QueryBus +{ + public FakeQueryBus(IServiceProvider serviceProvider) : base(serviceProvider) + { + } + + protected override bool ShouldRetry(IQuery query, Exception exception) + { + if (exception is TargetInvocationException targetInvocation && targetInvocation.InnerException is not null) + { + exception = targetInvocation.InnerException; + } + return exception is not NotImplementedException; + } + + public new async Task GetHandlerAsync(IQuery query, CancellationToken cancellationToken) + { + return await base.GetHandlerAsync(query, cancellationToken); + } + + public new int CalculateMillisecondsDelay(IQuery query, Exception exception, int attempt) + { + return base.CalculateMillisecondsDelay(query, exception, attempt); + } +} diff --git a/tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs b/tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs new file mode 100644 index 0000000..3a8112e --- /dev/null +++ b/tests/Logitar.CQRS.Tests/HandlelessCommandHandler.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +internal class HandlelessCommandHandler; diff --git a/tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs b/tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs new file mode 100644 index 0000000..e34d864 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/HandlelessQueryHandler.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +internal class HandlelessQueryHandler; diff --git a/tests/Logitar.CQRS.Tests/InvalidCommandBus.cs b/tests/Logitar.CQRS.Tests/InvalidCommandBus.cs new file mode 100644 index 0000000..163faf8 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidCommandBus.cs @@ -0,0 +1,23 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidCommandBus : CommandBus +{ + private readonly object _handler; + private readonly int _millisecondsDelay; + + public InvalidCommandBus(IServiceProvider serviceProvider, object handler, int millisecondsDelay = 0) : base(serviceProvider) + { + _handler = handler; + _millisecondsDelay = millisecondsDelay; + } + + protected override Task GetHandlerAsync(ICommand command, CancellationToken cancellationToken) + { + return Task.FromResult(_handler); + } + + protected override int CalculateMillisecondsDelay(ICommand command, Exception exception, int attempt) + { + return _millisecondsDelay; + } +} diff --git a/tests/Logitar.CQRS.Tests/InvalidQueryBus.cs b/tests/Logitar.CQRS.Tests/InvalidQueryBus.cs new file mode 100644 index 0000000..a6f1e82 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidQueryBus.cs @@ -0,0 +1,23 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidQueryBus : QueryBus +{ + private readonly object _handler; + private readonly int _millisecondsDelay; + + public InvalidQueryBus(IServiceProvider serviceProvider, object handler, int millisecondsDelay = 0) : base(serviceProvider) + { + _handler = handler; + _millisecondsDelay = millisecondsDelay; + } + + protected override Task GetHandlerAsync(IQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(_handler); + } + + protected override int CalculateMillisecondsDelay(IQuery query, Exception exception, int attempt) + { + return _millisecondsDelay; + } +} diff --git a/tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs b/tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs new file mode 100644 index 0000000..bdcdb4a --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidReturnCommandHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidReturnCommandHandler +{ + public Unit HandleAsync(ICommand command, CancellationToken cancellationToken) => Unit.Value; +} diff --git a/tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs b/tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs new file mode 100644 index 0000000..15124f9 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/InvalidReturnQueryHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class InvalidReturnQueryHandler +{ + public Unit HandleAsync(IQuery query, CancellationToken cancellationToken) => Unit.Value; +} diff --git a/tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj b/tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj new file mode 100644 index 0000000..572ba10 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Logitar.CQRS.Tests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + enable + false + + + + True + + + + True + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs b/tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs new file mode 100644 index 0000000..43997b3 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/NotImplementedCommandHandler.cs @@ -0,0 +1,9 @@ +namespace Logitar.CQRS.Tests; + +internal class NotImplementedCommandHandler : ICommandHandler +{ + public Task HandleAsync(Command command, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs b/tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs new file mode 100644 index 0000000..361c5f4 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/NotImplementedQueryHandler.cs @@ -0,0 +1,9 @@ +namespace Logitar.CQRS.Tests; + +internal class NotImplementedQueryHandler : IQueryHandler +{ + public Task HandleAsync(Query query, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/tests/Logitar.CQRS.Tests/Query.cs b/tests/Logitar.CQRS.Tests/Query.cs new file mode 100644 index 0000000..8a5850d --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Query.cs @@ -0,0 +1,3 @@ +namespace Logitar.CQRS.Tests; + +public record Query : IQuery; diff --git a/tests/Logitar.CQRS.Tests/QueryBusTests.cs b/tests/Logitar.CQRS.Tests/QueryBusTests.cs new file mode 100644 index 0000000..217068a --- /dev/null +++ b/tests/Logitar.CQRS.Tests/QueryBusTests.cs @@ -0,0 +1,442 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Logitar.CQRS.Tests; + +[Trait(Traits.Category, Categories.Unit)] +public class QueryBusTests +{ + private readonly CancellationToken _cancellationToken = default; + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the algorithm is None.")] + public void Given_AlgorithmIsNone_When_CalculateMillisecondsDelay_Then_ZeroReturned() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.None, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the delay is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay) + { + Assert.True(delay <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = delay + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the exponential base is less than 2.")] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Given_ExponentialBaseLessThanTwo_When_CalculateMillisecondsDelay_Then_ZeroReturned(int exponentialBase) + { + Assert.True(exponentialBase < 2); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is greater than or equal to the delay.")] + [InlineData(100, 100)] + [InlineData(100, 1000)] + public void Given_RandomVariationGreaterThanOrEqualToDelay_When_CalculateMillisecondsDelay_Then_ZeroReturned(int delay, int randomVariation) + { + Assert.True(delay > 0); + Assert.True(randomVariation > 0); + Assert.True(delay <= randomVariation); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return 0 when the random variation is zero or negative.")] + [InlineData(0)] + [InlineData(-1)] + public void Given_ZeroOrNegativeRandomVariation_When_CalculateMillisecondsDelay_Then_ZeroReturned(int randomVariation) + { + Assert.True(randomVariation <= 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 100, + RandomVariation = randomVariation + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 1; + Assert.Equal(0, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Theory(DisplayName = "CalculateMillisecondsDelay: it should return the correct exponential delay.")] + [InlineData(100, 10, 2, 1000)] + [InlineData(100, 2, 5, 1600)] + public void Given_Exponential_When_CalculateMillisecondsDelay_Then_CorrectDelay(int delay, int exponentialBase, int attempt, int millisecondsDelay) + { + Assert.True(delay > 0); + Assert.True(exponentialBase > 1); + Assert.True(attempt > 0); + Assert.True(millisecondsDelay > 0); + + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = delay, + ExponentialBase = exponentialBase + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + Assert.Equal(millisecondsDelay, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct fixed delay.")] + public void Given_Fixed_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 10; + Assert.Equal(settings.Delay, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct linear delay.")] + public void Given_Linear_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Linear, + Delay = 100 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 5; + Assert.Equal(settings.Delay * attempt, queryBus.CalculateMillisecondsDelay(query, exception, attempt)); + } + + [Fact(DisplayName = "CalculateMillisecondsDelay: it should return the correct random delay.")] + public void Given_Random_When_CalculateMillisecondsDelay_Then_CorrectDelay() + { + ServiceCollection services = new(); + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 500, + RandomVariation = 450 + }; + services.AddSingleton(settings); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Exception exception = new(); + int attempt = 5; + int delay = queryBus.CalculateMillisecondsDelay(query, exception, attempt); + + int minimum = settings.Delay - settings.RandomVariation; + int maximum = settings.Delay + settings.RandomVariation; + Assert.True(minimum <= delay && delay <= maximum); + } + + [Fact(DisplayName = "ExecuteAsync: it should log a warning when an execution is retried.")] + public async Task Given_Retry_When_ExecuteAsync_Then_WarningLogged() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = 2 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.AtLeastOnce()); + } + + [Fact(DisplayName = "ExecuteAsync: it should rethrow the exception when it should not be retried.")] + public async Task Given_ExceptionNotRetried_When_ExecuteAsync_Then_ExceptionRethrown() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.IsType(exception.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should retry the execution given a maximum delay.")] + public async Task Given_MaximumDelay_When_ExecuteAsync_Then_RetriedUntilReached() + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 100, + ExponentialBase = 2, + MaximumDelay = 1000 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(4)); // NOTE(fpion): 100, 200, 400, 800 + } + + [Theory(DisplayName = "ExecuteAsync: it should retry the execution given maximum retries.")] + [InlineData(1)] + [InlineData(5)] + public async Task Given_MaximumRetries_When_ExecuteAsync_Then_RetriedUntilReached(int maximumRetries) + { + Mock> logger = new(); + logger.Setup(x => x.IsEnabled(LogLevel.Warning)).Returns(true); + + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + services.AddSingleton(logger.Object); + services.AddSingleton(new RetrySettings + { + Algorithm = RetryAlgorithm.Fixed, + Delay = 100, + MaximumRetries = maximumRetries + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + + logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), Times.Exactly(maximumRetries)); + } + + [Fact(DisplayName = "ExecuteAsync: it should return the execution result when it succeeded.")] + public async Task Given_Success_When_ExecuteAsync_Then_ExecutionResult() + { + ServiceCollection services = new(); + services.AddSingleton, QueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + Unit result = await queryBus.ExecuteAsync(query, _cancellationToken); + Assert.Equal(Unit.Value, result); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the execution failed.")] + public async Task Given_ExecutionFailed_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, NotImplementedQueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.Equal($"Query '{query.GetType()}' execution failed after 1 attempts. See inner exception for more detail.", exception.Message); + Assert.IsType(exception.InnerException); + Assert.IsType(exception.InnerException.InnerException); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not have a handle.")] + public async Task Given_HandlerWithoutHandle_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidQueryBus queryBus = new(serviceProvider, new HandlelessQueryHandler()); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.Equal($"The handler {typeof(HandlelessQueryHandler)} must define a 'HandleAsync' method.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the handler does not return a task.")] + public async Task Given_HandlerDoesNotReturnTask_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidQueryBus queryBus = new(serviceProvider, new InvalidReturnQueryHandler()); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.IsType(exception.InnerException); + Assert.Equal($"The handler {typeof(InvalidReturnQueryHandler)} HandleAsync method must return a Task.", exception.InnerException.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the milliseconds delay is negative.")] + public async Task Given_NegativeDelay_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + InvalidQueryBus queryBus = new(serviceProvider, new NotImplementedQueryHandler(), millisecondsDelay: -1); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + Assert.Equal("The retry delay '-1' should be greater than or equal to 0ms.", exception.Message); + } + + [Fact(DisplayName = "ExecuteAsync: it should throw InvalidOperationException when the settings are not valid.")] + public async Task Given_InvalidSettings_When_ExecuteAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton(new RetrySettings + { + Delay = -1 + }); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + QueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.ExecuteAsync(query, _cancellationToken)); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than or equal to 0.", lines[1]); + } + + [Fact(DisplayName = "GetHandlerAsync: it should return the handler found.")] + public async Task Given_SingleHandler_When_GetHandlerAsync_Then_HandlerReturned() + { + ServiceCollection services = new(); + services.AddSingleton, QueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + object handler = await queryBus.GetHandlerAsync(query, _cancellationToken); + Assert.IsType(handler); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when many handlers were found.")] + public async Task Given_ManyHandlers_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + services.AddSingleton, QueryHandler>(); + services.AddSingleton, QueryHandler>(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.GetHandlerAsync(query, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for query of type '{query.GetType()}', but 2 were found.", exception.Message); + } + + [Fact(DisplayName = "GetHandlerAsync: it should throw InvalidOperationException when no handler was found.")] + public async Task Given_NoHandler_When_GetHandlerAsync_Then_InvalidOperationException() + { + ServiceCollection services = new(); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + FakeQueryBus queryBus = new(serviceProvider); + + Query query = new(); + var exception = await Assert.ThrowsAsync(async () => await queryBus.GetHandlerAsync(query, _cancellationToken)); + Assert.Equal($"Exactly one handler was expected for query of type '{query.GetType()}', but none was found.", exception.Message); + } +} diff --git a/tests/Logitar.CQRS.Tests/QueryHandler.cs b/tests/Logitar.CQRS.Tests/QueryHandler.cs new file mode 100644 index 0000000..e94007f --- /dev/null +++ b/tests/Logitar.CQRS.Tests/QueryHandler.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal class QueryHandler : IQueryHandler +{ + public Task HandleAsync(Query query, CancellationToken cancellationToken) => Unit.CompletedTask; +} diff --git a/tests/Logitar.CQRS.Tests/RetrySettingsTests.cs b/tests/Logitar.CQRS.Tests/RetrySettingsTests.cs new file mode 100644 index 0000000..33712ee --- /dev/null +++ b/tests/Logitar.CQRS.Tests/RetrySettingsTests.cs @@ -0,0 +1,105 @@ +namespace Logitar.CQRS.Tests; + +[Trait(Traits.Category, Categories.Unit)] +public class RetrySettingsTests +{ + [Fact(DisplayName = "Validate: it should not throw when the validation succeeded.")] + public void Given_Succeeded_When_Validate_Then_NothingThrown() + { + RetrySettings settings = new() + { + RandomVariation = 1000, + MaximumDelay = 1000 + }; + settings.Validate(); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the Exponential properties are not valid.")] + public void Given_InvalidExponentialProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Exponential, + Delay = 0, + ExponentialBase = 1 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than 0.", lines[1]); + Assert.Equal(" - 'ExponentialBase' must be greater than 1.", lines[2]); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the Fixed properties are not valid.")] + public void Given_InvalidFixedProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Fixed, + MaximumDelay = 500 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'MaximumDelay' must be 0 when 'Algorithm' is Fixed.", lines[1]); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the Linear properties are not valid.")] + public void Given_InvalidLinearProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Linear, + Delay = 0, + MaximumDelay = 500 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than 0.", lines[1]); + } + + [Theory(DisplayName = "Validate: it should throw InvalidOperationException when the Random properties are not valid.")] + [InlineData(false)] + [InlineData(true)] + public void Given_InvalidRandomProperties_When_Validate_Then_InvalidOperationException(bool greater) + { + RetrySettings settings = new() + { + Algorithm = RetryAlgorithm.Random, + Delay = 0, + RandomVariation = greater ? 1 : -1, + MaximumDelay = 1000 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(4, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than 0.", lines[1]); + Assert.Equal(greater ? " - 'RandomVariation' must be less than or equal to 'Delay'." : " - 'RandomVariation' must be greater than 0.", lines[2]); + Assert.Equal(" - 'MaximumDelay' must be 0 when 'Algorithm' is Random.", lines[3]); + } + + [Fact(DisplayName = "Validate: it should throw InvalidOperationException when the shared properties are not valid.")] + public void Given_InvalidSharedProperties_When_Validate_Then_InvalidOperationException() + { + RetrySettings settings = new() + { + Algorithm = (RetryAlgorithm)(-1), + Delay = -1, + MaximumDelay = -1, + MaximumRetries = -1 + }; + var exception = Assert.Throws(settings.Validate); + string[] lines = exception.Message.Remove("\r").Split('\n'); + Assert.Equal(5, lines.Length); + Assert.Equal("Validation failed.", lines[0]); + Assert.Equal(" - 'Delay' must be greater than or equal to 0.", lines[1]); + Assert.Equal(" - 'MaximumDelay' must be greater than or equal to 0.", lines[2]); + Assert.Equal(" - 'Algorithm' is not a valid retry algorithm.", lines[3]); + Assert.Equal(" - 'MaximumRetries' must be greater than or equal to 0.", lines[4]); + } +} diff --git a/tests/Logitar.CQRS.Tests/Traits.cs b/tests/Logitar.CQRS.Tests/Traits.cs new file mode 100644 index 0000000..fb84459 --- /dev/null +++ b/tests/Logitar.CQRS.Tests/Traits.cs @@ -0,0 +1,6 @@ +namespace Logitar.CQRS.Tests; + +internal static class Traits +{ + public const string Category = "Category"; +}