From d3d1efcfe274182aa8169701ddaaea207e021cfa Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:01:07 +0200 Subject: [PATCH 01/49] test: add validation builder contract tests --- .../Common/Validation/ValidationTests.cs | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs 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..5be1c84 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs @@ -0,0 +1,192 @@ +using Company.Template.Application.Common; +using Company.Template.Application.Common.Validation; + +namespace Company.Template.Application.Tests.Common.Validation; + +public sealed class ValidationTests +{ + [Fact] + public void For_WithNullValue_ThrowsArgumentNullException() + { + // Act + Action action = () => Validation.For(null!); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Map_WhenAllRulesPass_ReturnsMappedValue() + { + // Arrange + TestRequest request = new("Valid name", 42); + + // Act + ValidationResult result = Validation.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 = Validation.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 = Validation.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 = Validation.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 = Validation.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 = () => Validation.For(request) + .RuleFor(value => value.Name.Length, _ => null); + + // Assert + action.ShouldThrow(); + } + + [Fact] + public void Rule_WithNullRule_ThrowsArgumentNullException() + { + // Arrange + TestRequest request = new("Valid name", 42); + Func rule = null!; + + // Act + Action action = () => Validation.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 = () => Validation.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 = () => Validation.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 = () => Validation.For(request).Map(map); + + // Assert + action.ShouldThrow(); + } + + private sealed record TestRequest(string Name, int Amount); +} From cce8f359253999f870bac027c0a0cad779270977 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:01:33 +0200 Subject: [PATCH 02/49] test: add validation result contract tests --- .../Validation/ValidationResultTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/Validation/ValidationResultTests.cs 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); + } +} From e709b7c642a522a925441cd4504bc8f6a1b8d4c3 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:05:46 +0200 Subject: [PATCH 03/49] fix: disambiguate validation type in tests --- .../Common/Validation/ValidationTests.cs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs index 5be1c84..2dfd978 100644 --- a/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs @@ -1,6 +1,8 @@ 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 @@ -9,7 +11,7 @@ public sealed class ValidationTests public void For_WithNullValue_ThrowsArgumentNullException() { // Act - Action action = () => Validation.For(null!); + Action action = () => TemplateValidation.For(null!); // Assert action.ShouldThrow(); @@ -22,7 +24,7 @@ public void Map_WhenAllRulesPass_ReturnsMappedValue() TestRequest request = new("Valid name", 42); // Act - ValidationResult result = Validation.For(request) + ValidationResult result = TemplateValidation.For(request) .Rule(value => value.Name.Length > 0 ? null : Error.Validation("Name is required.")) @@ -44,7 +46,7 @@ public void Map_WhenRuleFails_DoesNotInvokeMapper() bool mapperWasCalled = false; // Act - ValidationResult result = Validation.For(request) + ValidationResult result = TemplateValidation.For(request) .Rule(value => string.IsNullOrWhiteSpace(value.Name) ? Error.Validation("Name is required.") : null) @@ -66,7 +68,7 @@ public void Map_WhenMultipleRulesFail_CollectsAllErrors() TestRequest request = new("", -1); // Act - ValidationResult result = Validation.For(request) + ValidationResult result = TemplateValidation.For(request) .Rule(value => string.IsNullOrWhiteSpace(value.Name) ? Error.Validation("Name is required.") : null) @@ -89,7 +91,7 @@ public void RuleFor_WhenRuleFails_AddsCamelCasePropertyNameAsTarget() TestRequest request = new("Valid name", -1); // Act - ValidationResult result = Validation.For(request) + ValidationResult result = TemplateValidation.For(request) .RuleFor(value => value.Amount, amount => amount < 0 ? Error.Validation("Amount cannot be negative.") : null) @@ -107,7 +109,7 @@ public void RuleFor_WhenErrorAlreadyHasTarget_PreservesExistingTarget() TestRequest request = new("Valid name", -1); // Act - ValidationResult result = Validation.For(request) + ValidationResult result = TemplateValidation.For(request) .RuleFor(value => value.Amount, amount => amount < 0 ? Error.Validation(ErrorCodes.ValidationError, "Amount cannot be negative.", "customAmount") : null) @@ -125,7 +127,7 @@ public void RuleFor_WithComplexSelector_ThrowsArgumentException() TestRequest request = new("Valid name", 42); // Act - Action action = () => Validation.For(request) + Action action = () => TemplateValidation.For(request) .RuleFor(value => value.Name.Length, _ => null); // Assert @@ -140,7 +142,7 @@ public void Rule_WithNullRule_ThrowsArgumentNullException() Func rule = null!; // Act - Action action = () => Validation.For(request).Rule(rule); + Action action = () => TemplateValidation.For(request).Rule(rule); // Assert action.ShouldThrow(); @@ -154,7 +156,7 @@ public void RuleFor_WithNullSelector_ThrowsArgumentNullException() Func rule = _ => null; // Act - Action action = () => Validation.For(request).RuleFor(null!, rule); + Action action = () => TemplateValidation.For(request).RuleFor(null!, rule); // Assert action.ShouldThrow(); @@ -168,7 +170,7 @@ public void RuleFor_WithNullRule_ThrowsArgumentNullException() Func rule = null!; // Act - Action action = () => Validation.For(request).RuleFor(value => value.Amount, rule); + Action action = () => TemplateValidation.For(request).RuleFor(value => value.Amount, rule); // Assert action.ShouldThrow(); @@ -182,7 +184,7 @@ public void Map_WithNullMapper_ThrowsArgumentNullException() Func map = null!; // Act - Action action = () => Validation.For(request).Map(map); + Action action = () => TemplateValidation.For(request).Map(map); // Assert action.ShouldThrow(); From 179b1ba8dbcc25d68fc8ec0eccee6ac3315dc159 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:08:33 +0200 Subject: [PATCH 04/49] fix: use unsupported selector shape in validation test --- .../Common/Validation/ValidationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs index 2dfd978..429a151 100644 --- a/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/Validation/ValidationTests.cs @@ -128,7 +128,7 @@ public void RuleFor_WithComplexSelector_ThrowsArgumentException() // Act Action action = () => TemplateValidation.For(request) - .RuleFor(value => value.Name.Length, _ => null); + .RuleFor(value => value.Amount + 1, _ => null); // Assert action.ShouldThrow(); From d2ba2c2377ea51128ec648a52b424ad04c1ecdc0 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:18:26 +0200 Subject: [PATCH 05/49] test: add endpoint result mapping contract tests --- .../EndpointResultExtensionsTests.cs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs 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..cbcd248 --- /dev/null +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json.Nodes; +using Company.Template.Api.Endpoints; +using Company.Template.Application.Common; +using Microsoft.AspNetCore.Http; + +namespace Company.Template.Api.Tests.Endpoints; + +public sealed class EndpointResultExtensionsTests +{ + [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.Body["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.Body["title"]!.GetValue().ShouldBe(expectedTitle); + response.Body["detail"]!.GetValue().ShouldBe("Example failure."); + response.Body["code"]!.GetValue().ShouldBe("example_error"); + } + + [Fact] + public async Task ToHttpResult_WithSingleValidationError_ReturnsValidationProblemForRequest() + { + // Arrange + Error error = Error.Validation(new ErrorCode("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.Body["title"]!.GetValue().ShouldBe("Validation failed."); + response.Body["detail"]!.GetValue().ShouldBe("Name is required."); + response.Body["code"]!.GetValue().ShouldBe("name_required"); + response.Body["errors"]!["request"]!.AsArray().Select(value => value!.GetValue()) + .ShouldBe(["Name is required."]); + } + + [Fact] + public async Task ToHttpResult_WithValidationDetails_GroupsErrorsByTarget() + { + // Arrange + Error[] details = + [ + Error.Validation(new ErrorCode("name_required"), "Name is required.", "name"), + Error.Validation(new ErrorCode("name_too_short"), "Name is too short.", "name"), + Error.Validation(new ErrorCode("price_invalid"), "Price must be positive.", "price") + ]; + + Error error = Error.Validation( + new ErrorCode("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.Body["errors"]!["name"]!.AsArray().Select(value => value!.GetValue()) + .ShouldBe(["Name is required.", "Name is too short."]); + response.Body["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.Body["value"]!.GetValue().ShouldBe("value"); + } + + private static Error CreateError(ErrorType type, string code, string message) + { + return type switch + { + ErrorType.NotFound => Error.NotFound(new ErrorCode(code), message), + ErrorType.Conflict => Error.Conflict(new ErrorCode(code), message), + ErrorType.Unknown => Error.Unknown(new ErrorCode(code), message), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + + private static async Task ExecuteAsync(IResult result) + { + DefaultHttpContext context = new(); + await using MemoryStream body = new(); + context.Response.Body = body; + + await result.ExecuteAsync(context); + + body.Position = 0; + using StreamReader reader = new(body); + 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); + + private sealed record TestResponse(string Value); +} From 36133080970de375b2c4c427a00c90a405cbe25b Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:37:50 +0200 Subject: [PATCH 06/49] fix endpoint result mapping tests --- .../EndpointResultExtensionsTests.cs | 74 ++++++++----------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs index cbcd248..9167f2d 100644 --- a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -10,27 +10,21 @@ public sealed class EndpointResultExtensionsTests [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.Body["value"]!.GetValue().ShouldBe("created"); + 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(); } @@ -44,103 +38,90 @@ public async Task ToHttpResult_WithFailure_ReturnsProblemResponse( 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.Body["title"]!.GetValue().ShouldBe(expectedTitle); - response.Body["detail"]!.GetValue().ShouldBe("Example failure."); - response.Body["code"]!.GetValue().ShouldBe("example_error"); + 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(new ErrorCode("name_required"), "Name is required."); + 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.Body["title"]!.GetValue().ShouldBe("Validation failed."); - response.Body["detail"]!.GetValue().ShouldBe("Name is required."); - response.Body["code"]!.GetValue().ShouldBe("name_required"); - response.Body["errors"]!["request"]!.AsArray().Select(value => value!.GetValue()) + 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(new ErrorCode("name_required"), "Name is required.", "name"), - Error.Validation(new ErrorCode("name_too_short"), "Name is too short.", "name"), - Error.Validation(new ErrorCode("price_invalid"), "Price must be positive.", "price") + 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( - new ErrorCode("validation_failed"), + 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.Body["errors"]!["name"]!.AsArray().Select(value => value!.GetValue()) + response.RequiredBody["errors"]!["name"]!.AsArray().Select(value => value!.GetValue()) .ShouldBe(["Name is required.", "Name is too short."]); - response.Body["errors"]!["price"]!.AsArray().Select(value => value!.GetValue()) + 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.Body["value"]!.GetValue().ShouldBe("value"); + 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(new ErrorCode(code), message), - ErrorType.Conflict => Error.Conflict(new ErrorCode(code), message), - ErrorType.Unknown => Error.Unknown(new ErrorCode(code), message), + 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) }; } @@ -148,13 +129,13 @@ private static Error CreateError(ErrorType type, string code, string message) private static async Task ExecuteAsync(IResult result) { DefaultHttpContext context = new(); - await using MemoryStream body = new(); - context.Response.Body = body; + await using MemoryStream responseBody = new(); + context.Response.Body = responseBody; await result.ExecuteAsync(context); - body.Position = 0; - using StreamReader reader = new(body); + responseBody.Position = 0; + using StreamReader reader = new(responseBody); string content = await reader.ReadToEndAsync(); JsonNode? json = string.IsNullOrWhiteSpace(content) @@ -164,7 +145,10 @@ private static async Task ExecuteAsync(IResult result) return new HttpResponseCapture(context.Response.StatusCode, json); } - private sealed record HttpResponseCapture(int StatusCode, JsonNode? Body); + private sealed record HttpResponseCapture(int StatusCode, JsonNode? Body) + { + public JsonNode RequiredBody => Body ?? throw new InvalidOperationException("Expected response body."); + } private sealed record TestResponse(string Value); } From 053337138b22395a06ae42f18de5b479f3dd3220 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:42:37 +0200 Subject: [PATCH 07/49] fix: keep arrange act assert comments in endpoint tests --- .../EndpointResultExtensionsTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs index 9167f2d..4c68175 100644 --- a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -10,10 +10,13 @@ public sealed class EndpointResultExtensionsTests [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"); } @@ -21,10 +24,13 @@ public async Task ToHttpResult_WithSuccessfulValueResult_UsesSuccessMapping() [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(); } @@ -38,11 +44,14 @@ public async Task ToHttpResult_WithFailure_ReturnsProblemResponse( 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."); @@ -52,11 +61,14 @@ public async Task ToHttpResult_WithFailure_ReturnsProblemResponse( [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."); @@ -68,6 +80,7 @@ public async Task ToHttpResult_WithSingleValidationError_ReturnsValidationProble [Fact] public async Task ToHttpResult_WithValidationDetails_GroupsErrorsByTarget() { + // Arrange Error[] details = [ Error.Validation(ErrorCode.Create("name_required"), "Name is required.", "name"), @@ -82,8 +95,10 @@ public async Task ToHttpResult_WithValidationDetails_GroupsErrorsByTarget() 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."]); @@ -94,21 +109,27 @@ public async Task ToHttpResult_WithValidationDetails_GroupsErrorsByTarget() [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"); } From c72bbca1e2c42e81f526e02975ff5b16f40f4af2 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:52:16 +0200 Subject: [PATCH 08/49] fix: configure request services for result mapping tests --- .../Endpoints/EndpointResultExtensionsTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs index 4c68175..1fecc90 100644 --- a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -2,11 +2,17 @@ using Company.Template.Api.Endpoints; using Company.Template.Application.Common; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Company.Template.Api.Tests.Endpoints; public sealed class EndpointResultExtensionsTests { + private static readonly IServiceProvider Services = new ServiceCollection() + .AddLogging() + .AddProblemDetails() + .BuildServiceProvider(); + [Fact] public async Task ToHttpResult_WithSuccessfulValueResult_UsesSuccessMapping() { @@ -149,7 +155,11 @@ private static Error CreateError(ErrorType type, string code, string message) private static async Task ExecuteAsync(IResult result) { - DefaultHttpContext context = new(); + DefaultHttpContext context = new() + { + RequestServices = Services + }; + await using MemoryStream responseBody = new(); context.Response.Body = responseBody; From b444911478a5970f66cdf1afcf71ee7f54b2749b Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 17:58:47 +0200 Subject: [PATCH 09/49] fix: reuse lightweight api test factory in result mapping tests --- .../Endpoints/EndpointResultExtensionsTests.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs index 1fecc90..f177c02 100644 --- a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -1,17 +1,14 @@ 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; -using Microsoft.Extensions.DependencyInjection; namespace Company.Template.Api.Tests.Endpoints; -public sealed class EndpointResultExtensionsTests +public sealed class EndpointResultExtensionsTests : IDisposable { - private static readonly IServiceProvider Services = new ServiceCollection() - .AddLogging() - .AddProblemDetails() - .BuildServiceProvider(); + private readonly ApiLightweightTestFactory _factory = new(); [Fact] public async Task ToHttpResult_WithSuccessfulValueResult_UsesSuccessMapping() @@ -140,6 +137,11 @@ public async Task ToHttpResultAsync_WithTaskResult_ReturnsMappedResponse() response.RequiredBody["value"]!.GetValue().ShouldBe("value"); } + public void Dispose() + { + _factory.Dispose(); + } + private static Error CreateError(ErrorType type, string code, string message) { ErrorCode errorCode = ErrorCode.Create(code); @@ -153,11 +155,11 @@ private static Error CreateError(ErrorType type, string code, string message) }; } - private static async Task ExecuteAsync(IResult result) + private async Task ExecuteAsync(IResult result) { DefaultHttpContext context = new() { - RequestServices = Services + RequestServices = _factory.Services }; await using MemoryStream responseBody = new(); From 0c9aca80b5b82573b4e89e978cb78e0f889bcefd Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Thu, 21 May 2026 18:02:06 +0200 Subject: [PATCH 10/49] refactor: use lightweight api factory fixture --- .../Endpoints/EndpointResultExtensionsTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs index f177c02..821db72 100644 --- a/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs +++ b/content/tests/Company.Template.Api.Tests/Endpoints/EndpointResultExtensionsTests.cs @@ -6,9 +6,14 @@ namespace Company.Template.Api.Tests.Endpoints; -public sealed class EndpointResultExtensionsTests : IDisposable +public sealed class EndpointResultExtensionsTests : IClassFixture { - private readonly ApiLightweightTestFactory _factory = new(); + private readonly ApiLightweightTestFactory _factory; + + public EndpointResultExtensionsTests(ApiLightweightTestFactory factory) + { + _factory = factory; + } [Fact] public async Task ToHttpResult_WithSuccessfulValueResult_UsesSuccessMapping() @@ -137,11 +142,6 @@ public async Task ToHttpResultAsync_WithTaskResult_ReturnsMappedResponse() response.RequiredBody["value"]!.GetValue().ShouldBe("value"); } - public void Dispose() - { - _factory.Dispose(); - } - private static Error CreateError(ErrorType type, string code, string message) { ErrorCode errorCode = ErrorCode.Create(code); From f10109f91297fc45cc00226d452ccd36cc96a7fa Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:21:41 +0200 Subject: [PATCH 11/49] test: cover global exception handler behavior --- .../Middleware/GlobalExceptionHandlerTests.cs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs 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..d6ed78d --- /dev/null +++ b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -0,0 +1,176 @@ +using System.Text.Json.Nodes; +using Company.Template.Api.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +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 = CreateHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + await context.Response.StartAsync(); + + // 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 = CreateHttpContext("/api/failing"); + InvalidOperationException exception = new("Sensitive internal failure."); + await context.Response.StartAsync(); + + // 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 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, leaveOpen: true); + + return await reader.ReadToEndAsync(); + } +} From 471c319a0420c211b41d245a31f19aa0c16770d5 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:22:35 +0200 Subject: [PATCH 12/49] fix: keep response body stream open in exception handler tests --- .../Middleware/GlobalExceptionHandlerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs index d6ed78d..34956a1 100644 --- a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs +++ b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json.Nodes; using Company.Template.Api.Middleware; using Microsoft.AspNetCore.Http; @@ -169,7 +170,7 @@ private static async Task ReadRequiredJsonBodyAsync(HttpContext contex private static async Task ReadBodyAsync(HttpContext context) { context.Response.Body.Position = 0; - using StreamReader reader = new(context.Response.Body, leaveOpen: true); + using StreamReader reader = new(context.Response.Body, Encoding.UTF8, leaveOpen: true); return await reader.ReadToEndAsync(); } From 9670e3c3cc5a8eecf2feae29c6de3c2d18490f9b Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:28:08 +0200 Subject: [PATCH 13/49] fix: simulate started response in exception handler tests --- .../Middleware/GlobalExceptionHandlerTests.cs | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs index 34956a1..3d173c2 100644 --- a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs +++ b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -2,7 +2,9 @@ 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; @@ -80,9 +82,8 @@ public async Task TryHandleAsync_WhenResponseAlreadyStarted_ReturnsFalse() { // Arrange GlobalExceptionHandler handler = CreateHandler(); - DefaultHttpContext context = CreateHttpContext("/api/failing"); + DefaultHttpContext context = CreateStartedHttpContext("/api/failing"); InvalidOperationException exception = new("Sensitive internal failure."); - await context.Response.StartAsync(); // Act bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None); @@ -96,9 +97,8 @@ public async Task TryHandleAsync_WhenResponseAlreadyStarted_DoesNotWriteProblemD { // Arrange GlobalExceptionHandler handler = CreateHandler(); - DefaultHttpContext context = CreateHttpContext("/api/failing"); + DefaultHttpContext context = CreateStartedHttpContext("/api/failing"); InvalidOperationException exception = new("Sensitive internal failure."); - await context.Response.StartAsync(); // Act await handler.TryHandleAsync(context, exception, CancellationToken.None); @@ -159,6 +159,18 @@ private static DefaultHttpContext CreateHttpContext(string path) return context; } + private static DefaultHttpContext CreateStartedHttpContext(string path) + { + StartedResponseFeature response = new(); + FeatureCollection features = new(); + features.Set(response); + + DefaultHttpContext context = new(features); + context.Request.Path = path; + + return context; + } + private static async Task ReadRequiredJsonBodyAsync(HttpContext context) { string body = await ReadBodyAsync(context); @@ -174,4 +186,25 @@ private static async Task ReadBodyAsync(HttpContext context) 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) + { + } + } } From ca831b6c8e5deed8f3165438ed4876173470a223 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:38:17 +0200 Subject: [PATCH 14/49] fix: configure request feature for started response tests --- .../Middleware/GlobalExceptionHandlerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs index 3d173c2..fd16c01 100644 --- a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs +++ b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -163,6 +163,7 @@ private static DefaultHttpContext CreateStartedHttpContext(string path) { StartedResponseFeature response = new(); FeatureCollection features = new(); + features.Set(new HttpRequestFeature()); features.Set(response); DefaultHttpContext context = new(features); From 49e66353daa37bc5bc1e1478148288aa4059e468 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:40:02 +0200 Subject: [PATCH 15/49] fix: configure response body feature for started response tests --- .../Middleware/GlobalExceptionHandlerTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs index fd16c01..7e5312b 100644 --- a/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs +++ b/content/tests/Company.Template.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -162,9 +162,11 @@ private static DefaultHttpContext CreateHttpContext(string path) 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; From 27f0c26766f6cbfd1010ba66cfa635a850254c33 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:54:46 +0200 Subject: [PATCH 16/49] test: harden result contract tests --- .../Common/ResultTests.cs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs index 8d7611a..c3cb0df 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs @@ -95,6 +95,34 @@ public void Match_WithFailure_CallsFailureBranch() 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(); + } + [Fact] public void Map_WithSuccess_ReturnsMappedResult() { @@ -124,6 +152,25 @@ public void Map_WithFailure_ReturnsOriginalFailure() 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() { @@ -167,6 +214,25 @@ public void Bind_WithFailure_ReturnsOriginalFailure() 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() { @@ -210,6 +276,25 @@ public async Task BindAsync_WithFailure_ReturnsOriginalFailure() 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() { @@ -290,4 +375,32 @@ public void NonGenericMatch_WithFailure_CallsFailureBranch() // 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(); + } } From bc372dc1556287d3dbcfb6a0e2e10690ffe8d186 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:56:25 +0200 Subject: [PATCH 17/49] test: harden option contract tests --- .../Common/OptionTests.cs | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs index ee89ff7..c6aac80 100644 --- a/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs @@ -80,6 +80,34 @@ public void Match_WithNone_CallsNoneBranch() 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(); + } + [Fact] public void Map_WithSome_TransformsValue() { @@ -107,6 +135,39 @@ public void Map_WithNone_ReturnsNone() 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(); + } + [Fact] public void Bind_WithSome_ReturnsBoundOption() { @@ -121,6 +182,65 @@ public void Bind_WithSome_ReturnsBoundOption() 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(); + } + + [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() { @@ -134,6 +254,224 @@ public void Where_WithPredicateFalse_ReturnsNone() 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(); + } + + [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(); + } + + [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(); + } + + [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(); + } + [Fact] public void TryGetValue_WithSome_ReturnsTrueAndValue() { From 98405db0f1d4f6c2d0a3c4f21d54e870d712a5be Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 08:57:23 +0200 Subject: [PATCH 18/49] test: harden error contract tests --- .../Common/ErrorTests.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs index 89ebc2f..5f6fdfc 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs @@ -19,6 +19,65 @@ public void None_ReturnsNoneError() error.IsNone.ShouldBeTrue(); } + [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(); + } + [Fact] public void Validation_WithMessage_ReturnsValidationError() { @@ -44,6 +103,41 @@ public void Validation_WithCodeAndMessage_ReturnsValidationError() 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 NotFound_WithMessage_ReturnsNotFoundError() { @@ -56,6 +150,18 @@ public void NotFound_WithMessage_ReturnsNotFoundError() 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."); + } + [Fact] public void Conflict_WithMessage_ReturnsConflictError() { @@ -68,6 +174,40 @@ public void Conflict_WithMessage_ReturnsConflictError() 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."); + } + + [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."); + } + + [Fact] + public void Validation_WithNoneCode_ThrowsArgumentException() + { + // Act + Action action = () => Error.Validation(ErrorCode.None, "Invalid input."); + + // Assert + action.ShouldThrow(); + } + [Theory] [InlineData("")] [InlineData(" ")] From 1d5ccff6ef2bc577aaf52b1bb601e2701719844a Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:11:37 +0200 Subject: [PATCH 19/49] refactor: split result tests into partials --- .../Common/ResultTests.cs | 405 +----------------- 1 file changed, 1 insertion(+), 404 deletions(-) diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs index c3cb0df..323e921 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs @@ -1,406 +1,3 @@ -using Company.Template.Application.Common; - namespace Company.Template.Application.Tests.Common; -public sealed 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 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(); - } - - [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(); - } - - [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(); - } - - [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(); - } - - [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(); - } -} +public sealed partial class ResultTests; From a6469f4e4f0b12053a5c654f953d8b474bfd26dd Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:12:03 +0200 Subject: [PATCH 20/49] refactor: add result success failure partial --- .../Common/ResultTests.SuccessFailure.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ResultTests.SuccessFailure.cs 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(); + } +} From 2d089a1691195a48a99274fcff6f9480dd8622e4 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:12:38 +0200 Subject: [PATCH 21/49] refactor: add result match partial --- .../Common/ResultTests.Match.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ResultTests.Match.cs 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(); + } +} From e56c4bad247cbdabad57de2a9952b40f22c7e6fb Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:13:25 +0200 Subject: [PATCH 22/49] fix: use explicit body for result partial base --- .../Company.Template.Application.Tests/Common/ResultTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs index 323e921..b26af6c 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ResultTests.cs @@ -1,3 +1,5 @@ namespace Company.Template.Application.Tests.Common; -public sealed partial class ResultTests; +public sealed partial class ResultTests +{ +} From b6a9e5929cf6366f305b8e250f03dc51f3ba06b2 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:14:05 +0200 Subject: [PATCH 23/49] refactor: add result map partial --- .../Common/ResultTests.Map.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ResultTests.Map.cs 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(); + } +} From 3cb9c823cd2ae48a2d9ec2bff3bf7a3d83afce70 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:14:29 +0200 Subject: [PATCH 24/49] refactor: add result bind partial --- .../Common/ResultTests.Bind.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ResultTests.Bind.cs 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(); + } +} From 07773fad6bb9e24a891cdea56861640b17be8f8f Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:15:09 +0200 Subject: [PATCH 25/49] refactor: add result bind async partial --- .../Common/ResultTests.BindAsync.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ResultTests.BindAsync.cs 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(); + } +} From fc46f880a9d9280291017bcd9a877bc0a7e3eaa4 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:15:58 +0200 Subject: [PATCH 26/49] refactor: add non generic result partial --- .../Common/ResultTests.NonGeneric.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ResultTests.NonGeneric.cs 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(); + } +} From 96420aaa323f61573337952765abc9dbccde2d95 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:16:32 +0200 Subject: [PATCH 27/49] refactor: split option tests into partials --- .../Common/OptionTests.cs | 499 +----------------- 1 file changed, 1 insertion(+), 498 deletions(-) diff --git a/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs b/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs index c6aac80..c7d76ad 100644 --- a/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/OptionTests.cs @@ -1,502 +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 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(); - } - - [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(); - } - - [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(); - } - - [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(); - } - - [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(); - } - - [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(); - } - - [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(); - } - - [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(); - } } From e73fac16684326129b32ede14e686eea514b7171 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:16:57 +0200 Subject: [PATCH 28/49] refactor: add option construction partial --- .../Common/OptionTests.Construction.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.Construction.cs 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(); + } +} From 7ab96903a70c7b80a98699caedb6dcbe474df2f3 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:17:55 +0200 Subject: [PATCH 29/49] refactor: add option match partial --- .../Common/OptionTests.Match.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.Match.cs 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(); + } +} From e43297c94de0d55571992f5e830def363866012f Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:18:24 +0200 Subject: [PATCH 30/49] refactor: add option map partial --- .../Common/OptionTests.Map.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.Map.cs 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(); + } +} From 3c0158cc7aa69b668f0368864522e32282962ad1 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:19:51 +0200 Subject: [PATCH 31/49] refactor: add option bind partial --- .../Common/OptionTests.Bind.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.Bind.cs 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(); + } +} From 974cddc0631bdfc1bb71dc13b678fdd49b6b9739 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:20:41 +0200 Subject: [PATCH 32/49] refactor: add option where partial --- .../Common/OptionTests.Where.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.Where.cs 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(); + } +} From 409746872983ac1a2de2a5658956af92462daea2 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:21:12 +0200 Subject: [PATCH 33/49] refactor: add option where not partial --- .../Common/OptionTests.WhereNot.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.WhereNot.cs 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(); + } +} From 01d282fc5d1270004e614e2c200aac7af4d8f8b1 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:21:47 +0200 Subject: [PATCH 34/49] refactor: add option or else partial --- .../Common/OptionTests.OrElse.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.OrElse.cs 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(); + } +} From 5c1a102b56f8e04739bd28029de8df25d15a555b Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:22:14 +0200 Subject: [PATCH 35/49] refactor: add option from nullable partial --- .../Common/OptionTests.FromNullable.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.FromNullable.cs 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(); + } +} From e80ceb0d7ca49eda61a0d266044748c3cd2ea4fb Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:22:42 +0200 Subject: [PATCH 36/49] refactor: add option try get value partial --- .../Common/OptionTests.TryGetValue.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/OptionTests.TryGetValue.cs 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(); + } +} From 8a75df123ca0a1b31e7daf86fd2020cb4729c91e Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:23:21 +0200 Subject: [PATCH 37/49] refactor: split error tests into partials --- .../Common/ErrorTests.cs | 253 +----------------- 1 file changed, 1 insertion(+), 252 deletions(-) diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs index 5f6fdfc..a4da441 100644 --- a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.cs @@ -1,256 +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 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(); - } - - [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 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."); - } - - [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."); - } - - [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."); - } - - [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(); - } - - [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); - } } From e7f4490b3d3f07fa6c909a8093fc21309cd9da32 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:23:57 +0200 Subject: [PATCH 38/49] refactor: add error none partial --- .../Common/ErrorTests.None.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs new file mode 100644 index 0000000..74c2e71 --- /dev/null +++ b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.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(); + } +} From dedbc3c8c170bd8ef62d093a8084ac5fe5568ee8 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:25:12 +0200 Subject: [PATCH 39/49] refactor: add error code partial --- .../Common/ErrorTests.ErrorCode.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.ErrorCode.cs 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(); + } +} From 8c2d1e720181e7a3a8b7e22931b52f1bffd132ea Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:25:42 +0200 Subject: [PATCH 40/49] refactor: add error validation partial --- .../Common/ErrorTests.Validation.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.Validation.cs 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(); + } +} From 364ba6e64ac1a394c7f65a9a6c77effe621e4b8f Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:26:09 +0200 Subject: [PATCH 41/49] refactor: add error not found partial --- .../Common/ErrorTests.NotFound.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.NotFound.cs 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."); + } +} From 901c68eca0c6aac5efeb4a9972f4b300c2621192 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:26:35 +0200 Subject: [PATCH 42/49] refactor: add error conflict partial --- .../Common/ErrorTests.Conflict.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.Conflict.cs 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."); + } +} From a0dcb50b955ba52ea369e6c6b989d6255905b2ee Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:27:13 +0200 Subject: [PATCH 43/49] refactor: add error unknown partial --- .../Common/ErrorTests.Unknown.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.Unknown.cs 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."); + } +} From a465b9605b28755a5fcee097a3b02df87f1c2f32 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:27:56 +0200 Subject: [PATCH 44/49] refactor: add error domain mapping partial --- .../Common/ErrorTests.DomainMapping.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.DomainMapping.cs 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); + } +} From 6a5f1dfb60def9a79a787127a17bb0df9dc6e74a Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:29:21 +0200 Subject: [PATCH 45/49] refactor: nest partial contract tests in project file --- .../Company.Template.Application.Tests.csproj | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) 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..ce4520c 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 + + + From 42c70dcf18d28eb44833396858a2bd93a83b5ad9 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:51:33 +0200 Subject: [PATCH 46/49] fix: avoid source variant suffix in error tests --- .../Common/ErrorTests.NoError.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.NoError.cs 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(); + } +} From 39807b067d2a7d996a325b2662e14a82d299602c Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:54:17 +0200 Subject: [PATCH 47/49] fix: remove source variant suffix test file --- .../Common/ErrorTests.None.cs | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs diff --git a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs b/content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs deleted file mode 100644 index 74c2e71..0000000 --- a/content/tests/Company.Template.Application.Tests/Common/ErrorTests.None.cs +++ /dev/null @@ -1,19 +0,0 @@ -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(); - } -} From 9b1fd27d658de72a4fbe334bdf2253a909df45a2 Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 09:55:31 +0200 Subject: [PATCH 48/49] fix: update nested error test file name --- .../Company.Template.Application.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ce4520c..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 @@ -76,7 +76,7 @@ OptionTests.cs - + ErrorTests.cs From d7915a2153503c39a120268226bce95e3739f8bc Mon Sep 17 00:00:00 2001 From: codefoxx <65673235+codefoxx@users.noreply.github.com> Date: Fri, 22 May 2026 10:25:43 +0200 Subject: [PATCH 49/49] test: harden feature composition contracts --- .../Composition/FeatureCompositionTests.cs | 268 +++++++++++++++++- 1 file changed, 254 insertions(+), 14 deletions(-) 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)