diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a0a6f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9e451e5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,141 @@ +[*] +indent_style = space +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = false:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:suggestion +csharp_space_around_binary_operators = before_and_after + +[*.{cs,vb}] +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = lf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:warning +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_readonly_field = true:suggestion +dotnet_diagnostic.IDE0005.severity = error +dotnet_diagnostic.CS1591.severity = none + +[src/**/*.cs] +dotnet_diagnostic.CS1591.severity = error diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..20a51ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,40 @@ +* text=auto + +# Source +*.cs text eol=lf +*.csx text eol=lf + +# Project files +*.csproj text eol=lf +*.props text eol=lf +*.targets text eol=lf +*.sln text eol=crlf +*.slnx text eol=lf + +# Config / docs +*.json text eol=lf +*.jsonc text eol=lf +*.xml text eol=lf +*.config text eol=lf +*.editorconfig text eol=lf +*.gitattributes text eol=lf +*.gitignore text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Scripts +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.sh text eol=lf + +# Binary assets +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.dll binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..620092a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + packages: write + +jobs: + format: + uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/dotnet-format.yml@main + secrets: + registry-user: ${{ secrets.REGISTRY_USER }} + registry-token: ${{ secrets.REGISTRY_TOKEN }} + + tests: + uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/dotnet-tests.yml@main + secrets: + registry-user: ${{ secrets.REGISTRY_USER }} + registry-token: ${{ secrets.REGISTRY_TOKEN }} + + publish: + needs: + - format + - tests + uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/dotnet-publish-global-nuget.yml@main + secrets: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + + notify: + if: ${{ always() && github.event_name != 'pull_request' }} + needs: [ publish ] + uses: PANiXiDA-Infrastructure/ci-cd/.github/workflows/notify-telegram.yml@main + with: + event-name: ${{ github.event_name }} + secrets: + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + github-token: ${{ secrets.REGISTRY_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fa8e50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.suo +*.user +[Bb]in/ +[Oo]bj/ +.vs/ +.idea +TestResults/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..722d98f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# AGENTS.md + +## Purpose + +This repository is a reference template for PANiXiDA .NET NuGet packages. +The goal is to keep package structure, CI, versioning, tests, documentation, and release flow consistent across repositories. + +## Repository layout + +- `src/` — package source code +- `tests/` — automated tests +- `README.md` — package overview and usage examples +- `Directory.Build.props` — shared build settings +- `Directory.Packages.props` — centralized package versions +- `version.json` — Nerdbank.GitVersioning configuration +- `ci.yml` — CI pipeline + +## Technology baseline + +- .NET 10 +- Nullable enabled +- Implicit usings enabled +- Central package management enabled +- Microsoft Testing Platform +- xUnit v3 +- FluentAssertions +- Nerdbank.GitVersioning + +## Coding rules + +- Preserve existing naming. +- Do not introduce expression-bodied method declarations. +- Prefer explicit, readable code over short clever code. +- Public APIs must include XML documentation written in English. +- Avoid breaking public API changes unless explicitly requested. +- Do not weaken nullability annotations. +- Do not silently change exception behavior. + +## Library design rules + +- Keep public API small and intentional. +- Favor immutability for public models. +- Validate all public method arguments. +- Prefer deterministic behavior and stable error messages. +- Do not add dependencies without a strong reason. + +## Tests + +- Add or update tests for every meaningful behavior change. +- Cover happy path, guard clauses, and failure scenarios. +- Verify public API behavior, not implementation details, unless required. +- When fixing a bug, add a regression test first. +- Do not add `using Xunit;` or `using FluentAssertions;` in test files, because they are provided as global usings in the test project. +- Write `DisplayName` values in English. +- Structure tests using the Arrange, Act, Assert pattern. + +## Documentation + +- Update `README.md` when public behavior, public API, package metadata, or development workflow changes. +- Keep the README structure aligned with the repository standard. +- Keep installation instructions, target framework, badges, and package information accurate. +- Keep all examples compilable and aligned with the current API. +- Ensure usage examples reflect real and recommended library usage. +- Remove outdated or duplicate documentation when updating existing sections. + +## Build and validation + +Before considering work complete, run: + +- `dotnet restore` +- `dotnet format` +- `dotnet build --configuration Release` +- `dotnet test --configuration Release` + +## Packaging and versioning + +- Package metadata must stay complete and consistent. +- README and icon must be included in the package. +- Changes affecting package contents must be reflected in versioning inputs. +- Do not change package id, repository url, or license without explicit request. + +## Definition of done + +A change is done only when: + +- code is formatted, +- code builds, +- tests pass, +- relevant tests were added or updated, +- README was updated if public behavior changed, +- no unnecessary files or dependencies were introduced. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..97d7c62 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + true + true + true + + true + true + latest + $(NoWarn);NU1507 + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..f6b4be9 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,29 @@ + + + PANiXiDA + PANiXiDA + git + + true + true + + true + snupkg + + README.md + Apache-2.0 + icon.png + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..1272c60 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,13 @@ + + + true + true + + + + + + + + + diff --git a/PANiXiDA.Core.ResultPattern.slnx b/PANiXiDA.Core.ResultPattern.slnx new file mode 100644 index 0000000..7934d64 --- /dev/null +++ b/PANiXiDA.Core.ResultPattern.slnx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index ce644d8..23a6920 100644 --- a/README.md +++ b/README.md @@ -1 +1,657 @@ -# panixida-core-result-pattern \ No newline at end of file +# PANiXiDA.Core.ResultPattern + +`PANiXiDA.Core.ResultPattern` is a small .NET library for explicit success and failure handling in business logic without using exceptions as the primary control-flow contract. + +It is designed for .NET developers who want predictable result-based workflows, typed errors, and composable synchronous and asynchronous operation pipelines. + +## Status + +[![CI](https://github.com/PANiXiDA-Dotnet-Core/result-pattern/actions/workflows/ci.yml/badge.svg)](https://github.com/PANiXiDA-Dotnet-Core/result-pattern/actions/workflows/ci.yml) +[![NuGet](https://img.shields.io/nuget/v/PANiXiDA.Core.ResultPattern.svg)](https://www.nuget.org/packages/PANiXiDA.Core.ResultPattern) +[![NuGet downloads](https://img.shields.io/nuget/dt/PANiXiDA.Core.ResultPattern.svg)](https://www.nuget.org/packages/PANiXiDA.Core.ResultPattern) +[![Target Framework](https://img.shields.io/badge/target-net10.0-512BD4)](https://dotnet.microsoft.com/) +[![License](https://img.shields.io/github/license/PANiXiDA-Dotnet-Core/result-pattern.svg)](LICENSE) + +## Overview + +When a method in business logic can end not only with success but also with an expected failure, exceptions often become an awkward contract: + +- the method signature does not show that the method can fail; +- business errors get mixed with technical exceptions; +- the code starts to grow with `try/catch` blocks; +- composing multiple steps becomes harder to read. + +`PANiXiDA.Core.ResultPattern` addresses this by making operation outcomes explicit: + +- `Result` represents success or failure without a value; +- `Result` represents success or failure with a value; +- `Error` provides a unified error model with type, message, and metadata; +- extension methods such as `Map`, `Bind`, `BindAsync`, `Ensure`, `Tap`, and `Match` help compose operation pipelines. + +This library is especially useful in: + +- application services; +- use cases; +- orchestrator layers; +- domain factories and validators; +- API boundaries. + +## Features + +- Explicit success/failure contract with `Result` and `Result` +- Typed error model through `Error` and `ErrorType` +- Support for both single and multiple errors +- Synchronous and asynchronous pipeline composition +- Validation-style workflow support with error aggregation +- Lightweight public API +- XML-documented public API surface +- Suitable for application, domain, and API boundary layers + +## Quick Start + +### Requirements + +- .NET 10 SDK + +### Installation + +The library targets `net10.0`. + +```xml + + + +``` + +### Minimal import + +```csharp +using PANiXiDA.Core.ResultPattern; +``` + +### First example + +```csharp +using PANiXiDA.Core.ResultPattern; + +Result GetUserName(bool exists) +{ + if (!exists) + { + return Result.Failure(Error.NotFound("User not found")); + } + + return Result.Success("John"); +} + +var result = GetUserName(exists: true); + +if (result.IsSuccess) +{ + Console.WriteLine(result.Value); +} +``` + +## Usage + +### Creating errors + +```csharp +using PANiXiDA.Core.ResultPattern; + +var validationError = Error.Validation("Email is required"); +var notFoundError = Error.NotFound("User not found"); +var conflictError = Error.Conflict("Email is already in use"); +var forbiddenError = Error.Forbidden("Insufficient permissions"); + +var fieldError = Error.Validation("Invalid email format") + .WithField("email") + .WithMetadata("attemptedValue", "not-an-email"); +``` + +### `Result` without a value + +```csharp +using PANiXiDA.Core.ResultPattern; + +Result DeleteUser(bool userExists) +{ + if (!userExists) + { + return Result.Failure(Error.NotFound("User not found")); + } + + return Result.Success(); +} +``` + +### `Result` with a value + +```csharp +using PANiXiDA.Core.ResultPattern; + +public sealed record UserDto(Guid Id, string Email); + +Result GetUser(Guid id, UserDto? user) +{ + if (user is null) + { + return Result.Failure(Error.NotFound("User not found")); + } + + return Result.Success(user); +} +``` + +### Checking `IsSuccess` / `IsFailure` and reading `FirstError` + +```csharp +var result = DeleteUser(userExists: false); + +if (result.IsFailure) +{ + Console.WriteLine(result.FirstError.Message); +} +``` + +### `Value`, `ValueOrDefault`, and `TryGetValue` + +```csharp +var userResult = GetUser(Guid.NewGuid(), new UserDto(Guid.NewGuid(), "user@example.com")); + +var value = userResult.Value; +var sameValue = userResult.ValueOrDefault; + +if (userResult.TryGetValue(out var user)) +{ + Console.WriteLine(user.Email); +} +``` + +```csharp +var failedResult = Result.Failure(Error.NotFound("User not found")); + +var defaultValue = failedResult.ValueOrDefault; +var hasValue = failedResult.TryGetValue(out var missingUser); + +Console.WriteLine(defaultValue is null); // True +Console.WriteLine(hasValue); // False +Console.WriteLine(missingUser is null); // True +``` + +### Returning multiple errors + +```csharp +Result ValidateRegistration(string email, string password) +{ + var errors = new List(); + + if (string.IsNullOrWhiteSpace(email)) + { + errors.Add(Error.Validation("Email is required").WithField("email")); + } + + if (string.IsNullOrWhiteSpace(password)) + { + errors.Add(Error.Validation("Password is required").WithField("password")); + } + + if (errors.Count > 0) + { + return Result.Failure(errors); + } + + return Result.Success(); +} +``` + +### `Combine` for joining multiple validations + +```csharp +var emailValidation = ValidateEmail(email); +var passwordValidation = ValidatePassword(password); +var agreementValidation = ValidateAgreement(agreementAccepted); + +var validationResult = Result.Combine( + emailValidation, + passwordValidation, + agreementValidation); + +if (validationResult.IsFailure) +{ + return validationResult; +} +``` + +### `Map` for transforming a result + +`Map` is useful when the source operation is already successful and you only need to transform the value. + +```csharp +Result validationResult = ValidateRegistration(email, password); +Result requestIdResult = validationResult.Map(() => Guid.NewGuid()); +``` + +```csharp +public sealed record User(Guid Id, string Email); +public sealed record UserResponse(Guid Id, string Email); + +Result userResult = Result.Success(new User(Guid.NewGuid(), "user@example.com")); + +Result responseResult = userResult.Map(user => +{ + return new UserResponse(user.Id, user.Email); +}); +``` + +### `Bind` for composing steps that already return `Result` + +`Bind` is useful when the next step can also fail. + +```csharp +Result validationResult = ValidateRegistration(email, password); +Result createUserResult = validationResult.Bind(() => +{ + return CreateUser(email, password); +}); +``` + +```csharp +Result userResult = GetUserById(userId); +Result activationResult = userResult.Bind(ActivateUser); +``` + +```csharp +Result userResult = GetUserById(userId); + +Result responseResult = userResult.Bind(user => +{ + return LoadProfile(user.Id).Map(profile => + { + return new UserResponse(user.Id, user.Email); + }); +}); +``` + +### `BindAsync` for asynchronous composition + +```csharp +Result validationResult = ValidateRegistration(email, password); +Result createUserResult = await validationResult.BindAsync(() => +{ + return CreateUserAsync(email, password); +}); +``` + +```csharp +Result userResult = await GetUserByIdAsync(userId); + +Result responseResult = await userResult.BindAsync(async user => +{ + var profileResult = await LoadProfileAsync(user.Id); + + return profileResult.Map(profile => + { + return new UserResponse(user.Id, user.Email); + }); +}); +``` + +### `Ensure` for additional checks after success + +```csharp +Result userResult = GetUserById(userId); + +Result activeUserResult = userResult + .Ensure( + user => user.IsActive, + Error.Forbidden("User is blocked")) + .Ensure( + user => user.EmailConfirmed, + Error.Validation("Email is not confirmed").WithField("email")); +``` + +### `Tap` for side effects + +`Tap` does not change the result and is useful for logging, auditing, metrics, and other side effects. + +```csharp +Result createResult = CreateUser(email, password); + +Result sameResult = createResult.Tap(user => +{ + Console.WriteLine($"User created: {user.Id}"); +}); +``` + +### `Match` for finishing the pipeline + +`Match` is convenient at the application boundary, when you need to choose the final behavior for success and failure. + +```csharp +Result result = GetUserById(userId) + .Map(user => + { + return new UserResponse(user.Id, user.Email); + }); + +var response = result.Match( + onSuccess: user => + { + return $"200 OK: {user.Email}"; + }, + onFailure: errors => + { + return $"400/404: {string.Join("; ", errors.Select(error => error.Message))}"; + }); +``` + +```csharp +Result deleteResult = DeleteUser(userExists: false); + +var message = deleteResult.Match( + onSuccess: () => + { + return "User deleted"; + }, + onFailure: errors => + { + return $"Deletion failed: {errors[0].Message}"; + }); +``` + +### Full pipeline example + +```csharp +using PANiXiDA.Core.ResultPattern; + +public sealed record RegisterUserCommand(string Email, string Password); +public sealed record User(Guid Id, string Email, bool IsActive, bool EmailConfirmed); +public sealed record UserResponse(Guid Id, string Email); + +public async Task> RegisterAsync(RegisterUserCommand command) +{ + var validationResult = ValidateRegistration(command.Email, command.Password); + var uniqueEmailResult = validationResult.Bind(() => + { + return EnsureEmailIsUnique(command.Email); + }); + + if (uniqueEmailResult.IsFailure) + { + return Result.Failure(uniqueEmailResult.Errors); + } + + var createResult = await uniqueEmailResult.BindAsync(() => + { + return CreateUserAsync(command); + }); + + var guardedResult = createResult + .Ensure(user => user.IsActive, Error.Failure("User was created in an inconsistent state")) + .Ensure(user => user.EmailConfirmed, Error.Validation("Email is not confirmed").WithField("email")) + .Tap(user => + { + Console.WriteLine($"Created user {user.Id}"); + }); + + return guardedResult.Map(user => + { + return new UserResponse(user.Id, user.Email); + }); +} +``` + +### API boundary example + +```csharp +public async Task Register(RegisterUserCommand command) +{ + var result = await RegisterAsync(command); + + return result.Match( + onSuccess: user => + { + return Results.Ok(user); + }, + onFailure: errors => + { + var firstError = errors[0]; + + return firstError.Type switch + { + ErrorType.Validation => Results.BadRequest(errors), + ErrorType.NotFound => Results.NotFound(errors), + ErrorType.Conflict => Results.Conflict(errors), + ErrorType.Unauthorized => Results.Unauthorized(), + ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden), + _ => Results.StatusCode(StatusCodes.Status500InternalServerError) + }; + }); +} +``` + +## Configuration + +This library does not require runtime configuration. + +There are no required: + +* environment variables; +* `appsettings.json` entries; +* secrets; +* ports; +* external services. + +The only consumer-side requirement is referencing the package from a compatible .NET project. + +## Project Structure + +```text +. +├── src/ +│ └── PANiXiDA.Core.ResultPattern/ +│ └── PANiXiDA.Core.ResultPattern.csproj +├── tests/ +│ └── PANiXiDA.Core.ResultPattern.UnitTests/ +│ └── PANiXiDA.Core.ResultPattern.UnitTests.csproj +├── .editorconfig +├── .gitattributes +├── .gitignore +├── Directory.Build.props +├── Directory.Build.targets +├── Directory.Packages.props +├── global.json +├── version.json +├── LICENSE +└── README.md +``` + +### Main repository files + +* `src/` — library source code +* `tests/` — automated tests +* `Directory.Build.props` — shared MSBuild settings +* `Directory.Build.targets` — shared package metadata and package content settings +* `Directory.Packages.props` — centralized package versions +* `global.json` — SDK and test runner configuration +* `version.json` — Nerdbank.GitVersioning configuration +* `.editorconfig` — code style rules +* `README.md` — package overview and usage documentation + +## Development + +### Build + +```bash +dotnet restore +dotnet build --configuration Release +``` + +### Format + +```bash +dotnet format +``` + +### Test + +```bash +dotnet test --configuration Release +``` + +### Full local validation + +```bash +dotnet restore +dotnet format +dotnet build --configuration Release +dotnet test --configuration Release +``` + +### Tooling and conventions + +This repository uses: + +* .NET 10 +* Nullable enabled +* Implicit usings enabled +* Central package management +* Microsoft Testing Platform +* xUnit v3 +* FluentAssertions +* Nerdbank.GitVersioning + +## API / Contracts / Examples + +### Core types + +* `Error` — immutable error model with `Message`, `Type`, and `Metadata` +* `ErrorType` — supported error categories: + + * `Validation` + * `NotFound` + * `Conflict` + * `Unauthorized` + * `Forbidden` + * `Failure` + * `Unexpected` +* `Result` — success or failure without a value +* `Result` — success or failure with a value + +### Core operations + +* `Result.Success()` +* `Result.Success(value)` +* `Result.Failure(...)` +* `Result.Combine(...)` +* `Map(...)` +* `Bind(...)` +* `BindAsync(...)` +* `Ensure(...)` +* `Tap(...)` +* `Match(...)` + +### Working with errors + +Factory methods: + +* `Error.Validation(message)` +* `Error.NotFound(message)` +* `Error.Conflict(message)` +* `Error.Unauthorized(message)` +* `Error.Forbidden(message)` +* `Error.Failure(message)` +* `Error.Unexpected(message)` + +Additional helpers: + +* `WithMetadata(key, value)` +* `WithField(field)` + +### Working with values in `Result` + +* `Value` — returns the value on success, otherwise throws `InvalidOperationException` +* `ValueOrDefault` — returns the value on success, or `default` on failure +* `TryGetValue(out value)` — safely attempts to get the value + +### Working with errors in `Result` + +* `Errors` — returns the list of errors +* `FirstError` — returns the first error, otherwise throws `InvalidOperationException` +* `IsSuccess` / `IsFailure` — explicit result state checks + +### Behavioral notes + +* `Value` throws `InvalidOperationException` when the result is a failure. +* `FirstError` throws `InvalidOperationException` when the result is successful. +* `Combine` aggregates errors from all failed results. +* `Match` is intended for finishing a result pipeline at the application boundary. + +## Roadmap / TODO + +Potential future improvements: + +* add more advanced composition helpers if a clear use case appears; +* extend documentation with more domain-oriented examples; +* add dedicated examples for ASP.NET Core minimal APIs; +* keep the package as a reusable standard for future PANiXiDA NuGet libraries. + +## Contributing + +Contributions are welcome if they keep the package focused and predictable. + +### General rules + +* keep the public API small and intentional; +* avoid unnecessary dependencies; +* preserve existing naming; +* do not introduce breaking API changes without a strong reason; +* public APIs must have XML documentation in English. + +### Code style + +* follow the repository `.editorconfig`; +* do not introduce expression-bodied method declarations; +* prefer explicit and readable code over overly compact code. + +### Tests + +* add or update tests for every meaningful behavior change; +* cover happy path, guard clauses, and failure scenarios; +* verify public API behavior, not implementation details, unless required; +* add a regression test first when fixing a bug; +* do not add `using Xunit;` or `using FluentAssertions;` in test files, because they are provided as global usings in the test project; +* write `DisplayName` values in English; +* structure tests using the Arrange, Act, Assert pattern. + +### Validation before completion + +Before considering work complete, run: + +```bash +dotnet restore +dotnet format +dotnet build --configuration Release +dotnet test --configuration Release +``` + +## License + +This project is licensed under the Apache License, Version 2.0. + +See the [LICENSE](LICENSE) file for details. + +## Maintainers / Contacts + +Maintained by the PANiXiDA. + +Repository: + +* `PANiXiDA-Dotnet-Core/result-pattern` + +For questions or improvements, use: + +* GitHub Issues +* Pull Requests +* repository discussions, if enabled diff --git a/global.json b/global.json new file mode 100644 index 0000000..1d364c6 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..0e15a5e Binary files /dev/null and b/icon.png differ diff --git a/src/PANiXiDA.Core.ResultPattern/Error.cs b/src/PANiXiDA.Core.ResultPattern/Error.cs new file mode 100644 index 0000000..275c55d --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/Error.cs @@ -0,0 +1,154 @@ +using System.Collections.ObjectModel; + +namespace PANiXiDA.Core.ResultPattern; + +/// +/// Represents an immutable error returned by an operation result. +/// +public sealed record Error +{ + /// + /// The metadata key used to store the field name for validation errors. + /// + public const string FieldMetadataKey = "field"; + + /// + /// Initializes a new instance of . + /// + /// A human-readable error message. + /// The error category. + /// Additional machine-readable error data. + public Error( + string message, + ErrorType type, + IReadOnlyDictionary? metadata = null) + { + if (string.IsNullOrWhiteSpace(message)) + { + throw new ArgumentException("Error message cannot be empty.", nameof(message)); + } + + Message = message; + Type = type; + Metadata = new ReadOnlyDictionary( + metadata is null + ? [] + : new Dictionary(metadata)); + } + + /// + /// Gets the error message. + /// + public string Message { get; } + + /// + /// Gets the error category. + /// + public ErrorType Type { get; } + + /// + /// Gets additional machine-readable error details. + /// + public IReadOnlyDictionary Metadata { get; } + + /// + /// Creates a validation error. + /// + /// The validation error message. + /// A new instance of with type . + public static Error Validation(string message) + { + return new Error(message, ErrorType.Validation); + } + + /// + /// Creates an error for a missing entity or resource. + /// + /// The error message. + /// A new instance of with type . + public static Error NotFound(string message) + { + return new Error(message, ErrorType.NotFound); + } + + /// + /// Creates a state conflict error. + /// + /// The conflict error message. + /// A new instance of with type . + public static Error Conflict(string message) + { + return new Error(message, ErrorType.Conflict); + } + + /// + /// Creates an authorization error. + /// + /// The authorization error message. + /// A new instance of with type . + public static Error Unauthorized(string message) + { + return new Error(message, ErrorType.Unauthorized); + } + + /// + /// Creates an insufficient permissions error. + /// + /// The access error message. + /// A new instance of with type . + public static Error Forbidden(string message) + { + return new Error(message, ErrorType.Forbidden); + } + + /// + /// Creates a business or application failure error. + /// + /// The error message. + /// A new instance of with type . + public static Error Failure(string message) + { + return new Error(message, ErrorType.Failure); + } + + /// + /// Creates an unexpected error. + /// + /// The error message. + /// A new instance of with type . + public static Error Unexpected(string message) + { + return new Error(message, ErrorType.Unexpected); + } + + /// + /// Returns a copy of the current error with an added or replaced metadata entry. + /// + /// The metadata key to add or replace. + /// The metadata value associated with the key. + /// A new instance of with updated metadata. + public Error WithMetadata(string key, object? value) + { + var metadata = new Dictionary(Metadata) + { + [key] = value + }; + + return new Error(Message, Type, metadata); + } + + /// + /// Returns a copy of the current error with the field name stored in metadata. + /// + /// The field name associated with the error. + /// A new instance of with the field metadata populated. + public Error WithField(string field) + { + if (string.IsNullOrWhiteSpace(field)) + { + throw new ArgumentException("Field cannot be empty.", nameof(field)); + } + + return WithMetadata(FieldMetadataKey, field); + } +} diff --git a/src/PANiXiDA.Core.ResultPattern/ErrorType.cs b/src/PANiXiDA.Core.ResultPattern/ErrorType.cs new file mode 100644 index 0000000..7d946a1 --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/ErrorType.cs @@ -0,0 +1,42 @@ +namespace PANiXiDA.Core.ResultPattern; + +/// +/// Defines the error categories supported by the library. +/// +public enum ErrorType +{ + /// + /// An input validation error. + /// + Validation = 1, + + /// + /// An error indicating that an entity or resource was not found. + /// + NotFound = 2, + + /// + /// An error indicating a conflict with the current state. + /// + Conflict = 3, + + /// + /// An authorization error. + /// + Unauthorized = 4, + + /// + /// An insufficient permissions error. + /// + Forbidden = 5, + + /// + /// An expected business or application failure. + /// + Failure = 6, + + /// + /// An unexpected error that does not belong to a narrower category. + /// + Unexpected = 7 +} diff --git a/src/PANiXiDA.Core.ResultPattern/GenericResult.cs b/src/PANiXiDA.Core.ResultPattern/GenericResult.cs new file mode 100644 index 0000000..527c891 --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/GenericResult.cs @@ -0,0 +1,77 @@ +namespace PANiXiDA.Core.ResultPattern; + +/// +/// Represents the result of an operation that can complete successfully with a value or fail with an error. +/// +/// The type of the successful value. +public sealed class Result : Result +{ + private readonly TValue? value; + + /// + /// Initializes a new successful instance of . + /// + /// The successful value. + internal Result(TValue value) + : base(true, EmptyErrors) + { + this.value = value; + } + + /// + /// Initializes a new failed instance of . + /// + /// Errors associated with the failed result. + internal Result(Error[] errors) + : base(false, errors) + { + value = default; + } + + /// + /// Gets the successful result value. + /// + /// Thrown when the result is a failure. + public TValue Value + { + get + { + if (IsFailure) + { + throw new InvalidOperationException("Cannot access value of failed result."); + } + + return value!; + } + } + + /// + /// Gets the successful value or if the result is a failure. + /// + public TValue? ValueOrDefault + { + get + { + return IsSuccess ? value : default; + } + } + + /// + /// Attempts to get the successful value without throwing an exception. + /// + /// + /// When this method returns, contains the successful value or if the result is a failure. + /// + /// if the result is successful; otherwise, . + public bool TryGetValue(out TValue? resultValue) + { + if (IsSuccess) + { + resultValue = value; + return true; + } + + resultValue = default; + return false; + } +} diff --git a/src/PANiXiDA.Core.ResultPattern/PANiXiDA.Core.ResultPattern.csproj b/src/PANiXiDA.Core.ResultPattern/PANiXiDA.Core.ResultPattern.csproj new file mode 100644 index 0000000..447089f --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/PANiXiDA.Core.ResultPattern.csproj @@ -0,0 +1,20 @@ + + + true + + PANiXiDA.Core.ResultPattern + + PANiXiDA.Core.ResultPattern + A library for implementing the Result pattern in .NET applications with typed errors, result composition support, and convenient error handling without exceptions in business logic. + Result;ResultPattern;Errors;Functional;DotNet + + https://github.com/PANiXiDA-Dotnet-Core/result-pattern + https://github.com/PANiXiDA-Dotnet-Core/result-pattern + + + + + all + + + diff --git a/src/PANiXiDA.Core.ResultPattern/Result.cs b/src/PANiXiDA.Core.ResultPattern/Result.cs new file mode 100644 index 0000000..202bee7 --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/Result.cs @@ -0,0 +1,186 @@ +using System.Collections.ObjectModel; + +namespace PANiXiDA.Core.ResultPattern; + +/// +/// Represents the result of an operation that can complete successfully or fail with an error. +/// +public class Result +{ + /// + /// An empty error collection used for successful results. + /// + protected static readonly Error[] EmptyErrors = []; + + /// + /// Initializes a new instance of . + /// + /// Indicates whether the result is successful. + /// Errors associated with the result. + protected Result(bool isSuccess, Error[] errors) + { + ArgumentNullException.ThrowIfNull(errors); + + if (isSuccess && errors.Length > 0) + { + throw new ArgumentException("Successful result cannot contain errors.", nameof(errors)); + } + + if (!isSuccess && errors.Length == 0) + { + throw new ArgumentException("Failed result must contain at least one error.", nameof(errors)); + } + + IsSuccess = isSuccess; + Errors = new ReadOnlyCollection(errors.ToArray()); + } + + /// + /// Gets a value indicating whether the result is successful. + /// + public bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the result completed with an error. + /// + public bool IsFailure + { + get + { + return !IsSuccess; + } + } + + /// + /// Gets the collection of errors associated with the result. + /// + public IReadOnlyList Errors { get; } + + /// + /// Gets the first error of the result. + /// + /// Thrown when the result is successful. + public Error FirstError + { + get + { + if (IsSuccess) + { + throw new InvalidOperationException("Successful result does not contain errors."); + } + + return Errors[0]; + } + } + + /// + /// Creates a successful result without a value. + /// + /// A successful instance of . + public static Result Success() + { + return new Result(true, EmptyErrors); + } + + /// + /// Creates a successful result with a value. + /// + /// The type of the successful value. + /// The value to wrap in the result. + /// A successful instance of . + public static Result Success(TValue value) + { + return new Result(value); + } + + /// + /// Creates a failed result with a single error. + /// + /// The error associated with the failed result. + /// A failed instance of . + public static Result Failure(Error error) + { + ArgumentNullException.ThrowIfNull(error); + return new Result(false, [error]); + } + + /// + /// Creates a failed result with multiple errors. + /// + /// Errors associated with the failed result. + /// A failed instance of . + public static Result Failure(IEnumerable errors) + { + ArgumentNullException.ThrowIfNull(errors); + + var materializedErrors = errors.Where(item => item is not null).ToArray(); + + if (materializedErrors.Length == 0) + { + throw new ArgumentException("Failed result must contain at least one error.", nameof(errors)); + } + + return new Result(false, materializedErrors); + } + + /// + /// Creates a failed generic result with a single error. + /// + /// The type of the value that would be returned on success. + /// The error associated with the failed result. + /// A failed instance of . + public static Result Failure(Error error) + { + ArgumentNullException.ThrowIfNull(error); + return new Result([error]); + } + + /// + /// Creates a failed generic result with multiple errors. + /// + /// The type of the value that would be returned on success. + /// Errors associated with the failed result. + /// A failed instance of . + public static Result Failure(IEnumerable errors) + { + ArgumentNullException.ThrowIfNull(errors); + + var materializedErrors = errors.Where(static item => item is not null).ToArray(); + + if (materializedErrors.Length == 0) + { + throw new ArgumentException("Failed result must contain at least one error.", nameof(errors)); + } + + return new Result(materializedErrors); + } + + /// + /// Combines multiple results into a single result. + /// + /// The results to combine. + /// + /// A successful result if all provided results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine(params Result[] results) + { + ArgumentNullException.ThrowIfNull(results); + + if (results.Any(static item => item is null)) + { + throw new ArgumentException("Results collection cannot contain null values.", nameof(results)); + } + + var errors = results + .Where(item => item.IsFailure) + .SelectMany(item => item.Errors) + .ToArray(); + + if (errors.Length == 0) + { + return Success(); + } + + return Failure(errors); + } +} diff --git a/src/PANiXiDA.Core.ResultPattern/ResultCombiner.cs b/src/PANiXiDA.Core.ResultPattern/ResultCombiner.cs new file mode 100644 index 0000000..28a0067 --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/ResultCombiner.cs @@ -0,0 +1,393 @@ +namespace PANiXiDA.Core.ResultPattern; + +/// +/// Provides helper methods for combining multiple generic results. +/// +public static class ResultCombiner +{ + /// + /// Combines two generic results into a single tuple result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The first result. + /// The second result. + /// + /// A successful result containing a tuple of values if both results are successful; otherwise, a failed result with all collected errors. + /// + public static Result<(T1, T2)> Combine( + Result first, + Result second) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + + return CombineCore( + () => Result.Combine(first, second), + () => Result.Success((first.Value, second.Value))); + } + + /// + /// Combines two generic results and continues the successful result with a function that returns another generic result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the continuation result value. + /// The first result. + /// The second result. + /// The continuation function to execute when all results are successful. + /// + /// The result returned by if both results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine( + Result first, + Result second, + Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return Combine(first, second) + .Bind(values => bind(values.Item1, values.Item2)); + } + + /// + /// Combines three generic results into a single tuple result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The first result. + /// The second result. + /// The third result. + /// + /// A successful result containing a tuple of values if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result<(T1, T2, T3)> Combine( + Result first, + Result second, + Result third) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + + return CombineCore( + () => Result.Combine(first, second, third), + () => Result.Success((first.Value, second.Value, third.Value))); + } + + /// + /// Combines three generic results and continues the successful result with a function that returns another generic result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the continuation result value. + /// The first result. + /// The second result. + /// The third result. + /// The continuation function to execute when all results are successful. + /// + /// The result returned by if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine( + Result first, + Result second, + Result third, + Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return Combine(first, second, third) + .Bind(values => bind(values.Item1, values.Item2, values.Item3)); + } + + /// + /// Combines four generic results into a single tuple result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// + /// A successful result containing a tuple of values if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result<(T1, T2, T3, T4)> Combine( + Result first, + Result second, + Result third, + Result fourth) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + + return CombineCore( + () => Result.Combine(first, second, third, fourth), + () => Result.Success((first.Value, second.Value, third.Value, fourth.Value))); + } + + /// + /// Combines four generic results and continues the successful result with a function that returns another generic result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the continuation result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The continuation function to execute when all results are successful. + /// + /// The result returned by if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine( + Result first, + Result second, + Result third, + Result fourth, + Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return Combine(first, second, third, fourth) + .Bind(values => bind(values.Item1, values.Item2, values.Item3, values.Item4)); + } + + /// + /// Combines five generic results into a single tuple result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the fifth result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The fifth result. + /// + /// A successful result containing a tuple of values if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result<(T1, T2, T3, T4, T5)> Combine( + Result first, + Result second, + Result third, + Result fourth, + Result fifth) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + + return CombineCore( + () => Result.Combine(first, second, third, fourth, fifth), + () => Result.Success((first.Value, second.Value, third.Value, fourth.Value, fifth.Value))); + } + + /// + /// Combines five generic results and continues the successful result with a function that returns another generic result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the fifth result value. + /// The type of the continuation result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The fifth result. + /// The continuation function to execute when all results are successful. + /// + /// The result returned by if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine( + Result first, + Result second, + Result third, + Result fourth, + Result fifth, + Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return Combine(first, second, third, fourth, fifth) + .Bind(values => bind(values.Item1, values.Item2, values.Item3, values.Item4, values.Item5)); + } + + /// + /// Combines six generic results into a single tuple result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the fifth result value. + /// The type of the sixth result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The fifth result. + /// The sixth result. + /// + /// A successful result containing a tuple of values if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result<(T1, T2, T3, T4, T5, T6)> Combine( + Result first, + Result second, + Result third, + Result fourth, + Result fifth, + Result sixth) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + ArgumentNullException.ThrowIfNull(sixth); + + return CombineCore( + () => Result.Combine(first, second, third, fourth, fifth, sixth), + () => Result.Success((first.Value, second.Value, third.Value, fourth.Value, fifth.Value, sixth.Value))); + } + + /// + /// Combines six generic results and continues the successful result with a function that returns another generic result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the fifth result value. + /// The type of the sixth result value. + /// The type of the continuation result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The fifth result. + /// The sixth result. + /// The continuation function to execute when all results are successful. + /// + /// The result returned by if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine( + Result first, + Result second, + Result third, + Result fourth, + Result fifth, + Result sixth, + Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return Combine(first, second, third, fourth, fifth, sixth) + .Bind(values => bind(values.Item1, values.Item2, values.Item3, values.Item4, values.Item5, values.Item6)); + } + + /// + /// Combines seven generic results into a single tuple result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the fifth result value. + /// The type of the sixth result value. + /// The type of the seventh result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The fifth result. + /// The sixth result. + /// The seventh result. + /// + /// A successful result containing a tuple of values if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result<(T1, T2, T3, T4, T5, T6, T7)> Combine( + Result first, + Result second, + Result third, + Result fourth, + Result fifth, + Result sixth, + Result seventh) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(second); + ArgumentNullException.ThrowIfNull(third); + ArgumentNullException.ThrowIfNull(fourth); + ArgumentNullException.ThrowIfNull(fifth); + ArgumentNullException.ThrowIfNull(sixth); + ArgumentNullException.ThrowIfNull(seventh); + + return CombineCore( + () => Result.Combine(first, second, third, fourth, fifth, sixth, seventh), + () => Result.Success((first.Value, second.Value, third.Value, fourth.Value, fifth.Value, sixth.Value, seventh.Value))); + } + + /// + /// Combines seven generic results and continues the successful result with a function that returns another generic result. + /// + /// The type of the first result value. + /// The type of the second result value. + /// The type of the third result value. + /// The type of the fourth result value. + /// The type of the fifth result value. + /// The type of the sixth result value. + /// The type of the seventh result value. + /// The type of the continuation result value. + /// The first result. + /// The second result. + /// The third result. + /// The fourth result. + /// The fifth result. + /// The sixth result. + /// The seventh result. + /// The continuation function to execute when all results are successful. + /// + /// The result returned by if all results are successful; otherwise, a failed result with all collected errors. + /// + public static Result Combine( + Result first, + Result second, + Result third, + Result fourth, + Result fifth, + Result sixth, + Result seventh, + Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return Combine(first, second, third, fourth, fifth, sixth, seventh) + .Bind(values => bind(values.Item1, values.Item2, values.Item3, values.Item4, values.Item5, values.Item6, values.Item7)); + } + + private static Result CombineCore(Func combine, Func> bind) + { + var combinedResult = combine(); + if (combinedResult.IsFailure) + { + return Result.Failure(combinedResult.Errors); + } + + return bind(); + } +} diff --git a/src/PANiXiDA.Core.ResultPattern/ResultExtensions.cs b/src/PANiXiDA.Core.ResultPattern/ResultExtensions.cs new file mode 100644 index 0000000..ad7986e --- /dev/null +++ b/src/PANiXiDA.Core.ResultPattern/ResultExtensions.cs @@ -0,0 +1,250 @@ +namespace PANiXiDA.Core.ResultPattern; + +/// +/// Provides helper methods for composing and transforming and . +/// +public static class ResultExtensions +{ + /// + /// Transforms a successful non-generic result into a successful generic result. + /// + /// The type of the resulting value. + /// The source result. + /// The mapping function to execute on success. + /// + /// A successful with the transformed value, or a failed result with the source result errors preserved. + /// + public static Result Map(this Result result, Func map) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(map); + + if (result.IsFailure) + { + return Result.Failure(result.Errors); + } + + return Result.Success(map()); + } + + /// + /// Transforms a successful generic result into another successful generic result. + /// + /// The type of the source value. + /// The type of the resulting value. + /// The source result. + /// The mapping function to execute on success. + /// + /// A successful with the transformed value, or a failed result with the source result errors preserved. + /// + public static Result Map(this Result result, Func map) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(map); + + if (result.IsFailure) + { + return Result.Failure(result.Errors); + } + + return Result.Success(map(result.Value)); + } + + /// + /// Continues a successful generic result with a function that returns a non-generic result. + /// + /// The type of the source value. + /// The source result. + /// The continuation function to execute on success. + /// + /// The result returned by if the source result is successful; otherwise, a failed result with the source result errors preserved. + /// + public static Result Bind(this Result result, Func bind) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(bind); + + if (result.IsFailure) + { + return Result.Failure(result.Errors); + } + + return bind(result.Value); + } + + /// + /// Continues a successful generic result with a function that returns another generic result. + /// + /// The type of the source value. + /// The type of the continuation result value. + /// The source result. + /// The continuation function to execute on success. + /// + /// The result returned by if the source result is successful; otherwise, a failed result with the source result errors preserved. + /// + public static Result Bind(this Result result, Func> bind) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(bind); + + if (result.IsFailure) + { + return Result.Failure(result.Errors); + } + + return bind(result.Value); + } + + /// + /// Continues a successful non-generic result with a function that returns a generic result. + /// + /// The type of the continuation result value. + /// The source result. + /// The continuation function to execute on success. + /// + /// The result returned by if the source result is successful; otherwise, a failed result with the source result errors preserved. + /// + public static Result Bind(this Result result, Func> bind) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(bind); + + if (result.IsFailure) + { + return Result.Failure(result.Errors); + } + + return bind(); + } + + /// + /// Asynchronously continues a successful generic result with a function that returns a generic result. + /// + /// The type of the source value. + /// The type of the continuation result value. + /// The source result. + /// The asynchronous continuation function to execute on success. + /// + /// A task returning the result of if the source result is successful; otherwise, a failed result with the source result errors preserved. + /// + public static Task> BindAsync(this Result result, Func>> bind) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(bind); + + if (result.IsFailure) + { + return Task.FromResult(Result.Failure(result.Errors)); + } + + return bind(result.Value); + } + + /// + /// Asynchronously continues a successful non-generic result with a function that returns a generic result. + /// + /// The type of the continuation result value. + /// The source result. + /// The asynchronous continuation function to execute on success. + /// + /// A task returning the result of if the source result is successful; otherwise, a failed result with the source result errors preserved. + /// + public static Task> BindAsync(this Result result, Func>> bind) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(bind); + + if (result.IsFailure) + { + return Task.FromResult(Result.Failure(result.Errors)); + } + + return bind(); + } + + /// + /// Validates a successful generic result with an additional predicate. + /// + /// The type of the source value. + /// The source result. + /// The predicate that must return for the result to remain successful. + /// The error returned when the predicate fails. + /// + /// The source result if it is already failed or if the predicate succeeds; otherwise, a failed result with the error. + /// + public static Result Ensure(this Result result, Func predicate, Error error) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(predicate); + ArgumentNullException.ThrowIfNull(error); + + if (result.IsFailure) + { + return result; + } + + return predicate(result.Value) + ? result + : Result.Failure(error); + } + + /// + /// Executes a side effect for a successful generic result and returns the original result. + /// + /// The type of the source value. + /// The source result. + /// The side effect to execute on success. + /// The source result. + public static Result Tap(this Result result, Action action) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(action); + + if (result.IsFailure) + { + return result; + } + + action(result.Value); + return result; + } + + /// + /// Converts a non-generic result into a final value by handling both success and failure. + /// + /// The type of the final value. + /// The source result. + /// The function to execute on success. + /// The function to execute on failure. + /// The value returned by the matching branch. + public static TOut Match(this Result result, Func onSuccess, Func, TOut> onFailure) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(onSuccess); + ArgumentNullException.ThrowIfNull(onFailure); + + return result.IsSuccess + ? onSuccess() + : onFailure(result.Errors); + } + + /// + /// Converts a generic result into a final value by handling both success and failure. + /// + /// The type of the source value. + /// The type of the final value. + /// The source result. + /// The function to execute on success. + /// The function to execute on failure. + /// The value returned by the matching branch. + public static TOut Match(this Result result, Func onSuccess, Func, TOut> onFailure) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(onSuccess); + ArgumentNullException.ThrowIfNull(onFailure); + + return result.IsSuccess + ? onSuccess(result.Value) + : onFailure(result.Errors); + } +} diff --git a/tests/PANiXiDA.Core.ResultPattern.UnitTests/ErrorTests.cs b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ErrorTests.cs new file mode 100644 index 0000000..40c7ffe --- /dev/null +++ b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ErrorTests.cs @@ -0,0 +1,290 @@ +namespace PANiXiDA.Core.ResultPattern.UnitTests; + +public sealed class ErrorTests +{ + [Fact(DisplayName = "Error ctor → sets properties when valid message, type, and metadata are provided")] + public void Constructor_Should_SetProperties_When_MessageTypeAndMetadataAreValid() + { + const string message = "Validation failed"; + const string metadataKey = "code"; + const int metadataValue = 42; + var metadata = new Dictionary + { + [metadataKey] = metadataValue + }; + + var error = new Error(message, ErrorType.Validation, metadata); + + error.Message.Should().Be(message); + error.Type.Should().Be(ErrorType.Validation); + error.Metadata.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair(metadataKey, metadataValue)); + error.Metadata.Should().NotBeSameAs(metadata); + } + + [Fact(DisplayName = "Error ctor → creates a defensive metadata copy when metadata is provided")] + public void Constructor_Should_CreateDefensiveMetadataCopy_When_MetadataIsProvided() + { + const string metadataKey = "code"; + const int originalMetadataValue = 42; + const int changedMetadataValue = 100; + var metadata = new Dictionary + { + [metadataKey] = originalMetadataValue + }; + + var error = new Error("Validation failed", ErrorType.Validation, metadata); + + metadata[metadataKey] = changedMetadataValue; + metadata["extra"] = true; + + error.Metadata.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair(metadataKey, originalMetadataValue)); + } + + [Fact(DisplayName = "Error metadata → throws NotSupportedException when metadata is cast to a mutable dictionary and modified")] + public void Metadata_Should_ThrowNotSupportedException_When_CastToMutableDictionaryAndModified() + { + var error = Error.Validation("Validation failed") + .WithMetadata("code", 42); + var metadata = error.Metadata.Should() + .BeAssignableTo>() + .Subject; + + Action act = () => metadata["code"] = 100; + + act.Should().Throw(); + } + + [Fact(DisplayName = "Error ctor → creates empty metadata when metadata is not provided")] + public void Constructor_Should_InitializeEmptyMetadata_When_MetadataIsNull() + { + const string message = "Validation failed"; + + var error = new Error(message, ErrorType.Validation); + + error.Metadata.Should().BeEmpty(); + } + + [Fact(DisplayName = "Error copy → returns an equivalent new error when copied with record syntax")] + public void Copy_Should_ReturnEquivalentNewError_When_RecordWithExpressionIsUsed() + { + var error = Error.Validation("Validation failed") + .WithField("email"); + + var copiedError = error with { }; + + copiedError.Should().NotBeSameAs(error); + copiedError.Should().Be(error); + } + + [Fact(DisplayName = "Error ctor → throws ArgumentException when message is null")] + public void Constructor_Should_ThrowArgumentException_When_MessageIsNull() + { + Action act = static () => _ = new Error(null!, ErrorType.Validation); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("message"); + } + + [Fact(DisplayName = "Error ctor → throws ArgumentException when message is empty")] + public void Constructor_Should_ThrowArgumentException_When_MessageIsEmpty() + { + Action act = () => _ = new Error(string.Empty, ErrorType.Validation); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("message"); + } + + [Fact(DisplayName = "Error ctor → throws ArgumentException when message contains only whitespace")] + public void Constructor_Should_ThrowArgumentException_When_MessageIsWhitespace() + { + Action act = () => _ = new Error(" ", ErrorType.Validation); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("message"); + } + + [Fact(DisplayName = "Validation → returns a Validation error when a message is provided")] + public void Validation_Should_ReturnValidationError_When_MessageIsProvided() + { + const string message = "Validation failed"; + + var error = Error.Validation(message); + + AssertError(error, message, ErrorType.Validation); + } + + [Fact(DisplayName = "NotFound → returns a NotFound error when a message is provided")] + public void NotFound_Should_ReturnNotFoundError_When_MessageIsProvided() + { + const string message = "Entity not found"; + + var error = Error.NotFound(message); + + AssertError(error, message, ErrorType.NotFound); + } + + [Fact(DisplayName = "Conflict → returns a Conflict error when a message is provided")] + public void Conflict_Should_ReturnConflictError_When_MessageIsProvided() + { + const string message = "Entity already exists"; + + var error = Error.Conflict(message); + + AssertError(error, message, ErrorType.Conflict); + } + + [Fact(DisplayName = "Unauthorized → returns an Unauthorized error when a message is provided")] + public void Unauthorized_Should_ReturnUnauthorizedError_When_MessageIsProvided() + { + const string message = "Authentication required"; + + var error = Error.Unauthorized(message); + + AssertError(error, message, ErrorType.Unauthorized); + } + + [Fact(DisplayName = "Forbidden → returns a Forbidden error when a message is provided")] + public void Forbidden_Should_ReturnForbiddenError_When_MessageIsProvided() + { + const string message = "Access denied"; + + var error = Error.Forbidden(message); + + AssertError(error, message, ErrorType.Forbidden); + } + + [Fact(DisplayName = "Failure → returns a Failure error when a message is provided")] + public void Failure_Should_ReturnFailureError_When_MessageIsProvided() + { + const string message = "Operation failed"; + + var error = Error.Failure(message); + + AssertError(error, message, ErrorType.Failure); + } + + [Fact(DisplayName = "Unexpected → returns an Unexpected error when a message is provided")] + public void Unexpected_Should_ReturnUnexpectedError_When_MessageIsProvided() + { + const string message = "Unexpected error"; + + var error = Error.Unexpected(message); + + AssertError(error, message, ErrorType.Unexpected); + } + + [Fact(DisplayName = "WithMetadata → adds new metadata when the key does not exist")] + public void WithMetadata_Should_AddMetadata_When_KeyDoesNotExist() + { + const string message = "Validation failed"; + var key = Error.FieldMetadataKey; + const string value = "name"; + var error = Error.Validation(message); + + var updatedError = error.WithMetadata(key, value); + + updatedError.Metadata.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair(key, value)); + } + + [Fact(DisplayName = "WithMetadata → overwrites metadata when the key already exists")] + public void WithMetadata_Should_OverrideMetadata_When_KeyAlreadyExists() + { + var key = Error.FieldMetadataKey; + const string message = "Validation failed"; + const string originalValue = "old-name"; + const string updatedValue = "new-name"; + var error = new Error( + message, + ErrorType.Validation, + new Dictionary { [key] = originalValue }); + + var updatedError = error.WithMetadata(key, updatedValue); + + updatedError.Metadata.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair(key, updatedValue)); + } + + [Fact(DisplayName = "WithMetadata → returns a new error and does not modify the original when metadata is updated")] + public void WithMetadata_Should_ReturnNewErrorWithoutMutatingOriginal_When_MetadataIsUpdated() + { + var key = Error.FieldMetadataKey; + const string message = "Validation failed"; + const string originalValue = "old-name"; + const string metadataKey = "code"; + const int metadataValue = 100; + var error = new Error( + message, + ErrorType.Validation, + new Dictionary { [key] = originalValue }); + + var updatedError = error.WithMetadata(metadataKey, metadataValue); + + updatedError.Should().NotBeSameAs(error); + + error.Metadata.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair(key, originalValue)); + + updatedError.Metadata.Should().HaveCount(2); + updatedError.Metadata.Should().Contain(new KeyValuePair(key, originalValue)); + updatedError.Metadata.Should().Contain(new KeyValuePair(metadataKey, metadataValue)); + } + + [Fact(DisplayName = "WithField → adds field metadata when a valid field name is provided")] + public void WithField_Should_AddFieldMetadata_When_FieldIsValid() + { + const string message = "Validation failed"; + const string field = "name"; + var error = Error.Validation(message); + + var updatedError = error.WithField(field); + + updatedError.Metadata.Should().ContainSingle() + .Which.Should().Be(new KeyValuePair(Error.FieldMetadataKey, field)); + } + + [Fact(DisplayName = "WithField → throws ArgumentException when field is null")] + public void WithField_Should_ThrowArgumentException_When_FieldIsNull() + { + var error = Error.Validation("Validation failed"); + Action act = () => error.WithField(null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("field"); + } + + [Fact(DisplayName = "WithField → throws ArgumentException when field is empty")] + public void WithField_Should_ThrowArgumentException_When_FieldIsEmpty() + { + var error = Error.Validation("Validation failed"); + Action act = () => error.WithField(string.Empty); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("field"); + } + + [Fact(DisplayName = "WithField → throws ArgumentException when field contains only whitespace")] + public void WithField_Should_ThrowArgumentException_When_FieldIsWhitespace() + { + var error = Error.Validation("Validation failed"); + Action act = () => error.WithField(" "); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("field"); + } + + private static void AssertError(Error error, string expectedMessage, ErrorType expectedType) + { + error.Message.Should().Be(expectedMessage); + error.Type.Should().Be(expectedType); + error.Metadata.Should().BeEmpty(); + } +} diff --git a/tests/PANiXiDA.Core.ResultPattern.UnitTests/GenericResultTests.cs b/tests/PANiXiDA.Core.ResultPattern.UnitTests/GenericResultTests.cs new file mode 100644 index 0000000..26bef24 --- /dev/null +++ b/tests/PANiXiDA.Core.ResultPattern.UnitTests/GenericResultTests.cs @@ -0,0 +1,166 @@ +namespace PANiXiDA.Core.ResultPattern.UnitTests; + +public sealed class GenericResultTests +{ + [Fact(DisplayName = "Success → returns a successful generic result when a value is provided")] + public void Success_Should_ReturnSuccessfulGenericResult_When_ValueIsProvided() + { + const int value = 42; + + var result = Result.Success(value); + + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + result.Errors.Should().BeEmpty(); + result.Value.Should().Be(value); + } + + [Fact(DisplayName = "Success → returns a successful generic result when null is provided")] + public void Success_Should_ReturnSuccessfulGenericResult_When_ValueIsNull() + { + string? value = null; + + var result = Result.Success(value); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + result.ValueOrDefault.Should().BeNull(); + } + + [Fact(DisplayName = "Value → returns the value when the result is successful")] + public void Value_Should_ReturnValue_When_ResultIsSuccess() + { + const string value = "value"; + + var result = Result.Success(value); + + result.Value.Should().Be(value); + } + + [Fact(DisplayName = "Value → throws InvalidOperationException when the result is a failure")] + public void Value_Should_ThrowInvalidOperationException_When_ResultIsFailure() + { + var result = Result.Failure(Error.Validation("Validation failed")); + Action act = () => _ = result.Value; + + var exception = act.Should().Throw().Which; + + exception.Message.Should().Be("Cannot access value of failed result."); + } + + [Fact(DisplayName = "ValueOrDefault → returns the value when the result is successful")] + public void ValueOrDefault_Should_ReturnValue_When_ResultIsSuccess() + { + const string value = "value"; + + var result = Result.Success(value); + + result.ValueOrDefault.Should().Be(value); + } + + [Fact(DisplayName = "ValueOrDefault → returns default when the result is a failure")] + public void ValueOrDefault_Should_ReturnDefault_When_ResultIsFailure() + { + var result = Result.Failure(Error.Validation("Validation failed")); + + var value = result.ValueOrDefault; + + value.Should().BeNull(); + } + + [Fact(DisplayName = "TryGetValue → returns true and the value when the result is successful")] + public void TryGetValue_Should_ReturnTrueAndValue_When_ResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + + var success = result.TryGetValue(out var actualValue); + + success.Should().BeTrue(); + actualValue.Should().Be(value); + } + + [Fact(DisplayName = "TryGetValue → returns false and default when the result is a failure")] + public void TryGetValue_Should_ReturnFalseAndDefault_When_ResultIsFailure() + { + var result = Result.Failure(Error.Validation("Validation failed")); + + var success = result.TryGetValue(out var value); + + success.Should().BeFalse(); + value.Should().BeNull(); + } + + [Fact(DisplayName = "Failure(Error) → returns a failed generic result when an error is provided")] + public void Failure_Should_ReturnFailureGenericResult_When_ErrorIsProvided() + { + var error = Error.Validation("Validation failed"); + + var result = Result.Failure(error); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact(DisplayName = "Failure(Error) → throws ArgumentNullException when error is null")] + public void Failure_Should_ThrowArgumentNullException_When_ErrorIsNull() + { + Action act = () => Result.Failure((Error)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("error"); + } + + [Fact(DisplayName = "Failure(IEnumerable) → returns a failed generic result when the collection contains errors")] + public void Failure_Should_ReturnFailureGenericResult_When_ErrorCollectionContainsErrors() + { + var firstError = Error.Validation("Validation failed"); + var secondError = Error.NotFound("Not found"); + + var result = Result.Failure([firstError, secondError]); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().Equal(firstError, secondError); + } + + [Fact(DisplayName = "Failure(IEnumerable) → filters out null values when the collection contains null")] + public void Failure_Should_FilterOutNullErrors_When_ErrorCollectionContainsNullValues() + { + var error = Error.Validation("Validation failed"); + + var result = Result.Failure([error, null!]); + + result.Errors.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact(DisplayName = "Failure(IEnumerable) → throws ArgumentNullException when the error collection is null")] + public void Failure_Should_ThrowArgumentNullException_When_ErrorCollectionIsNull() + { + Action act = () => Result.Failure((IEnumerable)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Failure(IEnumerable) → throws ArgumentException when the error collection is empty")] + public void Failure_Should_ThrowArgumentException_When_ErrorCollectionIsEmpty() + { + Action act = () => Result.Failure([]); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Failure(IEnumerable) → throws ArgumentException when the collection contains only null")] + public void Failure_Should_ThrowArgumentException_When_ErrorCollectionContainsOnlyNullValues() + { + Action act = () => Result.Failure([null!]); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("errors"); + } +} diff --git a/tests/PANiXiDA.Core.ResultPattern.UnitTests/PANiXiDA.Core.ResultPattern.UnitTests.csproj b/tests/PANiXiDA.Core.ResultPattern.UnitTests/PANiXiDA.Core.ResultPattern.UnitTests.csproj new file mode 100644 index 0000000..b6ae9de --- /dev/null +++ b/tests/PANiXiDA.Core.ResultPattern.UnitTests/PANiXiDA.Core.ResultPattern.UnitTests.csproj @@ -0,0 +1,21 @@ + + + Exe + + + + + + + + + + + + + + + + + + diff --git a/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultCombinerTests.cs b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultCombinerTests.cs new file mode 100644 index 0000000..7d8a796 --- /dev/null +++ b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultCombinerTests.cs @@ -0,0 +1,346 @@ +namespace PANiXiDA.Core.ResultPattern.UnitTests; + +public sealed class ResultCombinerTests +{ + [Fact(DisplayName = "Combine(Result, Result) → returns a tuple result when both results are successful")] + public void TupleCombine2_Should_ReturnTuple_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + + var result = ResultCombiner.Combine(first, second); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be((1, 2)); + } + + [Fact(DisplayName = "Combine(Result, Result) → throws ArgumentNullException when the first result is null")] + public void TupleCombine2_Should_ThrowArgumentNullException_When_FirstIsNull() + { + Action act = () => ResultCombiner.Combine( + null!, + Result.Success(2)); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("first"); + } + + [Fact(DisplayName = "Combine(Result, Result, Result) → returns a tuple result when all results are successful")] + public void TupleCombine3_Should_ReturnTuple_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + + var result = ResultCombiner.Combine(first, second, third); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be((1, 2, 3)); + } + + [Fact(DisplayName = "Combine(Result, Result, Result) → returns a failure with aggregated errors when the results contain errors")] + public void TupleCombine3_Should_ReturnFailure_When_AnyResultIsFailure() + { + var firstError = Error.Validation("first"); + var secondError = Error.Conflict("second"); + + var first = Result.Failure(firstError); + var second = Result.Success(2); + var third = Result.Failure(secondError); + + var result = ResultCombiner.Combine(first, second, third); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().Equal(firstError, secondError); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result) → returns a tuple result when all results are successful")] + public void TupleCombine4_Should_ReturnTuple_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + var fourth = Result.Success(4); + + var result = ResultCombiner.Combine(first, second, third, fourth); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be((1, 2, 3, 4)); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result) → returns a tuple result when all results are successful")] + public void TupleCombine5_Should_ReturnTuple_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + var fourth = Result.Success(4); + var fifth = Result.Success(5); + + var result = ResultCombiner.Combine(first, second, third, fourth, fifth); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be((1, 2, 3, 4, 5)); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result, Result) → returns a tuple result when all results are successful")] + public void TupleCombine6_Should_ReturnTuple_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + var fourth = Result.Success(4); + var fifth = Result.Success(5); + var sixth = Result.Success(6); + + var result = ResultCombiner.Combine(first, second, third, fourth, fifth, sixth); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be((1, 2, 3, 4, 5, 6)); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result, Result, Result) → returns a tuple result preserving the original value order")] + public void TupleCombine7_Should_ReturnTupleInOriginalOrder_When_AllResultsAreSuccessful() + { + var first = Result.Success("1"); + var second = Result.Success("2"); + var third = Result.Success("3"); + var fourth = Result.Success("4"); + var fifth = Result.Success("5"); + var sixth = Result.Success("6"); + var seventh = Result.Success("7"); + + var result = ResultCombiner.Combine(first, second, third, fourth, fifth, sixth, seventh); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(("1", "2", "3", "4", "5", "6", "7")); + } + + [Fact(DisplayName = "Combine(Result, Result, Func>) → returns the bind result when both results are successful")] + public void Combine2_Should_ReturnBindResult_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + + var result = ResultCombiner.Combine( + first, + second, + static (firstValue, secondValue) => Result.Success(firstValue + secondValue)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(3); + } + + [Fact(DisplayName = "Combine(Result, Result, Func>) → throws ArgumentNullException when the first result is null")] + public void Combine2_Should_ThrowArgumentNullException_When_FirstIsNull() + { + Action act = () => ResultCombiner.Combine( + null!, + Result.Success(2), + static (first, second) => Result.Success(first + second)); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("first"); + } + + [Fact(DisplayName = "Combine(Result, Result, Func>) → throws ArgumentNullException when bind is null")] + public void Combine2_Should_ThrowArgumentNullException_When_BindIsNull() + { + Action act = () => ResultCombiner.Combine( + Result.Success(1), + Result.Success(2), + (Func>)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("bind"); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Func>) → returns the bind result when all results are successful")] + public void Combine3_Should_ReturnBindResult_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + + var result = ResultCombiner.Combine( + first, + second, + third, + static (firstValue, secondValue, thirdValue) => Result.Success(firstValue + secondValue + thirdValue)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(6); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Func>) → returns failure without invoking bind when the results contain errors")] + public void Combine3_Should_ReturnFailureWithoutInvokingBind_When_AnyResultIsFailure() + { + var firstError = Error.Validation("first"); + var secondError = Error.Conflict("second"); + var wasInvoked = false; + + var first = Result.Failure(firstError); + var second = Result.Success(2); + var third = Result.Failure(secondError); + + var result = ResultCombiner.Combine( + first, + second, + third, + (firstValue, secondValue, thirdValue) => + { + wasInvoked = true; + return Result.Success(firstValue + secondValue + thirdValue); + }); + + wasInvoked.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Errors.Should().Equal(firstError, secondError); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Func>) → returns the bind result when all results are successful")] + public void Combine4_Should_ReturnBindResult_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + var fourth = Result.Success(4); + + var result = ResultCombiner.Combine( + first, + second, + third, + fourth, + static (firstValue, secondValue, thirdValue, fourthValue) => + Result.Success(firstValue + secondValue + thirdValue + fourthValue)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(10); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result, Func>) → returns the bind result when all results are successful")] + public void Combine5_Should_ReturnBindResult_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + var fourth = Result.Success(4); + var fifth = Result.Success(5); + + var result = ResultCombiner.Combine( + first, + second, + third, + fourth, + fifth, + static (firstValue, secondValue, thirdValue, fourthValue, fifthValue) => + Result.Success(firstValue + secondValue + thirdValue + fourthValue + fifthValue)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(15); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result, Result, Func>) → returns the bind result when all results are successful")] + public void Combine6_Should_ReturnBindResult_When_AllResultsAreSuccessful() + { + var first = Result.Success(1); + var second = Result.Success(2); + var third = Result.Success(3); + var fourth = Result.Success(4); + var fifth = Result.Success(5); + var sixth = Result.Success(6); + + var result = ResultCombiner.Combine( + first, + second, + third, + fourth, + fifth, + sixth, + static (firstValue, secondValue, thirdValue, fourthValue, fifthValue, sixthValue) => + Result.Success(firstValue + secondValue + thirdValue + fourthValue + fifthValue + sixthValue)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(21); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result, Result, Result, Func>) → passes values to bind in the original order")] + public void Combine7_Should_PassValuesInOrderToBind_When_AllResultsAreSuccessful() + { + var first = Result.Success("1"); + var second = Result.Success("2"); + var third = Result.Success("3"); + var fourth = Result.Success("4"); + var fifth = Result.Success("5"); + var sixth = Result.Success("6"); + var seventh = Result.Success("7"); + + var result = ResultCombiner.Combine( + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + static (firstValue, secondValue, thirdValue, fourthValue, fifthValue, sixthValue, seventhValue) => + { + return Result.Success(string.Concat( + firstValue, + secondValue, + thirdValue, + fourthValue, + fifthValue, + sixthValue, + seventhValue)); + }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("1234567"); + } + + [Fact(DisplayName = "Combine(Result, Result, Result, Result, Result, Result, Result, Func>) → returns failure without invoking bind when the results contain errors")] + public void Combine7_Should_ReturnFailureWithoutInvokingBind_When_AnyResultIsFailure() + { + var firstError = Error.Validation("first"); + var secondError = Error.NotFound("second"); + var thirdError = Error.Conflict("third"); + var wasInvoked = false; + + var first = Result.Success(1); + var second = Result.Failure(firstError); + var third = Result.Success(3); + var fourth = Result.Failure(secondError); + var fifth = Result.Success(5); + var sixth = Result.Failure(thirdError); + var seventh = Result.Success(7); + + var result = ResultCombiner.Combine( + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + (firstValue, secondValue, thirdValue, fourthValue, fifthValue, sixthValue, seventhValue) => + { + wasInvoked = true; + return Result.Success( + firstValue + + secondValue + + thirdValue + + fourthValue + + fifthValue + + sixthValue + + seventhValue); + }); + + wasInvoked.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Errors.Should().Equal(firstError, secondError, thirdError); + } +} diff --git a/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultExtensionsTests.cs b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultExtensionsTests.cs new file mode 100644 index 0000000..9606e6b --- /dev/null +++ b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultExtensionsTests.cs @@ -0,0 +1,643 @@ +namespace PANiXiDA.Core.ResultPattern.UnitTests; + +public sealed class ResultExtensionsTests +{ + [Fact(DisplayName = "Map(Result, Func) → throws ArgumentNullException when result is null")] + public void Map_Should_ThrowArgumentNullException_When_ResultIsNull() + { + Action act = () => ResultExtensions.Map(null!, () => 1); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Map(Result, Func) → throws ArgumentNullException when map is null")] + public void Map_Should_ThrowArgumentNullException_When_MapIsNull() + { + var result = Result.Success(); + Action act = () => result.Map((Func)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("map"); + } + + [Fact(DisplayName = "Map(Result, Func) → returns mapped result when the source result is successful")] + public void Map_Should_ReturnMappedResult_When_ResultIsSuccess() + { + const int expectedValue = 42; + var result = Result.Success(); + + var mappedResult = result.Map(() => expectedValue); + + mappedResult.IsSuccess.Should().BeTrue(); + mappedResult.Value.Should().Be(expectedValue); + } + + [Fact(DisplayName = "Map(Result, Func) → returns failure without invoking mapper when the source result is a failure")] + public void Map_Should_ReturnFailureWithoutInvokingMapper_When_ResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var mappedResult = result.Map(() => + { + wasInvoked = true; + return 42; + }); + + wasInvoked.Should().BeFalse(); + mappedResult.IsFailure.Should().BeTrue(); + mappedResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "Map(Result, Func) → throws ArgumentNullException when result is null")] + public void Map_Should_ThrowArgumentNullException_When_GenericResultIsNull() + { + Action act = () => ResultExtensions.Map(null!, value => value.Length); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Map(Result, Func) → throws ArgumentNullException when map is null")] + public void Map_Should_ThrowArgumentNullException_When_GenericMapIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Map((Func)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("map"); + } + + [Fact(DisplayName = "Map(Result, Func) → returns mapped result when the source generic result is successful")] + public void Map_Should_ReturnMappedResult_When_GenericResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + + var mappedResult = result.Map(item => item.Length); + + mappedResult.IsSuccess.Should().BeTrue(); + mappedResult.Value.Should().Be(value.Length); + } + + [Fact(DisplayName = "Map(Result, Func) → returns failure without invoking mapper when the source generic result is a failure")] + public void Map_Should_ReturnFailureWithoutInvokingMapper_When_GenericResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var mappedResult = result.Map(value => + { + wasInvoked = true; + return value.Length; + }); + + wasInvoked.Should().BeFalse(); + mappedResult.IsFailure.Should().BeTrue(); + mappedResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "Bind(Result, Func) → throws ArgumentNullException when result is null")] + public void Bind_Should_ThrowArgumentNullException_When_ResultIsNull() + { + Action act = () => ResultExtensions.Bind(null!, value => Result.Success()); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Bind(Result, Func) → throws ArgumentNullException when bind is null")] + public void Bind_Should_ThrowArgumentNullException_When_BindIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Bind((Func)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("bind"); + } + + [Fact(DisplayName = "Bind(Result, Func) → returns the bind result when the source generic result is successful")] + public void Bind_Should_ReturnBindResult_When_ResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + + var boundResult = result.Bind(item => + { + return item.Length > 0 + ? Result.Success() + : Result.Failure(Error.Validation("Validation failed")); + }); + + boundResult.IsSuccess.Should().BeTrue(); + } + + [Fact(DisplayName = "Bind(Result, Func) → returns failure without invoking bind when the source generic result is a failure")] + public void Bind_Should_ReturnFailureWithoutInvokingBind_When_ResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var boundResult = result.Bind(_ => + { + wasInvoked = true; + return Result.Success(); + }); + + wasInvoked.Should().BeFalse(); + boundResult.IsFailure.Should().BeTrue(); + boundResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "Bind(Result, Func>) → throws ArgumentNullException when result is null")] + public void Bind_Should_ThrowArgumentNullException_When_GenericResultIsNull() + { + Action act = () => ResultExtensions.Bind(null!, value => Result.Success(value.Length)); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Bind(Result, Func>) → throws ArgumentNullException when bind is null")] + public void Bind_Should_ThrowArgumentNullException_When_GenericBindIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Bind((Func>)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("bind"); + } + + [Fact(DisplayName = "Bind(Result, Func>) → returns the bind result when the source generic result is successful")] + public void Bind_Should_ReturnBindResult_When_GenericResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + + var boundResult = result.Bind(item => Result.Success(item.Length)); + + boundResult.IsSuccess.Should().BeTrue(); + boundResult.Value.Should().Be(value.Length); + } + + [Fact(DisplayName = "Bind(Result, Func>) → returns failure without invoking bind when the source generic result is a failure")] + public void Bind_Should_ReturnFailureWithoutInvokingBind_When_GenericResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var boundResult = result.Bind(value => + { + wasInvoked = true; + return Result.Success(value.Length); + }); + + wasInvoked.Should().BeFalse(); + boundResult.IsFailure.Should().BeTrue(); + boundResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "Bind(Result, Func>) → throws ArgumentNullException when result is null")] + public void Bind_Should_ThrowArgumentNullException_When_NonGenericResultIsNull() + { + Action act = () => ResultExtensions.Bind(null!, () => Result.Success(42)); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Bind(Result, Func>) → throws ArgumentNullException when bind is null")] + public void Bind_Should_ThrowArgumentNullException_When_NonGenericBindIsNull() + { + var result = Result.Success(); + Action act = () => result.Bind((Func>)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("bind"); + } + + [Fact(DisplayName = "Bind(Result, Func>) → returns the bind result when the source result is successful")] + public void Bind_Should_ReturnBindResult_When_NonGenericResultIsSuccess() + { + const int expectedValue = 42; + var result = Result.Success(); + + var boundResult = result.Bind(() => Result.Success(expectedValue)); + + boundResult.IsSuccess.Should().BeTrue(); + boundResult.Value.Should().Be(expectedValue); + } + + [Fact(DisplayName = "Bind(Result, Func>) → returns failure without invoking bind when the source result is a failure")] + public void Bind_Should_ReturnFailureWithoutInvokingBind_When_NonGenericResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var boundResult = result.Bind(() => + { + wasInvoked = true; + return Result.Success(42); + }); + + wasInvoked.Should().BeFalse(); + boundResult.IsFailure.Should().BeTrue(); + boundResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → throws ArgumentNullException when result is null")] + public async Task BindAsync_Should_ThrowArgumentNullException_When_ResultIsNull() + { + Func act = async () => _ = await ResultExtensions.BindAsync( + null!, + value => Task.FromResult(Result.Success(value.Length))); + + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → throws ArgumentNullException when bind is null")] + public async Task BindAsync_Should_ThrowArgumentNullException_When_BindIsNull() + { + var result = Result.Success("value"); + Func act = async () => _ = await result.BindAsync((Func>>)null!); + + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ParamName.Should().Be("bind"); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → returns the bind result when the source generic result is successful")] + public async Task BindAsync_Should_ReturnBindResult_When_ResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + + var boundResult = await result.BindAsync(item => Task.FromResult(Result.Success(item.Length))); + + boundResult.IsSuccess.Should().BeTrue(); + boundResult.Value.Should().Be(value.Length); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → returns failure without invoking bind when the source generic result is a failure")] + public async Task BindAsync_Should_ReturnFailureWithoutInvokingBind_When_ResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var boundResult = await result.BindAsync(value => + { + wasInvoked = true; + return Task.FromResult(Result.Success(value.Length)); + }); + + wasInvoked.Should().BeFalse(); + boundResult.IsFailure.Should().BeTrue(); + boundResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → throws ArgumentNullException when result is null")] + public async Task BindAsync_Should_ThrowArgumentNullException_When_NonGenericResultIsNull() + { + Func act = async () => _ = await ResultExtensions.BindAsync( + null!, + () => Task.FromResult(Result.Success(42))); + + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → throws ArgumentNullException when bind is null")] + public async Task BindAsync_Should_ThrowArgumentNullException_When_NonGenericBindIsNull() + { + var result = Result.Success(); + Func act = async () => _ = await result.BindAsync((Func>>)null!); + + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ParamName.Should().Be("bind"); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → returns the bind result when the source result is successful")] + public async Task BindAsync_Should_ReturnBindResult_When_NonGenericResultIsSuccess() + { + const int expectedValue = 42; + var result = Result.Success(); + + var boundResult = await result.BindAsync(() => Task.FromResult(Result.Success(expectedValue))); + + boundResult.IsSuccess.Should().BeTrue(); + boundResult.Value.Should().Be(expectedValue); + } + + [Fact(DisplayName = "BindAsync(Result, Func>>) → returns failure without invoking bind when the source result is a failure")] + public async Task BindAsync_Should_ReturnFailureWithoutInvokingBind_When_NonGenericResultIsFailure() + { + var sourceError = Error.Validation("Validation failed"); + var result = Result.Failure(sourceError); + var wasInvoked = false; + + var boundResult = await result.BindAsync(() => + { + wasInvoked = true; + return Task.FromResult(Result.Success(42)); + }); + + wasInvoked.Should().BeFalse(); + boundResult.IsFailure.Should().BeTrue(); + boundResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(sourceError); + } + + [Fact(DisplayName = "Ensure → throws ArgumentNullException when result is null")] + public void Ensure_Should_ThrowArgumentNullException_When_ResultIsNull() + { + Action act = () => ResultExtensions.Ensure( + null!, + _ => true, + Error.Validation("Validation failed")); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Ensure → throws ArgumentNullException when predicate is null")] + public void Ensure_Should_ThrowArgumentNullException_When_PredicateIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Ensure(null!, Error.Validation("Validation failed")); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("predicate"); + } + + [Fact(DisplayName = "Ensure → throws ArgumentNullException when error is null")] + public void Ensure_Should_ThrowArgumentNullException_When_ErrorIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Ensure(_ => true, null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("error"); + } + + [Fact(DisplayName = "Ensure → returns the original result when the source result is already a failure")] + public void Ensure_Should_ReturnOriginalResult_When_ResultIsFailure() + { + var result = Result.Failure(Error.Validation("Validation failed")); + var wasInvoked = false; + + var ensuredResult = result.Ensure( + _ => + { + wasInvoked = true; + return true; + }, + Error.Conflict("Conflict")); + + wasInvoked.Should().BeFalse(); + ensuredResult.Should().BeSameAs(result); + } + + [Fact(DisplayName = "Ensure → returns the original result when predicate returns true")] + public void Ensure_Should_ReturnOriginalResult_When_PredicateReturnsTrue() + { + const string value = "value"; + var result = Result.Success(value); + + var ensuredResult = result.Ensure( + item => item.Length == value.Length, + Error.Validation("Validation failed")); + + ensuredResult.Should().BeSameAs(result); + } + + [Fact(DisplayName = "Ensure → returns failure when predicate returns false")] + public void Ensure_Should_ReturnFailure_When_PredicateReturnsFalse() + { + var error = Error.Validation("Validation failed"); + const string value = "value"; + const int invalidLength = 10; + var result = Result.Success(value); + + var ensuredResult = result.Ensure(item => item.Length == invalidLength, error); + + ensuredResult.IsFailure.Should().BeTrue(); + ensuredResult.Errors.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact(DisplayName = "Tap → throws ArgumentNullException when result is null")] + public void Tap_Should_ThrowArgumentNullException_When_ResultIsNull() + { + Action act = () => ResultExtensions.Tap(null!, _ => { }); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Tap → throws ArgumentNullException when action is null")] + public void Tap_Should_ThrowArgumentNullException_When_ActionIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Tap(null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("action"); + } + + [Fact(DisplayName = "Tap → executes action and returns the original result when the result is successful")] + public void Tap_Should_InvokeActionAndReturnOriginalResult_When_ResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + string? tappedValue = null; + + var tappedResult = result.Tap(item => tappedValue = item); + + tappedValue.Should().Be(value); + tappedResult.Should().BeSameAs(result); + } + + [Fact(DisplayName = "Tap → returns the original result without invoking action when the result is a failure")] + public void Tap_Should_ReturnOriginalResultWithoutInvokingAction_When_ResultIsFailure() + { + var result = Result.Failure(Error.Validation("Validation failed")); + var wasInvoked = false; + + var tappedResult = result.Tap(_ => wasInvoked = true); + + wasInvoked.Should().BeFalse(); + tappedResult.Should().BeSameAs(result); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → throws ArgumentNullException when result is null")] + public void Match_Should_ThrowArgumentNullException_When_ResultIsNull() + { + Action act = () => ResultExtensions.Match(null!, () => 42, _ => 0); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → throws ArgumentNullException when onSuccess is null")] + public void Match_Should_ThrowArgumentNullException_When_OnSuccessIsNull() + { + var result = Result.Success(); + Action act = () => result.Match((Func)null!, _ => 0); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("onSuccess"); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → throws ArgumentNullException when onFailure is null")] + public void Match_Should_ThrowArgumentNullException_When_OnFailureIsNull() + { + var result = Result.Success(); + Action act = () => result.Match(() => 42, null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("onFailure"); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → invokes onSuccess when the result is successful")] + public void Match_Should_InvokeOnSuccess_When_ResultIsSuccess() + { + const int expectedValue = 42; + var result = Result.Success(); + var onFailureInvoked = false; + + var matchResult = result.Match( + () => expectedValue, + _ => + { + onFailureInvoked = true; + return 0; + }); + + matchResult.Should().Be(expectedValue); + onFailureInvoked.Should().BeFalse(); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → invokes onFailure with errors when the result is a failure")] + public void Match_Should_InvokeOnFailureWithErrors_When_ResultIsFailure() + { + var error = Error.Validation("Validation failed"); + var result = Result.Failure(error); + var onSuccessInvoked = false; + + var matchResult = result.Match( + () => + { + onSuccessInvoked = true; + return 42; + }, + errors => + { + errors.Should().ContainSingle().Which.Should().BeSameAs(error); + return 0; + }); + + matchResult.Should().Be(0); + onSuccessInvoked.Should().BeFalse(); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → throws ArgumentNullException when result is null")] + public void Match_Should_ThrowArgumentNullException_When_GenericResultIsNull() + { + Action act = () => ResultExtensions.Match(null!, _ => 42, _ => 0); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("result"); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → throws ArgumentNullException when onSuccess is null")] + public void Match_Should_ThrowArgumentNullException_When_GenericOnSuccessIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Match((Func)null!, _ => 0); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("onSuccess"); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → throws ArgumentNullException when onFailure is null")] + public void Match_Should_ThrowArgumentNullException_When_GenericOnFailureIsNull() + { + var result = Result.Success("value"); + Action act = () => result.Match(_ => 42, null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("onFailure"); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → invokes onSuccess with the value when the result is successful")] + public void Match_Should_InvokeOnSuccessWithValue_When_GenericResultIsSuccess() + { + const string value = "value"; + var result = Result.Success(value); + var onFailureInvoked = false; + + var matchResult = result.Match( + item => item.Length, + _ => + { + onFailureInvoked = true; + return 0; + }); + + matchResult.Should().Be(value.Length); + onFailureInvoked.Should().BeFalse(); + } + + [Fact(DisplayName = "Match(Result, Func, Func, TOut>) → invokes onFailure with errors when the result is a failure")] + public void Match_Should_InvokeOnFailureWithErrors_When_GenericResultIsFailure() + { + var error = Error.Validation("Validation failed"); + var result = Result.Failure(error); + var onSuccessInvoked = false; + + var matchResult = result.Match( + _ => + { + onSuccessInvoked = true; + return 42; + }, + errors => + { + errors.Should().ContainSingle().Which.Should().BeSameAs(error); + return 0; + }); + + matchResult.Should().Be(0); + onSuccessInvoked.Should().BeFalse(); + } +} diff --git a/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultTests.cs b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultTests.cs new file mode 100644 index 0000000..ffaa98b --- /dev/null +++ b/tests/PANiXiDA.Core.ResultPattern.UnitTests/ResultTests.cs @@ -0,0 +1,267 @@ +using System.Reflection; + +namespace PANiXiDA.Core.ResultPattern.UnitTests; + +public sealed class ResultTests +{ + [Fact(DisplayName = "Success() → returns a successful result when called without errors")] + public void Success_Should_ReturnSuccessfulResult_When_Called() + { + var result = Result.Success(); + + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + result.Errors.Should().BeEmpty(); + } + + [Fact(DisplayName = "FirstError → throws InvalidOperationException when the result is successful")] + public void FirstError_Should_ThrowInvalidOperationException_When_ResultIsSuccess() + { + var result = Result.Success(); + Action act = () => _ = result.FirstError; + + var exception = act.Should().Throw().Which; + + exception.Message.Should().Be("Successful result does not contain errors."); + } + + [Fact(DisplayName = "FirstError → returns the first error when the result is a failure")] + public void FirstError_Should_ReturnFirstError_When_ResultIsFailure() + { + var firstError = Error.Validation("Validation failed"); + var secondError = Error.Conflict("Conflict"); + var result = Result.Failure([firstError, secondError]); + + var firstResultError = result.FirstError; + + firstResultError.Should().BeSameAs(firstError); + } + + [Fact(DisplayName = "Failure(Error) → returns a failed result when an error is provided")] + public void Failure_Should_ReturnFailureResult_When_ErrorIsProvided() + { + var error = Error.Validation("Validation failed"); + + var result = Result.Failure(error); + + result.IsSuccess.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact(DisplayName = "Failure(Error) → throws ArgumentNullException when error is null")] + public void Failure_Should_ThrowArgumentNullException_When_ErrorIsNull() + { + Action act = () => Result.Failure((Error)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("error"); + } + + [Fact(DisplayName = "Failure(IEnumerable) → returns a failed result when the collection contains errors")] + public void Failure_Should_ReturnFailureResult_When_ErrorCollectionContainsErrors() + { + var firstError = Error.Validation("Validation failed"); + var secondError = Error.Conflict("Conflict"); + + var result = Result.Failure([firstError, secondError]); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().Equal(firstError, secondError); + } + + [Fact(DisplayName = "Result errors → creates a defensive error copy when constructed")] + public void Errors_Should_CreateDefensiveCopy_When_ResultIsConstructed() + { + var firstError = Error.Validation("Validation failed"); + var secondError = Error.Conflict("Conflict"); + var errors = new[] + { + firstError + }; + var result = CreateResult(false, errors); + + errors[0] = secondError; + + result.Errors.Should().ContainSingle().Which.Should().BeSameAs(firstError); + } + + [Fact(DisplayName = "Result errors → throws NotSupportedException when errors are cast to a mutable list and modified")] + public void Errors_Should_ThrowNotSupportedException_When_CastToMutableListAndModified() + { + var error = Error.Validation("Validation failed"); + var result = Result.Failure(error); + var errors = result.Errors.Should() + .BeAssignableTo>() + .Subject; + + Action act = () => errors[0] = Error.Conflict("Conflict"); + + act.Should().Throw(); + } + + [Fact(DisplayName = "Failure(IEnumerable) → filters out null values when the collection contains null")] + public void Failure_Should_FilterOutNullErrors_When_ErrorCollectionContainsNullValues() + { + var error = Error.Validation("Validation failed"); + + var result = Result.Failure([error, null!]); + + result.Errors.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact(DisplayName = "Failure(IEnumerable) → preserves error order when the collection contains multiple errors")] + public void Failure_Should_PreserveErrorOrder_When_ErrorCollectionContainsMultipleErrors() + { + var firstError = Error.Validation("Validation failed"); + var secondError = Error.NotFound("Not found"); + var thirdError = Error.Conflict("Conflict"); + + var result = Result.Failure([firstError, secondError, thirdError]); + + result.Errors.Should().Equal(firstError, secondError, thirdError); + } + + [Fact(DisplayName = "Failure(IEnumerable) → throws ArgumentNullException when the error collection is null")] + public void Failure_Should_ThrowArgumentNullException_When_ErrorCollectionIsNull() + { + Action act = () => Result.Failure((IEnumerable)null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Failure(IEnumerable) → throws ArgumentException when the error collection is empty")] + public void Failure_Should_ThrowArgumentException_When_ErrorCollectionIsEmpty() + { + Action act = () => Result.Failure([]); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Failure(IEnumerable) → throws ArgumentException when the collection contains only null")] + public void Failure_Should_ThrowArgumentException_When_ErrorCollectionContainsOnlyNullValues() + { + Action act = () => Result.Failure([null!]); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Combine → throws ArgumentNullException when the results array is null")] + public void Combine_Should_ThrowArgumentNullException_When_ResultsArrayIsNull() + { + Action act = () => Result.Combine(null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("results"); + } + + [Fact(DisplayName = "Combine → throws ArgumentException when the results array contains null")] + public void Combine_Should_ThrowArgumentException_When_ResultsArrayContainsNull() + { + Action act = () => Result.Combine(Result.Success(), null!); + + var exception = act.Should().Throw().Which; + + exception.ParamName.Should().Be("results"); + } + + [Fact(DisplayName = "Combine → returns a successful result when the results array is empty")] + public void Combine_Should_ReturnSuccessfulResult_When_ResultsArrayIsEmpty() + { + var result = Result.Combine(); + + result.IsSuccess.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact(DisplayName = "Combine → returns a successful result when all results are successful")] + public void Combine_Should_ReturnSuccessfulResult_When_AllResultsAreSuccessful() + { + var first = Result.Success(); + var second = Result.Success(); + + var result = Result.Combine(first, second); + + result.IsSuccess.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact(DisplayName = "Combine → returns a failure with aggregated errors when the results contain errors")] + public void Combine_Should_ReturnFailureWithAggregatedErrors_When_AnyResultIsFailure() + { + var firstError = Error.Validation("Validation failed"); + var secondError = Error.NotFound("Not found"); + + var first = Result.Success(); + var second = Result.Failure(firstError); + var third = Result.Failure([secondError]); + + var result = Result.Combine(first, second, third); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().Equal(firstError, secondError); + } + + [Fact(DisplayName = "Result ctor → throws ArgumentException when a successful result contains errors")] + public void Constructor_Should_ThrowArgumentException_When_SuccessResultContainsErrors() + { + var errors = new[] + { + Error.Validation("Validation failed") + }; + + Action act = () => _ = CreateResult(true, errors); + + var exception = act.Should().Throw() + .Which.InnerException.Should().BeOfType() + .Subject; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Result ctor → throws ArgumentNullException when errors are null")] + public void Constructor_Should_ThrowArgumentNullException_When_ErrorsAreNull() + { + Action act = () => _ = CreateResult(true, null!); + + var exception = act.Should().Throw() + .Which.InnerException.Should().BeOfType() + .Subject; + + exception.ParamName.Should().Be("errors"); + } + + [Fact(DisplayName = "Result ctor → throws ArgumentException when a failed result does not contain errors")] + public void Constructor_Should_ThrowArgumentException_When_FailureResultDoesNotContainErrors() + { + var errors = Array.Empty(); + Action act = () => _ = CreateResult(false, errors); + + var exception = act.Should().Throw() + .Which.InnerException.Should().BeOfType() + .Subject; + + exception.ParamName.Should().Be("errors"); + } + + private static Result CreateResult(bool isSuccess, Error[] errors) + { + var constructor = typeof(Result).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + [typeof(bool), typeof(Error[])], + modifiers: null); + + constructor.Should().NotBeNull(); + + return (Result)constructor.Invoke([isSuccess, errors]); + } +} diff --git a/version.json b/version.json new file mode 100644 index 0000000..427b1ae --- /dev/null +++ b/version.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.0", + "publicReleaseRefSpec": [ + "^refs/heads/main$", + "^refs/heads/master$" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + }, + "pathFilters": [ + ":/src/PANiXiDA.Core.ResultPattern/", + ":/Directory.Build.props", + ":/Directory.Build.targets", + ":/Directory.Packages.props", + ":/global.json", + ":/version.json", + ":/README.md", + ":/icon.png" + ] +}