first commit

This commit is contained in:
2026-06-29 07:36:04 +02:00
commit 5e5b875e37
108 changed files with 13347 additions and 0 deletions
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup Label="Test dependencies">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" PrivateAssets="all" />
<PackageReference Include="Moq" Version="4.20.72" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.10.0" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.9.3" PrivateAssets="all" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="10.0.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Dotnet.Samples.AspNetCore.WebApi\Dotnet.Samples.AspNetCore.WebApi.csproj" />
</ItemGroup>
<PropertyGroup>
<SatelliteResourceLanguages>en-US</SatelliteResourceLanguages>
</PropertyGroup>
</Project>
@@ -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);
}
}
@@ -0,0 +1,544 @@
using Dotnet.Samples.AspNetCore.WebApi.Controllers;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Moq;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit;
public class PlayerControllerTests : IDisposable
{
private bool _disposed;
public PlayerControllerTests()
{
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
}
/* -------------------------------------------------------------------------
* HTTP POST
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Post_Players_ValidationError_Returns422UnprocessableEntity()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(
new ValidationResult(
new List<ValidationFailure>
{
new("SquadNumber", "SquadNumber must be greater than 0."),
}
)
);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PostAsync(request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Never);
service.Verify(service => service.CreateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity);
var problemDetails = httpResult
.ProblemDetails.Should()
.BeOfType<HttpValidationProblemDetails>()
.Subject;
problemDetails.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
problemDetails
.Errors.Should()
.ContainKey("SquadNumber")
.WhoseValue.Should()
.Contain("SquadNumber must be greater than 0.");
}
[Fact]
[Trait("Category", "Unit")]
public async Task Post_Players_Existing_Returns409Conflict()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var response = PlayerFakes.MakeResponseModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PostAsync(request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.CreateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<Conflict<ProblemDetails>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status409Conflict);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Post_Players_Nonexistent_Returns201Created()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var response = PlayerFakes.MakeResponseModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(null as PlayerResponseModel);
service.Setup(service => service.CreateAsync(request)).ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object)
{
Url = PlayerMocks.SetupUrlHelperMock().Object,
};
// Act
var result = await controller.PostAsync(request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.CreateAsync(It.IsAny<PlayerRequestModel>()), Times.Once);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<CreatedAtRoute<PlayerResponseModel>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status201Created);
httpResult.Value.Should().BeEquivalentTo(response);
httpResult.RouteName.Should().Be("RetrieveBySquadNumber");
httpResult.RouteValues.Should().NotBeNull().And.ContainKey("squadNumber");
httpResult.RouteValues!["squadNumber"].Should().Be(response.Dorsal);
}
/* -------------------------------------------------------------------------
* HTTP GET
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Get_Players_Existing_ReturnsPlayers()
{
// Arrange
var response = PlayerFakes.MakeResponseModelsForRetrieve();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service.Setup(service => service.RetrieveAsync()).ReturnsAsync(response);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetAsync();
// Assert
service.Verify(service => service.RetrieveAsync(), Times.Once);
var httpResult = result.Should().BeOfType<Ok<List<PlayerResponseModel>>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeOfType<List<PlayerResponseModel>>();
httpResult.Value.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_Players_Empty_Returns200OkWithEmptyList()
{
// Arrange
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service.Setup(service => service.RetrieveAsync()).ReturnsAsync([]);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetAsync();
// Assert
service.Verify(service => service.RetrieveAsync(), Times.Once);
var httpResult = result.Should().BeOfType<Ok<List<PlayerResponseModel>>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeEmpty();
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerById_Unknown_Returns404NotFound()
{
// Arrange
var id = Guid.NewGuid();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveByIdAsync(id))
.ReturnsAsync(null as PlayerResponseModel);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetByIdAsync(id);
// Assert
service.Verify(service => service.RetrieveByIdAsync(It.IsAny<Guid>()), Times.Once);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerById_Existing_Returns200OK()
{
// Arrange
var id = Guid.NewGuid();
var response = PlayerFakes.MakeResponseModelForRetrieve(10);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service.Setup(service => service.RetrieveByIdAsync(id)).ReturnsAsync(response);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetByIdAsync(id);
// Assert
service.Verify(service => service.RetrieveByIdAsync(It.IsAny<Guid>()), Times.Once);
var httpResult = result.Should().BeOfType<Ok<PlayerResponseModel>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeOfType<PlayerResponseModel>();
httpResult.Value.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange
var squadNumber = 999;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as PlayerResponseModel);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetBySquadNumberAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerBySquadNumber_Existing_Returns200OK()
{
// Arrange
var squadNumber = 10;
var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetBySquadNumberAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
var httpResult = result.Should().BeOfType<Ok<PlayerResponseModel>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeOfType<PlayerResponseModel>();
httpResult.Value.Should().BeEquivalentTo(response);
}
/* -------------------------------------------------------------------------
* HTTP PUT
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity()
{
// Arrange
var squadNumber = 20;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
request.SquadNumber = -999; // Invalid Squad Number
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(
new ValidationResult(
new List<ValidationFailure>
{
new("SquadNumber", "SquadNumber must be greater than 0."),
}
)
);
// Act
var result = await controller.PutAsync(squadNumber, request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Never);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity);
var problemDetails = httpResult
.ProblemDetails.Should()
.BeOfType<HttpValidationProblemDetails>()
.Subject;
problemDetails.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
problemDetails
.Errors.Should()
.ContainKey("SquadNumber")
.WhoseValue.Should()
.Contain("SquadNumber must be greater than 0.");
}
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange
var squadNumber = 999;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as PlayerResponseModel);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PutAsync(
squadNumber,
new PlayerRequestModel { SquadNumber = squadNumber }
);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_SquadNumberMismatch_Returns400BadRequest()
{
// Arrange
var squadNumber = 23;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
request.SquadNumber = 99; // Mismatched squad number
var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PutAsync(squadNumber, request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Never);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Arrange
var squadNumber = 23;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber);
request.FirstName = "Emiliano";
request.MiddleName = string.Empty;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
service.Setup(service => service.UpdateAsync(request));
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PutAsync(squadNumber, request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Once);
var httpResult = result.Should().BeOfType<NoContent>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent);
}
/* -------------------------------------------------------------------------
* HTTP DELETE
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Delete_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange
var squadNumber = 999;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as PlayerResponseModel);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.DeleteAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.DeleteAsync(It.IsAny<int>()), Times.Never);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Delete_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Arrange
var response = PlayerFakes.MakeResponseModelForCreate();
var squadNumber = response.Dorsal;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
service.Setup(service => service.DeleteAsync(squadNumber));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.DeleteAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.DeleteAsync(It.IsAny<int>()), Times.Once);
var httpResult = result.Should().BeOfType<NoContent>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", null);
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,400 @@
using System.Diagnostics;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using Moq;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit;
public class PlayerServiceTests
{
/* -------------------------------------------------------------------------
* Create
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task CreateAsync_WhenCalled_AddsPlayerAndRemovesCache()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var response = PlayerFakes.MakeResponseModelForCreate();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
mapper.Setup(mapper => mapper.Map<PlayerResponseModel>(request)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.CreateAsync(request);
// Assert
repository.Verify(repository => repository.AddAsync(It.IsAny<Player>()), Times.Once);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Once);
mapper.Verify(
mapper => mapper.Map<PlayerResponseModel>(It.IsAny<PlayerRequestModel>()),
Times.Once
);
}
/* -------------------------------------------------------------------------
* Retrieve
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveAsync_CacheMiss_QueriesRepositoryAndCachesResult()
{
// Arrange
var value = It.IsAny<object>();
var players = PlayerFakes.MakeStarting11();
var response = PlayerFakes.MakeResponseModelsForRetrieve();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.GetAllAsync()).ReturnsAsync(players);
mapper.Setup(mapper => mapper.Map<List<PlayerResponseModel>>(players)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveAsync();
// Assert
repository.Verify(repository => repository.GetAllAsync(), Times.Once);
memoryCache.Verify(cache => cache.TryGetValue(It.IsAny<object>(), out value), Times.Once);
memoryCache.Verify(cache => cache.CreateEntry(It.IsAny<object>()), Times.Once);
mapper.Verify(
mapper => mapper.Map<List<PlayerResponseModel>>(It.IsAny<List<Player>>()),
Times.Once
);
result.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveAsync_CacheHit_ReturnsBeforeQueryingRepository()
{
// Arrange
var value = It.IsAny<object>();
var players = PlayerFakes.MakeStarting11();
var response = PlayerFakes.MakeResponseModelsForRetrieve();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.GetAllAsync()).ReturnsAsync(players);
mapper.Setup(mapper => mapper.Map<List<PlayerResponseModel>>(players)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var first = await ExecutionTimeAsync(() => service.RetrieveAsync());
var second = await ExecutionTimeAsync(() => service.RetrieveAsync());
// Assert
memoryCache.Verify(
cache => cache.TryGetValue(It.IsAny<object>(), out value),
Times.Exactly(2) // first + second
);
memoryCache.Verify(cache => cache.CreateEntry(It.IsAny<object>()), Times.Once); // first only
repository.Verify(repository => repository.GetAllAsync(), Times.Once); // first only
mapper.Verify(
mapper => mapper.Map<List<PlayerResponseModel>>(It.IsAny<List<Player>>()),
Times.Once // first only
);
second.Should().BeLessThan(first);
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveByIdAsync_PlayerNotFound_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveByIdAsync(id);
// Assert
repository.Verify(repository => repository.FindByIdAsync(It.IsAny<Guid>()), Times.Once);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Never);
result.Should().BeNull();
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveByIdAsync_PlayerFound_ReturnsMappedResponseModel()
{
// Arrange
var id = Guid.NewGuid();
var squadNumber = 10;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(player);
mapper.Setup(mapper => mapper.Map<PlayerResponseModel>(player)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveByIdAsync(id);
// Assert
repository.Verify(repository => repository.FindByIdAsync(It.IsAny<Guid>()), Times.Once);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Once);
result.Should().BeOfType<PlayerResponseModel>();
result.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveBySquadNumberAsync_PlayerNotFound_ReturnsNull()
{
// Arrange
var squadNumber = 999;
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveBySquadNumberAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Never);
result.Should().BeNull();
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveBySquadNumberAsync_PlayerFound_ReturnsMappedResponseModel()
{
// Arrange
var squadNumber = 10;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(player);
mapper.Setup(mapper => mapper.Map<PlayerResponseModel>(player)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveBySquadNumberAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Once);
result.Should().BeOfType<PlayerResponseModel>();
result.Should().BeEquivalentTo(response);
}
/* -------------------------------------------------------------------------
* Update
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task UpdateAsync_PlayerFound_UpdatesRepositoryAndRemovesCache()
{
// Arrange
var squadNumber = 23;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.UpdateAsync(request);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.UpdateAsync(It.IsAny<Player>()), Times.Once);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Once);
mapper.Verify(
mapper => mapper.Map(It.IsAny<PlayerRequestModel>(), It.IsAny<Player>()),
Times.Once
);
}
[Fact]
[Trait("Category", "Unit")]
public async Task UpdateAsync_PlayerNotFound_DoesNotUpdateRepository()
{
// Arrange
var squadNumber = 999;
var request = PlayerFakes.MakeRequestModelForCreate();
request.SquadNumber = squadNumber;
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.UpdateAsync(request);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.UpdateAsync(It.IsAny<Player>()), Times.Never);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Never);
mapper.Verify(
mapper => mapper.Map(It.IsAny<PlayerRequestModel>(), It.IsAny<Player>()),
Times.Never
);
}
/* -------------------------------------------------------------------------
* Delete
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task DeleteAsync_PlayerFound_RemovesFromRepositoryAndRemovesCache()
{
// Arrange
var squadNumber = 26;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.DeleteAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.RemoveAsync(It.IsAny<Guid>()), Times.Once);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Once);
}
[Fact]
[Trait("Category", "Unit")]
public async Task DeleteAsync_PlayerNotFound_DoesNotRemoveFromRepository()
{
// Arrange
var squadNumber = 999;
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.DeleteAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.RemoveAsync(It.IsAny<Guid>()), Times.Never);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Never);
}
private static async Task<long> ExecutionTimeAsync(Func<Task> awaitable)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
await awaitable();
stopwatch.Stop();
return stopwatch.ElapsedMilliseconds;
}
}
@@ -0,0 +1,381 @@
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using Dotnet.Samples.AspNetCore.WebApi.Validators;
using FluentAssertions;
using FluentValidation;
using Moq;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit;
public class PlayerValidatorTests
{
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private static PlayerRequestModelValidator CreateValidator(
Mock<IPlayerRepository>? repositoryMock = null,
TimeProvider? timeProvider = null
)
{
var mock = repositoryMock ?? new Mock<IPlayerRepository>();
return new PlayerRequestModelValidator(mock.Object, timeProvider);
}
/* -------------------------------------------------------------------------
* Valid request
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_ValidRequest_ReturnsNoErrors()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var repositoryMock = new Mock<IPlayerRepository>();
repositoryMock
.Setup(repository => repository.FindBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(null as Player);
var validator = CreateValidator(repositoryMock);
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
/* -------------------------------------------------------------------------
* FirstName
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_FirstNameEmpty_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.FirstName = string.Empty;
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "FirstName");
}
/* -------------------------------------------------------------------------
* LastName
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_LastNameEmpty_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.LastName = string.Empty;
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "LastName");
}
/* -------------------------------------------------------------------------
* SquadNumber
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_SquadNumberNotGreaterThanZero_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.SquadNumber = 0;
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "SquadNumber");
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_SquadNumberNegative_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.SquadNumber = -5;
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result
.Errors.Should()
.Contain(error =>
error.PropertyName == "SquadNumber" && error.ErrorMessage.Contains("greater than 0")
);
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_SquadNumberNotUnique_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var existingPlayer = PlayerFakes.MakeNew();
var repositoryMock = new Mock<IPlayerRepository>();
repositoryMock
.Setup(repository => repository.FindBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(existingPlayer);
var validator = CreateValidator(repositoryMock);
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result
.Errors.Should()
.Contain(error =>
error.PropertyName == "SquadNumber" && error.ErrorMessage.Contains("unique")
);
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_SquadNumberBelongsToPlayerBeingUpdated_ReturnsNoErrors()
{
// Arrange
// Simulate a PUT request for an existing player: the squad number in the
// body matches the one already in the database. The "Update" rule set must
// not run BeUniqueSquadNumber, otherwise this valid request would be rejected.
var request = PlayerFakes.MakeRequestModelForUpdate(10);
var existingPlayer = PlayerFakes.MakeFromStarting11(10);
var repositoryMock = new Mock<IPlayerRepository>();
repositoryMock
.Setup(repository => repository.FindBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(existingPlayer);
var validator = CreateValidator(repositoryMock);
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Update")
);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_FirstNameEmptyInUpdateRuleSet_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForUpdate(10);
request.FirstName = string.Empty;
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Update")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "FirstName");
}
/* -------------------------------------------------------------------------
* AbbrPosition
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_AbbrPositionEmpty_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.AbbrPosition = string.Empty;
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "AbbrPosition");
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_AbbrPositionInvalid_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.AbbrPosition = "INVALID";
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "AbbrPosition");
}
/* -------------------------------------------------------------------------
* DateOfBirth
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_DateOfBirthNull_ReturnsNoErrors()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.DateOfBirth = null;
var repositoryMock = new Mock<IPlayerRepository>();
repositoryMock
.Setup(repository => repository.FindBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(null as Player);
var validator = CreateValidator(repositoryMock);
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_DateOfBirthInFuture_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.DateOfBirth = DateTime.UtcNow.AddYears(1);
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "DateOfBirth");
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_DateOfBirthToday_ReturnsValidationError()
{
// Arrange
var fixedNow = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(fixedNow);
var request = PlayerFakes.MakeRequestModelForCreate();
request.DateOfBirth = fixedNow.Date; // same "today" the validator sees
var validator = CreateValidator(timeProvider: timeProvider);
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "DateOfBirth");
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_DateOfBirthBeforeYear1900_ReturnsValidationError()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.DateOfBirth = new DateTime(1899, 12, 31, 0, 0, 0, DateTimeKind.Utc);
var validator = CreateValidator();
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == "DateOfBirth");
}
[Fact]
[Trait("Category", "Unit")]
public async Task ValidateAsync_DateOfBirthOnJanuary1st1900_ReturnsNoErrors()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
request.DateOfBirth = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc); // rule: >= 1900-01-01
var repositoryMock = new Mock<IPlayerRepository>();
repositoryMock
.Setup(r => r.FindBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(null as Player);
var validator = CreateValidator(repositoryMock);
// Act
var result = await validator.ValidateAsync(
request,
options => options.IncludeRuleSets("Create")
);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,59 @@
using System.Data.Common;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Migrations;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities
{
/// <summary>
/// A Fake is a working implementation that's simpler than the real one.
/// It usually has some "real" logic but is not suitable for production
/// (e.g., an in‑memory database instead of a full SQL Server). Fakes are
/// useful when you need behavior that's closer to reality but still want
/// to avoid external dependencies.
/// </summary>
public static class DatabaseFakes
{
/// <summary>
/// Creates an in-memory SQLite connection and DbContext options for testing.
/// The connection remains open for the lifetime of the test.
/// </summary>
/// <returns>A tuple containing the SQLite connection and DbContext options.</returns>
public static (DbConnection, DbContextOptions<PlayerDbContext>) CreateSqliteConnection()
{
var dbConnection = new SqliteConnection("Filename=:memory:");
dbConnection.Open();
var dbContextOptions = new DbContextOptionsBuilder<PlayerDbContext>()
.UseSqlite(dbConnection)
.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>()
.Options;
return (dbConnection, dbContextOptions);
}
/// <summary>
/// Creates a PlayerDbContext instance with the specified options.
/// </summary>
/// <param name="dbContextOptions">The DbContext options to use.</param>
/// <returns>A new PlayerDbContext instance.</returns>
public static PlayerDbContext CreateDbContext(
DbContextOptions<PlayerDbContext> dbContextOptions
)
{
return new PlayerDbContext(dbContextOptions);
}
/// <summary>
/// Applies all pending EF Core migrations to the test database, including seed data.
/// Extension method for PlayerDbContext.
/// </summary>
/// <param name="context">The PlayerDbContext instance.</param>
public static async Task MigrateAsync(this PlayerDbContext context)
{
await context.Database.MigrateAsync();
}
}
}
@@ -0,0 +1,228 @@
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Utilities;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
/// <summary>
/// Test data factory for Player entities.
/// Wraps production data from PlayerData with test-specific modifications (e.g., GUID assignment).
/// A Fake is a working implementation that's simpler than the real one.
/// It usually has some "real" logic but is not suitable for production
/// (e.g., an in‑memory database instead of a full SQL Server). Fakes are
/// useful when you need behavior that's closer to reality but still want
/// to avoid external dependencies.
/// </summary>
/// <remarks>
/// Data-state vocabulary used across controller tests:
/// <list type="bullet">
/// <item><term>existing</term><description>Player is present in the database.</description></item>
/// <item><term>nonexistent</term><description>Player is absent, valid shape for creation (POST scenarios).</description></item>
/// <item><term>unknown</term><description>Valid ID format, absent from database (404-by-lookup scenarios).</description></item>
/// </list>
/// </remarks>
public static class PlayerFakes
{
private static string? FormatBirth(DateTime? dateOfBirth) =>
dateOfBirth.HasValue ? $"{dateOfBirth.Value:MMMM d, yyyy}" : null;
/// <summary>
/// Returns the starting 11 players with generated GUIDs for in-memory testing.
/// Reuses production player data from PlayerData.MakeStarting11().
/// </summary>
public static List<Player> MakeStarting11()
{
var players = PlayerData.MakeStarting11();
// Assign GUIDs for in-memory database testing
foreach (var player in players)
{
player.Id = Guid.NewGuid();
}
return players;
}
/// <summary>
/// Returns a specific player from the starting 11 by squad number.
/// Reuses production player data from PlayerData.MakeStarting11().
/// </summary>
public static Player MakeFromStarting11(int squadNumber)
{
var player =
PlayerData.MakeStarting11().SingleOrDefault(p => p.SquadNumber == squadNumber)
?? throw new ArgumentNullException(
$"Player with Squad Number {squadNumber} not found."
);
player.Id = Guid.NewGuid();
return player;
}
/// <summary>
/// Returns a new player (substitute) for testing create operations.
/// Reuses production player data from PlayerData.GetSubstitutes().
/// </summary>
public static Player MakeNew()
{
// Get Giovani Lo Celso (squad number 27) from substitutes
var player =
PlayerData.GetSubstitutes().SingleOrDefault(player => player.SquadNumber == 27)
?? throw new InvalidOperationException(
"Substitute player with squad number 27 not found."
);
player.Id = Guid.NewGuid();
return player;
}
/* -------------------------------------------------------------------------
* Create
* ---------------------------------------------------------------------- */
/// <summary>
/// Creates a PlayerRequestModel for testing create operations.
/// Uses data from a substitute player (Giovani Lo Celso).
/// </summary>
public static PlayerRequestModel MakeRequestModelForCreate()
{
var player = MakeNew();
return new PlayerRequestModel()
{
FirstName = player.FirstName,
MiddleName = player.MiddleName,
LastName = player.LastName,
DateOfBirth = player.DateOfBirth,
SquadNumber = player.SquadNumber,
AbbrPosition = player.AbbrPosition,
Team = player.Team,
League = player.League,
};
}
/// <summary>
/// Creates a PlayerResponseModel for testing create operation responses.
/// Uses data from a substitute player (Giovani Lo Celso).
/// </summary>
public static PlayerResponseModel MakeResponseModelForCreate()
{
var player = MakeNew();
return new PlayerResponseModel
{
FullName =
$"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(),
Birth = FormatBirth(player.DateOfBirth),
Dorsal = player.SquadNumber,
Position = player.Position,
Club = player.Team,
League = player.League,
Starting11 = player.Starting11 ? "Yes" : "No",
};
}
/* -------------------------------------------------------------------------
* Retrieve
* ---------------------------------------------------------------------- */
/// <summary>
/// Creates a PlayerRequestModel for testing retrieve operations.
/// Uses data from the starting 11 based on the specified squad number.
/// </summary>
/// <param name="squadNumber">The squad number of the player to retrieve.</param>
public static PlayerRequestModel MakeRequestModelForRetrieve(int squadNumber)
{
var player =
PlayerData.MakeStarting11().SingleOrDefault(p => p.SquadNumber == squadNumber)
?? throw new ArgumentNullException(
$"Player with Squad Number {squadNumber} not found."
);
return new PlayerRequestModel
{
FirstName = player.FirstName,
MiddleName = player.MiddleName,
LastName = player.LastName,
DateOfBirth = player.DateOfBirth,
SquadNumber = player.SquadNumber,
AbbrPosition = player.AbbrPosition,
Team = player.Team,
League = player.League,
};
}
/// <summary>
/// Creates a PlayerResponseModel for testing retrieve operation responses.
/// Uses data from the starting 11 based on the specified squad number.
/// </summary>
/// <param name="squadNumber">The squad number of the player to retrieve.</param>
public static PlayerResponseModel MakeResponseModelForRetrieve(int squadNumber)
{
var player =
PlayerData.MakeStarting11().SingleOrDefault(p => p.SquadNumber == squadNumber)
?? throw new ArgumentNullException(
$"Player with Squad Number {squadNumber} not found."
);
return new PlayerResponseModel
{
FullName =
$"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(),
Birth = FormatBirth(player.DateOfBirth),
Dorsal = player.SquadNumber,
Position = player.Position,
Club = player.Team,
League = player.League,
Starting11 = player.Starting11 ? "Yes" : "No",
};
}
/// <summary>
/// Creates a list of PlayerResponseModel for testing retrieve all operations.
/// Uses all players from the starting 11.
/// </summary>
public static List<PlayerResponseModel> MakeResponseModelsForRetrieve() =>
[
.. PlayerData
.MakeStarting11()
.Select(player =>
{
player.Id = Guid.NewGuid();
return new PlayerResponseModel
{
FullName =
$"{player.FirstName} {(string.IsNullOrWhiteSpace(player.MiddleName) ? "" : player.MiddleName + " ")}{player.LastName}".Trim(),
Birth = FormatBirth(player.DateOfBirth),
Dorsal = player.SquadNumber,
Position = player.Position,
Club = player.Team,
League = player.League,
Starting11 = player.Starting11 ? "Yes" : "No",
};
}),
];
/* -------------------------------------------------------------------------
* Update
* ---------------------------------------------------------------------- */
/// <summary>
/// Creates a PlayerRequestModel for testing update operations.
/// Delegates to MakeRequestModelForRetrieve.
/// </summary>
/// <param name="squadNumber">The squad number of the player to update.</param>
public static PlayerRequestModel MakeRequestModelForUpdate(int squadNumber)
{
return MakeRequestModelForRetrieve(squadNumber);
}
/// <summary>
/// Creates a PlayerResponseModel for testing update operation responses.
/// Delegates to MakeResponseModelForRetrieve.
/// </summary>
/// <param name="squadNumber">The squad number of the player to update.</param>
public static PlayerResponseModel MakeResponseModelForUpdate(int squadNumber)
{
return MakeResponseModelForRetrieve(squadNumber);
}
}
@@ -0,0 +1,113 @@
using AutoMapper;
using Dotnet.Samples.AspNetCore.WebApi.Controllers;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moq;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities
{
/// <summary>
/// A Mock is a dynamic test double that not only provides responses but
/// also records interactions. Mocks are pre‑programmed with expectations
/// (like “this method should be called once with these parameters”) and can
/// later verify that the expected calls occurred. Frameworks such as Moq
/// make it easy to set up and verify these interactions.
/// </summary>
public static class PlayerMocks
{
/// <summary>
/// Initializes mocks for PlayerController dependencies.
/// </summary>
/// <returns>A tuple containing mocked service, logger, and validator.</returns>
public static (
Mock<IPlayerService> service,
Mock<ILogger<PlayerController>> logger,
Mock<IValidator<PlayerRequestModel>> validator
) InitControllerMocks()
{
var service = new Mock<IPlayerService>();
var logger = new Mock<ILogger<PlayerController>>();
var validator = new Mock<IValidator<PlayerRequestModel>>();
return (service, logger, validator);
}
/// <summary>
/// Creates a mock IUrlHelper configured to return any string for Action calls.
/// </summary>
/// <returns>A configured Mock of IUrlHelper.</returns>
public static Mock<IUrlHelper> SetupUrlHelperMock()
{
var mock = new Mock<IUrlHelper>();
mock.Setup(url => url.Action(It.IsAny<UrlActionContext>())).Returns(It.IsAny<string>());
return mock;
}
/// <summary>
/// Initializes mocks for PlayerService dependencies.
/// </summary>
/// <param name="cacheValue">Optional cache value to return from memory cache.</param>
/// <param name="environmentName">The environment name (defaults to "Development").</param>
/// <returns>A tuple containing mocked repository, logger, cache, mapper, and environment.</returns>
public static (
Mock<IPlayerRepository> repository,
Mock<ILogger<PlayerService>> logger,
Mock<IMemoryCache> memoryCache,
Mock<IMapper> mapper,
Mock<IHostEnvironment> environment
) InitServiceMocks(object? cacheValue = null, string environmentName = "Development")
{
var repository = new Mock<IPlayerRepository>();
var logger = new Mock<ILogger<PlayerService>>();
var memoryCache = SetupMemoryCacheMock(cacheValue ?? It.IsAny<object>());
var mapper = new Mock<IMapper>();
var environment = new Mock<IHostEnvironment>();
environment.Setup(env => env.EnvironmentName).Returns(environmentName);
return (repository, logger, memoryCache, mapper, environment);
}
/// <summary>
/// Creates a mock IMemoryCache with configurable behavior.
/// First TryGetValue call returns false, subsequent calls return true with the specified value.
/// </summary>
/// <param name="value">The value to return from cache after the first call.</param>
/// <returns>A configured Mock of IMemoryCache.</returns>
public static Mock<IMemoryCache> SetupMemoryCacheMock(object? value)
{
var cachedValue = false;
var mock = new Mock<IMemoryCache>();
mock.Setup(cache => cache.TryGetValue(It.IsAny<object>(), out It.Ref<object>.IsAny!))
.Callback(
new TryGetValueDelegate(
(object _, out object? output) =>
{
output = cachedValue ? value : null;
}
)
)
.Returns(() =>
{
bool hasValue = cachedValue;
cachedValue = true; // Subsequent invocations will return true
return hasValue;
});
mock.Setup(cache => cache.CreateEntry(It.IsAny<object>()))
.Returns(Mock.Of<ICacheEntry>);
mock.Setup(cache => cache.Remove(It.IsAny<object>()));
return mock;
}
private delegate void TryGetValueDelegate(object key, out object? value);
}
}
@@ -0,0 +1,31 @@
using System.Data.Common;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities
{
/// <summary>
/// A Stub provides pre‑configured, hard‑coded responses to method calls.
/// Its purpose is simply to supply data to the system under test without
/// any behavior verification. For example, a Stub for a repository might
/// always return a specific user regardless of the input.
/// </summary>
public static class PlayerStubs
{
/// <summary>
/// Creates a ModelStateDictionary with a single validation error.
/// Used for testing validation failure scenarios.
/// </summary>
/// <param name="key">The property name or key for the error.</param>
/// <param name="errorMessage">The error message.</param>
/// <returns>A ModelStateDictionary containing the specified error.</returns>
public static ModelStateDictionary CreateModelError(string key, string errorMessage)
{
var modelStateDictionary = new ModelStateDictionary();
modelStateDictionary.AddModelError(key, errorMessage);
return modelStateDictionary;
}
}
}
@@ -0,0 +1,30 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
/// <summary>
/// A test authentication handler that automatically authenticates every request,
/// bypassing the real authentication scheme. Used in integration tests to exercise
/// endpoints protected with [Authorize] without requiring a real token.
/// </summary>
internal sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] { new Claim(ClaimTypes.Name, "TestUser") };
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -0,0 +1,767 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"coverlet.collector": {
"type": "Direct",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA=="
},
"FluentAssertions": {
"type": "Direct",
"requested": "[8.10.0, )",
"resolved": "8.10.0",
"contentHash": "yGvv+xp0gHFgGwhQcGoDdKIrhLhPi3zYYWWLLHPMctr+G6PWD7QH8L4wqoa5WRZquPbfSV/cP1MraI7ppAHrWw=="
},
"Microsoft.AspNetCore.Mvc.Testing": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "Mt+5CtYz+xmog1S1TJt2owVKU8YquZtNy9bmO+kfrrtjEDZPzCw1qZ7o97PLpIEpt3yy4F5YdAUh9nKPm0CX5Q==",
"dependencies": {
"Microsoft.AspNetCore.TestHost": "10.0.9",
"Microsoft.Extensions.DependencyModel": "10.0.9",
"Microsoft.Extensions.Hosting": "10.0.9"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.7.0, )",
"resolved": "18.7.0",
"contentHash": "49xH9j4UzCh2hMohJp53g3wUTvyycECw7CtVht4gfCz5ykudB1uBcF6D0TtgJPjCtP76UPW53bQElKdCeX+dUg==",
"dependencies": {
"Microsoft.CodeCoverage": "18.7.0",
"Microsoft.TestPlatform.TestHost": "18.7.0"
}
},
"Moq": {
"type": "Direct",
"requested": "[4.20.72, )",
"resolved": "4.20.72",
"contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.5, )",
"resolved": "3.1.5",
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
},
"AutoMapper": {
"type": "Transitive",
"resolved": "16.1.1",
"contentHash": "VNEky8JA15ci+oIDRGHITOGOpV4dILsf8pnn24QhDl2urtqgJ2IXiS/V2EtGU17P/+f6OeFQPJETaZXV9QOIZg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
"Microsoft.Extensions.Options": "10.0.0",
"Microsoft.IdentityModel.JsonWebTokens": "8.14.0"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"FluentValidation": {
"type": "Transitive",
"resolved": "12.1.1",
"contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw=="
},
"FluentValidation.DependencyInjectionExtensions": {
"type": "Transitive",
"resolved": "12.1.1",
"contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==",
"dependencies": {
"FluentValidation": "12.1.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0"
}
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.AspNetCore.TestHost": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "mR4y30XFsVbn7EwV3Ic5/KMBO5QGwukTJE2ztGmpAST5ACX+Z+9r+Y6D1eibJojsOIW5KHpM4myo9/aRALJOyg=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.7.0",
"contentHash": "+wFfx9s7D9wegM0RziXMj2kvYDT4qcqXXtyjiQwSZOGQ2wwcOAJQcD6eQXk02jt0MvRNawtp8TJxTrV+wD8X1g=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "iZrONyMKPjxfVZnUktqO30QjzNwAGH+AxM61s8lKQnVhgbQ3bn0hiXI129ZmVicEbIcwljyy2OVsIYUR51ZHKQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "tu85SRzOT021V7EQlViCiAE7TqldVn469Y6lt5TEn/+XC4/MeNCHgMRSxqYuWqvF4zAQZUhCmtNEZuM3ss4LeA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.9",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.9",
"Microsoft.Extensions.Caching.Memory": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "GRMaiPkqYna/gCsyDffYDWmefGPC3hDrdMw+2rrGcQwhs6uZOsaMQXMJnoXQ35tx9SkBV2ieRRU9N/jLOO6BZw=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "aiEFB+C5EsZGqxvMPazE07hbWsp4iPaufJpanGt5O+lrwv7mJLrqma5haVIgFAPCyhQkmk75XSCEubT1zUjxtA=="
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "dEoyYKgiaZHHgOFm1WMWm1sFEsEuhPWufX4L9PekKtqd/RaIcPjkCjvbrVvJtApErb5wPSJhYvnTlxhH+p9h2g==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.9",
"Microsoft.Extensions.Caching.Memory": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "HH9/nnRF/YmRrc3hUlgXjMBYKH5kFmd5UWC81l9U0ySQhwHTcgvDPSewB8DyQHzFJzNGgG7VFK6ynC6+XQz9WA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.9",
"Microsoft.Extensions.Caching.Memory": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.DependencyModel": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "3bPEmACAaPJJSw+m5XwTSi3yZnVtaifa4d8gLsNMzW0Qu28jS5kADSfgJRBlq49RJ1K098VCzEDRJwM8gE6f2w==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "10.0.9",
"Microsoft.EntityFrameworkCore.Relational": "10.0.9",
"Microsoft.Extensions.Caching.Memory": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.DependencyModel": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.ApiDescription.Server": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg=="
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "5fGxcw2vuYp8s0wio9H1ECiuk4iKSdTIlNuigdLIrkhg+5XAwgFVDB/5Ots3pfN/QhABLYXutA79JFtnUKDSHA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "G9mregdatGWMCQWeCw012LDeJVP7G/XIxH8Ddbjc8bD1//dA+8VVQdcRE9jI1moyoJxSSZhHITUnNQ8FUDl5+Q==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.9",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "8D4HaqxWdm5M/nuhQffjPoR1ekhlpyKTXjFMAT5KlP0dvxkJe5JLAP6MAsuUEUxKWG09Bi5aAUaYMFKrMqWHqA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "JhKySWIL8+N4yFt4HPm1rGKCHooze+MBdTdpXc0bd/PGm31TrSUi2m0Nek1y441Wlv/RE6VH0W/DCv2xnmy8FA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.9",
"Microsoft.Extensions.FileProviders.Physical": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.9",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "ockJRreRW/HbGwoyHzYOxMucFBimvAZ8lKNwQLMHrS6mwkDUaCJMWzzeE+Rm9vgFlv2o/xqk8fm+FpqrDCnkTA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Configuration.Json": "10.0.9",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.9",
"Microsoft.Extensions.FileProviders.Physical": "10.0.9"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "SCDTQ6HubnRvTUjR7dgMKHZvNoCb03t44ttHL8trlFTGgfDteWn/0nRdOxDhcI+lTWhKgd/flCVJEtAOPhSLNg=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.9",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w=="
},
"Microsoft.Extensions.Hosting": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "HTgnvmK0ubesUFO16pLC+i9+RS8lEGd6TmDouuy75FsAgIFrSwUVhYCqG2IENzBJwgxGc/6Rsulfsvd9ZG/XkA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Configuration.Binder": "10.0.9",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.9",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.9",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.9",
"Microsoft.Extensions.Configuration.Json": "10.0.9",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.9",
"Microsoft.Extensions.DependencyInjection": "10.0.9",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Diagnostics": "10.0.9",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.9",
"Microsoft.Extensions.FileProviders.Physical": "10.0.9",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging.Configuration": "10.0.9",
"Microsoft.Extensions.Logging.Console": "10.0.9",
"Microsoft.Extensions.Logging.Debug": "10.0.9",
"Microsoft.Extensions.Logging.EventLog": "10.0.9",
"Microsoft.Extensions.Logging.EventSource": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.9",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Configuration.Binder": "10.0.9",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "WyZEG/O8jKqBOBF6/M6IJqiEyWFBUv6PDyzNoXDA0mBZwKtkuf7GiZ/0/8eU8OpLKKQL0O95oPOY1szrWIKofQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging.Configuration": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "r/A0ahpXmZH/8ltPjrFFWp12BIizK9cCVJXPcHyOad8e4eIX7P/geW+uBYdczkeCAaMebT4jEU7snOm4GnmKfA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "goAl30/WwmdnWDPRwATaDPIK0iuDBnQSMTH2XYGVB1SwReg7hglhvDNjjpNhT25US3GF4I5q6BhTNs6nFYzEfg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9",
"System.Diagnostics.EventLog": "10.0.9"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "tHynPVHbTicuaDpS2JVTxX0qA5VTg15CXgVKTwWvvudb5BvW5aVew8MMyek6LrDGAom7UbON1jf1T5GhpTilFA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Logging": "10.0.9",
"Microsoft.Extensions.Logging.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.9",
"Microsoft.Extensions.Configuration.Binder": "10.0.9",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
"Microsoft.Extensions.Options": "10.0.9",
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g=="
},
"Microsoft.IdentityModel.Abstractions": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ=="
},
"Microsoft.IdentityModel.JsonWebTokens": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.14.0"
}
},
"Microsoft.IdentityModel.Logging": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==",
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.14.0"
}
},
"Microsoft.IdentityModel.Tokens": {
"type": "Transitive",
"resolved": "8.14.0",
"contentHash": "lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.IdentityModel.Logging": "8.14.0"
}
},
"Microsoft.OpenApi": {
"type": "Transitive",
"resolved": "2.7.5",
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.7.0",
"contentHash": "6rmgU4q3/WOpOPcncI0YW0Q/QpcQtwR2TTEXDR5+4TfSimPBAk6Z/BgKLeGgp1SOun0ROVUCCafXhRLwsHaPpA=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.7.0",
"contentHash": "kYwfmebCs8992zaxEDkvG7S+YEouTeKfYVKUFEkwh1W2dIoOaevBt80XSKVXCUFEhusjOIm1sFfHBnoJgygrRA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.7.0",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "PsNYgPOSW41Xx19gin7y4EdZAPteWr9Cb01XkdObxOsPzi+mgBupBEN7J7+erXFsROPOILM7MlIoO9QzL8+LGQ==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
"Npgsql": "10.0.3"
}
},
"Serilog": {
"type": "Transitive",
"resolved": "4.3.0",
"contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ=="
},
"Serilog.AspNetCore": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==",
"dependencies": {
"Serilog": "4.3.0",
"Serilog.Extensions.Hosting": "10.0.0",
"Serilog.Formatting.Compact": "3.0.0",
"Serilog.Settings.Configuration": "10.0.0",
"Serilog.Sinks.Console": "6.1.1",
"Serilog.Sinks.Debug": "3.0.0",
"Serilog.Sinks.File": "7.0.0"
}
},
"Serilog.Extensions.Hosting": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
"Serilog": "4.3.0",
"Serilog.Extensions.Logging": "10.0.0"
}
},
"Serilog.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==",
"dependencies": {
"Microsoft.Extensions.Logging": "10.0.0",
"Serilog": "4.2.0"
}
},
"Serilog.Formatting.Compact": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Settings.Configuration": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "ayFE7h66mqMqzwfPrzDCMbWU27FdNC2bkCG+jnkeHFZTBRh+yWdr4aa/2WuX7c8RmqxGPMW2UqoJ3fw9hK3QhA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "10.0.0",
"Microsoft.Extensions.DependencyModel": "10.0.0",
"Serilog": "4.3.0"
}
},
"Serilog.Sinks.Console": {
"type": "Transitive",
"resolved": "6.1.1",
"contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Sinks.Debug": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Sinks.File": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==",
"dependencies": {
"Serilog": "4.2.0"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.11",
"contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.11",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.11"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "2.1.11",
"contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.11",
"contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.11",
"contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Swashbuckle.AspNetCore": {
"type": "Transitive",
"resolved": "10.2.3",
"contentHash": "8KNh1RWvofdU6DVLyBs4Z/OpUMnmf8oNvJQc0QxpwySRbi42bwLfdVMMrXZWANg5U5KQGQq1xW6r/hlcqw99tQ==",
"dependencies": {
"Microsoft.Extensions.ApiDescription.Server": "10.0.0",
"Swashbuckle.AspNetCore.Swagger": "10.2.3",
"Swashbuckle.AspNetCore.SwaggerGen": "10.2.3",
"Swashbuckle.AspNetCore.SwaggerUI": "10.2.3"
}
},
"Swashbuckle.AspNetCore.Swagger": {
"type": "Transitive",
"resolved": "10.2.3",
"contentHash": "1jUUs3WQnrS0FUtaZPLSy1yYMEwS1zlvDmvQ2/eldPHUANX0LJSLVZecCMgSMdeGiRqeaRrIXLtSz++TCiTMww==",
"dependencies": {
"Microsoft.OpenApi": "2.7.5"
}
},
"Swashbuckle.AspNetCore.SwaggerGen": {
"type": "Transitive",
"resolved": "10.2.3",
"contentHash": "y7t4coDRAeFYChmvlMRiH2OjbiRrm9AVIDgt17fQfs3x9PVAI5PiwWYOhg+4F13R4Q36WDc9lqfoOnNa3tNbGg==",
"dependencies": {
"Swashbuckle.AspNetCore.Swagger": "10.2.3"
}
},
"Swashbuckle.AspNetCore.SwaggerUI": {
"type": "Transitive",
"resolved": "10.2.3",
"contentHash": "nthWONRs/FJ4yyG206g1cC52WEG8EqrjuMWjGdR+5XG7lbjFto6NqcI9EMICgVFom/UivIjUVwI76ZHbHwTPfQ=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.9",
"contentHash": "s2PcxHK4IYQ6gmD3VSBkym9tWGkFisKjcjWBdl7a+n4Yy66ae4beJ1ZdjDp060SSll4W3Rt4H2LW87dWckv+QQ=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"dotnet.samples.aspnetcore.webapi": {
"type": "Project",
"dependencies": {
"AutoMapper": "[16.1.1, 17.0.0)",
"FluentValidation": "[12.1.1, )",
"FluentValidation.DependencyInjectionExtensions": "[12.1.1, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.9, )",
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.2, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Settings.Configuration": "[10.0.1, )",
"Serilog.Sinks.Console": "[6.1.1, )",
"Serilog.Sinks.File": "[7.0.0, )",
"Swashbuckle.AspNetCore": "[10.2.3, )"
}
}
}
}
}