diff --git a/.editorconfig b/.editorconfig index 9e451e5..5a9d98a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -139,3 +139,6 @@ dotnet_diagnostic.CS1591.severity = none [src/**/*.cs] dotnet_diagnostic.CS1591.severity = error + +[I{Query,Command,Request}.cs] +dotnet_diagnostic.S2326.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 1272c60..765e603 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,13 +1,15 @@ - - true - true - - - - - - - - - + + true + true + + + + + + + + + + + \ No newline at end of file diff --git a/PANiXiDA.Core.Template.slnx b/PANiXiDA.Core.Application.slnx similarity index 70% rename from PANiXiDA.Core.Template.slnx rename to PANiXiDA.Core.Application.slnx index 736a666..05ec795 100644 --- a/PANiXiDA.Core.Template.slnx +++ b/PANiXiDA.Core.Application.slnx @@ -16,9 +16,9 @@ - + - + diff --git a/README.md b/README.md index 22cff3e..d04f5c3 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,173 @@ -## What to do after creating a repository from this template +# PANiXiDA.Core.Application -### 1. Rename repository metadata -- change repository name -- change solution / project names -- change package ID -- change assembly name -- change repository URLs -- change ProjectReference in test project +`PANiXiDA.Core.Application` is a .NET library with application-layer abstractions for Clean Architecture, CQRS, and DDD-based services. -### 2. Update package metadata -- description -- tags - -### 3. Update documentation -- replace this template README with the project README -- fill all placeholder sections -- update badges -- update installation instructions -- add real usage examples - -### 4. Configure GitHub repository -- check repository visibility -- configure default branch -- configure branch protection rules -- configure Issues / Discussions if needed -- configure repository description, topics and website - -### 5. Prepare the first release -- update versioning configuration pathFilters in version.json -- verify NuGet metadata -- verify README and icon inside the package -- publish the first package version -- the version is updated automatically based on the commit history - ---- - -# Universal README template for the NuGet package - -# - -`` is a .NET library for . - -It is designed for who need
. +It defines contracts and small reusable building blocks for commands, queries, request behaviors, domain event publishing, unit-of-work orchestration, repositories, aggregate tracking, and read-side paging helpers. The package intentionally does not provide a concrete mediator, database provider, dependency injection module, or transport-specific implementation. ## Status -[![CI](https://github.com///actions/workflows/ci.yml/badge.svg)](https://github.com///actions/workflows/ci.yml) -[![NuGet](https://img.shields.io/nuget/v/.svg)](https://www.nuget.org/packages/) -[![NuGet downloads](https://img.shields.io/nuget/dt/.svg)](https://www.nuget.org/packages/) +[![CI](https://github.com/panixida-dotnet-core/application/actions/workflows/ci.yml/badge.svg)](https://github.com/panixida-dotnet-core/application/actions/workflows/ci.yml) +[![NuGet](https://img.shields.io/nuget/v/PANiXiDA.Core.Application.svg)](https://www.nuget.org/packages/PANiXiDA.Core.Application) +[![NuGet downloads](https://img.shields.io/nuget/dt/PANiXiDA.Core.Application.svg)](https://www.nuget.org/packages/PANiXiDA.Core.Application) [![Target Framework](https://img.shields.io/badge/target-net10.0-512BD4)](https://dotnet.microsoft.com/) -[![License](https://img.shields.io/github/license//.svg)](LICENSE) - -## Overview - -Describe: - -- what problem this package solves; -- why it exists; -- where it fits in the system or ecosystem; -- how it differs from alternatives, if that matters. - -Keep this section short and practical. +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) ## Features -- Feature 1 -- Feature 2 -- Feature 3 -- Feature 4 -- Feature 5 - -## Quick Start +- 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. +- Built-in behaviors for transaction start, save, commit, cleanup, and domain event publishing. +- Event bus and event handler abstractions for `DomainEvent` integration. +- Unit of work, repository, and aggregate tracker abstractions for DDD persistence boundaries. +- Read-side helper models for page-based pagination, cursor pagination, sorting, and filtering. -### Requirements +## Requirements - .NET 10 SDK +- Nullable reference types enabled in consuming projects is recommended -### Installation +## Installation ```xml - + -```` +``` + +## Basic Usage -### Minimal import +### Command Contract ```csharp -using ; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Messaging.Mediator.Handlers; +using PANiXiDA.Core.ResultPattern; + +public sealed record PingCommand : ICommand; + +public sealed class PingCommandHandler : ICommandHandler +{ + public Task HandleAsync( + PingCommand command, + CancellationToken cancellationToken) + { + return Task.FromResult(Result.Success()); + } +} ``` -### First example +### Query Contract ```csharp -// Add a minimal example here +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Messaging.Mediator.Handlers; +using PANiXiDA.Core.ResultPattern; + +public sealed record GetNameQuery(Guid Id) : IQuery>; + +public sealed class GetNameQueryHandler : IQueryHandler> +{ + public Task> HandleAsync( + GetNameQuery query, + CancellationToken cancellationToken) + { + return Task.FromResult(Result.Success("PANiXiDA")); + } +} ``` -## Usage - -### Basic usage +### Page-Based Query Result ```csharp -// Add a basic example here +using PANiXiDA.Core.Application.Querying.Pagination; + +var result = PaginationResult.Create( + items: ["first", "second"], + pageNumber: 1, + pageSize: 10, + totalCount: 2); + +var hasNextPage = result.HasNextPage; ``` -### Typical scenario +### Cursor-Based Query Result ```csharp -// Add a realistic example here +using PANiXiDA.Core.Application.Querying.Cursor; + +var result = CursorPaginationResult.Create( + items: ["first", "second"], + limit: 10, + nextCursor: "cursor-2", + hasNextPage: true); ``` -### Advanced scenario +## Request Behaviors -```csharp -// Add an advanced example here if needed +The package includes reusable mediator behavior implementations for command transaction orchestration and domain event publication: + +- `BeginTransactionBehavior` starts a transaction before a command handler runs. +- `SaveChangesBehavior` persists changes after a successful command result when a transaction is active. +- `PublishDomainEventsBehavior` publishes domain events collected from tracked aggregate roots after a successful request 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. + +A consuming mediator implementation should register these behaviors in a deterministic order. A typical command pipeline is: + +```text +before: BeginTransactionBehavior +handler: ICommandHandler +after: SaveChangesBehavior +after: PublishDomainEventsBehavior +after: CommitTransactionBehavior +finally: CleanupTransactionBehavior ``` -## Configuration +The exact registration mechanism depends on the mediator or composition root used by the consuming application. -Describe configuration only if the package actually requires it. +## API Overview -Possible topics: +### Messaging -* environment variables; -* `appsettings.json`; -* feature flags; -* external services; -* secrets; -* runtime prerequisites. +- `IMediator` dispatches commands and queries. +- `ICommandHandler` handles state-changing requests. +- `IQueryHandler` handles read-only requests. +- `IBeforeRequestBehavior` runs before a handler and is defined in the mediator behavior abstractions namespace. +- `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. -If the package does not require runtime configuration, say so explicitly. +### Domain Events -## Project Structure +- `IEventBus` publishes domain events. +- `IEventHandler` handles a specific domain event type. +- `IAggregateTracker` tracks aggregate roots touched during a request so their domain events can be published and cleared. -```text -. -├── src/ -│ └── / -├── tests/ -│ └── .UnitTests/ -├── .editorconfig -├── .gitattributes -├── .gitignore -├── Directory.Build.props -├── Directory.Build.targets -├── Directory.Packages.props -├── global.json -├── version.json -├── LICENSE -└── README.md -``` +### Persistence -### Main repository files +- `IUnitOfWork` defines persistence and transaction operations. +- `IRepository` defines basic aggregate persistence operations. +- `IReadRepository` defines read-only existence checks. -* `src/` — source code -* `tests/` — automated tests -* `Directory.Build.props` — shared MSBuild settings -* `Directory.Build.targets` — shared build / packaging settings -* `Directory.Packages.props` — centralized package versions -* `global.json` — SDK and tooling configuration -* `version.json` — versioning configuration -* `README.md` — package overview and usage documentation +### Querying Models + +- `PaginationParameters` calculates `Skip` and `Take` for page-based reads. +- `PaginationResult` returns page metadata and items. +- `CursorPaginationParameters` represents cursor pagination input. +- `CursorPaginationResult` returns cursor pagination metadata and items. +- `SortParameters` and `SortOrder` represent read sorting options. +- `FilterParameters` is the base type for custom read filter records. + +## Configuration + +The package does not require runtime configuration. Consumers provide concrete implementations for mediator dispatch, persistence, event bus delivery, aggregate tracking, and dependency injection registration. ## Development -### Build +### Restore ```bash dotnet restore -dotnet build --configuration Release ``` ### Format @@ -177,110 +176,42 @@ dotnet build --configuration Release dotnet format ``` -### Test +### Build ```bash -dotnet test --configuration Release +dotnet build --configuration Release ``` -### Pack +### Test ```bash -dotnet pack --configuration Release +dotnet test --configuration Release ``` -### Full local validation +### Pack ```bash -dotnet restore -dotnet format -dotnet build --configuration Release -dotnet test --configuration Release dotnet pack --configuration Release ``` -### Tooling and conventions - -This repository uses: - -* .NET 10 -* Nullable enabled -* Implicit usings enabled -* Central package management -* GitHub Actions -* Nerdbank.GitVersioning - -Add more items only if they are actually relevant for the repository. - -## API / Contracts / Examples - -Describe the public API surface here. - -Suggested structure: - -* core abstractions; -* main entry points; -* key extension methods; -* important behavioral notes; -* typical integration examples. - -## Roadmap / TODO - -Potential future improvements: - -* item 1; -* item 2; -* item 3. - -Remove this section if it does not provide value. - -## Contributing - -Contributions are welcome. - -### General rules - -* keep the public API intentional; -* avoid unnecessary dependencies; -* preserve repository conventions; -* do not introduce breaking changes without review; -* keep documentation updated. - -### Code style - -* follow the repository `.editorconfig`; -* prefer readable and explicit code; -* keep naming consistent with the existing codebase. - -### Tests - -* add or update tests for meaningful behavior changes; -* cover both success and failure scenarios where applicable; -* add regression tests for bug fixes. - -### Validation before completion - -Run: +## Project Structure -```bash -dotnet restore -dotnet format -dotnet build --configuration Release -dotnet test --configuration Release +```text +. +├── src/ +│ └── PANiXiDA.Core.Application/ +├── tests/ +│ └── PANiXiDA.Core.Application.UnitTests/ +├── Directory.Build.props +├── Directory.Build.targets +├── Directory.Packages.props +├── global.json +├── version.json +├── icon.png +├── LICENSE +└── README.md ``` ## License -This project is licensed under the license. - -See the [LICENSE](LICENSE) file for details. - -## Maintainers / Contacts - -Maintained by . - -For questions or improvements, use: - -* GitHub Issues -* Pull Requests -* GitHub Discussions, if enabled +This project is licensed under the Apache-2.0 license. See the [LICENSE](LICENSE) file for details. diff --git a/src/PANiXiDA.Core.Application/Messaging/EventBus/Handlers/IEventHandler.cs b/src/PANiXiDA.Core.Application/Messaging/EventBus/Handlers/IEventHandler.cs new file mode 100644 index 0000000..c77932b --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/EventBus/Handlers/IEventHandler.cs @@ -0,0 +1,19 @@ +namespace PANiXiDA.Core.Application.Messaging.EventBus.Handlers; + +/// +/// Handles a published domain event. +/// +/// The domain event type handled by the handler. +public interface IEventHandler + where TEvent : DomainEvent +{ + /// + /// Handles the specified domain event. + /// + /// The domain event to handle. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task HandleAsync( + TEvent @event, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/EventBus/IEventBus.cs b/src/PANiXiDA.Core.Application/Messaging/EventBus/IEventBus.cs new file mode 100644 index 0000000..694f5a4 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/EventBus/IEventBus.cs @@ -0,0 +1,19 @@ +namespace PANiXiDA.Core.Application.Messaging.EventBus; + +/// +/// Publishes domain events to their subscribers. +/// +public interface IEventBus +{ + /// + /// Publishes the specified domain event. + /// + /// The type of the domain event to publish. + /// The domain event to publish. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task PublishAsync( + TEvent @event, + CancellationToken cancellationToken) + where TEvent : DomainEvent; +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IAfterRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IAfterRequestBehavior.cs new file mode 100644 index 0000000..c576c9f --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IAfterRequestBehavior.cs @@ -0,0 +1,25 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; + +/// +/// Defines behavior that runs after a request handler returns a result. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +public interface IAfterRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Executes behavior after the request handler returns a result. + /// + /// The request that was processed. + /// The result returned by the request handler. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task AfterAsync( + TRequest request, + TResult result, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IBeforeRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IBeforeRequestBehavior.cs new file mode 100644 index 0000000..7a8e2fe --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IBeforeRequestBehavior.cs @@ -0,0 +1,23 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; + +/// +/// Defines behavior that runs before a request handler is executed. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +public interface IBeforeRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Executes behavior before the request handler runs. + /// + /// The request being processed. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task BeforeAsync( + TRequest request, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs new file mode 100644 index 0000000..ba89519 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs @@ -0,0 +1,27 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; + +/// +/// Defines behavior that runs after request processing completes or fails. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +public interface IFinallyRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Executes behavior after request processing completes, including failure paths. + /// + /// The request that was processed. + /// The result returned by the request handler, 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( + TRequest request, + TResult? result, + Exception? exception, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/BeginTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/BeginTransactionBehavior.cs new file mode 100644 index 0000000..b8e6316 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/BeginTransactionBehavior.cs @@ -0,0 +1,30 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Begins a unit-of-work transaction before a command handler executes. +/// +/// The command type processed by the behavior. +/// The result type returned by the command. +/// The unit of work used to manage transactions. +public sealed class BeginTransactionBehavior(IUnitOfWork unitOfWork) + : IBeforeRequestBehavior + where TCommand : ICommand + where TResult : Result +{ + /// + /// Begins a transaction before the command handler runs. + /// + /// The command being processed. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + public async Task BeforeAsync( + TCommand request, + CancellationToken cancellationToken) + { + await unitOfWork.BeginTransactionAsync(cancellationToken); + } +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs new file mode 100644 index 0000000..fe196c4 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs @@ -0,0 +1,44 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Rolls back failed command transactions and releases transaction resources. +/// +/// The command type processed by the behavior. +/// The result type returned by the command. +/// The unit of work used to manage transactions. +public sealed class CleanupTransactionBehavior(IUnitOfWork unitOfWork) + : IFinallyRequestBehavior + where TCommand : ICommand + where TResult : Result +{ + /// + /// Rolls back the active transaction on failure and releases transaction resources. + /// + /// The command that was processed. + /// The command execution 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. + public async Task FinallyAsync( + TCommand request, + TResult? result, + Exception? exception, + CancellationToken cancellationToken) + { + if (!unitOfWork.HasActiveTransaction) + { + return; + } + + if (exception is not null || result is null || !result.IsSuccess) + { + await unitOfWork.RollbackTransactionAsync(cancellationToken); + } + + await unitOfWork.DisposeTransactionAsync(); + } +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CommitTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CommitTransactionBehavior.cs new file mode 100644 index 0000000..9ca7121 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CommitTransactionBehavior.cs @@ -0,0 +1,37 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Commits the active unit-of-work transaction after a successful command result. +/// +/// The command type processed by the behavior. +/// The result type returned by the command. +/// The unit of work used to manage transactions. +public sealed class CommitTransactionBehavior(IUnitOfWork unitOfWork) + : IAfterRequestBehavior + where TCommand : ICommand + where TResult : Result +{ + /// + /// Commits the active transaction when the command succeeded. + /// + /// The command that was processed. + /// The command execution result. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + public Task AfterAsync( + TCommand request, + TResult result, + CancellationToken cancellationToken) + { + if (!unitOfWork.HasActiveTransaction || !result.IsSuccess) + { + return Task.CompletedTask; + } + + return unitOfWork.CommitTransactionAsync(cancellationToken); + } +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/PublishDomainEventsBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/PublishDomainEventsBehavior.cs new file mode 100644 index 0000000..1383a67 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/PublishDomainEventsBehavior.cs @@ -0,0 +1,63 @@ +using PANiXiDA.Core.Application.Messaging.EventBus; +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Publishes domain events collected from tracked aggregate roots after a successful request result. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +/// The event bus used to publish domain events. +/// The tracker that stores aggregate roots touched by the request. +public sealed class PublishDomainEventsBehavior( + IEventBus eventBus, + IAggregateTracker aggregateTracker) : IAfterRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Publishes domain events when the request succeeded and clears tracked events after completed publication. + /// + /// The request that was processed. + /// The request execution result. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + public async Task AfterAsync( + TRequest request, + TResult result, + CancellationToken cancellationToken) + { + var aggregateRoots = aggregateTracker.GetAll(); + + if (result.IsFailure) + { + ClearDomainEvents(aggregateRoots); + aggregateTracker.Clear(); + return; + } + + foreach (var aggregateRoot in aggregateRoots) + { + var domainEvents = aggregateRoot.GetDomainEvents(); + + foreach (var domainEvent in domainEvents) + { + await eventBus.PublishAsync(domainEvent, cancellationToken); + } + } + + ClearDomainEvents(aggregateRoots); + aggregateTracker.Clear(); + } + + private static void ClearDomainEvents(IReadOnlyCollection aggregateRoots) + { + foreach (var aggregateRoot in aggregateRoots) + { + aggregateRoot.ClearDomainEvents(); + } + } +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/SaveChangesBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/SaveChangesBehavior.cs new file mode 100644 index 0000000..b1af626 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/SaveChangesBehavior.cs @@ -0,0 +1,37 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Persists unit-of-work changes after a successful command result. +/// +/// The command type processed by the behavior. +/// The result type returned by the command. +/// The unit of work used to persist changes. +public sealed class SaveChangesBehavior(IUnitOfWork unitOfWork) + : IAfterRequestBehavior + where TCommand : ICommand + where TResult : Result +{ + /// + /// Persists pending changes when the command succeeded inside an active transaction. + /// + /// The command that was processed. + /// The command execution result. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + public Task AfterAsync( + TCommand request, + TResult result, + CancellationToken cancellationToken) + { + if (!unitOfWork.HasActiveTransaction || !result.IsSuccess) + { + return Task.CompletedTask; + } + + return unitOfWork.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/ICommand.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/ICommand.cs new file mode 100644 index 0000000..4661a0e --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/ICommand.cs @@ -0,0 +1,10 @@ +namespace PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +/// +/// Defines a state-changing application request. +/// +/// The result type returned by the command. +public interface ICommand : IRequest + where TResult : Result +{ +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IQuery.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IQuery.cs new file mode 100644 index 0000000..42ad158 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IQuery.cs @@ -0,0 +1,10 @@ +namespace PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +/// +/// Defines a read-only application request. +/// +/// The result type returned by the query. +public interface IQuery : IRequest + where TResult : Result +{ +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IRequest.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IRequest.cs new file mode 100644 index 0000000..8359b54 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IRequest.cs @@ -0,0 +1,9 @@ +namespace PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +/// +/// Defines an application request that returns a result. +/// +/// The result type returned by the request. +public interface IRequest +{ +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/ICommandHandler.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/ICommandHandler.cs new file mode 100644 index 0000000..e83006f --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/ICommandHandler.cs @@ -0,0 +1,23 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Handlers; + +/// +/// Handles a command and returns its execution result. +/// +/// The command type handled by the handler. +/// The result type returned by the command. +public interface ICommandHandler + where TCommand : ICommand + where TResult : Result +{ + /// + /// Handles the specified command. + /// + /// The command to handle. + /// The token used to cancel the operation. + /// The command execution result. + Task HandleAsync( + TCommand command, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/IQueryHandler.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/IQueryHandler.cs new file mode 100644 index 0000000..9215a37 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/IQueryHandler.cs @@ -0,0 +1,23 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Handlers; + +/// +/// Handles a query and returns its execution result. +/// +/// The query type handled by the handler. +/// The result type returned by the query. +public interface IQueryHandler + where TQuery : IQuery + where TResult : Result +{ + /// + /// Handles the specified query. + /// + /// The query to handle. + /// The token used to cancel the operation. + /// The query execution result. + Task HandleAsync( + TQuery query, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/IMediator.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/IMediator.cs new file mode 100644 index 0000000..191c04b --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/IMediator.cs @@ -0,0 +1,33 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator; + +/// +/// Dispatches application commands and queries to their handlers. +/// +public interface IMediator +{ + /// + /// Sends a command to the matching command handler. + /// + /// The result type returned by the command. + /// The command to dispatch. + /// The token used to cancel the operation. + /// The command execution result. + Task SendAsync( + ICommand command, + CancellationToken cancellationToken) + where TResult : Result; + + /// + /// Sends a query to the matching query handler. + /// + /// The result type returned by the query. + /// The query to dispatch. + /// The token used to cancel the operation. + /// The query execution result. + Task QueryAsync( + IQuery query, + CancellationToken cancellationToken) + where TResult : Result; +} diff --git a/src/PANiXiDA.Core.Application/PANiXiDA.Core.Application.csproj b/src/PANiXiDA.Core.Application/PANiXiDA.Core.Application.csproj new file mode 100644 index 0000000..16da558 --- /dev/null +++ b/src/PANiXiDA.Core.Application/PANiXiDA.Core.Application.csproj @@ -0,0 +1,28 @@ + + + true + + PANiXiDA.Core.Application + + PANiXiDA.Core.Application + Core application-layer abstractions and building blocks for .NET applications, including contracts, messaging, validation, and use case orchestration. + dotnet;application-layer;clean-architecture;ddd;cqrs;use-cases;abstractions;contracts;validation;mediator + + https://github.com/panixida-dotnet-core/application + https://github.com/panixida-dotnet-core/application + + + + + all + + + + + + + + + + + diff --git a/src/PANiXiDA.Core.Application/Persistence/IAggregateTracker.cs b/src/PANiXiDA.Core.Application/Persistence/IAggregateTracker.cs new file mode 100644 index 0000000..8ef7f09 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Persistence/IAggregateTracker.cs @@ -0,0 +1,24 @@ +namespace PANiXiDA.Core.Application.Persistence; + +/// +/// Tracks aggregate roots touched during an application request. +/// +public interface IAggregateTracker +{ + /// + /// Adds an aggregate root to the current tracking scope. + /// + /// The aggregate root to track. + void Track(IAggregateRoot aggregateRoot); + + /// + /// Gets all aggregate roots tracked in the current scope. + /// + /// The tracked aggregate roots. + IReadOnlyCollection GetAll(); + + /// + /// Clears the current aggregate tracking scope. + /// + void Clear(); +} diff --git a/src/PANiXiDA.Core.Application/Persistence/IReadRepository.cs b/src/PANiXiDA.Core.Application/Persistence/IReadRepository.cs new file mode 100644 index 0000000..fe801a3 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Persistence/IReadRepository.cs @@ -0,0 +1,26 @@ +namespace PANiXiDA.Core.Application.Persistence; + +/// +/// Defines read-only persistence checks for an entity or aggregate root identifier. +/// +/// The identifier type. +public interface IReadRepository + where TId : struct +{ + /// + /// Determines whether an item with the specified identifier exists. + /// + /// The item identifier. + /// The token used to cancel the operation. + /// if an item with the identifier exists; otherwise, . + Task ExistsByIdAsync( + TId id, + CancellationToken cancellationToken); + + /// + /// Determines whether the read repository contains any items. + /// + /// The token used to cancel the operation. + /// if at least one item exists; otherwise, . + Task AnyAsync(CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Persistence/IRepository.cs b/src/PANiXiDA.Core.Application/Persistence/IRepository.cs new file mode 100644 index 0000000..3f5bbff --- /dev/null +++ b/src/PANiXiDA.Core.Application/Persistence/IRepository.cs @@ -0,0 +1,37 @@ +namespace PANiXiDA.Core.Application.Persistence; + +/// +/// Defines basic persistence operations for an aggregate root. +/// +/// The aggregate root identifier type. +/// The aggregate root type. +public interface IRepository + where TId : struct + where TAggregateRoot : class, IAggregateRoot +{ + /// + /// Gets an aggregate root by its identifier. + /// + /// The aggregate root identifier. + /// The token used to cancel the operation. + /// The aggregate root when found; otherwise, . + Task GetByIdAsync(TId id, CancellationToken cancellationToken); + + /// + /// Marks the aggregate root for insertion. + /// + /// The aggregate root to add. + void Add(TAggregateRoot aggregateRoot); + + /// + /// Marks the aggregate root for update. + /// + /// The aggregate root to update. + void Update(TAggregateRoot aggregateRoot); + + /// + /// Marks the aggregate root for deletion. + /// + /// The aggregate root to delete. + void Delete(TAggregateRoot aggregateRoot); +} diff --git a/src/PANiXiDA.Core.Application/Persistence/IUnitOfWork.cs b/src/PANiXiDA.Core.Application/Persistence/IUnitOfWork.cs new file mode 100644 index 0000000..ccdcbfb --- /dev/null +++ b/src/PANiXiDA.Core.Application/Persistence/IUnitOfWork.cs @@ -0,0 +1,54 @@ +namespace PANiXiDA.Core.Application.Persistence; + +/// +/// Coordinates persistence changes and transaction boundaries for an application request. +/// +public interface IUnitOfWork +{ + /// + /// Persists pending changes in the current unit of work. + /// + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task SaveChangesAsync(CancellationToken cancellationToken); + + /// + /// Executes an action inside a transaction managed by the unit of work. + /// + /// The action to execute inside the transaction. + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task ExecuteInTransactionAsync(Func action, CancellationToken cancellationToken); + + /// + /// Begins a new transaction. + /// + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task BeginTransactionAsync(CancellationToken cancellationToken); + + /// + /// Commits the active transaction. + /// + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task CommitTransactionAsync(CancellationToken cancellationToken); + + /// + /// Rolls back the active transaction. + /// + /// The token used to cancel the operation. + /// A task that represents the asynchronous operation. + Task RollbackTransactionAsync(CancellationToken cancellationToken); + + /// + /// Releases resources owned by the active transaction. + /// + /// A value task that represents the asynchronous operation. + ValueTask DisposeTransactionAsync(); + + /// + /// Gets a value indicating whether the unit of work has an active transaction. + /// + bool HasActiveTransaction { get; } +} diff --git a/src/PANiXiDA.Core.Application/Querying/Cursor/CursorDirection.cs b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorDirection.cs new file mode 100644 index 0000000..90c5a63 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorDirection.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace PANiXiDA.Core.Application.Querying.Cursor; + +/// +/// Defines cursor pagination directions. +/// +public enum CursorDirection +{ + /// + /// Reads items after the current cursor. + /// + [Display(Name = "Вперёд")] + Forward = 1, + + /// + /// Reads items before the current cursor. + /// + [Display(Name = "Назад")] + Backward = 2 +} diff --git a/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationParameters.cs b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationParameters.cs new file mode 100644 index 0000000..5ab7c73 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationParameters.cs @@ -0,0 +1,26 @@ +namespace PANiXiDA.Core.Application.Querying.Cursor; + +/// +/// Represents cursor-based pagination request parameters. +/// +/// The cursor used as the page boundary. +/// The maximum number of items to return. +/// The read direction relative to the cursor. +public sealed record CursorPaginationParameters( + string? Cursor, + int Limit = 10, + CursorDirection Direction = CursorDirection.Forward) +{ + /// + /// Creates parameters for the first page. + /// + /// The maximum number of items to return. + /// Cursor pagination parameters for the first page. + public static CursorPaginationParameters FirstPage(int limit) + { + return new CursorPaginationParameters( + null, + limit, + CursorDirection.Forward); + } +} diff --git a/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationResult.cs b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationResult.cs new file mode 100644 index 0000000..ed2b0e1 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationResult.cs @@ -0,0 +1,111 @@ +namespace PANiXiDA.Core.Application.Querying.Cursor; + +/// +/// Represents a cursor-based pagination result. +/// +/// The item type. +public sealed class CursorPaginationResult +{ + /// + /// Gets the current page items. + /// + public IReadOnlyList Items { get; } + + /// + /// Gets the requested item limit. + /// + public int Limit { get; } + + /// + /// Gets the cursor used to request the next page. + /// + public string? NextCursor { get; } + + /// + /// Gets the cursor used to request the previous page. + /// + public string? PreviousCursor { get; } + + /// + /// Gets a value indicating whether a next page exists. + /// + public bool HasNextPage { get; } + + /// + /// Gets a value indicating whether a previous page exists. + /// + public bool HasPreviousPage { get; } + + private CursorPaginationResult( + IReadOnlyList items, + int limit, + string? nextCursor, + string? previousCursor, + bool hasNextPage, + bool hasPreviousPage) + { + ArgumentNullException.ThrowIfNull(items); + + if (limit <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(limit), + limit, + "Лимит должен быть больше 0."); + } + + Items = items; + Limit = limit; + NextCursor = nextCursor; + PreviousCursor = previousCursor; + HasNextPage = hasNextPage; + HasPreviousPage = hasPreviousPage; + } + + /// + /// Creates a cursor-based pagination result. + /// + /// The current page items. + /// The requested item limit. + /// The cursor used to request the next page. + /// The cursor used to request the previous page. + /// A value indicating whether a next page exists. + /// A value indicating whether a previous page exists. + /// A cursor-based pagination result. + public static CursorPaginationResult Create( + IEnumerable items, + int limit, + string? nextCursor = null, + string? previousCursor = null, + bool hasNextPage = false, + bool hasPreviousPage = false) + { + ArgumentNullException.ThrowIfNull(items); + + return new CursorPaginationResult( + items is IReadOnlyList readOnlyList + ? readOnlyList + : [.. items], + limit, + nextCursor, + previousCursor, + hasNextPage, + hasPreviousPage); + } + + /// + /// Creates an empty cursor-based pagination result. + /// + /// The requested item limit. + /// An empty cursor-based pagination result. + public static CursorPaginationResult Empty(int limit) + { + return new CursorPaginationResult( + items: [], + limit: limit, + nextCursor: null, + previousCursor: null, + hasNextPage: false, + hasPreviousPage: false); + } +} diff --git a/src/PANiXiDA.Core.Application/Querying/Filtering/FilterParameters.cs b/src/PANiXiDA.Core.Application/Querying/Filtering/FilterParameters.cs new file mode 100644 index 0000000..c2723e0 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Filtering/FilterParameters.cs @@ -0,0 +1,6 @@ +namespace PANiXiDA.Core.Application.Querying.Filtering; + +/// +/// Represents the base type for read request filter parameters. +/// +public abstract record FilterParameters; diff --git a/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationParameters.cs b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationParameters.cs new file mode 100644 index 0000000..4660a9d --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationParameters.cs @@ -0,0 +1,19 @@ +namespace PANiXiDA.Core.Application.Querying.Pagination; + +/// +/// Represents page-based pagination request parameters. +/// +/// The requested page number. +/// The number of items per page. +public sealed record PaginationParameters(int PageNumber = 1, int PageSize = 10) +{ + /// + /// Gets the number of items to skip for the current page. + /// + public int Skip => (Math.Max(PageNumber, 1) - 1) * Math.Max(PageSize, 1); + + /// + /// Gets the number of items to take for the current page. + /// + public int Take => Math.Max(PageSize, 1); +} diff --git a/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationResult.cs b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationResult.cs new file mode 100644 index 0000000..f09294a --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationResult.cs @@ -0,0 +1,127 @@ +namespace PANiXiDA.Core.Application.Querying.Pagination; + +/// +/// Represents a page-based pagination result. +/// +/// The item type. +public sealed class PaginationResult +{ + /// + /// Gets the current page items. + /// + public IReadOnlyList Items { get; } + + /// + /// Gets the current page number. + /// + public int PageNumber { get; } + + /// + /// Gets the current page size. + /// + public int PageSize { get; } + + /// + /// Gets the total item count. + /// + public long TotalCount { get; } + + /// + /// Gets the total page count. + /// + public long TotalPages { get; } + + /// + /// Gets a value indicating whether a previous page exists. + /// + public bool HasPreviousPage { get; } + + /// + /// Gets a value indicating whether a next page exists. + /// + public bool HasNextPage { get; } + + private PaginationResult( + IReadOnlyList items, + int pageNumber, + int pageSize, + long totalCount) + { + ArgumentNullException.ThrowIfNull(items); + + if (pageNumber <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(pageNumber), + pageNumber, + "Номер страницы должен быть больше 0."); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(pageSize), + pageSize, + "Размер страницы должен быть больше 0."); + } + + if (totalCount < 0) + { + throw new ArgumentOutOfRangeException( + nameof(totalCount), + totalCount, + "Общее количество элементов не может быть отрицательным."); + } + + Items = items; + PageNumber = pageNumber; + PageSize = pageSize; + TotalCount = totalCount; + TotalPages = totalCount == 0 + ? 0 + : (totalCount + pageSize - 1) / pageSize; + + HasPreviousPage = pageNumber > 1; + HasNextPage = pageNumber < TotalPages; + } + + /// + /// Creates a page-based pagination result. + /// + /// The current page items. + /// The current page number. + /// The current page size. + /// The total item count. + /// A page-based pagination result. + public static PaginationResult Create( + IEnumerable items, + int pageNumber, + int pageSize, + long totalCount) + { + ArgumentNullException.ThrowIfNull(items); + + return new PaginationResult( + items is IReadOnlyList readOnlyList + ? readOnlyList + : [.. items], + pageNumber, + pageSize, + totalCount); + } + + /// + /// Creates an empty page-based pagination result. + /// + /// The current page number. + /// The current page size. + /// An empty page-based pagination result. + public static PaginationResult Empty(int pageNumber, int pageSize) + { + return new PaginationResult( + items: [], + pageNumber: pageNumber, + pageSize: pageSize, + totalCount: 0); + } +} diff --git a/src/PANiXiDA.Core.Application/Querying/Sorting/SortOrder.cs b/src/PANiXiDA.Core.Application/Querying/Sorting/SortOrder.cs new file mode 100644 index 0000000..bb5db00 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Sorting/SortOrder.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace PANiXiDA.Core.Application.Querying.Sorting; + +/// +/// Defines sort directions. +/// +public enum SortOrder +{ + /// + /// Sorts values in ascending order. + /// + [Display(Name = "По возрастанию")] + Ascending = 0, + + /// + /// Sorts values in descending order. + /// + [Display(Name = "По убыванию")] + Descending = 1, +} diff --git a/src/PANiXiDA.Core.Application/Querying/Sorting/SortParameters.cs b/src/PANiXiDA.Core.Application/Querying/Sorting/SortParameters.cs new file mode 100644 index 0000000..ddcdb00 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Querying/Sorting/SortParameters.cs @@ -0,0 +1,20 @@ +namespace PANiXiDA.Core.Application.Querying.Sorting; + +/// +/// Represents sorting parameters. +/// +/// The field to sort by. +/// The sort direction. +public sealed record SortParameters( + string? Field = null, + SortOrder Order = SortOrder.Ascending) +{ + /// + /// Creates default sorting parameters. + /// + /// Default sorting parameters. + public static SortParameters Default() + { + return new SortParameters(); + } +} diff --git a/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj b/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj deleted file mode 100644 index 03bc1a3..0000000 --- a/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - true - - PANiXiDA.Core.Template - - PANiXiDA.Core.Template - Change me. - Template;ChangeMe - - https://github.com/panixida-dotnet-core/template - https://github.com/panixida-dotnet-core/template - - - - - all - - - diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateRoot.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateRoot.cs new file mode 100644 index 0000000..1fa0982 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateRoot.cs @@ -0,0 +1,12 @@ +using PANiXiDA.Core.Domain.AggregateRoots; +using PANiXiDA.Core.Domain.DomainEvents; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed class TestAggregateRoot(Guid id) : AggregateRoot(id) +{ + public void Raise(DomainEvent domainEvent) + { + AddDomainEvent(domainEvent); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateTracker.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateTracker.cs new file mode 100644 index 0000000..4345eb0 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateTracker.cs @@ -0,0 +1,27 @@ +using PANiXiDA.Core.Application.Persistence; +using PANiXiDA.Core.Domain.AggregateRoots; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed class TestAggregateTracker : IAggregateTracker +{ + private readonly List aggregateRoots = []; + + public int ClearCalls { get; private set; } + + public void Track(IAggregateRoot aggregateRoot) + { + aggregateRoots.Add(aggregateRoot); + } + + public IReadOnlyCollection GetAll() + { + return aggregateRoots; + } + + public void Clear() + { + ClearCalls++; + aggregateRoots.Clear(); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestCommand.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestCommand.cs new file mode 100644 index 0000000..e1a4886 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestCommand.cs @@ -0,0 +1,6 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.ResultPattern; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed record TestCommand : ICommand; diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestDomainEvent.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestDomainEvent.cs new file mode 100644 index 0000000..4046208 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestDomainEvent.cs @@ -0,0 +1,5 @@ +using PANiXiDA.Core.Domain.DomainEvents; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed record TestDomainEvent : DomainEvent; diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestEventBus.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestEventBus.cs new file mode 100644 index 0000000..139fc74 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestEventBus.cs @@ -0,0 +1,23 @@ +using PANiXiDA.Core.Application.Messaging.EventBus; +using PANiXiDA.Core.Domain.DomainEvents; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed class TestEventBus : IEventBus +{ + public List PublishedEvents { get; } = []; + public Exception? Exception { get; init; } + + public Task PublishAsync(TEvent @event, CancellationToken cancellationToken) + where TEvent : DomainEvent + { + if (Exception is not null) + { + throw Exception; + } + + PublishedEvents.Add(@event); + + return Task.CompletedTask; + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestRequest.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestRequest.cs new file mode 100644 index 0000000..fef10fd --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestRequest.cs @@ -0,0 +1,6 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.ResultPattern; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed record TestRequest : IRequest; diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestUnitOfWork.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestUnitOfWork.cs new file mode 100644 index 0000000..ab938da --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestUnitOfWork.cs @@ -0,0 +1,65 @@ +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; + +internal sealed class TestUnitOfWork : IUnitOfWork +{ + public int SaveChangesCalls { get; private set; } + public int ExecuteInTransactionCalls { get; private set; } + public int BeginTransactionCalls { get; private set; } + public int CommitTransactionCalls { get; private set; } + public int RollbackTransactionCalls { get; private set; } + public int DisposeTransactionCalls { get; private set; } + public CancellationToken LastCancellationToken { get; private set; } + public bool HasActiveTransaction { get; set; } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + SaveChangesCalls++; + LastCancellationToken = cancellationToken; + + return Task.CompletedTask; + } + + public async Task ExecuteInTransactionAsync( + Func action, + CancellationToken cancellationToken) + { + ExecuteInTransactionCalls++; + LastCancellationToken = cancellationToken; + await action(cancellationToken); + } + + public Task BeginTransactionAsync(CancellationToken cancellationToken) + { + BeginTransactionCalls++; + LastCancellationToken = cancellationToken; + HasActiveTransaction = true; + + return Task.CompletedTask; + } + + public Task CommitTransactionAsync(CancellationToken cancellationToken) + { + CommitTransactionCalls++; + LastCancellationToken = cancellationToken; + + return Task.CompletedTask; + } + + public Task RollbackTransactionAsync(CancellationToken cancellationToken) + { + RollbackTransactionCalls++; + LastCancellationToken = cancellationToken; + + return Task.CompletedTask; + } + + public ValueTask DisposeTransactionAsync() + { + DisposeTransactionCalls++; + HasActiveTransaction = false; + + return ValueTask.CompletedTask; + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs new file mode 100644 index 0000000..be362e2 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs @@ -0,0 +1,246 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; +using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; +using PANiXiDA.Core.ResultPattern; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors; + +public sealed class RequestBehaviorsTests +{ + [Fact(DisplayName = "BeginTransactionBehavior begins a transaction")] + public async Task BeforeAsync_WhenCalled_BeginsTransaction() + { + var unitOfWork = new TestUnitOfWork(); + var behavior = new BeginTransactionBehavior(unitOfWork); + using var cancellationTokenSource = new CancellationTokenSource(); + + await behavior.BeforeAsync(new TestCommand(), cancellationTokenSource.Token); + + unitOfWork.BeginTransactionCalls.Should().Be(1); + unitOfWork.HasActiveTransaction.Should().BeTrue(); + unitOfWork.LastCancellationToken.Should().Be(cancellationTokenSource.Token); + } + + [Fact(DisplayName = "SaveChangesBehavior saves changes for successful commands in an active transaction")] + public async Task AfterAsync_WhenCommandSucceededInActiveTransaction_SavesChanges() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new SaveChangesBehavior(unitOfWork); + + await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None); + + unitOfWork.SaveChangesCalls.Should().Be(1); + } + + [Fact(DisplayName = "SaveChangesBehavior skips when there is no active transaction")] + public async Task AfterAsync_WhenTransactionIsNotActive_DoesNotSaveChanges() + { + var unitOfWork = new TestUnitOfWork(); + var behavior = new SaveChangesBehavior(unitOfWork); + + await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None); + + unitOfWork.SaveChangesCalls.Should().Be(0); + } + + [Fact(DisplayName = "SaveChangesBehavior skips failed command results")] + public async Task AfterAsync_WhenCommandFailed_DoesNotSaveChanges() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new SaveChangesBehavior(unitOfWork); + + await behavior.AfterAsync(new TestCommand(), CreateFailureResult(), CancellationToken.None); + + unitOfWork.SaveChangesCalls.Should().Be(0); + } + + [Fact(DisplayName = "CommitTransactionBehavior commits successful commands in an active transaction")] + public async Task AfterAsync_WhenCommandSucceededInActiveTransaction_CommitsTransaction() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new CommitTransactionBehavior(unitOfWork); + + await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None); + + unitOfWork.CommitTransactionCalls.Should().Be(1); + } + + [Fact(DisplayName = "CommitTransactionBehavior skips when there is no active transaction")] + public async Task AfterAsync_WhenTransactionIsNotActive_DoesNotCommitTransaction() + { + var unitOfWork = new TestUnitOfWork(); + var behavior = new CommitTransactionBehavior(unitOfWork); + + await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None); + + unitOfWork.CommitTransactionCalls.Should().Be(0); + } + + [Fact(DisplayName = "CommitTransactionBehavior skips failed command results")] + public async Task AfterAsync_WhenCommandFailed_DoesNotCommitTransaction() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new CommitTransactionBehavior(unitOfWork); + + await behavior.AfterAsync(new TestCommand(), CreateFailureResult(), CancellationToken.None); + + unitOfWork.CommitTransactionCalls.Should().Be(0); + } + + [Fact(DisplayName = "CleanupTransactionBehavior skips when there is no active transaction")] + public async Task FinallyAsync_WhenTransactionIsNotActive_DoesNotRollbackOrDispose() + { + var unitOfWork = new TestUnitOfWork(); + var behavior = new CleanupTransactionBehavior(unitOfWork); + + await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None); + + unitOfWork.RollbackTransactionCalls.Should().Be(0); + unitOfWork.DisposeTransactionCalls.Should().Be(0); + } + + [Fact(DisplayName = "CleanupTransactionBehavior disposes successful active transactions")] + public async Task FinallyAsync_WhenCommandSucceededInActiveTransaction_DisposesTransaction() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new CleanupTransactionBehavior(unitOfWork); + + await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None); + + unitOfWork.RollbackTransactionCalls.Should().Be(0); + unitOfWork.DisposeTransactionCalls.Should().Be(1); + } + + [Fact(DisplayName = "CleanupTransactionBehavior rolls back failed command results")] + public async Task FinallyAsync_WhenCommandFailedInActiveTransaction_RollsBackAndDisposesTransaction() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new CleanupTransactionBehavior(unitOfWork); + + await behavior.FinallyAsync(new TestCommand(), CreateFailureResult(), null, CancellationToken.None); + + unitOfWork.RollbackTransactionCalls.Should().Be(1); + unitOfWork.DisposeTransactionCalls.Should().Be(1); + } + + [Fact(DisplayName = "CleanupTransactionBehavior rolls back when the command result is missing")] + public async Task FinallyAsync_WhenResultIsNullInActiveTransaction_RollsBackAndDisposesTransaction() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new CleanupTransactionBehavior(unitOfWork); + + await behavior.FinallyAsync(new TestCommand(), null, null, CancellationToken.None); + + unitOfWork.RollbackTransactionCalls.Should().Be(1); + unitOfWork.DisposeTransactionCalls.Should().Be(1); + } + + [Fact(DisplayName = "CleanupTransactionBehavior rolls back when an exception is provided")] + public async Task FinallyAsync_WhenExceptionIsProvidedInActiveTransaction_RollsBackAndDisposesTransaction() + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true + }; + var behavior = new CleanupTransactionBehavior(unitOfWork); + + await behavior.FinallyAsync( + new TestCommand(), + Result.Success(), + new InvalidOperationException(), + CancellationToken.None); + + unitOfWork.RollbackTransactionCalls.Should().Be(1); + unitOfWork.DisposeTransactionCalls.Should().Be(1); + } + + [Fact(DisplayName = "PublishDomainEventsBehavior publishes events from tracked aggregates")] + public async Task AfterAsync_WhenRequestSucceeded_PublishesDomainEventsAndClearsTracking() + { + var eventBus = new TestEventBus(); + var aggregateTracker = new TestAggregateTracker(); + var aggregateRoot = new TestAggregateRoot(Guid.NewGuid()); + var firstEvent = new TestDomainEvent(); + var secondEvent = new TestDomainEvent(); + aggregateRoot.Raise(firstEvent); + aggregateRoot.Raise(secondEvent); + aggregateTracker.Track(aggregateRoot); + + var behavior = new PublishDomainEventsBehavior(eventBus, aggregateTracker); + + await behavior.AfterAsync(new TestRequest(), Result.Success(), CancellationToken.None); + + eventBus.PublishedEvents.Should().Equal(firstEvent, secondEvent); + aggregateRoot.GetDomainEvents().Should().BeEmpty(); + aggregateTracker.ClearCalls.Should().Be(1); + aggregateTracker.GetAll().Should().BeEmpty(); + } + + [Fact(DisplayName = "PublishDomainEventsBehavior clears tracking without publishing failed request results")] + public async Task AfterAsync_WhenRequestFailed_ClearsTrackingWithoutPublishing() + { + var eventBus = new TestEventBus(); + var aggregateTracker = new TestAggregateTracker(); + var aggregateRoot = new TestAggregateRoot(Guid.NewGuid()); + aggregateRoot.Raise(new TestDomainEvent()); + aggregateTracker.Track(aggregateRoot); + + var behavior = new PublishDomainEventsBehavior(eventBus, aggregateTracker); + + await behavior.AfterAsync(new TestRequest(), CreateFailureResult(), CancellationToken.None); + + eventBus.PublishedEvents.Should().BeEmpty(); + aggregateRoot.GetDomainEvents().Should().BeEmpty(); + aggregateTracker.ClearCalls.Should().Be(1); + aggregateTracker.GetAll().Should().BeEmpty(); + } + + [Fact(DisplayName = "PublishDomainEventsBehavior does not clear tracking when publishing throws")] + public async Task AfterAsync_WhenPublishingThrows_DoesNotClearTrackingAndRethrows() + { + var eventBus = new TestEventBus + { + Exception = new InvalidOperationException("Publish failed.") + }; + var aggregateTracker = new TestAggregateTracker(); + var aggregateRoot = new TestAggregateRoot(Guid.NewGuid()); + aggregateRoot.Raise(new TestDomainEvent()); + aggregateTracker.Track(aggregateRoot); + + var behavior = new PublishDomainEventsBehavior(eventBus, aggregateTracker); + + Func act = () => behavior.AfterAsync(new TestRequest(), Result.Success(), CancellationToken.None); + + await act.Should() + .ThrowAsync() + .WithMessage("Publish failed."); + aggregateRoot.GetDomainEvents().Should().NotBeEmpty(); + aggregateTracker.ClearCalls.Should().Be(0); + aggregateTracker.GetAll().Should().ContainSingle(); + } + + private static Result CreateFailureResult() + { + return Result.Failure(Error.Failure("Failure.")); + } +} diff --git a/tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj b/tests/PANiXiDA.Core.Application.UnitTests/PANiXiDA.Core.Application.UnitTests.csproj similarity index 84% rename from tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj rename to tests/PANiXiDA.Core.Application.UnitTests/PANiXiDA.Core.Application.UnitTests.csproj index 7e03d53..621b41e 100644 --- a/tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj +++ b/tests/PANiXiDA.Core.Application.UnitTests/PANiXiDA.Core.Application.UnitTests.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationParametersTests.cs new file mode 100644 index 0000000..c5de1fe --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationParametersTests.cs @@ -0,0 +1,62 @@ +using PANiXiDA.Core.Application.Querying.Cursor; +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace PANiXiDA.Core.Application.UnitTests.Querying.Cursor; + +public sealed class CursorPaginationParametersTests +{ + [Fact(DisplayName = "FirstPage returns forward parameters without a cursor")] + public void FirstPage_WhenLimitIsProvided_ReturnsForwardParametersWithoutCursor() + { + var parameters = CursorPaginationParameters.FirstPage(limit: 50); + + parameters.Cursor.Should().BeNull(); + parameters.Limit.Should().Be(50); + parameters.Direction.Should().Be(CursorDirection.Forward); + } + + [Fact(DisplayName = "Constructor stores cursor pagination values")] + public void Constructor_WhenValuesAreProvided_StoresValues() + { + var parameters = new CursorPaginationParameters( + Cursor: "cursor-1", + Limit: 10, + Direction: CursorDirection.Backward); + + parameters.Cursor.Should().Be("cursor-1"); + parameters.Limit.Should().Be(10); + parameters.Direction.Should().Be(CursorDirection.Backward); + } + + [Fact(DisplayName = "With expression copies and updates cursor pagination parameters")] + public void WithExpression_WhenValuesAreChanged_CopiesAndUpdatesParameters() + { + var parameters = CursorPaginationParameters.FirstPage(limit: 10); + + var updated = parameters with + { + Cursor = "cursor-2", + Limit = 20, + Direction = CursorDirection.Backward + }; + + updated.Cursor.Should().Be("cursor-2"); + updated.Limit.Should().Be(20); + updated.Direction.Should().Be(CursorDirection.Backward); + } + + [Theory(DisplayName = "CursorDirection has localized display names")] + [InlineData(CursorDirection.Forward, "Вперёд")] + [InlineData(CursorDirection.Backward, "Назад")] + public void DisplayName_WhenCursorDirectionIsProvided_ReturnsLocalizedName( + CursorDirection direction, + string expectedDisplayName) + { + var member = typeof(CursorDirection).GetMember(direction.ToString()).Single(); + var displayAttribute = member.GetCustomAttribute(); + + displayAttribute.Should().NotBeNull(); + displayAttribute!.Name.Should().Be(expectedDisplayName); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationResultTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationResultTests.cs new file mode 100644 index 0000000..7320820 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationResultTests.cs @@ -0,0 +1,76 @@ +using PANiXiDA.Core.Application.Querying.Cursor; + +namespace PANiXiDA.Core.Application.UnitTests.Querying.Cursor; + +public sealed class CursorPaginationResultTests +{ + [Fact(DisplayName = "Create returns cursor page metadata")] + public void Create_WhenParametersAreValid_ReturnsCursorMetadata() + { + IReadOnlyList items = [1, 2]; + + var result = CursorPaginationResult.Create( + items, + limit: 2, + nextCursor: "next", + previousCursor: "previous", + hasNextPage: true, + hasPreviousPage: true); + + result.Items.Should().BeSameAs(items); + result.Limit.Should().Be(2); + result.NextCursor.Should().Be("next"); + result.PreviousCursor.Should().Be("previous"); + result.HasNextPage.Should().BeTrue(); + result.HasPreviousPage.Should().BeTrue(); + } + + [Fact(DisplayName = "Create copies enumerable cursor items into a read-only list")] + public void Create_WhenItemsAreEnumerable_CopiesItemsIntoReadOnlyList() + { + var items = Enumerable.Range(1, 2).Where(item => item > 0); + + var result = CursorPaginationResult.Create(items, limit: 10); + + result.Items.Should().Equal(1, 2); + result.Items.Should().BeAssignableTo>(); + result.NextCursor.Should().BeNull(); + result.PreviousCursor.Should().BeNull(); + result.HasNextPage.Should().BeFalse(); + result.HasPreviousPage.Should().BeFalse(); + } + + [Fact(DisplayName = "Empty returns an empty cursor result")] + public void Empty_WhenLimitIsValid_ReturnsEmptyResult() + { + var result = CursorPaginationResult.Empty(limit: 25); + + result.Items.Should().BeEmpty(); + result.Limit.Should().Be(25); + result.NextCursor.Should().BeNull(); + result.PreviousCursor.Should().BeNull(); + result.HasNextPage.Should().BeFalse(); + result.HasPreviousPage.Should().BeFalse(); + } + + [Fact(DisplayName = "Create throws when cursor items are null")] + public void Create_WhenItemsAreNull_Throws() + { + Action act = () => CursorPaginationResult.Create(items: null!, limit: 10); + + act.Should() + .Throw() + .WithParameterName("items"); + } + + [Fact(DisplayName = "Create throws when cursor limit is not positive")] + public void Create_WhenLimitIsNotPositive_Throws() + { + Action act = () => CursorPaginationResult.Create(items: [], limit: 0); + + act.Should() + .Throw() + .WithParameterName("limit") + .WithMessage("*Лимит должен быть больше 0.*"); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Filtering/FilterParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Filtering/FilterParametersTests.cs new file mode 100644 index 0000000..60ed656 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Filtering/FilterParametersTests.cs @@ -0,0 +1,29 @@ +using PANiXiDA.Core.Application.Querying.Filtering; + +namespace PANiXiDA.Core.Application.UnitTests.Querying.Filtering; + +public sealed class FilterParametersTests +{ + [Fact(DisplayName = "FilterParameters can be used as a read filter base type")] + public void FilterParameters_WhenDerived_CanBeUsedAsBaseType() + { + FilterParameters parameters = new TestFilterParameters("active"); + + parameters.Should().BeOfType(); + } + + [Fact(DisplayName = "With expression copies filter parameters")] + public void WithExpression_WhenFilterValuesAreChanged_CopiesFilterParameters() + { + var parameters = new TestFilterParameters("active"); + + var updated = parameters with + { + Status = "inactive" + }; + + updated.Status.Should().Be("inactive"); + } + + private sealed record TestFilterParameters(string Status) : FilterParameters; +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationParametersTests.cs new file mode 100644 index 0000000..40f7850 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationParametersTests.cs @@ -0,0 +1,45 @@ +using PANiXiDA.Core.Application.Querying.Pagination; + +namespace PANiXiDA.Core.Application.UnitTests.Querying.Pagination; + +public sealed class PaginationParametersTests +{ + [Fact(DisplayName = "Skip and Take use the requested positive page values")] + public void SkipAndTake_WhenValuesArePositive_UseRequestedValues() + { + var parameters = new PaginationParameters(PageNumber: 3, PageSize: 20); + + var skip = parameters.Skip; + var take = parameters.Take; + + skip.Should().Be(40); + take.Should().Be(20); + } + + [Fact(DisplayName = "Skip and Take clamp invalid page values to one")] + public void SkipAndTake_WhenValuesAreInvalid_ClampToOne() + { + var parameters = new PaginationParameters(PageNumber: 0, PageSize: 0); + + var skip = parameters.Skip; + var take = parameters.Take; + + skip.Should().Be(0); + take.Should().Be(1); + } + + [Fact(DisplayName = "With expression copies and updates pagination parameters")] + public void WithExpression_WhenValuesAreChanged_CopiesAndUpdatesParameters() + { + var parameters = new PaginationParameters(PageNumber: 1, PageSize: 10); + + var updated = parameters with + { + PageNumber = 2, + PageSize = 20 + }; + + updated.PageNumber.Should().Be(2); + updated.PageSize.Should().Be(20); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationResultTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationResultTests.cs new file mode 100644 index 0000000..a5399ea --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationResultTests.cs @@ -0,0 +1,116 @@ +using PANiXiDA.Core.Application.Querying.Pagination; + +namespace PANiXiDA.Core.Application.UnitTests.Querying.Pagination; + +public sealed class PaginationResultTests +{ + [Fact(DisplayName = "Create returns calculated page metadata")] + public void Create_WhenParametersAreValid_ReturnsCalculatedMetadata() + { + IReadOnlyList items = [3, 4]; + + var result = PaginationResult.Create( + items, + pageNumber: 2, + pageSize: 2, + totalCount: 5); + + result.Items.Should().BeSameAs(items); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + result.TotalCount.Should().Be(5); + result.TotalPages.Should().Be(3); + result.HasPreviousPage.Should().BeTrue(); + result.HasNextPage.Should().BeTrue(); + } + + [Fact(DisplayName = "Create copies enumerable items into a read-only list")] + public void Create_WhenItemsAreEnumerable_CopiesItemsIntoReadOnlyList() + { + var items = Enumerable.Range(1, 2).Where(item => item > 0); + + var result = PaginationResult.Create( + items, + pageNumber: 1, + pageSize: 10, + totalCount: 2); + + result.Items.Should().Equal(1, 2); + result.Items.Should().BeAssignableTo>(); + result.HasPreviousPage.Should().BeFalse(); + result.HasNextPage.Should().BeFalse(); + } + + [Fact(DisplayName = "Empty returns an empty page result")] + public void Empty_WhenParametersAreValid_ReturnsEmptyResult() + { + var result = PaginationResult.Empty(pageNumber: 1, pageSize: 25); + + result.Items.Should().BeEmpty(); + result.PageNumber.Should().Be(1); + result.PageSize.Should().Be(25); + result.TotalCount.Should().Be(0); + result.TotalPages.Should().Be(0); + result.HasPreviousPage.Should().BeFalse(); + result.HasNextPage.Should().BeFalse(); + } + + [Fact(DisplayName = "Create throws when items are null")] + public void Create_WhenItemsAreNull_Throws() + { + Action act = () => PaginationResult.Create( + items: null!, + pageNumber: 1, + pageSize: 10, + totalCount: 0); + + act.Should() + .Throw() + .WithParameterName("items"); + } + + [Fact(DisplayName = "Create throws when page number is not positive")] + public void Create_WhenPageNumberIsNotPositive_Throws() + { + Action act = () => PaginationResult.Create( + items: [], + pageNumber: 0, + pageSize: 10, + totalCount: 0); + + act.Should() + .Throw() + .WithParameterName("pageNumber") + .WithMessage("*Номер страницы должен быть больше 0.*"); + } + + [Fact(DisplayName = "Create throws when page size is not positive")] + public void Create_WhenPageSizeIsNotPositive_Throws() + { + Action act = () => PaginationResult.Create( + items: [], + pageNumber: 1, + pageSize: 0, + totalCount: 0); + + act.Should() + .Throw() + .WithParameterName("pageSize") + .WithMessage("*Размер страницы должен быть больше 0.*"); + } + + [Fact(DisplayName = "Create throws when total count is negative")] + public void Create_WhenTotalCountIsNegative_Throws() + { + Action act = () => PaginationResult.Create( + items: [], + pageNumber: 1, + pageSize: 10, + totalCount: -1); + + act.Should() + .Throw() + .WithParameterName("totalCount") + .WithMessage("*Общее количество элементов не может быть отрицательным.*"); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Sorting/SortParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Sorting/SortParametersTests.cs new file mode 100644 index 0000000..debfb94 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Sorting/SortParametersTests.cs @@ -0,0 +1,55 @@ +using PANiXiDA.Core.Application.Querying.Sorting; +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace PANiXiDA.Core.Application.UnitTests.Querying.Sorting; + +public sealed class SortParametersTests +{ + [Fact(DisplayName = "Default returns ascending sorting without a field")] + public void Default_WhenCalled_ReturnsAscendingSortingWithoutField() + { + var parameters = SortParameters.Default(); + + parameters.Field.Should().BeNull(); + parameters.Order.Should().Be(SortOrder.Ascending); + } + + [Fact(DisplayName = "Constructor stores sorting values")] + public void Constructor_WhenValuesAreProvided_StoresValues() + { + var parameters = new SortParameters(Field: "name", Order: SortOrder.Descending); + + parameters.Field.Should().Be("name"); + parameters.Order.Should().Be(SortOrder.Descending); + } + + [Fact(DisplayName = "With expression copies and updates sorting parameters")] + public void WithExpression_WhenSortValuesAreChanged_CopiesAndUpdatesParameters() + { + var parameters = new SortParameters(Field: "name", Order: SortOrder.Ascending); + + var updated = parameters with + { + Field = "createdAt", + Order = SortOrder.Descending + }; + + updated.Field.Should().Be("createdAt"); + updated.Order.Should().Be(SortOrder.Descending); + } + + [Theory(DisplayName = "SortOrder has localized display names")] + [InlineData(SortOrder.Ascending, "По возрастанию")] + [InlineData(SortOrder.Descending, "По убыванию")] + public void DisplayName_WhenSortOrderIsProvided_ReturnsLocalizedName( + SortOrder sortOrder, + string expectedDisplayName) + { + var member = typeof(SortOrder).GetMember(sortOrder.ToString()).Single(); + var displayAttribute = member.GetCustomAttribute(); + + displayAttribute.Should().NotBeNull(); + displayAttribute!.Name.Should().Be(expectedDisplayName); + } +} diff --git a/version.json b/version.json index 9ed2b25..15b5840 100644 --- a/version.json +++ b/version.json @@ -11,7 +11,7 @@ } }, "pathFilters": [ - ":/src/PANiXiDA.Core.Template/", + ":/src/PANiXiDA.Core.Application/", ":/Directory.Build.props", ":/Directory.Build.targets", ":/Directory.Packages.props",