Files
2026-06-29 07:36:04 +02:00

346 lines
13 KiB
C#

using System.Data.Common;
using System.Net;
using System.Net.Http.Json;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Migrations;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Integration;
/// <summary>
/// Integration tests for the player endpoints exposed by the web application.
/// Each test exercises the full ASP.NET Core request pipeline — routing,
/// middleware, validation, serialization — via <see cref="WebApplicationFactory{TEntryPoint}"/>
/// backed by an in-memory SQLite database. The factory is created fresh per test
/// (via <see cref="IAsyncLifetime"/>) to ensure state isolation.
/// </summary>
public class PlayerWebApplicationTests : IAsyncLifetime
{
private WebApplicationFactory<Program> _factory = default!;
private WebApplicationFactory<Program> _authorizedFactory = default!;
private HttpClient _client = default!;
private HttpClient _authorizedClient = default!;
private DbConnection _connection = default!;
public Task InitializeAsync()
{
var (connection, _) = DatabaseFakes.CreateSqliteConnection();
_connection = connection;
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// AddDbContextPool registers three service types. All three must be
// removed before re-registering with AddDbContext; leaving any one
// causes startup to fail when the pool tries to resolve its options.
var descriptors = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<PlayerDbContext>)
|| d.ServiceType == typeof(PlayerDbContext)
|| (
d.ServiceType.IsGenericType
&& d.ServiceType.GetGenericArguments().Length > 0
&& d.ServiceType.GetGenericArguments()[0] == typeof(PlayerDbContext)
)
)
.ToList();
foreach (var descriptor in descriptors)
services.Remove(descriptor);
services.AddDbContext<PlayerDbContext>(options =>
options
.UseSqlite(_connection)
.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>()
);
});
});
_client = _factory.CreateClient();
// Derived factory that layers authentication on top of the base factory.
// Used only by tests targeting endpoints protected with [Authorize].
// Keeping auth out of the base factory means unauthenticated tests will
// fail if an endpoint accidentally becomes protected.
_authorizedFactory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services =>
services
.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName,
_ => { }
)
)
);
_authorizedClient = _authorizedFactory.CreateClient();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_authorizedClient.Dispose();
_client.Dispose();
await _authorizedFactory.DisposeAsync();
await _factory.DisposeAsync();
await _connection.DisposeAsync();
}
/* -------------------------------------------------------------------------
* GET /players
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_Players_Existing_Returns200Ok()
{
// Act
var response = await _client.GetAsync("/players");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var players = await response.Content.ReadFromJsonAsync<List<PlayerResponseModel>>();
players.Should().HaveCount(26);
}
/* -------------------------------------------------------------------------
* GET /players/{id:Guid}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerById_Existing_Returns200Ok()
{
// Arrange — resolve a real ID from the seeded database
await using var scope = _authorizedFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<PlayerDbContext>();
var existingId = (await db.Players.FirstAsync()).Id;
// Act
var response = await _authorizedClient.GetAsync($"/players/{existingId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var player = await response.Content.ReadFromJsonAsync<PlayerResponseModel>();
player.Should().NotBeNull();
}
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerById_Unknown_Returns404NotFound()
{
// Act
var response = await _authorizedClient.GetAsync($"/players/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
/* -------------------------------------------------------------------------
* GET /players/squadNumber/{squadNumber:int}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerBySquadNumber_Existing_Returns200Ok()
{
// Act
var response = await _client.GetAsync("/players/squadNumber/23");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var player = await response.Content.ReadFromJsonAsync<PlayerResponseModel>();
player.Should().NotBeNull();
player!.Dorsal.Should().Be(23);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Act
var response = await _client.GetAsync("/players/squadNumber/999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
/* -------------------------------------------------------------------------
* POST /players
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Post_Players_Nonexistent_Returns201Created()
{
// Arrange — squad number 27 (Lo Celso) is not in the seeded data
var request = PlayerFakes.MakeRequestModelForCreate();
// Act
var response = await _client.PostAsJsonAsync("/players", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var player = await response.Content.ReadFromJsonAsync<PlayerResponseModel>();
player.Should().NotBeNull();
player!.Dorsal.Should().Be(request.SquadNumber);
}
// Note: the controller's 409 Conflict branch (squad number already exists) is
// unreachable via the HTTP pipeline. The "Create" validation rule set includes
// BeUniqueSquadNumber, which returns a 422 validation error (Unprocessable Entity)
// before the controller's own duplicate check ever runs. The 409 path is covered
// by the unit test Post_Players_Existing_Returns409Conflict, where validation is
// mocked to pass so the controller logic can be exercised in isolation.
[Fact]
[Trait("Category", "Integration")]
public async Task Post_Players_ValidationError_Returns422UnprocessableEntity()
{
// Arrange — SquadNumber 0 is the int default, fails NotEmpty
var request = PlayerFakes.MakeRequestModelForCreate();
request.SquadNumber = 0;
// Act
var response = await _client.PostAsJsonAsync("/players", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
}
/* -------------------------------------------------------------------------
* PUT /players/squadNumber/{squadNumber:int}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForUpdate(23);
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange — squad number 999 does not exist; body matches route to avoid mismatch error
var request = PlayerFakes.MakeRequestModelForUpdate(23);
request.SquadNumber = 999;
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/999", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity()
{
// Arrange — SquadNumber -1 fails GreaterThan(0)
var request = PlayerFakes.MakeRequestModelForUpdate(23);
request.SquadNumber = -1;
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_SquadNumberMismatch_Returns400BadRequest()
{
// Arrange — body squad number (99) differs from route (23); both are valid values
// so validation passes and the mismatch guard in the controller fires instead
var request = PlayerFakes.MakeRequestModelForUpdate(23);
request.SquadNumber = 99;
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status400BadRequest);
problem.Title.Should().Be("Bad Request");
}
/* -------------------------------------------------------------------------
* DELETE /players/squadNumber/{squadNumber:int}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Delete_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Act
var response = await _client.DeleteAsync("/players/squadNumber/23");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Delete_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Act
var response = await _client.DeleteAsync("/players/squadNumber/999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
/* -------------------------------------------------------------------------
* GET /health
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_Health_Healthy_Returns200Ok()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}