diff --git a/README.md b/README.md index 0e53bab..36bd2c1 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,14 @@ It defines contracts and small reusable building blocks for commands, queries, h - CQRS request contracts: `ICommand`, `IQuery`, and `IRequest`. - Mediator contracts for command/query dispatch and handler implementation. -- Pipeline behavior contracts for before, after, and finally request stages, including before-stage success/failure results. +- Pipeline behavior contracts for before, after, finally, and exception request stages, including failure-result proposals after successful cleanup. - Built-in behaviors for permission-based authorization, FluentValidation request validation, transaction start, commit, cleanup, and domain event publishing. - Transport-independent current caller information and handler-level authentication and permission requirements. - FluentValidation extensions for converting single-property and complex domain factory `Result` errors into validation failures. - Event bus and event handler abstractions for `DomainEvent` integration. - A shared scheduler contract with typed command and domain event overloads for delayed delivery or delivery at a specified time. - Unit of work, read repository, and aggregate tracker abstractions for application persistence boundaries. +- Provider-independent optimistic concurrency exceptions and a behavior that maps them to conflict results. - File storage abstraction for uploading, downloading, and deleting content by caller-supplied keys, including signed URLs for direct transfers. - `IReadModel` marker interface for immutable read-side result models. - Read-side helper models for page-based pagination, cursor pagination, multi-field sorting, filtering, and validated result limits. @@ -37,7 +38,7 @@ It defines contracts and small reusable building blocks for commands, queries, h ```xml - + ``` @@ -269,16 +270,17 @@ there is no criterion count limit. Errors retain paths such as `Sorting.Fields[0 ## Request Behaviors -The package includes reusable mediator behavior implementations for authorization, request validation, command transaction orchestration, and domain event publication: +The package includes reusable mediator behavior implementations for authorization, request validation, command transaction orchestration, domain event publication, and optimistic concurrency handling: - `AuthorizationBehavior` checks authentication and permissions declared by handlers implementing `IRequireAuthorization`. - `ValidationBehavior` validates requests with registered FluentValidation `IValidator` implementations and returns a failed `Result` before the handler runs when validation fails. - `BeginTransactionBehavior` starts a transaction before a command handler runs. - `PublishDomainEventsBehavior` publishes domain events collected from tracked aggregate roots after a successful command result and clears tracked events after a failed result or completed successful publication. - `CommitTransactionBehavior` commits the active transaction after a successful command result. -- `CleanupTransactionBehavior` rolls back failed command transactions and disposes transaction resources. +- `CleanupTransactionBehavior` rolls back failed command transactions, disposes transaction resources, and returns the supplied result unchanged. +- `ConcurrencyExceptionBehavior` is a finally behavior that replaces a null or successful result with a conflict failure for a direct `ConcurrencyException`. Otherwise, it returns the supplied result unchanged. -Transaction cleanup always attempts disposal, even if rollback fails. A single failure retains its original exception instance and stack trace. When cleanup adds failures to the command exception, or both rollback and disposal fail, the behavior throws an `AggregateException` containing the failures in execution order, with the command exception first. Existing aggregates are preserved; use `Flatten()` to inspect nested failures. If cleanup succeeds, the mediator remains responsible for propagating the original command exception. A single cancellation remains cancellation; cancellation combined with another failure is reported as an aggregate failure. +Transaction cleanup always attempts disposal, even if rollback fails. A single failure retains its original exception instance and stack trace. When cleanup adds failures to the command exception, or both rollback and disposal fail, the behavior throws an `AggregateException` containing the failures in execution order, with the command exception first. Existing aggregates are preserved; use `Flatten()` to inspect nested failures. If cleanup succeeds, the mediator remains responsible for propagating the original command exception unless a configured finally behavior handles it by returning a new failure result. A single cancellation remains cancellation; cancellation combined with another failure is reported as an aggregate failure. Domain event publication and transaction behaviors require `ICommand` and do not apply to `IQuery` or plain `IRequest` implementations. Query pipelines retain request validation and exclude these command-only behaviors. Directly closing `PublishDomainEventsBehavior` for a query or plain request is no longer supported. @@ -292,12 +294,21 @@ handler: ICommandHandler after: PublishDomainEventsBehavior after: CommitTransactionBehavior finally: CleanupTransactionBehavior +finally: ConcurrencyExceptionBehavior ``` The exact registration mechanism depends on the mediator or composition root used by the consuming application. A consuming mediator should continue to the handler when a before behavior returns `Result.Success()`. When a before behavior returns a failed `Result`, the mediator should stop the pipeline and return a failed request `TResult` with the same errors. +### Optimistic Concurrency + +Persistence adapters report optimistic concurrency conflicts with `ConcurrencyException` from `PANiXiDA.Core.Application.Persistence.Errors`. `ConcurrencyExceptionBehavior` converts direct conflicts to `Error.Conflict`, preserving existing failed results and leaving other exceptions unhandled. + +`FinallyAsync` now returns `Task`: behaviors pass the current result through or replace it. Mediator adapters must pass the returned result to the next behavior and accept exception handling only after all cleanup succeeds. Cleanup failures retain the original processing exception. + +Existing finally implementations must adopt the new return type. Provider exception translation and Wolverine integration require separate adapter changes. + ### Handler Authorization Implement `IRequireAuthorization` on a command or query handler and provide @@ -475,7 +486,8 @@ The aggregate repository contract is intentionally not defined by this package; - `IReadModel` identifies query and read repository result payloads. - `IBeforeRequestBehavior` runs before a handler and returns `Result.Success()` to continue request processing, or a failed `Result` to stop it. - `IAfterRequestBehavior` runs after a handler returns a result and is defined in the mediator behavior abstractions namespace. -- `IFinallyRequestBehavior` runs after request processing completes or fails and is defined in the mediator behavior abstractions namespace. +- `IFinallyRequestBehavior` runs after request processing completes or fails and returns the current result or a replacement for the next finally behavior. +- `ConcurrencyExceptionBehavior` maps direct optimistic concurrency exceptions to `Error.Conflict` without replacing existing failure results. ### Validation @@ -492,6 +504,7 @@ The aggregate repository contract is intentionally not defined by this package; - `IUnitOfWork` defines persistence and transaction operations. - `IReadRepository` defines read-only `ExistsByIdAsync` and `AnyAsync` checks. +- `ConcurrencyException` reports an optimistic concurrency conflict independently of a database provider. - Aggregate persistence uses `IRepository` from `PANiXiDA.Core.Domain`. ### Querying Models diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs index ba89519..af27c91 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs @@ -7,6 +7,18 @@ namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; /// /// The request type processed by the behavior. /// The result type returned by the request. +/// +/// Behaviors run in registration order, passing each returned result to the next behavior. +/// Return the supplied result unchanged when no replacement is needed; null means no result exists. +/// The mediator converts a non-generic failure to the declared result type before calling the next behavior. +/// Returning a new failure for a processing exception proposes handling that exception. Passing through an +/// existing result does not handle it. Existing failure results must be preserved, and a non-null result +/// must not be replaced with null. Successful replacements must be assignable to the declared result type. +/// The original processing exception remains available to all behaviors. The mediator must run every behavior, +/// collect cleanup failures separately, and accept exception handling only if all finally behaviors succeed. +/// Cleanup failures must propagate together with the original exception, even if a result was replaced. +/// Unhandled exceptions retain their original instance and stack trace. Background delivery requires an explicit policy. +/// public interface IFinallyRequestBehavior where TRequest : IRequest where TResult : Result @@ -15,11 +27,11 @@ public interface IFinallyRequestBehavior /// Executes behavior after request processing completes, including failure paths. /// /// The request that was processed. - /// The result returned by the request handler, if any. + /// The current result returned by the handler or a preceding finally behavior, if any. /// The exception thrown during request processing, if any. /// The token used to cancel the operation. - /// A task that represents the asynchronous operation. - Task FinallyAsync( + /// The supplied result or its replacement, or null when no result exists. + Task FinallyAsync( TRequest request, TResult? result, Exception? exception, diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs index 50a31e3..af81716 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs @@ -21,16 +21,16 @@ public sealed class CleanupTransactionBehavior(IUnitOfWork un /// Rolls back the active transaction on failure and releases transaction resources. /// /// The command that was processed. - /// The command execution result, if any. + /// The current command result, if any. /// The exception thrown during command processing, if any. /// The token used to cancel the operation. - /// A task that represents the asynchronous operation. + /// The supplied result unchanged after successful cleanup. /// /// Disposal is attempted even when rollback fails. A single cleanup failure is rethrown unchanged. /// Multiple failures, including the supplied exception, are reported in an . /// If cleanup succeeds, propagating the supplied exception remains the caller's responsibility. /// - public async Task FinallyAsync( + public async Task FinallyAsync( TCommand request, TResult? result, Exception? exception, @@ -38,7 +38,7 @@ public async Task FinallyAsync( { if (!unitOfWork.HasActiveTransaction) { - return; + return result; } List? cleanupExceptions = null; @@ -64,21 +64,23 @@ public async Task FinallyAsync( (cleanupExceptions ??= []).Add(disposeException); } - if (cleanupExceptions is null) + ExceptionDispatchInfo? cleanupFailure = null; + if (cleanupExceptions is not null) { - return; - } + if (exception is not null) + { + cleanupExceptions.Insert(0, exception); + } - if (exception is not null) - { - cleanupExceptions.Insert(0, exception); - } + if (cleanupExceptions.Count > 1) + { + throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); + } - if (cleanupExceptions.Count > 1) - { - throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); + cleanupFailure = ExceptionDispatchInfo.Capture(cleanupExceptions[0]); } - ExceptionDispatchInfo.Throw(cleanupExceptions[0]); + cleanupFailure?.Throw(); + return result; } } diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs new file mode 100644 index 0000000..694529f --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs @@ -0,0 +1,43 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence.Errors; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Converts a direct optimistic concurrency exception to a conflict failure result. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +/// +/// Existing failure results are preserved. Other exception types, including aggregates and cancellation, +/// are not handled. Diagnostic exception messages are not exposed in the returned error. +/// The mediator must accept exception handling only after all finally behaviors succeed. +/// +public sealed class ConcurrencyExceptionBehavior : IFinallyRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Offers a conflict result for a concurrency exception when there is no existing failure result. + /// + /// The request that was processed. + /// The current request result, or null when no result was produced. + /// The processing exception to inspect, if any. + /// The cancellation token for the operation. + /// A conflict failure result for a handled exception, or the supplied result unchanged. + public Task FinallyAsync( + TRequest request, + TResult? result, + Exception? exception, + CancellationToken cancellationToken) + { + if (result is { IsFailure: true } || exception is not ConcurrencyException) + { + return Task.FromResult(result); + } + + return Task.FromResult(Result.Failure( + Error.Conflict("The entity was modified or deleted. Reload it and try again."))); + } +} diff --git a/src/PANiXiDA.Core.Application/Persistence/Errors/ConcurrencyException.cs b/src/PANiXiDA.Core.Application/Persistence/Errors/ConcurrencyException.cs new file mode 100644 index 0000000..b1fb58e --- /dev/null +++ b/src/PANiXiDA.Core.Application/Persistence/Errors/ConcurrencyException.cs @@ -0,0 +1,43 @@ +namespace PANiXiDA.Core.Application.Persistence.Errors; + +/// +/// Indicates that a persistence operation could not match the expected entity version. +/// +/// +/// Persistence adapters use this exception for optimistic concurrency conflicts, including concurrent deletion. +/// Provider-specific exceptions can be retained as the inner exception. +/// +public sealed class ConcurrencyException : Exception +{ + /// + /// Initializes a concurrency exception with a default message. + /// + public ConcurrencyException() + : this("The entity was modified or deleted by another operation.") + { + } + + /// + /// Initializes a concurrency exception with a diagnostic message. + /// + /// A nonblank diagnostic message. + /// The message is null. + /// The message is empty or whitespace. + public ConcurrencyException(string message) + : this(message, null) + { + } + + /// + /// Initializes a concurrency exception with a diagnostic message and the original failure. + /// + /// A nonblank diagnostic message. + /// The original failure, or null when the adapter detected the conflict directly. + /// The message is null. + /// The message is empty or whitespace. + public ConcurrencyException(string message, Exception? innerException) + : base(message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs index ed7e0aa..b038f78 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs @@ -1,4 +1,5 @@ using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; using PANiXiDA.Core.ResultPattern; @@ -6,6 +7,52 @@ namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors; public sealed class CleanupTransactionBehaviorTests { + [Theory(DisplayName = "Cleanup preserves generic results with and without an active transaction")] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task FinallyAsync_WhenGenericResultExists_ReturnsOriginalResult(bool hasActiveTransaction, bool isFailure) + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = hasActiveTransaction }; + var behavior = new CleanupTransactionBehavior>(unitOfWork); + var originalResult = isFailure ? Result.Failure(Error.Validation("Invalid value.")) : Result.Success("saved value"); + + var result = await behavior.FinallyAsync(new ValueCommand(), originalResult, null, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + unitOfWork.RollbackTransactionCalls.ShouldBe(hasActiveTransaction && isFailure ? 1 : 0); + unitOfWork.DisposeTransactionCalls.ShouldBe(hasActiveTransaction ? 1 : 0); + } + + [Fact(DisplayName = "Cleanup preserves a missing result when there is no active transaction")] + public async Task FinallyAsync_WhenResultAndTransactionAreMissing_ReturnsNull() + { + var unitOfWork = new TestUnitOfWork(); + var behavior = new CleanupTransactionBehavior(unitOfWork); + + var result = await behavior.FinallyAsync(new TestCommand(), null, null, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + unitOfWork.RollbackTransactionCalls.ShouldBe(0); + unitOfWork.DisposeTransactionCalls.ShouldBe(0); + } + + [Theory(DisplayName = "Cleanup preserves a missing generic result and rolls back an active transaction")] + [InlineData(false)] + [InlineData(true)] + public async Task FinallyAsync_WhenGenericResultIsMissing_ReturnsNull(bool hasActiveTransaction) + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = hasActiveTransaction }; + var behavior = new CleanupTransactionBehavior>(unitOfWork); + + var result = await behavior.FinallyAsync(new ValueCommand(), null, null, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + unitOfWork.RollbackTransactionCalls.ShouldBe(hasActiveTransaction ? 1 : 0); + unitOfWork.DisposeTransactionCalls.ShouldBe(hasActiveTransaction ? 1 : 0); + } + [Theory(DisplayName = "Cleanup preserves single failures and aggregates multiple failures")] [InlineData(false, false, false)] [InlineData(true, false, false)] @@ -192,4 +239,6 @@ public async Task FinallyAsync_WhenSuccessfulCommandDisposalFails_PreservesFailu unitOfWork.RollbackTransactionCalls.ShouldBe(0); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } + + private sealed record ValueCommand : ICommand>; } diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs new file mode 100644 index 0000000..5868299 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs @@ -0,0 +1,242 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence.Errors; +using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; +using PANiXiDA.Core.ResultPattern; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors; + +public sealed class ConcurrencyExceptionBehaviorTests +{ + [Theory(DisplayName = "ConcurrencyExceptionBehavior offers a conflict for requests returning Result")] + [InlineData(false)] + [InlineData(true)] + public async Task FinallyAsync_WhenResultIsMissingOrSuccessful_ReturnsConflict(bool hasResult) + { + var behavior = new ConcurrencyExceptionBehavior(); + var exception = new ConcurrencyException("Private provider diagnostics."); + var originalResult = hasResult ? Result.Success() : null; + + var result = await behavior.FinallyAsync( + new TestCommand(), originalResult, exception, TestContext.Current.CancellationToken); + + result.ShouldNotBeNull(); + result.IsFailure.ShouldBeTrue(); + var error = result.Errors.ShouldHaveSingleItem(); + error.Type.ShouldBe(ErrorType.Conflict); + error.Message.ShouldBe("The entity was modified or deleted. Reload it and try again."); + error.Metadata.ShouldBeEmpty(); + originalResult?.IsSuccess.ShouldBeTrue(); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior supports requests returning Result of T")] + [InlineData(false)] + [InlineData(true)] + public async Task FinallyAsync_WhenRequestReturnsGenericResult_ReturnsConflict(bool hasResult) + { + var behavior = new ConcurrencyExceptionBehavior>(); + var originalResult = hasResult ? Result.Success("saved value") : null; + + var result = await behavior.FinallyAsync( + new ValueCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); + + result.ShouldNotBeNull(); + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldHaveSingleItem().Type.ShouldBe(ErrorType.Conflict); + originalResult?.Value.ShouldBe("saved value"); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior leaves existing failure results unchanged")] + public async Task FinallyAsync_WhenResultAlreadyFailed_ReturnsOriginalResult() + { + var behavior = new ConcurrencyExceptionBehavior(); + var error = Error.Validation("The order name is required."); + var originalResult = Result.Failure(error); + + var result = await behavior.FinallyAsync( + new TestCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + originalResult.Errors.ShouldHaveSingleItem().ShouldBeSameAs(error); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior leaves generic failure results unchanged")] + public async Task FinallyAsync_WhenGenericResultAlreadyFailed_ReturnsOriginalResult() + { + var behavior = new ConcurrencyExceptionBehavior>(); + var error = Error.NotFound("The order was not found."); + var originalResult = Result.Failure(error); + + var result = await behavior.FinallyAsync( + new ValueCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + originalResult.Errors.ShouldHaveSingleItem().ShouldBeSameAs(error); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior declines unrelated, wrapped, aggregate and cancellation failures")] + [InlineData("unexpected", false)] + [InlineData("unexpected", true)] + [InlineData("canceled", false)] + [InlineData("canceled", true)] + [InlineData("task-canceled", false)] + [InlineData("task-canceled", true)] + [InlineData("wrapped", false)] + [InlineData("wrapped", true)] + [InlineData("aggregate", false)] + [InlineData("aggregate", true)] + [InlineData("mixed-aggregate", false)] + [InlineData("mixed-aggregate", true)] + public async Task FinallyAsync_WhenExceptionIsNotDirectConcurrency_ReturnsOriginalResult(string scenario, bool hasResult) + { + var behavior = new ConcurrencyExceptionBehavior(); + var originalResult = hasResult ? Result.Success() : null; + Exception exception = scenario switch + { + "unexpected" => new InvalidOperationException("Unexpected failure."), + "canceled" => new OperationCanceledException(), + "task-canceled" => new TaskCanceledException(), + "wrapped" => new InvalidOperationException("Wrapper failure.", new ConcurrencyException()), + "aggregate" => new AggregateException(new ConcurrencyException()), + "mixed-aggregate" => new AggregateException(new ConcurrencyException(), new InvalidOperationException("Rollback failed.")), + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, "Unknown exception scenario.") + }; + + var result = await behavior.FinallyAsync( + new TestCommand(), originalResult, exception, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior passes through results when no exception occurred")] + [InlineData("missing")] + [InlineData("success")] + [InlineData("failure")] + public async Task FinallyAsync_WhenExceptionIsNull_ReturnsOriginalResult(string scenario) + { + var behavior = new ConcurrencyExceptionBehavior(); + var originalResult = scenario switch + { + "missing" => null, + "success" => Result.Success(), + "failure" => Result.Failure(Error.Validation("The order name is required.")), + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, "Unknown result scenario.") + }; + + var result = await behavior.FinallyAsync( + new TestCommand(), originalResult, null, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior preserves a successful generic result and its value")] + public async Task FinallyAsync_WhenGenericRequestSucceeds_ReturnsOriginalResult() + { + var behavior = new ConcurrencyExceptionBehavior>(); + var originalResult = Result.Success("saved value"); + + var result = await behavior.FinallyAsync( + new ValueCommand(), originalResult, null, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + originalResult.Value.ShouldBe("saved value"); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior maps a conflict after successful transaction cleanup")] + public async Task FinallyAsync_WhenTransactionCleanupSucceeds_ReturnsConflict() + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = true }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var exception = new ConcurrencyException(); + + var currentResult = await cleanup.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); + var result = await behavior.FinallyAsync(request, currentResult, exception, TestContext.Current.CancellationToken); + + unitOfWork.RollbackTransactionCalls.ShouldBe(1); + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + result.ShouldNotBeNull().FirstError.Type.ShouldBe(ErrorType.Conflict); + } + + [Fact(DisplayName = "Transaction cleanup passes through a conflict returned by a preceding finally behavior")] + public async Task FinallyAsync_WhenConcurrencyIsHandledBeforeCleanup_PreservesConflict() + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = true }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var exception = new ConcurrencyException(); + + var conflict = await behavior.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); + var result = await cleanup.FinallyAsync(request, conflict, exception, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(conflict); + result.ShouldNotBeNull().FirstError.Type.ShouldBe(ErrorType.Conflict); + unitOfWork.RollbackTransactionCalls.ShouldBe(1); + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + } + + [Theory(DisplayName = "A conflict result does not hide later transaction cleanup failures")] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task FinallyAsync_WhenCleanupFailsAfterConcurrencyHandling_PreservesExceptions(bool failRollback, bool failDispose) + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true, + RollbackException = failRollback ? new InvalidOperationException("Rollback failed.") : null, + DisposeException = failDispose ? new InvalidOperationException("Dispose failed.") : null + }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var originalException = new ConcurrencyException(); + + var conflict = await behavior.FinallyAsync(request, null, originalException, TestContext.Current.CancellationToken); + var exception = await Should.ThrowAsync(() => cleanup.FinallyAsync( + request, conflict, originalException, TestContext.Current.CancellationToken)); + + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + exception.InnerExceptions.ShouldBe(new[] + { + originalException, + unitOfWork.RollbackException, + unitOfWork.DisposeException + }.OfType()); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior does not hide transaction cleanup failures")] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task FinallyAsync_WhenCleanupAggregatesFailures_DeclinesHandling(bool failRollback, bool failDispose) + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true, + RollbackException = failRollback ? new InvalidOperationException("Rollback failed.") : null, + DisposeException = failDispose ? new InvalidOperationException("Dispose failed.") : null + }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var originalException = new ConcurrencyException(); + + var exception = await Should.ThrowAsync(() => cleanup.FinallyAsync( + request, null, originalException, TestContext.Current.CancellationToken)); + var result = await behavior.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + exception.InnerExceptions.ShouldBe(new[] + { + originalException, + unitOfWork.RollbackException, + unitOfWork.DisposeException + }.OfType()); + } + + private sealed record ValueCommand : ICommand>; +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs index f76b47f..e8eac34 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs @@ -145,9 +145,11 @@ public async Task FinallyAsync_WhenTransactionIsNotActive_DoesNotRollbackOrDispo { var unitOfWork = new TestUnitOfWork(); var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = Result.Success(); - await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), originalResult, null, CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(0); unitOfWork.DisposeTransactionCalls.ShouldBe(0); } @@ -160,9 +162,11 @@ public async Task FinallyAsync_WhenCommandSucceededInActiveTransaction_DisposesT HasActiveTransaction = true }; var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = Result.Success(); - await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), originalResult, null, CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(0); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } @@ -175,9 +179,11 @@ public async Task FinallyAsync_WhenCommandFailedInActiveTransaction_RollsBackAnd HasActiveTransaction = true }; var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = CreateFailureResult(); - await behavior.FinallyAsync(new TestCommand(), CreateFailureResult(), null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), originalResult, null, CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } @@ -191,8 +197,9 @@ public async Task FinallyAsync_WhenResultIsNullInActiveTransaction_RollsBackAndD }; var behavior = new CleanupTransactionBehavior(unitOfWork); - await behavior.FinallyAsync(new TestCommand(), null, null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), null, null, CancellationToken.None); + result.ShouldBeNull(); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } @@ -205,13 +212,15 @@ public async Task FinallyAsync_WhenExceptionIsProvidedInActiveTransaction_RollsB HasActiveTransaction = true }; var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = Result.Success(); - await behavior.FinallyAsync( + var result = await behavior.FinallyAsync( new TestCommand(), - Result.Success(), + originalResult, new InvalidOperationException(), CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Persistence/Errors/ConcurrencyExceptionTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Persistence/Errors/ConcurrencyExceptionTests.cs new file mode 100644 index 0000000..b3d7d98 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Persistence/Errors/ConcurrencyExceptionTests.cs @@ -0,0 +1,81 @@ +using PANiXiDA.Core.Application.Persistence.Errors; + +namespace PANiXiDA.Core.Application.UnitTests.Persistence.Errors; + +public sealed class ConcurrencyExceptionTests +{ + [Fact(DisplayName = "ConcurrencyException provides a stable default message")] + public void Constructor_WhenCalledWithoutArguments_UsesDefaultMessage() + { + var exception = new ConcurrencyException(); + + exception.Message.ShouldBe("The entity was modified or deleted by another operation."); + exception.InnerException.ShouldBeNull(); + } + + [Fact(DisplayName = "ConcurrencyException preserves a custom diagnostic message")] + public void Constructor_WhenGivenMessage_PreservesMessage() + { + const string message = "The expected order version no longer exists."; + + var exception = new ConcurrencyException(message); + + exception.Message.ShouldBe(message); + exception.InnerException.ShouldBeNull(); + } + + [Fact(DisplayName = "ConcurrencyException preserves the original provider exception")] + public void Constructor_WhenGivenInnerException_PreservesInstance() + { + const string message = "The order could not be updated."; + var providerException = new InvalidOperationException("Provider diagnostics."); + + var exception = new ConcurrencyException(message, providerException); + + exception.Message.ShouldBe(message); + exception.InnerException.ShouldBeSameAs(providerException); + } + + [Fact(DisplayName = "ConcurrencyException accepts a null inner exception")] + public void Constructor_WhenInnerExceptionIsNull_PreservesMessage() + { + var exception = new ConcurrencyException("Version mismatch.", null); + + exception.Message.ShouldBe("Version mismatch."); + exception.InnerException.ShouldBeNull(); + } + + [Theory(DisplayName = "ConcurrencyException rejects a null message in both message constructors")] + [InlineData(false)] + [InlineData(true)] + public void Constructor_WhenMessageIsNull_ThrowsArgumentNullException(bool includeInnerException) + { + var innerException = new InvalidOperationException("Provider diagnostics."); + + var exception = Should.Throw(() => + includeInnerException + ? new ConcurrencyException(null!, innerException) + : new ConcurrencyException(null!)); + + exception.ParamName.ShouldBe("message"); + } + + [Theory(DisplayName = "ConcurrencyException rejects blank messages in both message constructors")] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("\t\r\n", false)] + [InlineData("", true)] + [InlineData(" ", true)] + [InlineData("\t\r\n", true)] + public void Constructor_WhenMessageIsBlank_ThrowsArgumentException(string message, bool includeInnerException) + { + var innerException = new InvalidOperationException("Provider diagnostics."); + + var exception = Should.Throw(() => + includeInnerException + ? new ConcurrencyException(message, innerException) + : new ConcurrencyException(message)); + + exception.ParamName.ShouldBe("message"); + } +} diff --git a/version.json b/version.json index e608736..572db95 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "5.0", + "version": "5.1", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$"