first commit
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
using System.Data.Common;
|
||||
using Dotnet.Samples.AspNetCore.WebApi.Data;
|
||||
using Dotnet.Samples.AspNetCore.WebApi.Models;
|
||||
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
|
||||
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="Repository{T}"/> and <see cref="PlayerRepository"/>.
|
||||
/// Each test runs against an in-memory SQLite database with the full EF Core migration
|
||||
/// chain applied via <see cref="DatabaseFakes.MigrateAsync"/>, which also validates
|
||||
/// that the migration chain itself is healthy as a side effect.
|
||||
/// </summary>
|
||||
public class PlayerRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private DbConnection _connection = default!;
|
||||
private PlayerDbContext _dbContext = default!;
|
||||
private PlayerRepository _repository = default!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var (connection, options) = DatabaseFakes.CreateSqliteConnection();
|
||||
_connection = connection;
|
||||
_dbContext = DatabaseFakes.CreateDbContext(options);
|
||||
await _dbContext.MigrateAsync();
|
||||
_repository = new PlayerRepository(_dbContext);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dbContext.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* GetAllAsync
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task GetAllAsync_WhenCalled_ReturnsAllSeededPlayers()
|
||||
{
|
||||
// Act
|
||||
var players = await _repository.GetAllAsync();
|
||||
|
||||
// Assert
|
||||
players.Should().HaveCount(26);
|
||||
_dbContext.ChangeTracker.Entries<Player>().Should().BeEmpty();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* FindByIdAsync
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task FindByIdAsync_ExistingId_ReturnsPlayer()
|
||||
{
|
||||
// Arrange — resolve a real ID from the seeded database
|
||||
var seeded = await _repository.GetAllAsync();
|
||||
var existingId = seeded[0].Id;
|
||||
|
||||
// Act
|
||||
var player = await _repository.FindByIdAsync(existingId);
|
||||
|
||||
// Assert
|
||||
player.Should().NotBeNull();
|
||||
player!.Id.Should().Be(existingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task FindByIdAsync_UnknownId_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var player = await _repository.FindByIdAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
player.Should().BeNull();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* RemoveAsync
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task RemoveAsync_ExistingEntity_RemovesFromDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var seeded = await _repository.GetAllAsync();
|
||||
var existingId = seeded[0].Id;
|
||||
|
||||
// Act
|
||||
await _repository.RemoveAsync(existingId);
|
||||
|
||||
// Assert
|
||||
var player = await _repository.FindByIdAsync(existingId);
|
||||
player.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task RemoveAsync_UnknownId_NoExceptionThrown()
|
||||
{
|
||||
// Arrange
|
||||
var countBefore = (await _repository.GetAllAsync()).Count;
|
||||
|
||||
// Act
|
||||
var act = async () => await _repository.RemoveAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync();
|
||||
var countAfter = (await _repository.GetAllAsync()).Count;
|
||||
countAfter.Should().Be(countBefore);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* FindBySquadNumberAsync
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task FindBySquadNumberAsync_ExistingSquadNumber_ReturnsPlayer()
|
||||
{
|
||||
// Arrange
|
||||
var expected = PlayerFakes.MakeFromStarting11(23);
|
||||
|
||||
// Act
|
||||
var player = await _repository.FindBySquadNumberAsync(expected.SquadNumber);
|
||||
|
||||
// Assert
|
||||
player.Should().NotBeNull();
|
||||
player!.SquadNumber.Should().Be(expected.SquadNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task FindBySquadNumberAsync_UnknownSquadNumber_ReturnsNull()
|
||||
{
|
||||
// Arrange — derive a squad number guaranteed not to exist in the seeded data
|
||||
var seeded = await _repository.GetAllAsync();
|
||||
var unknownSquadNumber = seeded.Max(p => p.SquadNumber) + 1;
|
||||
|
||||
// Act
|
||||
var player = await _repository.FindBySquadNumberAsync(unknownSquadNumber);
|
||||
|
||||
// Assert
|
||||
player.Should().BeNull();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* SquadNumberExistsAsync
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task SquadNumberExistsAsync_ExistingSquadNumber_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var expected = PlayerFakes.MakeFromStarting11(23);
|
||||
|
||||
// Act
|
||||
var exists = await _repository.SquadNumberExistsAsync(expected.SquadNumber);
|
||||
|
||||
// Assert
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
public async Task SquadNumberExistsAsync_UnknownSquadNumber_ReturnsFalse()
|
||||
{
|
||||
// Arrange — derive a squad number guaranteed not to exist in the seeded data
|
||||
var seeded = await _repository.GetAllAsync();
|
||||
var unknownSquadNumber = seeded.Max(p => p.SquadNumber) + 1;
|
||||
|
||||
// Act
|
||||
var exists = await _repository.SquadNumberExistsAsync(unknownSquadNumber);
|
||||
|
||||
// Assert
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user