diff --git a/content/tests/Company.Template.Api.Tests/Composition/FeatureCompositionTests.cs b/content/tests/Company.Template.Api.Tests/Composition/FeatureCompositionTests.cs index 4ef60ab..9e4162f 100644 --- a/content/tests/Company.Template.Api.Tests/Composition/FeatureCompositionTests.cs +++ b/content/tests/Company.Template.Api.Tests/Composition/FeatureCompositionTests.cs @@ -1,15 +1,123 @@ using Company.Template.Composition.Abstractions.Contexts; using Company.Template.Composition.Abstractions.Contracts; +using Microsoft.Extensions.Configuration; namespace Company.Template.Api.Tests.Composition; public sealed class FeatureCompositionTests { + [Fact] + public void AddFeatureServicesFromAssemblies_WithEmptyMarkers_ThrowsArgumentException() + { + // Arrange + ServiceCollection services = []; + + // Act + Action action = () => services.AddFeatureServicesFromAssemblies(); + + // Assert + ArgumentException exception = action.ShouldThrow(); + exception.Message.ShouldContain("At least one assembly is required."); + } + + [Fact] + public void AddFeatureServicesFromAssemblies_WithNullMarker_ThrowsArgumentNullException() + { + // Arrange + ServiceCollection services = []; + + // Act + Action action = () => services.AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests), null!); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void WithConfiguration_PassesConfigurationToServiceModule() + { + // Arrange + ServiceCollection services = []; + IConfiguration configuration = CreateConfiguration("composition-value"); + + // Act + services + .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) + .WithConfiguration(configuration) + .ComposeFeatures(features => features.Add()); + + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Value + .ShouldBe("composition-value"); + } + + [Fact] + public void RequireConfiguration_WithoutConfiguration_ThrowsClearInvalidOperationException() + { + // Arrange + ServiceCollection services = []; + + // Act + Action action = () => services + .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) + .ComposeFeatures(features => features.Add()); + + // Assert + InvalidOperationException exception = action.ShouldThrow(); + exception.Message.ShouldContain("requires configuration"); + exception.Message.ShouldContain("WithConfiguration"); + } + + [Fact] + public void ComposeFeatures_AppliesServiceModulesInDeterministicOrder() + { + // Arrange + ServiceCollection services = []; + + // Act + services + .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) + .ComposeFeatures(features => features.Add()); + + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetServices() + .Select(registration => registration.Name) + .ShouldBe(["alpha", "beta"]); + } + + [Fact] + public void ComposeFeatures_AppliesDecoratorModulesInDeterministicOrder() + { + // Arrange + ServiceCollection services = []; + + // Act + services + .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) + .ComposeFeatures(features => features + .Add() + .Decorate()); + + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Execute() + .ShouldBe("beta alpha inner"); + } + [Fact] public void ComposeFeatures_AppliesQueuedDecoratorsAfterServiceModules() { + // Arrange ServiceCollection services = []; + // Act services .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) .ComposeFeatures(features => features @@ -18,6 +126,7 @@ public void ComposeFeatures_AppliesQueuedDecoratorsAfterServiceModules() using ServiceProvider provider = services.BuildServiceProvider(); + // Assert provider.GetRequiredService() .Execute() .ShouldBe("decorated inner"); @@ -26,8 +135,10 @@ public void ComposeFeatures_AppliesQueuedDecoratorsAfterServiceModules() [Fact] public void ComposeFeatures_IgnoresDecoratorModulesForDifferentDecoratorFeature() { + // Arrange ServiceCollection services = []; + // Act services .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) .ComposeFeatures(features => features @@ -36,6 +147,7 @@ public void ComposeFeatures_IgnoresDecoratorModulesForDifferentDecoratorFeature( using ServiceProvider provider = services.BuildServiceProvider(); + // Assert provider.GetRequiredService() .Execute() .ShouldBe("decorated inner"); @@ -44,36 +156,58 @@ public void ComposeFeatures_IgnoresDecoratorModulesForDifferentDecoratorFeature( [Fact] public void ComposeFeatures_ThrowsWhenDecoratorFeatureIsQueuedTwice() { + // Arrange ServiceCollection services = []; - InvalidOperationException exception = Should.Throw(() => - { - services - .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) - .ComposeFeatures(features => features - .Decorate() - .Decorate()); - }); + // Act + Action action = () => services + .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) + .ComposeFeatures(features => features + .Decorate() + .Decorate()); + // Assert + InvalidOperationException exception = action.ShouldThrow(); exception.Message.ShouldContain("was queued more than once"); } [Fact] public void ComposeFeatures_ThrowsWhenDecoratorModuleIsMissing() { + // Arrange ServiceCollection services = []; - InvalidOperationException exception = Should.Throw(() => - { - services - .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) - .ComposeFeatures(features => features.Decorate()); - }); + // Act + Action action = () => services + .AddFeatureServicesFromAssemblies(typeof(FeatureCompositionTests)) + .ComposeFeatures(features => features.Decorate()); + // Assert + InvalidOperationException exception = action.ShouldThrow(); exception.Message.ShouldContain("No service decorator modules were found"); exception.Message.ShouldContain(nameof(MissingDecoratorFeature)); } + private static IConfiguration CreateConfiguration(string value) + { + Dictionary values = new() + { + ["Feature:Value"] = value + }; + + return new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + } + + public sealed class ConfigurationFeature : IFeature; + + public sealed class OrderedServiceFeature : IFeature; + + public sealed class OrderedDecoratorServiceFeature : IFeature; + + public sealed class OrderedDecoratorFeature : IFeature; + public sealed class TestServiceFeature : IFeature; public sealed class TestDecoratorFeature : IFeature; @@ -82,6 +216,58 @@ public sealed class OtherDecoratorFeature : IFeature; public sealed class MissingDecoratorFeature : IFeature; + public sealed record ConfigurationValue(string Value); + + public interface IOrderedModuleRegistration + { + string Name { get; } + } + + public sealed record OrderedModuleRegistration(string Name) : IOrderedModuleRegistration; + + public interface IOrderedDecoratorService + { + string Execute(); + } + + public sealed class OrderedDecoratorService : IOrderedDecoratorService + { + public string Execute() + { + return "inner"; + } + } + + public sealed class AlphaOrderedDecoratorService : IOrderedDecoratorService + { + private readonly IOrderedDecoratorService _inner; + + public AlphaOrderedDecoratorService(IOrderedDecoratorService inner) + { + _inner = inner; + } + + public string Execute() + { + return $"alpha {_inner.Execute()}"; + } + } + + public sealed class BetaOrderedDecoratorService : IOrderedDecoratorService + { + private readonly IOrderedDecoratorService _inner; + + public BetaOrderedDecoratorService(IOrderedDecoratorService inner) + { + _inner = inner; + } + + public string Execute() + { + return $"beta {_inner.Execute()}"; + } + } + public interface ITestService { string Execute(); @@ -125,6 +311,60 @@ public string Execute() } } + public sealed class ConfigurationModule : IFeatureServiceModule + { + public void Register(FeatureServiceContext context) + { + IConfiguration configuration = context.RequireConfiguration(); + string value = configuration["Feature:Value"] + ?? throw new InvalidOperationException("Feature value is missing."); + + context.Services.AddSingleton(new ConfigurationValue(value)); + } + } + + public sealed class AlphaOrderedServiceModule : IFeatureServiceModule + { + public void Register(FeatureServiceContext context) + { + context.Services.AddSingleton(new OrderedModuleRegistration("alpha")); + } + } + + public sealed class BetaOrderedServiceModule : IFeatureServiceModule + { + public void Register(FeatureServiceContext context) + { + context.Services.AddSingleton(new OrderedModuleRegistration("beta")); + } + } + + public sealed class OrderedDecoratorServiceModule : IFeatureServiceModule + { + public void Register(FeatureServiceContext context) + { + context.Services.AddScoped(); + } + } + + public sealed class AlphaOrderedDecoratorModule : + IFeatureServiceDecoratorModule + { + public void Decorate(FeatureServiceContext context) + { + context.Services.Decorate(); + } + } + + public sealed class BetaOrderedDecoratorModule : + IFeatureServiceDecoratorModule + { + public void Decorate(FeatureServiceContext context) + { + context.Services.Decorate(); + } + } + public sealed class TestServiceModule : IFeatureServiceModule { public void Register(FeatureServiceContext context) diff --git a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs new file mode 100644 index 0000000..821db72 --- /dev/null +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -0,0 +1,187 @@ +using System.Text.Json.Nodes; +using Company.Template.Api.Endpoints; +using Company.Template.Api.Tests.TestSupport; +using Company.Template.Application.Common; +using Microsoft.AspNetCore.Http; + +namespace Company.Template.Api.Tests.Endpoints; + +public sealed class EndpointResultExtensionsTests : IClassFixture +{ + private readonly ApiLightweightTestFactory _factory; + + public EndpointResultExtensionsTests(ApiLightweightTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ToHttpResult_WithSuccessfulValueResult_UsesSuccessMapping() + { + // Arrange + Result result = Result.Success("created"); + + // Act + HttpResponseCapture response = await ExecuteAsync(result.ToHttpResult(value => Results.Ok(new TestResponse(value)))); + + // Assert + response.StatusCode.ShouldBe(StatusCodes.Status200OK); + response.RequiredBody["value"]!.GetValue().ShouldBe("created"); + } + + [Fact] + public async Task ToHttpResult_WithSuccessfulResultWithoutValue_ReturnsNoContent() + { + // Arrange + Result result = Result.Success(); + + // Act + HttpResponseCapture response = await ExecuteAsync(result.ToHttpResult()); + + // Assert + response.StatusCode.ShouldBe(StatusCodes.Status204NoContent); + response.Body.ShouldBeNull(); + } + + [Theory] + [InlineData(ErrorType.NotFound, StatusCodes.Status404NotFound, "Resource not found.")] + [InlineData(ErrorType.Conflict, StatusCodes.Status409Conflict, "Conflict.")] + [InlineData(ErrorType.Unknown, StatusCodes.Status400BadRequest, "Request failed.")] + public async Task ToHttpResult_WithFailure_ReturnsProblemResponse( + ErrorType errorType, + int expectedStatusCode, + string expectedTitle) + { + // Arrange + Error error = CreateError(errorType, "example_error", "Example failure."); + Result result = Result.Failure(error); + + // Act + HttpResponseCapture response = await ExecuteAsync(result.ToHttpResult(_ => Results.Ok())); + + // Assert + response.StatusCode.ShouldBe(expectedStatusCode); + response.RequiredBody["title"]!.GetValue().ShouldBe(expectedTitle); + response.RequiredBody["detail"]!.GetValue().ShouldBe("Example failure."); + response.RequiredBody["code"]!.GetValue().ShouldBe("example_error"); + } + + [Fact] + public async Task ToHttpResult_WithSingleValidationError_ReturnsValidationProblemForRequest() + { + // Arrange + Error error = Error.Validation(ErrorCode.Create("name_required"), "Name is required."); + Result result = Result.Failure(error); + + // Act + HttpResponseCapture response = await ExecuteAsync(result.ToHttpResult(_ => Results.Ok())); + + // Assert + response.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity); + response.RequiredBody["title"]!.GetValue().ShouldBe("Validation failed."); + response.RequiredBody["detail"]!.GetValue().ShouldBe("Name is required."); + response.RequiredBody["code"]!.GetValue().ShouldBe("name_required"); + response.RequiredBody["errors"]!["request"]!.AsArray().Select(value => value!.GetValue()) + .ShouldBe(["Name is required."]); + } + + [Fact] + public async Task ToHttpResult_WithValidationDetails_GroupsErrorsByTarget() + { + // Arrange + Error[] details = + [ + Error.Validation(ErrorCode.Create("name_required"), "Name is required.", "name"), + Error.Validation(ErrorCode.Create("name_too_short"), "Name is too short.", "name"), + Error.Validation(ErrorCode.Create("price_invalid"), "Price must be positive.", "price") + ]; + + Error error = Error.Validation( + ErrorCode.Create("validation_failed"), + "One or more validation errors occurred.", + details); + + Result result = Result.Failure(error); + + // Act + HttpResponseCapture response = await ExecuteAsync(result.ToHttpResult(_ => Results.Ok())); + + // Assert + response.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity); + response.RequiredBody["errors"]!["name"]!.AsArray().Select(value => value!.GetValue()) + .ShouldBe(["Name is required.", "Name is too short."]); + response.RequiredBody["errors"]!["price"]!.AsArray().Select(value => value!.GetValue()) + .ShouldBe(["Price must be positive."]); + } + + [Fact] + public void ToProblemResult_WithSuccessfulResult_ThrowsInvalidOperationException() + { + // Arrange + Result result = Result.Success("value"); + + // Act + Action action = () => result.ToProblemResult(); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public async Task ToHttpResultAsync_WithTaskResult_ReturnsMappedResponse() + { + // Arrange + Task> resultTask = Task.FromResult(Result.Success("value")); + + // Act + HttpResponseCapture response = await ExecuteAsync( + await resultTask.ToHttpResultAsync(value => Results.Ok(new TestResponse(value)))); + + // Assert + response.StatusCode.ShouldBe(StatusCodes.Status200OK); + response.RequiredBody["value"]!.GetValue().ShouldBe("value"); + } + + private static Error CreateError(ErrorType type, string code, string message) + { + ErrorCode errorCode = ErrorCode.Create(code); + + return type switch + { + ErrorType.NotFound => Error.NotFound(errorCode, message), + ErrorType.Conflict => Error.Conflict(errorCode, message), + ErrorType.Unknown => Error.Unknown(errorCode, message), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + + private async Task ExecuteAsync(IResult result) + { + DefaultHttpContext context = new() + { + RequestServices = _factory.Services + }; + + await using MemoryStream responseBody = new(); + context.Response.Body = responseBody; + + await result.ExecuteAsync(context); + + responseBody.Position = 0; + using StreamReader reader = new(responseBody); + string content = await reader.ReadToEndAsync(); + + JsonNode? json = string.IsNullOrWhiteSpace(content) + ? null + : JsonNode.Parse(content); + + return new HttpResponseCapture(context.Response.StatusCode, json); + } + + private sealed record HttpResponseCapture(int StatusCode, JsonNode? Body) + { + public JsonNode RequiredBody => Body ?? throw new InvalidOperationException("Expected response body."); + } + + private sealed record TestResponse(string Value); +} diff --git a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..7e5312b --- /dev/null +++ b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -0,0 +1,213 @@ +using System.Text; +using System.Text.Json.Nodes; +using Company.Template.Api.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; + +namespace Company.Template.Api.Tests.Middleware; + +public sealed class GlobalExceptionHandlerTests +{ + [Fact] + public async Task TryHandleAsync_WithUnexpectedException_ReturnsTrue() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + DefaultHttpContext context = CreateHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + + // Act + bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None); + + // Assert + handled.ShouldBeTrue(); + } + + [Fact] + public async Task TryHandleAsync_WithUnexpectedException_WritesInternalServerErrorProblemDetails() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + DefaultHttpContext context = CreateHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + + // Act + await handler.TryHandleAsync(context, exception, CancellationToken.None); + JsonNode body = await ReadRequiredJsonBodyAsync(context); + + // Assert + context.Response.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError); + body["title"]!.GetValue().ShouldBe("An unexpected error occurred."); + body["detail"]!.GetValue().ShouldBe("The server encountered an unexpected condition."); + body["status"]!.GetValue().ShouldBe(StatusCodes.Status500InternalServerError); + } + + [Fact] + public async Task TryHandleAsync_WithUnexpectedException_DoesNotLeakExceptionMessage() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + DefaultHttpContext context = CreateHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + + // Act + await handler.TryHandleAsync(context, exception, CancellationToken.None); + string body = await ReadBodyAsync(context); + + // Assert + body.ShouldNotContain("Sensitive internal failure"); + body.ShouldNotContain(nameof(InvalidOperationException)); + } + + [Fact] + public async Task TryHandleAsync_WithUnexpectedException_UsesRequestPathAsProblemInstance() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + DefaultHttpContext context = CreateHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + + // Act + await handler.TryHandleAsync(context, exception, CancellationToken.None); + JsonNode body = await ReadRequiredJsonBodyAsync(context); + + // Assert + body["instance"]!.GetValue().ShouldBe("/api/failing"); + } + + [Fact] + public async Task TryHandleAsync_WhenResponseAlreadyStarted_ReturnsFalse() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + DefaultHttpContext context = CreateStartedHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + + // Act + bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None); + + // Assert + handled.ShouldBeFalse(); + } + + [Fact] + public async Task TryHandleAsync_WhenResponseAlreadyStarted_DoesNotWriteProblemDetails() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + DefaultHttpContext context = CreateStartedHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + + // Act + await handler.TryHandleAsync(context, exception, CancellationToken.None); + string body = await ReadBodyAsync(context); + + // Assert + body.ShouldBeEmpty(); + } + + [Fact] + public async Task TryHandleAsync_WhenRequestWasCancelled_ReturnsTrue() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + DefaultHttpContext context = CreateHttpContext("/api/failing"); + context.RequestAborted = cancellation.Token; + OperationCanceledException exception = new(cancellation.Token); + + // Act + bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None); + + // Assert + handled.ShouldBeTrue(); + } + + [Fact] + public async Task TryHandleAsync_WhenRequestWasCancelled_DoesNotWriteProblemDetails() + { + // Arrange + GlobalExceptionHandler handler = CreateHandler(); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + DefaultHttpContext context = CreateHttpContext("/api/failing"); + context.RequestAborted = cancellation.Token; + OperationCanceledException exception = new(cancellation.Token); + + // Act + await handler.TryHandleAsync(context, exception, CancellationToken.None); + string body = await ReadBodyAsync(context); + + // Assert + body.ShouldBeEmpty(); + } + + private static GlobalExceptionHandler CreateHandler() + { + return new GlobalExceptionHandler(NullLogger.Instance); + } + + private static DefaultHttpContext CreateHttpContext(string path) + { + DefaultHttpContext context = new(); + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + + return context; + } + + private static DefaultHttpContext CreateStartedHttpContext(string path) + { + StartedResponseFeature response = new(); + StreamResponseBodyFeature body = new(response.Body); + FeatureCollection features = new(); + features.Set(new HttpRequestFeature()); + features.Set(response); + features.Set(body); + + DefaultHttpContext context = new(features); + context.Request.Path = path; + + return context; + } + + private static async Task ReadRequiredJsonBodyAsync(HttpContext context) + { + string body = await ReadBodyAsync(context); + JsonNode? json = JsonNode.Parse(body); + + return json ?? throw new InvalidOperationException("Expected JSON response body."); + } + + private static async Task ReadBodyAsync(HttpContext context) + { + context.Response.Body.Position = 0; + using StreamReader reader = new(context.Response.Body, Encoding.UTF8, leaveOpen: true); + + return await reader.ReadToEndAsync(); + } + + private sealed class StartedResponseFeature : IHttpResponseFeature + { + public int StatusCode { get; set; } = StatusCodes.Status200OK; + + public string? ReasonPhrase { get; set; } + + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + + public Stream Body { get; set; } = new MemoryStream(); + + public bool HasStarted => true; + + public void OnCompleted(Func callback, object state) + { + } + + public void OnStarting(Func callback, object state) + { + } + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Conflict.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Conflict.cs new file mode 100644 index 0000000..b52a117 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Conflict.cs @@ -0,0 +1,30 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void Conflict_WithMessage_ReturnsConflictError() + { + // Act + Error error = Error.Conflict("Product already exists."); + + // Assert + error.Type.ShouldBe(ErrorType.Conflict); + error.Code.ShouldBe(ErrorCodes.Conflict); + error.Message.ShouldBe("Product already exists."); + } + + [Fact] + public void Conflict_WithCodeAndMessage_ReturnsConflictError() + { + // Act + Error error = Error.Conflict(ErrorCodes.Conflict, "Product already exists."); + + // Assert + error.Type.ShouldBe(ErrorType.Conflict); + error.Code.ShouldBe(ErrorCodes.Conflict); + error.Message.ShouldBe("Product already exists."); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.DomainMapping.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.DomainMapping.cs new file mode 100644 index 0000000..1b4f068 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.DomainMapping.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using Company.Template.Application.Common; +using Company.Template.Domain.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void ToApplicationError_AllKnownDomainErrorCodes_AreMapped() + { + // Arrange + DomainErrorCode[] codes = + [ + .. typeof(DomainErrorCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.FieldType == typeof(DomainErrorCode)) + .Select(field => (DomainErrorCode)field.GetValue(null)!) + .Where(code => !code.IsNone) + ]; + + // Act + Error[] errors = [.. codes.Select(code => DomainError.Create(code, "Test message.").ToApplicationError())]; + + // Assert + errors.ShouldAllBe(error => error.Type != ErrorType.Unknown); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.ErrorCode.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.ErrorCode.cs new file mode 100644 index 0000000..bbfe723 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.ErrorCode.cs @@ -0,0 +1,66 @@ +using Company.Template.Application.Common; +using Company.Template.Domain.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void ErrorCodeCreate_WithRegularValue_ReturnsCode() + { + // Act + ErrorCode code = ErrorCode.Create("custom_error"); + + // Assert + code.Value.ShouldBe("custom_error"); + code.ToString().ShouldBe("custom_error"); + code.IsNone.ShouldBeFalse(); + } + + [Fact] + public void ErrorCodeCreate_WithNoneValue_ReturnsNoneCode() + { + // Act + ErrorCode code = ErrorCode.Create("none"); + + // Assert + code.ShouldBe(ErrorCode.None); + code.IsNone.ShouldBeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void ErrorCodeCreate_WithMissingValue_ThrowsArgumentException(string value) + { + // Act + Action action = () => ErrorCode.Create(value); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void ErrorCodeFromDomain_WithDomainCode_PreservesCodeValue() + { + // Arrange + DomainErrorCode domainCode = DomainErrorCodes.ProductNameRequired; + + // Act + ErrorCode code = ErrorCode.FromDomain(domainCode); + + // Assert + code.Value.ShouldBe(domainCode.Value); + } + + [Fact] + public void ErrorCodeFromDomain_WithNullDomainCode_ThrowsArgumentNullException() + { + // Act + Action action = () => ErrorCode.FromDomain(null!); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.NoError.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.NoError.cs new file mode 100644 index 0000000..74c2e71 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.NoError.cs @@ -0,0 +1,19 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void None_ReturnsNoneError() + { + // Act + Error error = Error.None; + + // Assert + error.Type.ShouldBe(ErrorType.None); + error.Code.ShouldBe(ErrorCode.None); + error.Message.ShouldBe("No error."); + error.IsNone.ShouldBeTrue(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.NotFound.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.NotFound.cs new file mode 100644 index 0000000..f7e61b0 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.NotFound.cs @@ -0,0 +1,30 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void NotFound_WithMessage_ReturnsNotFoundError() + { + // Act + Error error = Error.NotFound("Product was not found."); + + // Assert + error.Type.ShouldBe(ErrorType.NotFound); + error.Code.ShouldBe(ErrorCodes.NotFound); + error.Message.ShouldBe("Product was not found."); + } + + [Fact] + public void NotFound_WithCodeAndMessage_ReturnsNotFoundError() + { + // Act + Error error = Error.NotFound(ErrorCodes.NotFound, "Product was not found."); + + // Assert + error.Type.ShouldBe(ErrorType.NotFound); + error.Code.ShouldBe(ErrorCodes.NotFound); + error.Message.ShouldBe("Product was not found."); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Unknown.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Unknown.cs new file mode 100644 index 0000000..8a16b2a --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Unknown.cs @@ -0,0 +1,18 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void Unknown_WithCodeAndMessage_ReturnsUnknownError() + { + // Act + Error error = Error.Unknown(ErrorCode.Create("unexpected_error"), "Unexpected error."); + + // Assert + error.Type.ShouldBe(ErrorType.Unknown); + error.Code.Value.ShouldBe("unexpected_error"); + error.Message.ShouldBe("Unexpected error."); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Validation.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Validation.cs new file mode 100644 index 0000000..c0a03dd --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.Validation.cs @@ -0,0 +1,102 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ErrorTests +{ + [Fact] + public void Validation_WithMessage_ReturnsValidationError() + { + // Act + Error error = Error.Validation("Invalid input."); + + // Assert + error.Type.ShouldBe(ErrorType.Validation); + error.Code.ShouldBe(ErrorCodes.ValidationError); + error.Message.ShouldBe("Invalid input."); + error.IsNone.ShouldBeFalse(); + } + + [Fact] + public void Validation_WithCodeAndMessage_ReturnsValidationError() + { + // Act + Error error = Error.Validation(ErrorCodes.ProductNameRequired, "Product name is required."); + + // Assert + error.Type.ShouldBe(ErrorType.Validation); + error.Code.ShouldBe(ErrorCodes.ProductNameRequired); + error.Message.ShouldBe("Product name is required."); + } + + [Fact] + public void Validation_WithTarget_PreservesTarget() + { + // Act + Error error = Error.Validation(ErrorCodes.ProductNameRequired, "Product name is required.", "name"); + + // Assert + error.Type.ShouldBe(ErrorType.Validation); + error.Code.ShouldBe(ErrorCodes.ProductNameRequired); + error.Message.ShouldBe("Product name is required."); + error.Target.ShouldBe("name"); + } + + [Fact] + public void Validation_WithDetails_PreservesDetails() + { + // Arrange + Error[] details = + [ + Error.Validation(ErrorCodes.ProductNameRequired, "Product name is required.", "name"), + Error.Validation(ErrorCodes.AmountNegative, "Amount cannot be negative.", "price") + ]; + + // Act + Error error = Error.Validation( + ErrorCodes.ValidationError, + "One or more validation errors occurred.", + details); + + // Assert + error.Type.ShouldBe(ErrorType.Validation); + error.Code.ShouldBe(ErrorCodes.ValidationError); + error.Details.ShouldBe(details); + } + + [Fact] + public void Validation_WithNoneCode_ThrowsArgumentException() + { + // Act + Action action = () => Error.Validation(ErrorCode.None, "Invalid input."); + + // Assert + action.ShouldThrow(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Validation_WithMissingCode_ThrowsArgumentException(string code) + { + // Act + Action action = () => Error.Validation(ErrorCode.Create(code), "Invalid input."); + + // Assert + action.ShouldThrow(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Validation_WithMissingMessage_ThrowsArgumentException(string message) + { + // Act + Action action = () => Error.Validation(ErrorCodes.ValidationError, message); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs index 89ebc2f..a4da441 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs @@ -1,116 +1,5 @@ -using System.Reflection; -using Company.Template.Application.Common; -using Company.Template.Domain.Common; - namespace Company.Template.Application.Tests.Common; -public sealed class ErrorTests +public sealed partial class ErrorTests { - [Fact] - public void None_ReturnsNoneError() - { - // Act - Error error = Error.None; - - // Assert - error.Type.ShouldBe(ErrorType.None); - error.Code.ShouldBe(ErrorCode.None); - error.Message.ShouldBe("No error."); - error.IsNone.ShouldBeTrue(); - } - - [Fact] - public void Validation_WithMessage_ReturnsValidationError() - { - // Act - Error error = Error.Validation("Invalid input."); - - // Assert - error.Type.ShouldBe(ErrorType.Validation); - error.Code.ShouldBe(ErrorCodes.ValidationError); - error.Message.ShouldBe("Invalid input."); - error.IsNone.ShouldBeFalse(); - } - - [Fact] - public void Validation_WithCodeAndMessage_ReturnsValidationError() - { - // Act - Error error = Error.Validation(ErrorCodes.ProductNameRequired, "Product name is required."); - - // Assert - error.Type.ShouldBe(ErrorType.Validation); - error.Code.ShouldBe(ErrorCodes.ProductNameRequired); - error.Message.ShouldBe("Product name is required."); - } - - [Fact] - public void NotFound_WithMessage_ReturnsNotFoundError() - { - // Act - Error error = Error.NotFound("Product was not found."); - - // Assert - error.Type.ShouldBe(ErrorType.NotFound); - error.Code.ShouldBe(ErrorCodes.NotFound); - error.Message.ShouldBe("Product was not found."); - } - - [Fact] - public void Conflict_WithMessage_ReturnsConflictError() - { - // Act - Error error = Error.Conflict("Product already exists."); - - // Assert - error.Type.ShouldBe(ErrorType.Conflict); - error.Code.ShouldBe(ErrorCodes.Conflict); - error.Message.ShouldBe("Product already exists."); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("\t")] - public void Validation_WithMissingCode_ThrowsArgumentException(string code) - { - // Act - Action action = () => Error.Validation(ErrorCode.Create(code), "Invalid input."); - - // Assert - action.ShouldThrow(); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("\t")] - public void Validation_WithMissingMessage_ThrowsArgumentException(string message) - { - // Act - Action action = () => Error.Validation(ErrorCodes.ValidationError, message); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void ToApplicationError_AllKnownDomainErrorCodes_AreMapped() - { - // Arrange - DomainErrorCode[] codes = - [ - .. typeof(DomainErrorCodes) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(field => field.FieldType == typeof(DomainErrorCode)) - .Select(field => (DomainErrorCode)field.GetValue(null)!) - .Where(code => !code.IsNone) - ]; - - // Act - Error[] errors = [.. codes.Select(code => DomainError.Create(code, "Test message.").ToApplicationError())]; - - // Assert - errors.ShouldAllBe(error => error.Type != ErrorType.Unknown); - } } diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.Bind.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Bind.cs new file mode 100644 index 0000000..f5782b5 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Bind.cs @@ -0,0 +1,66 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void Bind_WithSome_ReturnsBoundOption() + { + // Arrange + Option option = Option.Some(21); + + // Act + Option result = option.Bind(value => Option.Some(value * 2)); + + // Assert + result.HasValue.ShouldBeTrue(); + result.Value.ShouldBe(42); + } + + [Fact] + public void Bind_WithNone_ReturnsNone() + { + // Arrange + Option option = Option.None(); + + // Act + Option result = option.Bind(value => Option.Some(value * 2)); + + // Assert + result.IsNone.ShouldBeTrue(); + } + + [Fact] + public void Bind_WithNone_DoesNotInvokeBinder() + { + // Arrange + Option option = Option.None(); + bool binderWasCalled = false; + + // Act + Option result = option.Bind(_ => + { + binderWasCalled = true; + return Option.Some(42); + }); + + // Assert + result.IsNone.ShouldBeTrue(); + binderWasCalled.ShouldBeFalse(); + } + + [Fact] + public void Bind_WithNullDelegate_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.Some(21); + Func> bind = null!; + + // Act + Action action = () => option.Bind(bind); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.Construction.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Construction.cs new file mode 100644 index 0000000..a664b57 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Construction.cs @@ -0,0 +1,52 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void Some_WithValue_ReturnsOptionWithValue() + { + // Act + Option option = Option.Some("value"); + + // Assert + option.HasValue.ShouldBeTrue(); + option.IsNone.ShouldBeFalse(); + option.Value.ShouldBe("value"); + } + + [Fact] + public void Some_WithNullValue_ThrowsArgumentNullException() + { + // Act + Action action = () => Option.Some(null!); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void None_ReturnsOptionWithoutValue() + { + // Act + Option option = Option.None(); + + // Assert + option.HasValue.ShouldBeFalse(); + option.IsNone.ShouldBeTrue(); + } + + [Fact] + public void Value_OnNone_ThrowsInvalidOperationException() + { + // Arrange + Option option = Option.None(); + + // Act + Action action = () => _ = option.Value; + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.FromNullable.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.FromNullable.cs new file mode 100644 index 0000000..ce8106e --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.FromNullable.cs @@ -0,0 +1,60 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void FromNullable_WithNonNullReference_ReturnsSome() + { + // Arrange + string? value = "value"; + + // Act + Option option = Option.FromNullable(value); + + // Assert + option.HasValue.ShouldBeTrue(); + option.Value.ShouldBe("value"); + } + + [Fact] + public void FromNullable_WithNullReference_ReturnsNone() + { + // Arrange + string? value = null; + + // Act + Option option = Option.FromNullable(value); + + // Assert + option.IsNone.ShouldBeTrue(); + } + + [Fact] + public void FromNullable_WithNullableStructValue_ReturnsSome() + { + // Arrange + int? value = 42; + + // Act + Option option = Option.FromNullable(value); + + // Assert + option.HasValue.ShouldBeTrue(); + option.Value.ShouldBe(42); + } + + [Fact] + public void FromNullable_WithNullNullableStruct_ReturnsNone() + { + // Arrange + int? value = null; + + // Act + Option option = Option.FromNullable(value); + + // Assert + option.IsNone.ShouldBeTrue(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.Map.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Map.cs new file mode 100644 index 0000000..e151660 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Map.cs @@ -0,0 +1,66 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void Map_WithSome_TransformsValue() + { + // Arrange + Option option = Option.Some(21); + + // Act + Option result = option.Map(value => value * 2); + + // Assert + result.HasValue.ShouldBeTrue(); + result.Value.ShouldBe(42); + } + + [Fact] + public void Map_WithNone_ReturnsNone() + { + // Arrange + Option option = Option.None(); + + // Act + Option result = option.Map(value => value * 2); + + // Assert + result.IsNone.ShouldBeTrue(); + } + + [Fact] + public void Map_WithNone_DoesNotInvokeMapper() + { + // Arrange + Option option = Option.None(); + bool mapperWasCalled = false; + + // Act + Option result = option.Map(_ => + { + mapperWasCalled = true; + return 42; + }); + + // Assert + result.IsNone.ShouldBeTrue(); + mapperWasCalled.ShouldBeFalse(); + } + + [Fact] + public void Map_WithNullDelegate_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.Some(21); + Func map = null!; + + // Act + Action action = () => option.Map(map); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.Match.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Match.cs new file mode 100644 index 0000000..2a00ef0 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Match.cs @@ -0,0 +1,64 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void Match_WithSome_CallsSomeBranch() + { + // Arrange + Option option = Option.Some("value"); + + // Act + string result = option.Match( + value => $"some:{value}", + () => "none"); + + // Assert + result.ShouldBe("some:value"); + } + + [Fact] + public void Match_WithNone_CallsNoneBranch() + { + // Arrange + Option option = Option.None(); + + // Act + string result = option.Match( + value => $"some:{value}", + () => "none"); + + // Assert + result.ShouldBe("none"); + } + + [Fact] + public void Match_WithNullSomeDelegate_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.Some("value"); + Func some = null!; + + // Act + Action action = () => option.Match(some, () => "none"); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Match_WithNullNoneDelegate_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.None(); + Func none = null!; + + // Act + Action action = () => option.Match(value => $"some:{value}", none); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.OrElse.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.OrElse.cs new file mode 100644 index 0000000..15e15d4 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.OrElse.cs @@ -0,0 +1,78 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void OrElse_WithSome_ReturnsValue() + { + // Arrange + Option option = Option.Some("value"); + + // Act + string result = option.OrElse("fallback"); + + // Assert + result.ShouldBe("value"); + } + + [Fact] + public void OrElse_WithNone_ReturnsFallback() + { + // Arrange + Option option = Option.None(); + + // Act + string result = option.OrElse("fallback"); + + // Assert + result.ShouldBe("fallback"); + } + + [Fact] + public void OrElseFactory_WithSome_DoesNotInvokeFallbackFactory() + { + // Arrange + Option option = Option.Some("value"); + bool fallbackWasCalled = false; + + // Act + string result = option.OrElse(() => + { + fallbackWasCalled = true; + return "fallback"; + }); + + // Assert + result.ShouldBe("value"); + fallbackWasCalled.ShouldBeFalse(); + } + + [Fact] + public void OrElseFactory_WithNone_ReturnsFallbackFactoryValue() + { + // Arrange + Option option = Option.None(); + + // Act + string result = option.OrElse(() => "fallback"); + + // Assert + result.ShouldBe("fallback"); + } + + [Fact] + public void OrElseFactory_WithNullFallbackFactory_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.None(); + Func fallback = null!; + + // Act + Action action = () => option.OrElse(fallback); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.TryGetValue.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.TryGetValue.cs new file mode 100644 index 0000000..6d83bcf --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.TryGetValue.cs @@ -0,0 +1,34 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void TryGetValue_WithSome_ReturnsTrueAndValue() + { + // Arrange + Option option = Option.Some("value"); + + // Act + bool result = option.TryGetValue(out string? value); + + // Assert + result.ShouldBeTrue(); + value.ShouldBe("value"); + } + + [Fact] + public void TryGetValue_WithNone_ReturnsFalseAndDefault() + { + // Arrange + Option option = Option.None(); + + // Act + bool result = option.TryGetValue(out string? value); + + // Assert + result.ShouldBeFalse(); + value.ShouldBeNull(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.Where.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Where.cs new file mode 100644 index 0000000..9d7b269 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.Where.cs @@ -0,0 +1,65 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void Where_WithPredicateTrue_ReturnsSameOption() + { + // Arrange + Option option = Option.Some(21); + + // Act + Option result = option.Where(value => value > 0); + + // Assert + result.ShouldBe(option); + } + + [Fact] + public void Where_WithPredicateFalse_ReturnsNone() + { + // Arrange + Option option = Option.Some(21); + + // Act + Option result = option.Where(value => value > 100); + + // Assert + result.IsNone.ShouldBeTrue(); + } + + [Fact] + public void Where_WithNone_DoesNotInvokePredicate() + { + // Arrange + Option option = Option.None(); + bool predicateWasCalled = false; + + // Act + Option result = option.Where(_ => + { + predicateWasCalled = true; + return true; + }); + + // Assert + result.IsNone.ShouldBeTrue(); + predicateWasCalled.ShouldBeFalse(); + } + + [Fact] + public void Where_WithNullPredicate_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.Some(21); + Func predicate = null!; + + // Act + Action action = () => option.Where(predicate); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.WhereNot.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.WhereNot.cs new file mode 100644 index 0000000..5e5ea04 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.WhereNot.cs @@ -0,0 +1,65 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class OptionTests +{ + [Fact] + public void WhereNot_WithPredicateFalse_ReturnsSameOption() + { + // Arrange + Option option = Option.Some(21); + + // Act + Option result = option.WhereNot(value => value > 100); + + // Assert + result.ShouldBe(option); + } + + [Fact] + public void WhereNot_WithPredicateTrue_ReturnsNone() + { + // Arrange + Option option = Option.Some(21); + + // Act + Option result = option.WhereNot(value => value > 0); + + // Assert + result.IsNone.ShouldBeTrue(); + } + + [Fact] + public void WhereNot_WithNone_DoesNotInvokePredicate() + { + // Arrange + Option option = Option.None(); + bool predicateWasCalled = false; + + // Act + Option result = option.WhereNot(_ => + { + predicateWasCalled = true; + return true; + }); + + // Assert + result.IsNone.ShouldBeTrue(); + predicateWasCalled.ShouldBeFalse(); + } + + [Fact] + public void WhereNot_WithNullPredicate_ThrowsArgumentNullException() + { + // Arrange + Option option = Option.Some(21); + Func predicate = null!; + + // Act + Action action = () => option.WhereNot(predicate); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs index ee89ff7..c7d76ad 100644 --- a/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs @@ -1,164 +1,5 @@ -using Company.Template.Application.Common; - namespace Company.Template.Application.Tests.Common; -public sealed class OptionTests +public sealed partial class OptionTests { - [Fact] - public void Some_WithValue_ReturnsOptionWithValue() - { - // Act - Option option = Option.Some("value"); - - // Assert - option.HasValue.ShouldBeTrue(); - option.IsNone.ShouldBeFalse(); - option.Value.ShouldBe("value"); - } - - [Fact] - public void Some_WithNullValue_ThrowsArgumentNullException() - { - // Act - Action action = () => Option.Some(null!); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void None_ReturnsOptionWithoutValue() - { - // Act - Option option = Option.None(); - - // Assert - option.HasValue.ShouldBeFalse(); - option.IsNone.ShouldBeTrue(); - } - - [Fact] - public void Value_OnNone_ThrowsInvalidOperationException() - { - // Arrange - Option option = Option.None(); - - // Act - Action action = () => _ = option.Value; - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void Match_WithSome_CallsSomeBranch() - { - // Arrange - Option option = Option.Some("value"); - - // Act - string result = option.Match( - value => $"some:{value}", - () => "none"); - - // Assert - result.ShouldBe("some:value"); - } - - [Fact] - public void Match_WithNone_CallsNoneBranch() - { - // Arrange - Option option = Option.None(); - - // Act - string result = option.Match( - value => $"some:{value}", - () => "none"); - - // Assert - result.ShouldBe("none"); - } - - [Fact] - public void Map_WithSome_TransformsValue() - { - // Arrange - Option option = Option.Some(21); - - // Act - Option result = option.Map(value => value * 2); - - // Assert - result.HasValue.ShouldBeTrue(); - result.Value.ShouldBe(42); - } - - [Fact] - public void Map_WithNone_ReturnsNone() - { - // Arrange - Option option = Option.None(); - - // Act - Option result = option.Map(value => value * 2); - - // Assert - result.IsNone.ShouldBeTrue(); - } - - [Fact] - public void Bind_WithSome_ReturnsBoundOption() - { - // Arrange - Option option = Option.Some(21); - - // Act - Option result = option.Bind(value => Option.Some(value * 2)); - - // Assert - result.HasValue.ShouldBeTrue(); - result.Value.ShouldBe(42); - } - - [Fact] - public void Where_WithPredicateFalse_ReturnsNone() - { - // Arrange - Option option = Option.Some(21); - - // Act - Option result = option.Where(value => value > 100); - - // Assert - result.IsNone.ShouldBeTrue(); - } - - [Fact] - public void TryGetValue_WithSome_ReturnsTrueAndValue() - { - // Arrange - Option option = Option.Some("value"); - - // Act - bool result = option.TryGetValue(out string? value); - - // Assert - result.ShouldBeTrue(); - value.ShouldBe("value"); - } - - [Fact] - public void TryGetValue_WithNone_ReturnsFalseAndDefault() - { - // Arrange - Option option = Option.None(); - - // Act - bool result = option.TryGetValue(out string? value); - - // Assert - result.ShouldBeFalse(); - value.ShouldBeNull(); - } } diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.Bind.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.Bind.cs new file mode 100644 index 0000000..f15d082 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.Bind.cs @@ -0,0 +1,68 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ResultTests +{ + [Fact] + public void Bind_WithSuccess_ReturnsBoundResult() + { + // Arrange + Result result = Result.Success("ok"); + + // Act + Result bound = result.Bind(text => Result.Success(text.Length)); + + // Assert + bound.IsSuccess.ShouldBeTrue(); + bound.Value.ShouldBe(2); + } + + [Fact] + public void Bind_WithFailure_ReturnsOriginalFailure() + { + // Arrange + Error error = Error.Validation("Invalid input."); + Result result = Result.Failure(error); + + // Act + Result bound = result.Bind(text => Result.Success(text.Length)); + + // Assert + bound.IsFailure.ShouldBeTrue(); + bound.Error.ShouldBe(error); + } + + [Fact] + public void Bind_WithFailure_DoesNotInvokeBinder() + { + // Arrange + Result result = Result.Failure(Error.Validation("Invalid input.")); + bool binderWasCalled = false; + + // Act + Result bound = result.Bind(_ => + { + binderWasCalled = true; + return Result.Success(42); + }); + + // Assert + bound.IsFailure.ShouldBeTrue(); + binderWasCalled.ShouldBeFalse(); + } + + [Fact] + public void Bind_WithNullDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Success("ok"); + Func> bind = null!; + + // Act + Action action = () => result.Bind(bind); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.BindAsync.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.BindAsync.cs new file mode 100644 index 0000000..1012275 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.BindAsync.cs @@ -0,0 +1,68 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ResultTests +{ + [Fact] + public async Task BindAsync_WithSuccess_ReturnsBoundResult() + { + // Arrange + Result result = Result.Success("ok"); + + // Act + Result bound = await result.BindAsync(text => Task.FromResult(Result.Success(text.Length))); + + // Assert + bound.IsSuccess.ShouldBeTrue(); + bound.Value.ShouldBe(2); + } + + [Fact] + public async Task BindAsync_WithFailure_ReturnsOriginalFailure() + { + // Arrange + Error error = Error.Validation("Invalid input."); + Result result = Result.Failure(error); + + // Act + Result bound = await result.BindAsync(text => Task.FromResult(Result.Success(text.Length))); + + // Assert + bound.IsFailure.ShouldBeTrue(); + bound.Error.ShouldBe(error); + } + + [Fact] + public async Task BindAsync_WithFailure_DoesNotInvokeBinder() + { + // Arrange + Result result = Result.Failure(Error.Validation("Invalid input.")); + bool binderWasCalled = false; + + // Act + Result bound = await result.BindAsync(_ => + { + binderWasCalled = true; + return Task.FromResult(Result.Success(42)); + }); + + // Assert + bound.IsFailure.ShouldBeTrue(); + binderWasCalled.ShouldBeFalse(); + } + + [Fact] + public void BindAsync_WithNullDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Success("ok"); + Func>> bind = null!; + + // Act + Func action = () => result.BindAsync(bind); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.Map.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.Map.cs new file mode 100644 index 0000000..b74625a --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.Map.cs @@ -0,0 +1,68 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ResultTests +{ + [Fact] + public void Map_WithSuccess_ReturnsMappedResult() + { + // Arrange + Result result = Result.Success("ok"); + + // Act + Result mapped = result.Map(text => text.Length); + + // Assert + mapped.IsSuccess.ShouldBeTrue(); + mapped.Value.ShouldBe(2); + } + + [Fact] + public void Map_WithFailure_ReturnsOriginalFailure() + { + // Arrange + Error error = Error.Validation("Invalid input."); + Result result = Result.Failure(error); + + // Act + Result mapped = result.Map(text => text.Length); + + // Assert + mapped.IsFailure.ShouldBeTrue(); + mapped.Error.ShouldBe(error); + } + + [Fact] + public void Map_WithFailure_DoesNotInvokeMapper() + { + // Arrange + Result result = Result.Failure(Error.Validation("Invalid input.")); + bool mapperWasCalled = false; + + // Act + Result mapped = result.Map(_ => + { + mapperWasCalled = true; + return 42; + }); + + // Assert + mapped.IsFailure.ShouldBeTrue(); + mapperWasCalled.ShouldBeFalse(); + } + + [Fact] + public void Map_WithNullDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Success("ok"); + Func map = null!; + + // Act + Action action = () => result.Map(map); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.Match.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.Match.cs new file mode 100644 index 0000000..b6b7c83 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.Match.cs @@ -0,0 +1,64 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ResultTests +{ + [Fact] + public void Match_WithSuccess_CallsSuccessBranch() + { + // Arrange + Result result = Result.Success("ok"); + + // Act + string value = result.Match( + text => $"success:{text}", + error => $"failure:{error.Code}"); + + // Assert + value.ShouldBe("success:ok"); + } + + [Fact] + public void Match_WithFailure_CallsFailureBranch() + { + // Arrange + Result result = Result.Failure(Error.Validation("Invalid input.")); + + // Act + string value = result.Match( + text => $"success:{text}", + error => $"failure:{error.Code}"); + + // Assert + value.ShouldBe("failure:validation_error"); + } + + [Fact] + public void Match_WithNullSuccessDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Success("ok"); + Func success = null!; + + // Act + Action action = () => result.Match(success, error => $"failure:{error.Code}"); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Match_WithNullFailureDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Failure(Error.Validation("Invalid input.")); + Func failure = null!; + + // Act + Action action = () => result.Match(text => $"success:{text}", failure); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.NonGeneric.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.NonGeneric.cs new file mode 100644 index 0000000..f0077ba --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.NonGeneric.cs @@ -0,0 +1,101 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ResultTests +{ + [Fact] + public void NonGenericSuccess_ReturnsSuccessfulResult() + { + // Act + Result result = Result.Success(); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.IsFailure.ShouldBeFalse(); + result.Error.ShouldBe(Error.None); + } + + [Fact] + public void NonGenericFailure_WithError_ReturnsFailedResult() + { + // Arrange + Error error = Error.Conflict("Conflict."); + + // Act + Result result = Result.Failure(error); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(error); + } + + [Fact] + public void NonGenericFailure_WithNoneError_ThrowsArgumentException() + { + // Act + Action action = () => Result.Failure(Error.None); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void NonGenericMatch_WithSuccess_CallsSuccessBranch() + { + // Arrange + Result result = Result.Success(); + + // Act + string value = result.Match( + () => "success", + error => $"failure:{error.Code}"); + + // Assert + value.ShouldBe("success"); + } + + [Fact] + public void NonGenericMatch_WithFailure_CallsFailureBranch() + { + // Arrange + Result result = Result.Failure(Error.Conflict("Conflict.")); + + // Act + string value = result.Match( + () => "success", + error => $"failure:{error.Code}"); + + // Assert + value.ShouldBe("failure:conflict"); + } + + [Fact] + public void NonGenericMatch_WithNullSuccessDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Success(); + Func success = null!; + + // Act + Action action = () => result.Match(success, error => $"failure:{error.Code}"); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void NonGenericMatch_WithNullFailureDelegate_ThrowsArgumentNullException() + { + // Arrange + Result result = Result.Failure(Error.Conflict("Conflict.")); + Func failure = null!; + + // Act + Action action = () => result.Match(() => "success", failure); + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.SuccessFailure.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.SuccessFailure.cs new file mode 100644 index 0000000..48d5e2f --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.SuccessFailure.cs @@ -0,0 +1,67 @@ +using Company.Template.Application.Common; + +namespace Company.Template.Application.Tests.Common; + +public sealed partial class ResultTests +{ + [Fact] + public void Success_WithValue_ReturnsSuccessfulResult() + { + // Act + Result result = Result.Success("ok"); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.IsFailure.ShouldBeFalse(); + result.Value.ShouldBe("ok"); + result.Error.ShouldBe(Error.None); + } + + [Fact] + public void Success_WithNullValue_ThrowsArgumentNullException() + { + // Act + Action action = () => Result.Success(null!); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Failure_WithError_ReturnsFailedResult() + { + // Arrange + Error error = Error.Validation("Invalid input."); + + // Act + Result result = Result.Failure(error); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldBe(error); + } + + [Fact] + public void Failure_WithNoneError_ThrowsArgumentException() + { + // Act + Action action = () => Result.Failure(Error.None); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Value_OnFailure_ThrowsInvalidOperationException() + { + // Arrange + Result result = Result.Failure(Error.Validation("Invalid input.")); + + // Act + Action action = () => _ = result.Value; + + // Assert + action.ShouldThrow(); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs index 8d7611a..b26af6c 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs @@ -1,293 +1,5 @@ -using Company.Template.Application.Common; - namespace Company.Template.Application.Tests.Common; -public sealed class ResultTests +public sealed partial class ResultTests { - [Fact] - public void Success_WithValue_ReturnsSuccessfulResult() - { - // Act - Result result = Result.Success("ok"); - - // Assert - result.IsSuccess.ShouldBeTrue(); - result.IsFailure.ShouldBeFalse(); - result.Value.ShouldBe("ok"); - result.Error.ShouldBe(Error.None); - } - - [Fact] - public void Success_WithNullValue_ThrowsArgumentNullException() - { - // Act - Action action = () => Result.Success(null!); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void Failure_WithError_ReturnsFailedResult() - { - // Arrange - Error error = Error.Validation("Invalid input."); - - // Act - Result result = Result.Failure(error); - - // Assert - result.IsSuccess.ShouldBeFalse(); - result.IsFailure.ShouldBeTrue(); - result.Error.ShouldBe(error); - } - - [Fact] - public void Failure_WithNoneError_ThrowsArgumentException() - { - // Act - Action action = () => Result.Failure(Error.None); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void Value_OnFailure_ThrowsInvalidOperationException() - { - // Arrange - Result result = Result.Failure(Error.Validation("Invalid input.")); - - // Act - Action action = () => _ = result.Value; - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void Match_WithSuccess_CallsSuccessBranch() - { - // Arrange - Result result = Result.Success("ok"); - - // Act - string value = result.Match( - text => $"success:{text}", - error => $"failure:{error.Code}"); - - // Assert - value.ShouldBe("success:ok"); - } - - [Fact] - public void Match_WithFailure_CallsFailureBranch() - { - // Arrange - Result result = Result.Failure(Error.Validation("Invalid input.")); - - // Act - string value = result.Match( - text => $"success:{text}", - error => $"failure:{error.Code}"); - - // Assert - value.ShouldBe("failure:validation_error"); - } - - [Fact] - public void Map_WithSuccess_ReturnsMappedResult() - { - // Arrange - Result result = Result.Success("ok"); - - // Act - Result mapped = result.Map(text => text.Length); - - // Assert - mapped.IsSuccess.ShouldBeTrue(); - mapped.Value.ShouldBe(2); - } - - [Fact] - public void Map_WithFailure_ReturnsOriginalFailure() - { - // Arrange - Error error = Error.Validation("Invalid input."); - Result result = Result.Failure(error); - - // Act - Result mapped = result.Map(text => text.Length); - - // Assert - mapped.IsFailure.ShouldBeTrue(); - mapped.Error.ShouldBe(error); - } - - [Fact] - public void Map_WithNullDelegate_ThrowsArgumentNullException() - { - // Arrange - Result result = Result.Success("ok"); - Func map = null!; - - // Act - Action action = () => result.Map(map); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void Bind_WithSuccess_ReturnsBoundResult() - { - // Arrange - Result result = Result.Success("ok"); - - // Act - Result bound = result.Bind(text => Result.Success(text.Length)); - - // Assert - bound.IsSuccess.ShouldBeTrue(); - bound.Value.ShouldBe(2); - } - - [Fact] - public void Bind_WithFailure_ReturnsOriginalFailure() - { - // Arrange - Error error = Error.Validation("Invalid input."); - Result result = Result.Failure(error); - - // Act - Result bound = result.Bind(text => Result.Success(text.Length)); - - // Assert - bound.IsFailure.ShouldBeTrue(); - bound.Error.ShouldBe(error); - } - - [Fact] - public void Bind_WithNullDelegate_ThrowsArgumentNullException() - { - // Arrange - Result result = Result.Success("ok"); - Func> bind = null!; - - // Act - Action action = () => result.Bind(bind); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public async Task BindAsync_WithSuccess_ReturnsBoundResult() - { - // Arrange - Result result = Result.Success("ok"); - - // Act - Result bound = await result.BindAsync(text => Task.FromResult(Result.Success(text.Length))); - - // Assert - bound.IsSuccess.ShouldBeTrue(); - bound.Value.ShouldBe(2); - } - - [Fact] - public async Task BindAsync_WithFailure_ReturnsOriginalFailure() - { - // Arrange - Error error = Error.Validation("Invalid input."); - Result result = Result.Failure(error); - - // Act - Result bound = await result.BindAsync(text => Task.FromResult(Result.Success(text.Length))); - - // Assert - bound.IsFailure.ShouldBeTrue(); - bound.Error.ShouldBe(error); - } - - [Fact] - public void BindAsync_WithNullDelegate_ThrowsArgumentNullException() - { - // Arrange - Result result = Result.Success("ok"); - Func>> bind = null!; - - // Act - Func action = () => result.BindAsync(bind); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void NonGenericSuccess_ReturnsSuccessfulResult() - { - // Act - Result result = Result.Success(); - - // Assert - result.IsSuccess.ShouldBeTrue(); - result.IsFailure.ShouldBeFalse(); - result.Error.ShouldBe(Error.None); - } - - [Fact] - public void NonGenericFailure_WithError_ReturnsFailedResult() - { - // Arrange - Error error = Error.Conflict("Conflict."); - - // Act - Result result = Result.Failure(error); - - // Assert - result.IsSuccess.ShouldBeFalse(); - result.IsFailure.ShouldBeTrue(); - result.Error.ShouldBe(error); - } - - [Fact] - public void NonGenericFailure_WithNoneError_ThrowsArgumentException() - { - // Act - Action action = () => Result.Failure(Error.None); - - // Assert - action.ShouldThrow(); - } - - [Fact] - public void NonGenericMatch_WithSuccess_CallsSuccessBranch() - { - // Arrange - Result result = Result.Success(); - - // Act - string value = result.Match( - () => "success", - error => $"failure:{error.Code}"); - - // Assert - value.ShouldBe("success"); - } - - [Fact] - public void NonGenericMatch_WithFailure_CallsFailureBranch() - { - // Arrange - Result result = Result.Failure(Error.Conflict("Conflict.")); - - // Act - string value = result.Match( - () => "success", - error => $"failure:{error.Code}"); - - // Assert - value.ShouldBe("failure:conflict"); - } } diff --git a/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationResultTests.cs b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationResultTests.cs new file mode 100644 index 0000000..5922a36 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationResultTests.cs @@ -0,0 +1,108 @@ +using Company.Template.Application.Common; +using Company.Template.Application.Common.Validation; + +namespace Company.Template.Application.Tests.Common.Validation; + +public sealed class ValidationResultTests +{ + [Fact] + public void Success_WithValue_ReturnsValidResult() + { + // Act + ValidationResult result = ValidationResult.Success("value"); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Value.ShouldBe("value"); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Success_WithNullValue_ThrowsArgumentNullException() + { + // Act + Action action = () => ValidationResult.Success(null!); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Failure_WithErrors_ReturnsInvalidResult() + { + // Arrange + Error[] errors = + [ + Error.Validation(ErrorCodes.ValidationError, "Name is required.", "name") + ]; + + // Act + ValidationResult result = ValidationResult.Failure(errors); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldBe(errors); + } + + [Fact] + public void Failure_WithEmptyErrors_ThrowsArgumentException() + { + // Act + Action action = () => ValidationResult.Failure([]); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Value_OnFailure_ThrowsInvalidOperationException() + { + // Arrange + ValidationResult result = ValidationResult.Failure([ + Error.Validation("Invalid input.") + ]); + + // Act + Action action = () => _ = result.Value; + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void ToResult_WithSuccess_ReturnsSuccessfulResult() + { + // Arrange + ValidationResult validationResult = ValidationResult.Success("value"); + + // Act + Result result = validationResult.ToResult(); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe("value"); + } + + [Fact] + public void ToResult_WithFailure_ReturnsValidationFailureWithDetails() + { + // Arrange + Error[] errors = + [ + Error.Validation(ErrorCodes.ValidationError, "Name is required.", "name"), + Error.Validation(ErrorCodes.ValidationError, "Amount must be positive.", "amount") + ]; + + ValidationResult validationResult = ValidationResult.Failure(errors); + + // Act + Result result = validationResult.ToResult(); + + // Assert + result.IsFailure.ShouldBeTrue(); + result.Error.Type.ShouldBe(ErrorType.Validation); + result.Error.Code.ShouldBe(ErrorCodes.ValidationError); + result.Error.Message.ShouldBe("One or more validation errors occurred."); + result.Error.Details.ShouldBe(errors); + } +} diff --git a/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs new file mode 100644 index 0000000..429a151 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs @@ -0,0 +1,194 @@ +using Company.Template.Application.Common; +using Company.Template.Application.Common.Validation; + +using TemplateValidation = Company.Template.Application.Common.Validation.Validation; + +namespace Company.Template.Application.Tests.Common.Validation; + +public sealed class ValidationTests +{ + [Fact] + public void For_WithNullValue_ThrowsArgumentNullException() + { + // Act + Action action = () => TemplateValidation.For(null!); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Map_WhenAllRulesPass_ReturnsMappedValue() + { + // Arrange + TestRequest request = new("Valid name", 42); + + // Act + ValidationResult result = TemplateValidation.For(request) + .Rule(value => value.Name.Length > 0 + ? null + : Error.Validation("Name is required.")) + .RuleFor(value => value.Amount, amount => amount > 0 + ? null + : Error.Validation("Amount must be positive.")) + .Map(value => value.Name); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Value.ShouldBe("Valid name"); + } + + [Fact] + public void Map_WhenRuleFails_DoesNotInvokeMapper() + { + // Arrange + TestRequest request = new("", 42); + bool mapperWasCalled = false; + + // Act + ValidationResult result = TemplateValidation.For(request) + .Rule(value => string.IsNullOrWhiteSpace(value.Name) + ? Error.Validation("Name is required.") + : null) + .Map(_ => + { + mapperWasCalled = true; + return "mapped"; + }); + + // Assert + result.IsValid.ShouldBeFalse(); + mapperWasCalled.ShouldBeFalse(); + } + + [Fact] + public void Map_WhenMultipleRulesFail_CollectsAllErrors() + { + // Arrange + TestRequest request = new("", -1); + + // Act + ValidationResult result = TemplateValidation.For(request) + .Rule(value => string.IsNullOrWhiteSpace(value.Name) + ? Error.Validation("Name is required.") + : null) + .RuleFor(value => value.Amount, amount => amount < 0 + ? Error.Validation("Amount cannot be negative.") + : null) + .Map(value => value.Name); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(2); + result.Errors.Select(error => error.Message) + .ShouldBe(["Name is required.", "Amount cannot be negative."]); + } + + [Fact] + public void RuleFor_WhenRuleFails_AddsCamelCasePropertyNameAsTarget() + { + // Arrange + TestRequest request = new("Valid name", -1); + + // Act + ValidationResult result = TemplateValidation.For(request) + .RuleFor(value => value.Amount, amount => amount < 0 + ? Error.Validation("Amount cannot be negative.") + : null) + .Map(value => value.Name); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Single().Target.ShouldBe("amount"); + } + + [Fact] + public void RuleFor_WhenErrorAlreadyHasTarget_PreservesExistingTarget() + { + // Arrange + TestRequest request = new("Valid name", -1); + + // Act + ValidationResult result = TemplateValidation.For(request) + .RuleFor(value => value.Amount, amount => amount < 0 + ? Error.Validation(ErrorCodes.ValidationError, "Amount cannot be negative.", "customAmount") + : null) + .Map(value => value.Name); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Single().Target.ShouldBe("customAmount"); + } + + [Fact] + public void RuleFor_WithComplexSelector_ThrowsArgumentException() + { + // Arrange + TestRequest request = new("Valid name", 42); + + // Act + Action action = () => TemplateValidation.For(request) + .RuleFor(value => value.Amount + 1, _ => null); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Rule_WithNullRule_ThrowsArgumentNullException() + { + // Arrange + TestRequest request = new("Valid name", 42); + Func rule = null!; + + // Act + Action action = () => TemplateValidation.For(request).Rule(rule); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void RuleFor_WithNullSelector_ThrowsArgumentNullException() + { + // Arrange + TestRequest request = new("Valid name", 42); + Func rule = _ => null; + + // Act + Action action = () => TemplateValidation.For(request).RuleFor(null!, rule); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void RuleFor_WithNullRule_ThrowsArgumentNullException() + { + // Arrange + TestRequest request = new("Valid name", 42); + Func rule = null!; + + // Act + Action action = () => TemplateValidation.For(request).RuleFor(value => value.Amount, rule); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Map_WithNullMapper_ThrowsArgumentNullException() + { + // Arrange + TestRequest request = new("Valid name", 42); + Func map = null!; + + // Act + Action action = () => TemplateValidation.For(request).Map(map); + + // Assert + action.ShouldThrow(); + } + + private sealed record TestRequest(string Name, int Amount); +} diff --git a/content/tests/Company.Template.Application.Tests/Company.Template.Application.Tests.csproj b/content/tests/Company.Template.Application.Tests/Company.Template.Application.Tests.csproj index 9dba367..5f2c1f3 100644 --- a/content/tests/Company.Template.Application.Tests/Company.Template.Application.Tests.csproj +++ b/content/tests/Company.Template.Application.Tests/Company.Template.Application.Tests.csproj @@ -30,6 +30,75 @@ + + + ResultTests.cs + + + ResultTests.cs + + + ResultTests.cs + + + ResultTests.cs + + + ResultTests.cs + + + ResultTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + OptionTests.cs + + + ErrorTests.cs + + + ErrorTests.cs + + + ErrorTests.cs + + + ErrorTests.cs + + + ErrorTests.cs + + + ErrorTests.cs + + + ErrorTests.cs + + +