Skip to content

Commit 95c9b1a

Browse files
authored
test: add full public-API coverage for SharedKernel.Api and polish the WebApi sample (#343)
1 parent 5a696c8 commit 95c9b1a

12 files changed

Lines changed: 754 additions & 7 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Shouldly;
2+
using Vulthil.Extensions.Testing;
3+
using Vulthil.Results;
4+
using WebApi.Application.MainEntities.Create;
5+
using WebApi.Application.SideEffects;
6+
using WebApi.Domain.SideEffects;
7+
using WebApi.Tests.Fixtures;
8+
9+
namespace WebApi.Tests;
10+
11+
public sealed class SideEffectsControllerIntegrationTests(CustomWebApplicationFactory factory, ITestOutputHelper testOutputHelper)
12+
: BaseIntegrationTestCase(factory, testOutputHelper)
13+
{
14+
[Fact]
15+
public async Task Test_GetInProgress_Endpoint()
16+
{
17+
// Arrange
18+
var command = new CreateMainEntityCommand(Guid.NewGuid().ToString());
19+
var createResult = await Sender.SendAsync(command, CancellationToken);
20+
createResult.IsSuccess.ShouldBeTrue();
21+
22+
// Act
23+
var result = await Polling.WaitAsync(TimeSpan.FromSeconds(10), async () =>
24+
{
25+
var response = await Client.GetAsync("api/SideEffects/in-progress", CancellationToken);
26+
var sideEffects = await response.GetResponseAsync<List<SideEffectDto>>(CancellationToken);
27+
28+
if (!sideEffects.Exists(s => s.MainEntityId == createResult.Value))
29+
{
30+
return Result.Failure<List<SideEffectDto>>(Error.NotFound("SideEffect.NotFound", "No side effects found"));
31+
}
32+
33+
return Result.Success(sideEffects);
34+
}, cancellationToken: CancellationToken);
35+
36+
// Assert
37+
result.IsSuccess.ShouldBeTrue();
38+
result.Value.ShouldContain(s => s.MainEntityId == createResult.Value && s.Status is Status.InProgressStatus);
39+
}
40+
}

samples/WebApi/WebApi/MainEntity/Create.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using Microsoft.AspNetCore.Http.HttpResults;
21
using Vulthil.Results;
32
using Vulthil.SharedKernel.Api;
43
using Vulthil.SharedKernel.Application.Messaging;
@@ -20,10 +19,10 @@ public class Endpoint : IEndpoint
2019
{
2120
public void MapEndpoint(IEndpointRouteBuilder app)
2221
{
23-
app.MapPost("main-entities", async Task<Results<CreatedAtRoute<Response>, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> (ICommandHandler<CreateMainEntityCommand, Result<Guid>> handler, Request request) =>
22+
app.MapPost("main-entities", async (ICommandHandler<CreateMainEntityCommand, Result<Guid>> handler, Request request, CancellationToken cancellationToken) =>
2423
{
2524
var command = new CreateMainEntityCommand(request.Name);
26-
var result = await handler.HandleAsync(command);
25+
var result = await handler.HandleAsync(command, cancellationToken);
2726
return result
2827
.Map(id => new Response(id))
2928
.ToCreatedAtRouteHttpResult("GetMainEntity", r => r);

samples/WebApi/WebApi/MainEntity/GetAll.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ public class Endpoint : IEndpoint
1414
{
1515
public void MapEndpoint(IEndpointRouteBuilder app)
1616
{
17-
app.MapGet("main-entities", async (ISender sender) =>
17+
app.MapGet("main-entities", async (ISender sender, CancellationToken cancellationToken) =>
1818
{
1919
var query = new GetMainEntities();
20-
var result = await sender.SendAsync(query);
20+
var result = await sender.SendAsync(query, cancellationToken);
2121
return result.Map(r => new Response(r)).ToIResult();
2222
})
2323
.WithName("GetMainEntities");

samples/WebApi/WebApi/MainEntity/GetById.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ public class Endpoint : IEndpoint
1111
{
1212
public void MapEndpoint(IEndpointRouteBuilder app)
1313
{
14-
app.MapGet("main-entities/{id:guid}", async (IQueryHandler<GetMainEntityByIdQuery, Result<MainEntityDto>> sender, Guid id) =>
14+
app.MapGet("main-entities/{id:guid}", async (IQueryHandler<GetMainEntityByIdQuery, Result<MainEntityDto>> sender, Guid id, CancellationToken cancellationToken) =>
1515
{
1616
var query = new GetMainEntityByIdQuery(id);
17-
var result = await sender.HandleAsync(query);
17+
var result = await sender.HandleAsync(query, cancellationToken);
1818
return result.ToIResult();
1919
})
2020
.WithName("GetMainEntity");
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Vulthil.SharedKernel.Api;
3+
using Vulthil.SharedKernel.Application.Messaging;
4+
using WebApi.Application.SideEffects;
5+
using WebApi.Application.SideEffects.GetInProgress;
6+
7+
namespace WebApi.SideEffects;
8+
9+
/// <summary>
10+
/// Demonstrates the MVC controller path kept alongside minimal API endpoints: <see cref="BaseController"/> supplies
11+
/// route conventions and a scoped logger, and <see cref="ResultHttpExtensions"/>' <c>ToActionResult</c> translates
12+
/// the query result into the equivalent <see cref="IActionResult"/>.
13+
/// </summary>
14+
/// <param name="sender">Dispatches the query to its registered handler.</param>
15+
public sealed class SideEffectsController(ISender sender) : BaseController
16+
{
17+
/// <summary>
18+
/// Gets every side effect that is currently in progress.
19+
/// </summary>
20+
/// <param name="cancellationToken">A token to observe for cancellation.</param>
21+
/// <returns>200 OK with the in-progress side effects.</returns>
22+
[HttpGet("in-progress")]
23+
public async Task<IActionResult> GetInProgress(CancellationToken cancellationToken)
24+
{
25+
var result = await sender.SendAsync(new GetInProgressQuery(), cancellationToken);
26+
return result.ToActionResult(this);
27+
}
28+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Logging;
5+
using Vulthil.xUnit;
6+
7+
namespace Vulthil.SharedKernel.Api.Tests;
8+
9+
public sealed class BaseControllerTests : BaseUnitTestCase<BaseControllerTests.TestController>
10+
{
11+
private readonly Mock<ILoggerFactory> _loggerFactoryMock = new();
12+
private readonly ILogger _logger = Mock.Of<ILogger>();
13+
private string? _capturedCategoryName;
14+
15+
protected override TestController CreateInstance()
16+
{
17+
_loggerFactoryMock
18+
.Setup(factory => factory.CreateLogger(It.IsAny<string>()))
19+
.Callback<string>(categoryName => _capturedCategoryName = categoryName)
20+
.Returns(_logger);
21+
22+
var services = new ServiceCollection();
23+
services.AddSingleton(_loggerFactoryMock.Object);
24+
var provider = services.BuildServiceProvider();
25+
26+
return new TestController
27+
{
28+
ControllerContext = new ControllerContext
29+
{
30+
HttpContext = new DefaultHttpContext { RequestServices = provider }
31+
}
32+
};
33+
}
34+
35+
[Fact]
36+
public void LoggerResolvesCategoryForTheConcreteControllerTypeAndCachesTheInstance()
37+
{
38+
// Arrange
39+
40+
// Act
41+
var first = Target.ExposeLogger();
42+
var second = Target.ExposeLogger();
43+
44+
// Assert
45+
Assert.Same(_logger, first);
46+
Assert.Same(first, second);
47+
Assert.Contains(nameof(TestController), _capturedCategoryName, StringComparison.Ordinal);
48+
_loggerFactoryMock.Verify(factory => factory.CreateLogger(It.IsAny<string>()), Times.Once);
49+
}
50+
51+
public sealed class TestController : BaseController
52+
{
53+
public ILogger ExposeLogger() => Logger;
54+
}
55+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.OpenApi;
3+
using Microsoft.AspNetCore.Routing;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Options;
6+
using Vulthil.xUnit;
7+
8+
namespace Vulthil.SharedKernel.Api.Tests;
9+
10+
public sealed class DependencyInjectionTests : BaseUnitTestCase
11+
{
12+
[Fact]
13+
public void AddOpenApiServicesWithNoArgumentsRegistersOpenApiServicesForTheDefaultDocumentName()
14+
{
15+
// Arrange
16+
var services = new ServiceCollection();
17+
18+
// Act
19+
services.AddOpenApiServices();
20+
using var provider = services.BuildServiceProvider();
21+
var options = provider.GetRequiredService<IOptionsMonitor<OpenApiOptions>>().Get(DependencyInjection.DefaultDocumentName);
22+
23+
// Assert
24+
Assert.Equal("v1", DependencyInjection.DefaultDocumentName);
25+
Assert.Equal(DependencyInjection.DefaultDocumentName, options.DocumentName);
26+
}
27+
28+
[Fact]
29+
public void AddOpenApiServicesWithDocumentNameRegistersOpenApiServicesUnderThatName()
30+
{
31+
// Arrange
32+
var services = new ServiceCollection();
33+
const string documentName = "internal";
34+
35+
// Act
36+
services.AddOpenApiServices(documentName);
37+
using var provider = services.BuildServiceProvider();
38+
var options = provider.GetRequiredService<IOptionsMonitor<OpenApiOptions>>().Get(documentName);
39+
40+
// Assert
41+
Assert.Equal(documentName, options.DocumentName);
42+
}
43+
44+
[Fact]
45+
public void AddOpenApiServicesWithConfigureInvokesTheConfigureCallbackForTheNamedDocument()
46+
{
47+
// Arrange
48+
var services = new ServiceCollection();
49+
const string documentName = "internal";
50+
var configureCalled = false;
51+
52+
// Act
53+
services.AddOpenApiServices(documentName, _ => configureCalled = true);
54+
using var provider = services.BuildServiceProvider();
55+
var options = provider.GetRequiredService<IOptionsMonitor<OpenApiOptions>>().Get(documentName);
56+
57+
// Assert
58+
Assert.True(configureCalled);
59+
Assert.Equal(documentName, options.DocumentName);
60+
}
61+
62+
[Fact]
63+
public void AddOpenApiServicesWithConfigureThrowsOnNullConfigure()
64+
{
65+
// Arrange
66+
var services = new ServiceCollection();
67+
68+
// Act & Assert
69+
Assert.Throws<ArgumentNullException>(() => services.AddOpenApiServices("v1", null!));
70+
}
71+
72+
[Fact]
73+
public void MapOpenApiEndpointsMapsTheOpenApiDocumentRouteAndReturnsAConventionBuilder()
74+
{
75+
// Arrange
76+
var builder = WebApplication.CreateBuilder();
77+
builder.Services.AddOpenApiServices();
78+
using var app = builder.Build();
79+
80+
// Act
81+
var conventionBuilder = app.MapOpenApiEndpoints();
82+
83+
// Assert
84+
Assert.NotNull(conventionBuilder);
85+
IEndpointRouteBuilder endpointRouteBuilder = app;
86+
var routePatterns = endpointRouteBuilder.DataSources
87+
.SelectMany(dataSource => dataSource.Endpoints)
88+
.OfType<RouteEndpoint>()
89+
.Select(endpoint => endpoint.RoutePattern.RawText)
90+
.ToArray();
91+
Assert.Contains(routePatterns, pattern => pattern != null && pattern.Contains("openapi", StringComparison.OrdinalIgnoreCase));
92+
}
93+
}

tests/Vulthil.SharedKernel.Api.Tests/EndpointExtensionsTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Http;
23
using Microsoft.AspNetCore.Routing;
34
using Microsoft.Extensions.DependencyInjection;
45
using Microsoft.Extensions.Hosting;
@@ -48,6 +49,64 @@ public void MapEndpointsResolvesEndpointsFromAScopeNotTheRootProvider()
4849
Assert.Null(exception);
4950
}
5051

52+
[Fact]
53+
public void MapEndpointsMapsEachDiscoveredEndpointThroughTheProvidedRouteGroup()
54+
{
55+
// Arrange
56+
var builder = WebApplication.CreateBuilder();
57+
builder.Services.AddScoped<ScopedMarker>();
58+
builder.Services.AddEndpoints(typeof(EndpointExtensionsTests).Assembly);
59+
using var app = builder.Build();
60+
var group = app.MapGroup("scoped-group");
61+
62+
// Act
63+
var result = app.MapEndpoints(group);
64+
65+
// Assert
66+
Assert.Same(app, result);
67+
Assert.Contains(builder.Services, descriptor => descriptor.ImplementationType == typeof(RecordingEndpoint));
68+
IEndpointRouteBuilder endpointRouteBuilder = app;
69+
var routePatterns = endpointRouteBuilder.DataSources
70+
.SelectMany(dataSource => dataSource.Endpoints)
71+
.OfType<RouteEndpoint>()
72+
.Select(endpoint => endpoint.RoutePattern.RawText)
73+
.ToArray();
74+
Assert.Contains("scoped-group/recording-endpoint", routePatterns);
75+
}
76+
77+
[Fact]
78+
public void MapEndpointsThrowsOnNullApp()
79+
{
80+
// Arrange
81+
82+
// Act & Assert
83+
Assert.Throws<ArgumentNullException>(() => EndpointExtensions.MapEndpoints(null!));
84+
}
85+
86+
[Fact]
87+
public void AddEndpointsThrowsOnNullServices()
88+
{
89+
// Arrange
90+
91+
// Act & Assert
92+
Assert.Throws<ArgumentNullException>(() => EndpointExtensions.AddEndpoints(null!, typeof(EndpointExtensionsTests).Assembly));
93+
}
94+
95+
[Fact]
96+
public void AddEndpointsThrowsOnNullAssembly()
97+
{
98+
// Arrange
99+
var services = new ServiceCollection();
100+
101+
// Act & Assert
102+
Assert.Throws<ArgumentNullException>(() => services.AddEndpoints(null!));
103+
}
104+
105+
private sealed class RecordingEndpoint : IEndpoint
106+
{
107+
public void MapEndpoint(IEndpointRouteBuilder app) => app.MapGet("recording-endpoint", () => TypedResults.Ok());
108+
}
109+
51110
private sealed class ScopedMarker
52111
{
53112
public Guid Id { get; } = Guid.NewGuid();

0 commit comments

Comments
 (0)