diff --git a/samples/WebApi/WebApi.Tests/SideEffectsControllerIntegrationTests.cs b/samples/WebApi/WebApi.Tests/SideEffectsControllerIntegrationTests.cs new file mode 100644 index 00000000..b14ed9e7 --- /dev/null +++ b/samples/WebApi/WebApi.Tests/SideEffectsControllerIntegrationTests.cs @@ -0,0 +1,40 @@ +using Shouldly; +using Vulthil.Extensions.Testing; +using Vulthil.Results; +using WebApi.Application.MainEntities.Create; +using WebApi.Application.SideEffects; +using WebApi.Domain.SideEffects; +using WebApi.Tests.Fixtures; + +namespace WebApi.Tests; + +public sealed class SideEffectsControllerIntegrationTests(CustomWebApplicationFactory factory, ITestOutputHelper testOutputHelper) + : BaseIntegrationTestCase(factory, testOutputHelper) +{ + [Fact] + public async Task Test_GetInProgress_Endpoint() + { + // Arrange + var command = new CreateMainEntityCommand(Guid.NewGuid().ToString()); + var createResult = await Sender.SendAsync(command, CancellationToken); + createResult.IsSuccess.ShouldBeTrue(); + + // Act + var result = await Polling.WaitAsync(TimeSpan.FromSeconds(10), async () => + { + var response = await Client.GetAsync("api/SideEffects/in-progress", CancellationToken); + var sideEffects = await response.GetResponseAsync>(CancellationToken); + + if (!sideEffects.Exists(s => s.MainEntityId == createResult.Value)) + { + return Result.Failure>(Error.NotFound("SideEffect.NotFound", "No side effects found")); + } + + return Result.Success(sideEffects); + }, cancellationToken: CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldContain(s => s.MainEntityId == createResult.Value && s.Status is Status.InProgressStatus); + } +} diff --git a/samples/WebApi/WebApi/MainEntity/Create.cs b/samples/WebApi/WebApi/MainEntity/Create.cs index 95a45cb6..a271f05e 100644 --- a/samples/WebApi/WebApi/MainEntity/Create.cs +++ b/samples/WebApi/WebApi/MainEntity/Create.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Http.HttpResults; using Vulthil.Results; using Vulthil.SharedKernel.Api; using Vulthil.SharedKernel.Application.Messaging; @@ -20,10 +19,10 @@ public class Endpoint : IEndpoint { public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapPost("main-entities", async Task, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> (ICommandHandler> handler, Request request) => + app.MapPost("main-entities", async (ICommandHandler> handler, Request request, CancellationToken cancellationToken) => { var command = new CreateMainEntityCommand(request.Name); - var result = await handler.HandleAsync(command); + var result = await handler.HandleAsync(command, cancellationToken); return result .Map(id => new Response(id)) .ToCreatedAtRouteHttpResult("GetMainEntity", r => r); diff --git a/samples/WebApi/WebApi/MainEntity/GetAll.cs b/samples/WebApi/WebApi/MainEntity/GetAll.cs index d2b4ab84..e8a4d147 100644 --- a/samples/WebApi/WebApi/MainEntity/GetAll.cs +++ b/samples/WebApi/WebApi/MainEntity/GetAll.cs @@ -14,10 +14,10 @@ public class Endpoint : IEndpoint { public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapGet("main-entities", async (ISender sender) => + app.MapGet("main-entities", async (ISender sender, CancellationToken cancellationToken) => { var query = new GetMainEntities(); - var result = await sender.SendAsync(query); + var result = await sender.SendAsync(query, cancellationToken); return result.Map(r => new Response(r)).ToIResult(); }) .WithName("GetMainEntities"); diff --git a/samples/WebApi/WebApi/MainEntity/GetById.cs b/samples/WebApi/WebApi/MainEntity/GetById.cs index 0ef73b77..9737ce08 100644 --- a/samples/WebApi/WebApi/MainEntity/GetById.cs +++ b/samples/WebApi/WebApi/MainEntity/GetById.cs @@ -11,10 +11,10 @@ public class Endpoint : IEndpoint { public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapGet("main-entities/{id:guid}", async (IQueryHandler> sender, Guid id) => + app.MapGet("main-entities/{id:guid}", async (IQueryHandler> sender, Guid id, CancellationToken cancellationToken) => { var query = new GetMainEntityByIdQuery(id); - var result = await sender.HandleAsync(query); + var result = await sender.HandleAsync(query, cancellationToken); return result.ToIResult(); }) .WithName("GetMainEntity"); diff --git a/samples/WebApi/WebApi/SideEffects/SideEffectsController.cs b/samples/WebApi/WebApi/SideEffects/SideEffectsController.cs new file mode 100644 index 00000000..afdc5cc4 --- /dev/null +++ b/samples/WebApi/WebApi/SideEffects/SideEffectsController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; +using Vulthil.SharedKernel.Api; +using Vulthil.SharedKernel.Application.Messaging; +using WebApi.Application.SideEffects; +using WebApi.Application.SideEffects.GetInProgress; + +namespace WebApi.SideEffects; + +/// +/// Demonstrates the MVC controller path kept alongside minimal API endpoints: supplies +/// route conventions and a scoped logger, and ' ToActionResult translates +/// the query result into the equivalent . +/// +/// Dispatches the query to its registered handler. +public sealed class SideEffectsController(ISender sender) : BaseController +{ + /// + /// Gets every side effect that is currently in progress. + /// + /// A token to observe for cancellation. + /// 200 OK with the in-progress side effects. + [HttpGet("in-progress")] + public async Task GetInProgress(CancellationToken cancellationToken) + { + var result = await sender.SendAsync(new GetInProgressQuery(), cancellationToken); + return result.ToActionResult(this); + } +} diff --git a/tests/Vulthil.SharedKernel.Api.Tests/BaseControllerTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/BaseControllerTests.cs new file mode 100644 index 00000000..bc162425 --- /dev/null +++ b/tests/Vulthil.SharedKernel.Api.Tests/BaseControllerTests.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Vulthil.xUnit; + +namespace Vulthil.SharedKernel.Api.Tests; + +public sealed class BaseControllerTests : BaseUnitTestCase +{ + private readonly Mock _loggerFactoryMock = new(); + private readonly ILogger _logger = Mock.Of(); + private string? _capturedCategoryName; + + protected override TestController CreateInstance() + { + _loggerFactoryMock + .Setup(factory => factory.CreateLogger(It.IsAny())) + .Callback(categoryName => _capturedCategoryName = categoryName) + .Returns(_logger); + + var services = new ServiceCollection(); + services.AddSingleton(_loggerFactoryMock.Object); + var provider = services.BuildServiceProvider(); + + return new TestController + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = provider } + } + }; + } + + [Fact] + public void LoggerResolvesCategoryForTheConcreteControllerTypeAndCachesTheInstance() + { + // Arrange + + // Act + var first = Target.ExposeLogger(); + var second = Target.ExposeLogger(); + + // Assert + Assert.Same(_logger, first); + Assert.Same(first, second); + Assert.Contains(nameof(TestController), _capturedCategoryName, StringComparison.Ordinal); + _loggerFactoryMock.Verify(factory => factory.CreateLogger(It.IsAny()), Times.Once); + } + + public sealed class TestController : BaseController + { + public ILogger ExposeLogger() => Logger; + } +} diff --git a/tests/Vulthil.SharedKernel.Api.Tests/DependencyInjectionTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/DependencyInjectionTests.cs new file mode 100644 index 00000000..dc650535 --- /dev/null +++ b/tests/Vulthil.SharedKernel.Api.Tests/DependencyInjectionTests.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Vulthil.xUnit; + +namespace Vulthil.SharedKernel.Api.Tests; + +public sealed class DependencyInjectionTests : BaseUnitTestCase +{ + [Fact] + public void AddOpenApiServicesWithNoArgumentsRegistersOpenApiServicesForTheDefaultDocumentName() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddOpenApiServices(); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(DependencyInjection.DefaultDocumentName); + + // Assert + Assert.Equal("v1", DependencyInjection.DefaultDocumentName); + Assert.Equal(DependencyInjection.DefaultDocumentName, options.DocumentName); + } + + [Fact] + public void AddOpenApiServicesWithDocumentNameRegistersOpenApiServicesUnderThatName() + { + // Arrange + var services = new ServiceCollection(); + const string documentName = "internal"; + + // Act + services.AddOpenApiServices(documentName); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(documentName); + + // Assert + Assert.Equal(documentName, options.DocumentName); + } + + [Fact] + public void AddOpenApiServicesWithConfigureInvokesTheConfigureCallbackForTheNamedDocument() + { + // Arrange + var services = new ServiceCollection(); + const string documentName = "internal"; + var configureCalled = false; + + // Act + services.AddOpenApiServices(documentName, _ => configureCalled = true); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get(documentName); + + // Assert + Assert.True(configureCalled); + Assert.Equal(documentName, options.DocumentName); + } + + [Fact] + public void AddOpenApiServicesWithConfigureThrowsOnNullConfigure() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddOpenApiServices("v1", null!)); + } + + [Fact] + public void MapOpenApiEndpointsMapsTheOpenApiDocumentRouteAndReturnsAConventionBuilder() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddOpenApiServices(); + using var app = builder.Build(); + + // Act + var conventionBuilder = app.MapOpenApiEndpoints(); + + // Assert + Assert.NotNull(conventionBuilder); + IEndpointRouteBuilder endpointRouteBuilder = app; + var routePatterns = endpointRouteBuilder.DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType() + .Select(endpoint => endpoint.RoutePattern.RawText) + .ToArray(); + Assert.Contains(routePatterns, pattern => pattern != null && pattern.Contains("openapi", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/Vulthil.SharedKernel.Api.Tests/EndpointExtensionsTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/EndpointExtensionsTests.cs index 9b7dd5f5..5127500c 100644 --- a/tests/Vulthil.SharedKernel.Api.Tests/EndpointExtensionsTests.cs +++ b/tests/Vulthil.SharedKernel.Api.Tests/EndpointExtensionsTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -48,6 +49,64 @@ public void MapEndpointsResolvesEndpointsFromAScopeNotTheRootProvider() Assert.Null(exception); } + [Fact] + public void MapEndpointsMapsEachDiscoveredEndpointThroughTheProvidedRouteGroup() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddScoped(); + builder.Services.AddEndpoints(typeof(EndpointExtensionsTests).Assembly); + using var app = builder.Build(); + var group = app.MapGroup("scoped-group"); + + // Act + var result = app.MapEndpoints(group); + + // Assert + Assert.Same(app, result); + Assert.Contains(builder.Services, descriptor => descriptor.ImplementationType == typeof(RecordingEndpoint)); + IEndpointRouteBuilder endpointRouteBuilder = app; + var routePatterns = endpointRouteBuilder.DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType() + .Select(endpoint => endpoint.RoutePattern.RawText) + .ToArray(); + Assert.Contains("scoped-group/recording-endpoint", routePatterns); + } + + [Fact] + public void MapEndpointsThrowsOnNullApp() + { + // Arrange + + // Act & Assert + Assert.Throws(() => EndpointExtensions.MapEndpoints(null!)); + } + + [Fact] + public void AddEndpointsThrowsOnNullServices() + { + // Arrange + + // Act & Assert + Assert.Throws(() => EndpointExtensions.AddEndpoints(null!, typeof(EndpointExtensionsTests).Assembly)); + } + + [Fact] + public void AddEndpointsThrowsOnNullAssembly() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddEndpoints(null!)); + } + + private sealed class RecordingEndpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder app) => app.MapGet("recording-endpoint", () => TypedResults.Ok()); + } + private sealed class ScopedMarker { public Guid Id { get; } = Guid.NewGuid(); diff --git a/tests/Vulthil.SharedKernel.Api.Tests/GlobalExceptionHandlerTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/GlobalExceptionHandlerTests.cs new file mode 100644 index 00000000..560cb94e --- /dev/null +++ b/tests/Vulthil.SharedKernel.Api.Tests/GlobalExceptionHandlerTests.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Vulthil.xUnit; + +namespace Vulthil.SharedKernel.Api.Tests; + +public sealed class GlobalExceptionHandlerTests : BaseUnitTestCase +{ + private readonly Lazy _lazyTarget; + private GlobalExceptionHandler Target => _lazyTarget.Value; + + public GlobalExceptionHandlerTests() + { + _lazyTarget = new(CreateInstance); + } + + [Fact] + public async Task TryHandleAsyncWritesFiveHundredProblemDetailsWithoutLeakingTheExceptionMessage() + { + // Arrange + ProblemDetailsContext? capturedContext = null; + GetMock() + .Setup(service => service.TryWriteAsync(It.IsAny())) + .Callback(context => capturedContext = context) + .ReturnsAsync(true); + var httpContext = new DefaultHttpContext(); + var exception = new InvalidOperationException("sensitive internal detail"); + + // Act + var handled = await Target.TryHandleAsync(httpContext, exception, CancellationToken); + + // Assert + Assert.True(handled); + Assert.Equal(StatusCodes.Status500InternalServerError, httpContext.Response.StatusCode); + Assert.NotNull(capturedContext); + Assert.Same(exception, capturedContext.Exception); + Assert.Same(httpContext, capturedContext.HttpContext); + Assert.Equal("An unexpected error occurred", capturedContext.ProblemDetails.Title); + Assert.Equal(StatusCodes.Status500InternalServerError, capturedContext.ProblemDetails.Status); + Assert.Null(capturedContext.ProblemDetails.Detail); + } + + [Fact] + public async Task TryHandleAsyncReturnsFalseWhenTheProblemDetailsServiceCannotWriteTheResponse() + { + // Arrange + GetMock() + .Setup(service => service.TryWriteAsync(It.IsAny())) + .ReturnsAsync(false); + var httpContext = new DefaultHttpContext(); + + // Act + var handled = await Target.TryHandleAsync(httpContext, new InvalidOperationException(), CancellationToken); + + // Assert + Assert.False(handled); + } + + [Fact] + public async Task TryHandleAsyncThrowsOnNullHttpContext() + { + // Arrange + + // Act & Assert + await Assert.ThrowsAsync( + async () => await Target.TryHandleAsync(null!, new InvalidOperationException(), CancellationToken)); + } +} diff --git a/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsExtensionsTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsExtensionsTests.cs index 5990c64a..979d4b1a 100644 --- a/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsExtensionsTests.cs +++ b/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -46,4 +48,128 @@ public void AddProblemDetailsHandlingComposesConsumerCustomizationRegardlessOfRe Assert.True(consumerCalled); Assert.True(problemDetails.Extensions.ContainsKey("requestId")); } + + [Fact] + public void AddProblemDetailsHandlingThrowsOnNullServices() + { + // Arrange + + // Act & Assert + Assert.Throws(() => ProblemDetailsExtensions.AddProblemDetailsHandling(null!)); + } + + [Fact] + public void UseProblemDetailsHandlingRegistersMiddlewareAndReturnsTheApplicationBuilder() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddProblemDetailsHandling(); + using var app = builder.Build(); + + // Act + var result = app.UseProblemDetailsHandling(); + + // Assert + Assert.Same(app, result); + } + + [Fact] + public void UseProblemDetailsHandlingThrowsOnNullApp() + { + // Arrange + + // Act & Assert + Assert.Throws(() => ProblemDetailsExtensions.UseProblemDetailsHandling(null!)); + } + + [Fact] + public void ComposedCustomizationSetsInstanceFromRequestMethodAndPathWhenNotAlreadySet() + { + // Arrange + var customize = CreateComposedCustomization(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/widgets/1"; + var problemDetails = new ProblemDetails(); + var context = new ProblemDetailsContext { HttpContext = httpContext, ProblemDetails = problemDetails }; + + // Act + customize.Invoke(context); + + // Assert + Assert.Equal("GET /widgets/1", problemDetails.Instance); + } + + [Fact] + public void ComposedCustomizationPreservesAnAlreadySetInstance() + { + // Arrange + var customize = CreateComposedCustomization(); + var problemDetails = new ProblemDetails { Instance = "custom-instance" }; + var context = new ProblemDetailsContext { HttpContext = new DefaultHttpContext(), ProblemDetails = problemDetails }; + + // Act + customize.Invoke(context); + + // Assert + Assert.Equal("custom-instance", problemDetails.Instance); + } + + [Fact] + public void ComposedCustomizationDoesNotAddTraceIdExtensionWhenNoActivityIsCurrent() + { + // Arrange + var previousActivity = Activity.Current; + Activity.Current = null; + try + { + var customize = CreateComposedCustomization(); + var problemDetails = new ProblemDetails(); + var context = new ProblemDetailsContext { HttpContext = new DefaultHttpContext(), ProblemDetails = problemDetails }; + + // Act + customize.Invoke(context); + + // Assert + Assert.False(problemDetails.Extensions.ContainsKey("traceId")); + } + finally + { + Activity.Current = previousActivity; + } + } + + [Fact] + public void ComposedCustomizationAddsTraceIdExtensionWhenAnActivityIsCurrent() + { + // Arrange + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + using var activitySource = new ActivitySource(nameof(ComposedCustomizationAddsTraceIdExtensionWhenAnActivityIsCurrent)); + using var activity = activitySource.StartActivity("test-activity"); + Assert.NotNull(activity); + var customize = CreateComposedCustomization(); + var problemDetails = new ProblemDetails(); + var context = new ProblemDetailsContext { HttpContext = new DefaultHttpContext(), ProblemDetails = problemDetails }; + + // Act + customize.Invoke(context); + + // Assert + Assert.Equal(activity!.Id, problemDetails.Extensions["traceId"]); + } + + private static Action CreateComposedCustomization() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddProblemDetailsHandling(); + using var provider = services.BuildServiceProvider(); + + return provider.GetRequiredService>().Value.CustomizeProblemDetails!; + } } diff --git a/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsOperationTransformerTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsOperationTransformerTests.cs index 887ee8d5..f146853d 100644 --- a/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsOperationTransformerTests.cs +++ b/tests/Vulthil.SharedKernel.Api.Tests/ProblemDetailsOperationTransformerTests.cs @@ -121,6 +121,16 @@ public async Task TransformAsyncAttachesObjectSchemaWithProblemDetailsProperties } #endif + [Fact] + public async Task TransformAsyncThrowsOnNullOperation() + { + // Arrange + + // Act & Assert + await Assert.ThrowsAsync( + async () => await Target.TransformAsync(null!, CreateContext(), CancellationToken)); + } + private static OpenApiOperationTransformerContext CreateContext() => new() { DocumentName = "v1", diff --git a/tests/Vulthil.SharedKernel.Api.Tests/ResultHttpExtensionsTests.cs b/tests/Vulthil.SharedKernel.Api.Tests/ResultHttpExtensionsTests.cs index f4307950..1dcf2702 100644 --- a/tests/Vulthil.SharedKernel.Api.Tests/ResultHttpExtensionsTests.cs +++ b/tests/Vulthil.SharedKernel.Api.Tests/ResultHttpExtensionsTests.cs @@ -17,6 +17,30 @@ public sealed class ResultHttpExtensionsTests : BaseUnitTestCase { Error.Failure("Entity.Failure", "Something went wrong"), StatusCodes.Status500InternalServerError }, }; + [Theory] + [MemberData(nameof(NonValidationErrors))] + public void CustomResultsProblemReturnsProblemHttpResultWithCodeAndDescription(Error error, int expectedStatusCode) + { + // Arrange + + // Act + var problemResult = CustomResults.Problem(error); + + // Assert + Assert.Equal(expectedStatusCode, problemResult.StatusCode); + Assert.Equal(error.Description, problemResult.ProblemDetails.Detail); + Assert.Contains(error.Code, problemResult.ProblemDetails.Extensions.Keys); + } + + [Fact] + public void CustomResultsProblemThrowsOnNullError() + { + // Arrange + + // Act & Assert + Assert.Throws(() => CustomResults.Problem(null!)); + } + [Theory] [MemberData(nameof(NonValidationErrors))] public void ErrorToIResultReturnsProblemDetailsWithCodeAndDescription(Error error, int expectedStatusCode) @@ -136,6 +160,251 @@ public void ToIResultAndToActionResultProduceTheSameStatusCodeAndDetailForTheSam Assert.Equal(actionProblemDetails.Detail, httpResult.ProblemDetails.Detail); } + [Fact] + public void ResultToActionResultOnSuccessReturnsNoContent() + { + // Arrange + var controller = CreateController(); + var result = Result.Success(); + + // Act + var actionResult = result.ToActionResult(controller); + + // Assert + Assert.IsType(actionResult); + } + + [Theory] + [MemberData(nameof(NonValidationErrors))] + public void ResultToActionResultOnFailureReturnsProblemObjectResultWithCodeAndDescription(Error error, int expectedStatusCode) + { + // Arrange + var controller = CreateController(); + var result = Result.Failure(error); + + // Act + var actionResult = result.ToActionResult(controller); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal(expectedStatusCode, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal(error.Description, problemDetails.Detail); + } + + [Fact] + public void ResultOfTToActionResultOnSuccessReturnsOkWithValue() + { + // Arrange + var controller = CreateController(); + var result = Result.Success("value"); + + // Act + var actionResult = result.ToActionResult(controller); + + // Assert + var okResult = Assert.IsType(actionResult); + Assert.Equal("value", okResult.Value); + } + + [Theory] + [MemberData(nameof(NonValidationErrors))] + public void ResultOfTToActionResultOnFailureReturnsProblemObjectResultWithCodeAndDescription(Error error, int expectedStatusCode) + { + // Arrange + var controller = CreateController(); + var result = Result.Failure(error); + + // Act + var actionResult = result.ToActionResult(controller); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal(expectedStatusCode, objectResult.StatusCode); + } + + [Fact] + public void ResultToIResultOnSuccessReturnsNoContent() + { + // Arrange + var result = Result.Success(); + + // Act + var httpResult = result.ToIResult(); + + // Assert + Assert.IsType(httpResult.Result); + } + + [Fact] + public void ResultOfTToIResultOnSuccessReturnsOkWithValue() + { + // Arrange + var result = Result.Success("value"); + + // Act + var httpResult = result.ToIResult(); + + // Assert + var ok = Assert.IsType>(httpResult.Result); + Assert.Equal("value", ok.Value); + } + + [Fact] + public async Task ToActionResultAsyncOnSuccessReturnsNoContent() + { + // Arrange + var controller = CreateController(); + var resultTask = Task.FromResult(Result.Success()); + + // Act + var actionResult = await resultTask.ToActionResultAsync(controller); + + // Assert + Assert.IsType(actionResult); + } + + [Theory] + [MemberData(nameof(NonValidationErrors))] + public async Task ToActionResultAsyncOnFailureReturnsProblemObjectResultWithCodeAndDescription(Error error, int expectedStatusCode) + { + // Arrange + var controller = CreateController(); + var resultTask = Task.FromResult(Result.Failure(error)); + + // Act + var actionResult = await resultTask.ToActionResultAsync(controller); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal(expectedStatusCode, objectResult.StatusCode); + } + + [Fact] + public async Task ToActionResultAsyncThrowsOnNullTask() + { + // Arrange + var controller = CreateController(); + Task resultTask = null!; + + // Act & Assert + await Assert.ThrowsAsync(() => resultTask.ToActionResultAsync(controller)); + } + + [Fact] + public async Task ToActionResultAsyncThrowsOnNullController() + { + // Arrange + var resultTask = Task.FromResult(Result.Success()); + + // Act & Assert + await Assert.ThrowsAsync(() => resultTask.ToActionResultAsync(null!)); + } + + [Fact] + public async Task ToActionResultAsyncOfTOnSuccessReturnsOkWithValue() + { + // Arrange + var controller = CreateController(); + var resultTask = Task.FromResult(Result.Success("value")); + + // Act + var actionResult = await resultTask.ToActionResultAsync(controller); + + // Assert + var okResult = Assert.IsType(actionResult); + Assert.Equal("value", okResult.Value); + } + + [Theory] + [MemberData(nameof(NonValidationErrors))] + public async Task ToActionResultAsyncOfTOnFailureReturnsProblemObjectResultWithCodeAndDescription(Error error, int expectedStatusCode) + { + // Arrange + var controller = CreateController(); + var resultTask = Task.FromResult(Result.Failure(error)); + + // Act + var actionResult = await resultTask.ToActionResultAsync(controller); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal(expectedStatusCode, objectResult.StatusCode); + } + + [Fact] + public void ToCreatedAtRouteHttpResultOnSuccessReturnsCreatedAtRouteWithRouteNameAndRouteValues() + { + // Arrange + var result = Result.Success("created-value"); + + // Act + var httpResult = result.ToCreatedAtRouteHttpResult("GetThing", value => new { id = value }); + + // Assert + var createdAtRoute = Assert.IsType>(httpResult.Result); + Assert.Equal("GetThing", createdAtRoute.RouteName); + Assert.Equal("created-value", createdAtRoute.Value); + Assert.Equal(StatusCodes.Status201Created, createdAtRoute.StatusCode); + Assert.NotNull(createdAtRoute.RouteValues); + } + + [Fact] + public void ToCreatedAtRouteHttpResultOnSuccessWithoutRouteNameOrValueFactoryUsesNullRouteNameAndEmptyRouteValues() + { + // Arrange + var result = Result.Success("created-value"); + + // Act + var httpResult = result.ToCreatedAtRouteHttpResult(); + + // Assert + var createdAtRoute = Assert.IsType>(httpResult.Result); + Assert.Null(createdAtRoute.RouteName); + Assert.True(createdAtRoute.RouteValues is null or { Count: 0 }); + } + + [Theory] + [MemberData(nameof(NonValidationErrors))] + public void ToCreatedAtRouteHttpResultOnFailureReturnsProblemDetailsWithCodeAndDescription(Error error, int expectedStatusCode) + { + // Arrange + var result = Result.Failure(error); + + // Act + var httpResult = result.ToCreatedAtRouteHttpResult("GetThing"); + + // Assert + var problemResult = Assert.IsType(httpResult.Result); + Assert.Equal(expectedStatusCode, problemResult.StatusCode); + Assert.Equal(error.Description, problemResult.ProblemDetails.Detail); + } + + [Fact] + public void ToCreatedAtRouteHttpResultOnValidationFailureReturnsValidationProblemWithFieldErrors() + { + // Arrange + var validationError = new ValidationError([Error.Validation("Entity.Field", "Field is required")]); + var result = Result.Failure(validationError); + + // Act + var httpResult = result.ToCreatedAtRouteHttpResult("GetThing"); + + // Assert + var validationProblem = Assert.IsType(httpResult.Result); + Assert.Contains("Entity.Field", validationProblem.ProblemDetails.Errors.Keys); + } + + [Fact] + public void ToCreatedAtRouteHttpResultThrowsOnNullResult() + { + // Arrange + Result result = null!; + + // Act & Assert + Assert.Throws(() => result.ToCreatedAtRouteHttpResult()); + } + private static TestController CreateController() { var services = new ServiceCollection();