Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<List<SideEffectDto>>(CancellationToken);

if (!sideEffects.Exists(s => s.MainEntityId == createResult.Value))
{
return Result.Failure<List<SideEffectDto>>(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);
}
}
5 changes: 2 additions & 3 deletions samples/WebApi/WebApi/MainEntity/Create.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Vulthil.Results;
using Vulthil.SharedKernel.Api;
using Vulthil.SharedKernel.Application.Messaging;
Expand All @@ -20,10 +19,10 @@ public class Endpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("main-entities", async Task<Results<CreatedAtRoute<Response>, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> (ICommandHandler<CreateMainEntityCommand, Result<Guid>> handler, Request request) =>
app.MapPost("main-entities", async (ICommandHandler<CreateMainEntityCommand, Result<Guid>> 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);
Expand Down
4 changes: 2 additions & 2 deletions samples/WebApi/WebApi/MainEntity/GetAll.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions samples/WebApi/WebApi/MainEntity/GetById.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ public class Endpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("main-entities/{id:guid}", async (IQueryHandler<GetMainEntityByIdQuery, Result<MainEntityDto>> sender, Guid id) =>
app.MapGet("main-entities/{id:guid}", async (IQueryHandler<GetMainEntityByIdQuery, Result<MainEntityDto>> 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");
Expand Down
28 changes: 28 additions & 0 deletions samples/WebApi/WebApi/SideEffects/SideEffectsController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Demonstrates the MVC controller path kept alongside minimal API endpoints: <see cref="BaseController"/> supplies
/// route conventions and a scoped logger, and <see cref="ResultHttpExtensions"/>' <c>ToActionResult</c> translates
/// the query result into the equivalent <see cref="IActionResult"/>.
/// </summary>
/// <param name="sender">Dispatches the query to its registered handler.</param>
public sealed class SideEffectsController(ISender sender) : BaseController
{
/// <summary>
/// Gets every side effect that is currently in progress.
/// </summary>
/// <param name="cancellationToken">A token to observe for cancellation.</param>
/// <returns>200 OK with the in-progress side effects.</returns>
[HttpGet("in-progress")]
public async Task<IActionResult> GetInProgress(CancellationToken cancellationToken)
{
var result = await sender.SendAsync(new GetInProgressQuery(), cancellationToken);
return result.ToActionResult(this);
}
}
55 changes: 55 additions & 0 deletions tests/Vulthil.SharedKernel.Api.Tests/BaseControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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<BaseControllerTests.TestController>
{
private readonly Mock<ILoggerFactory> _loggerFactoryMock = new();
private readonly ILogger _logger = Mock.Of<ILogger>();
private string? _capturedCategoryName;

protected override TestController CreateInstance()
{
_loggerFactoryMock
.Setup(factory => factory.CreateLogger(It.IsAny<string>()))
.Callback<string>(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<string>()), Times.Once);
}

public sealed class TestController : BaseController
{
public ILogger ExposeLogger() => Logger;
}
}
93 changes: 93 additions & 0 deletions tests/Vulthil.SharedKernel.Api.Tests/DependencyInjectionTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOptionsMonitor<OpenApiOptions>>().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<IOptionsMonitor<OpenApiOptions>>().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<IOptionsMonitor<OpenApiOptions>>().Get(documentName);

// Assert
Assert.True(configureCalled);
Assert.Equal(documentName, options.DocumentName);
}

[Fact]
public void AddOpenApiServicesWithConfigureThrowsOnNullConfigure()
{
// Arrange
var services = new ServiceCollection();

// Act & Assert
Assert.Throws<ArgumentNullException>(() => 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<RouteEndpoint>()
.Select(endpoint => endpoint.RoutePattern.RawText)
.ToArray();
Assert.Contains(routePatterns, pattern => pattern != null && pattern.Contains("openapi", StringComparison.OrdinalIgnoreCase));
}
}
59 changes: 59 additions & 0 deletions tests/Vulthil.SharedKernel.Api.Tests/EndpointExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
Expand Down Expand Up @@ -48,6 +49,64 @@ public void MapEndpointsResolvesEndpointsFromAScopeNotTheRootProvider()
Assert.Null(exception);
}

[Fact]
public void MapEndpointsMapsEachDiscoveredEndpointThroughTheProvidedRouteGroup()
{
// Arrange
var builder = WebApplication.CreateBuilder();
builder.Services.AddScoped<ScopedMarker>();
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<RouteEndpoint>()
.Select(endpoint => endpoint.RoutePattern.RawText)
.ToArray();
Assert.Contains("scoped-group/recording-endpoint", routePatterns);
}

[Fact]
public void MapEndpointsThrowsOnNullApp()
{
// Arrange

// Act & Assert
Assert.Throws<ArgumentNullException>(() => EndpointExtensions.MapEndpoints(null!));
}

[Fact]
public void AddEndpointsThrowsOnNullServices()
{
// Arrange

// Act & Assert
Assert.Throws<ArgumentNullException>(() => EndpointExtensions.AddEndpoints(null!, typeof(EndpointExtensionsTests).Assembly));
}

[Fact]
public void AddEndpointsThrowsOnNullAssembly()
{
// Arrange
var services = new ServiceCollection();

// Act & Assert
Assert.Throws<ArgumentNullException>(() => 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();
Expand Down
Loading