Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.30" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.30" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API\API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,121 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HU-101: sensitive actions are recorded in the audit trail. Services are
|
||||
/// resolved from the real host container (over a shared SQLite database), so
|
||||
/// the audit wiring is exercised end to end. With no HTTP request bound the
|
||||
/// actor resolves to the system user, which is the expected value for these
|
||||
/// service-level invocations.
|
||||
/// </summary>
|
||||
public class AuditTrailTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public AuditTrailTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WipeSampleData_WritesDataWipeAuditEntry()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
int before = await db.AuditLogs.CountAsync(a => a.Action == AuditAction.DataWipe);
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
|
||||
int after = await db.AuditLogs.CountAsync(a => a.Action == AuditAction.DataWipe);
|
||||
Assert.Equal(before + 1, after);
|
||||
|
||||
// The wipe never deletes the audit trail itself.
|
||||
Assert.True(await db.AuditLogs.AnyAsync(a => a.Action == AuditAction.DataWipe));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeTournamentStatus_WritesStatusChangeAuditEntry()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService<ITournamentService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "test",
|
||||
Name = $"Audit Tournament {Guid.NewGuid()}",
|
||||
Description = "Status-change audit fixture.",
|
||||
Slug = $"audit-tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StartDate = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Status = TournamentStatus.Scheduled,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
int before = await db.AuditLogs.CountAsync(a => a.Action == AuditAction.TournamentStatusChange);
|
||||
|
||||
// Scheduled -> OpenForRegistration is a valid transition that does not
|
||||
// trigger fixture generation.
|
||||
await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.OpenForRegistration);
|
||||
|
||||
int after = await db.AuditLogs.CountAsync(a => a.Action == AuditAction.TournamentStatusChange);
|
||||
Assert.Equal(before + 1, after);
|
||||
|
||||
AuditLog entry = await db.AuditLogs
|
||||
.Where(a => a.Action == AuditAction.TournamentStatusChange && a.TargetId == tournament.Id.ToString())
|
||||
.OrderByDescending(a => a.DateCreated)
|
||||
.FirstAsync();
|
||||
|
||||
Assert.Equal(nameof(Tournament), entry.TargetType);
|
||||
// Captured at write time so the trail still reads clearly even if the
|
||||
// tournament is later renamed or deleted.
|
||||
Assert.Equal(tournament.Name, entry.TargetName);
|
||||
// The user-facing audit Detail must be Spanish (not the English enum
|
||||
// names): "Programado → Inscripción abierta".
|
||||
Assert.Contains("Programado", entry.Detail);
|
||||
Assert.Contains("Inscripción abierta", entry.Detail);
|
||||
Assert.DoesNotContain("Scheduled", entry.Detail);
|
||||
Assert.DoesNotContain("OpenForRegistration", entry.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WipeSampleData_AuditDetail_IsSpanish()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
|
||||
AuditLog entry = await db.AuditLogs
|
||||
.Where(a => a.Action == AuditAction.DataWipe)
|
||||
.OrderByDescending(a => a.DateCreated)
|
||||
.FirstAsync();
|
||||
|
||||
Assert.NotNull(entry.Detail);
|
||||
Assert.Contains("torneos", entry.Detail);
|
||||
Assert.DoesNotContain("Wiped", entry.Detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Application.DTOs.Auth.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Infrastructure.Identity;
|
||||
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression test locking AuthController.Logout's observable contract — 204 No
|
||||
/// Content and refresh-token/expiry clearing for an existing user, no-op for a missing
|
||||
/// user — before the boundary fix that routes the side effect through the new
|
||||
/// IAuthenticationService.LogoutAsync instead of a direct
|
||||
/// UserManager<ApplicationUser> injection in the controller. Written first
|
||||
/// (RED) against the pre-refactor controller so it proves behavior is unchanged (GREEN)
|
||||
/// after the refactor lands.
|
||||
/// </summary>
|
||||
public class AuthControllerLogoutTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public AuthControllerLogoutTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies token clearing through a fresh scope/DbContext: the request
|
||||
/// pipeline persists through its own scoped DbContext, so re-querying with
|
||||
/// a new one avoids reading a stale change-tracker snapshot instead of the
|
||||
/// actual persisted row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Logout_ExistingUserWithRefreshToken_Returns204AndClearsToken()
|
||||
{
|
||||
ApplicationUser user;
|
||||
using (IServiceScope seedScope = _factory.Services.CreateScope())
|
||||
{
|
||||
UserManager<ApplicationUser> seedUserManager =
|
||||
seedScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
user = await SeedUserWithRefreshTokenAsync(seedUserManager);
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", await CreateAccessTokenAsync(user.Id));
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/auth/logout", content: null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
UserManager<ApplicationUser> verifyUserManager =
|
||||
verifyScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
|
||||
ApplicationUser? persisted = await verifyUserManager.FindByIdAsync(user.Id.ToString());
|
||||
Assert.NotNull(persisted);
|
||||
Assert.Null(persisted!.RefreshToken);
|
||||
Assert.Null(persisted.RefreshTokenExpiryTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logout_MissingUser_Returns204AndNoOp()
|
||||
{
|
||||
Guid missingUserId = Guid.NewGuid();
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", await CreateAccessTokenAsync(missingUserId));
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/auth/logout", content: null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<ApplicationUser> SeedUserWithRefreshTokenAsync(
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
string uniqueEmail = $"logout-test-{Guid.NewGuid()}@test.local";
|
||||
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
UserName = uniqueEmail,
|
||||
Email = uniqueEmail,
|
||||
EmailConfirmed = true,
|
||||
RefreshToken = Guid.NewGuid().ToString("N"),
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7),
|
||||
};
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(user, "Test-Passw0rd!1");
|
||||
Assert.True(result.Succeeded, string.Join(" | ", result.Errors.Select(e => e.Description)));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<string> CreateAccessTokenAsync(Guid userId)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IAuthService authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
|
||||
|
||||
Claim[] claims =
|
||||
[
|
||||
new(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new(ClaimTypes.Role, "ADMIN"),
|
||||
];
|
||||
|
||||
TokenResponse token = await authService.GenerateJwtTokenAsync(claims);
|
||||
return token.AccessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Application.DTOs.Auth.Response;
|
||||
using Application.Services;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for AuthService.GenerateJwtTokenAsync — locks in the
|
||||
/// current claim shape, 24h expiry, signature verifiability, and refresh-token uniqueness of
|
||||
/// the JWT/refresh-token generation behavior before any future refactor.
|
||||
/// AuthService depends only on IConfiguration, so it is
|
||||
/// instantiated directly against an in-memory configuration — no host, no DB.
|
||||
/// </summary>
|
||||
public class AuthServiceJwtTests
|
||||
{
|
||||
private const string SigningKey = "unit-test-signing-key-at-least-32-characters-long";
|
||||
private const string Issuer = "club12-unit-tests";
|
||||
private const string Audience = "club12-unit-tests";
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJwtTokenAsync_IncludesUserIdAndRoleClaims()
|
||||
{
|
||||
AuthService authService = new(BuildConfig());
|
||||
Guid userId = Guid.NewGuid();
|
||||
Claim[] claims =
|
||||
[
|
||||
new(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new(ClaimTypes.Role, "ADMIN"),
|
||||
];
|
||||
|
||||
TokenResponse response = await authService.GenerateJwtTokenAsync(claims);
|
||||
ClaimsPrincipal principal = ValidateToken(response.AccessToken);
|
||||
|
||||
Assert.Equal(userId.ToString(), principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||
Assert.True(principal.IsInRole("ADMIN"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJwtTokenAsync_ExpiresApproximately24HoursFromIssuance()
|
||||
{
|
||||
AuthService authService = new(BuildConfig());
|
||||
Claim[] claims = [new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())];
|
||||
|
||||
DateTime beforeIssuance = DateTime.UtcNow;
|
||||
TokenResponse response = await authService.GenerateJwtTokenAsync(claims);
|
||||
|
||||
JwtSecurityTokenHandler handler = new();
|
||||
JwtSecurityToken token = handler.ReadJwtToken(response.AccessToken);
|
||||
|
||||
DateTime expectedExpiry = beforeIssuance.AddHours(24);
|
||||
Assert.True(
|
||||
Math.Abs((token.ValidTo - expectedExpiry).TotalMinutes) < 2,
|
||||
$"Expected expiry near {expectedExpiry:o}, got {token.ValidTo:o}");
|
||||
Assert.Equal(TimeSpan.FromHours(24), response.ExpiresIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJwtTokenAsync_AccessTokenValidatesAgainstConfiguredKeyIssuerAudience()
|
||||
{
|
||||
AuthService authService = new(BuildConfig());
|
||||
Claim[] claims = [new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())];
|
||||
|
||||
TokenResponse response = await authService.GenerateJwtTokenAsync(claims);
|
||||
|
||||
JwtSecurityTokenHandler handler = new();
|
||||
ClaimsPrincipal principal = handler.ValidateToken(
|
||||
response.AccessToken, BuildValidationParameters(), out SecurityToken validatedToken);
|
||||
|
||||
Assert.NotNull(principal);
|
||||
JwtSecurityToken jwt = Assert.IsType<JwtSecurityToken>(validatedToken);
|
||||
Assert.Equal(Issuer, jwt.Issuer);
|
||||
Assert.Contains(Audience, jwt.Audiences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJwtTokenAsync_TwoCallsYieldDifferentRefreshTokens()
|
||||
{
|
||||
AuthService authService = new(BuildConfig());
|
||||
Claim[] claims = [new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())];
|
||||
|
||||
TokenResponse first = await authService.GenerateJwtTokenAsync(claims);
|
||||
TokenResponse second = await authService.GenerateJwtTokenAsync(claims);
|
||||
|
||||
Assert.NotEqual(first.RefreshToken, second.RefreshToken);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal ValidateToken(string accessToken)
|
||||
{
|
||||
JwtSecurityTokenHandler handler = new();
|
||||
return handler.ValidateToken(accessToken, BuildValidationParameters(), out _);
|
||||
}
|
||||
|
||||
private static TokenValidationParameters BuildValidationParameters()
|
||||
{
|
||||
#pragma warning disable S6781
|
||||
return new()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
#pragma warning restore S6781
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfig()
|
||||
{
|
||||
Dictionary<string, string?> settings = new()
|
||||
{
|
||||
["JWT:Key"] = SigningKey,
|
||||
["JWT:Issuer"] = Issuer,
|
||||
["JWT:Audience"] = Audience,
|
||||
};
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(settings)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Application.DTOs.Tournament.Request;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the authorization gap found during this session's audit is
|
||||
/// closed: previously, almost no controller declared [Authorize], so any
|
||||
/// anonymous caller could reach write endpoints (and the admin
|
||||
/// player-detail endpoint exposing document number/birth date/phone/
|
||||
/// social security) directly via the API, bypassing the frontend's
|
||||
/// role-gated UI entirely. These are real HTTP round trips through
|
||||
/// CustomWebApplicationFactory, not direct service calls, since
|
||||
/// [Authorize] only takes effect in the MVC pipeline.
|
||||
/// </summary>
|
||||
public class AuthorizationGatingTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public AuthorizationGatingTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerCompleteData_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/players/admin/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guest is not one of the staff roles that may see full player details.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerCompleteData_WrongRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/players/admin/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expects not-found (missing player), never a permission error —
|
||||
/// proves the role check passed and the request reached the handler.
|
||||
/// HU-05: staff is now just Owner/Admin, so Admin stands in for the
|
||||
/// former manager roles here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerCompleteData_StaffRole_IsAuthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/players/admin/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The admin player-detail route now accepts an id OR a slug. Widening the
|
||||
/// route parameter must not weaken the gate: an anonymous caller on the slug
|
||||
/// form is still rejected before the handler runs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerCompleteData_BySlug_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/players/admin/lopez-carlos");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guest is not a staff role, so the slug form of the admin player-detail
|
||||
/// route is forbidden — never a routing 404, never 200.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerCompleteData_BySlug_WrongRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/players/admin/lopez-carlos");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTournament_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
CreateTournamentRequest request = BuildTournamentRequest();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/tournaments", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTournament_WrongRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
CreateTournamentRequest request = BuildTournamentRequest();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/tournaments", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTournament_OwnerRole_Succeeds()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
CreateTournamentRequest request = BuildTournamentRequest();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/tournaments", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBlogPost_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.DeleteAsync($"api/blogposts/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public reads must remain anonymous — this is a regression guard, not
|
||||
/// a gap: 404 (not 401/403) proves the request reached the handler
|
||||
/// without a role check blocking it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetTournamentById_Anonymous_StillAllowed()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/tournaments/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static CreateTournamentRequest BuildTournamentRequest()
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
return new CreateTournamentRequest
|
||||
{
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Description = "Authorization gating test tournament",
|
||||
StartDate = startDate,
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
using API.AutoMapperProfiles;
|
||||
|
||||
using Application.DTOs.Divisions.Request;
|
||||
using Application.DTOs.Divisions.Response;
|
||||
using Application.DTOs.Match.Request;
|
||||
using Application.DTOs.Match.Response;
|
||||
using Application.DTOs.Team.Response;
|
||||
using Application.DTOs.TeamStaff.Response;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Match to MinimalMatchResponse mapping resolves home and visitor team names
|
||||
/// correctly when teams are assigned, and returns null instead of throwing when they are not.
|
||||
/// </summary>
|
||||
public class AutoMapperProfilesTests
|
||||
{
|
||||
private static IMapper CreateMapper()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<MatchProfile>(), NullLoggerFactory.Instance);
|
||||
return configuration.CreateMapper();
|
||||
}
|
||||
|
||||
private static Team CreateTeam(string name)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
Name = name,
|
||||
Slug = $"{name}-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = name[..Math.Min(3, name.Length)].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.com/logo.png",
|
||||
ShirtColor = "red",
|
||||
Players = [],
|
||||
};
|
||||
}
|
||||
|
||||
private static Match CreateMatch(Team? homeTeam, Team? visitorTeam)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
MatchDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Type = Domain.Enums.MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = homeTeam,
|
||||
VisitorTeam = visitorTeam,
|
||||
IsFinished = false,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_ToMinimalMatchResponse_WithAssignedTeams_ResolvesRealTeamNames()
|
||||
{
|
||||
Team homeTeam = CreateTeam("River Plate");
|
||||
Team visitorTeam = CreateTeam("Boca Juniors");
|
||||
Match match = CreateMatch(homeTeam, visitorTeam);
|
||||
IMapper mapper = CreateMapper();
|
||||
|
||||
MinimalMatchResponse response = mapper.Map<MinimalMatchResponse>(match);
|
||||
|
||||
Assert.Equal("River Plate", response.HomeTeamName);
|
||||
Assert.Equal("Boca Juniors", response.VisitorTeamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a match with no assigned home or visitor team maps to null team names
|
||||
/// instead of throwing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_ToMinimalMatchResponse_WithUnassignedTeams_DegradesToNullInsteadOfThrowing()
|
||||
{
|
||||
Match match = CreateMatch(homeTeam: null, visitorTeam: null);
|
||||
IMapper mapper = CreateMapper();
|
||||
|
||||
MinimalMatchResponse response = mapper.Map<MinimalMatchResponse>(match);
|
||||
|
||||
Assert.Null(response.HomeTeamName);
|
||||
Assert.Null(response.VisitorTeamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-67: editing a match's calendar date/time (the UpdateMatchRequest path)
|
||||
/// must never move it to another round. The request carries no Round, so
|
||||
/// mapping it onto an existing match leaves the round untouched while still
|
||||
/// applying the new date.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_UpdateMatchRequestOntoMatch_ChangesDate_ButLeavesRoundUnchanged()
|
||||
{
|
||||
Match match = CreateMatch(homeTeam: null, visitorTeam: null);
|
||||
match.Round = 3;
|
||||
DateTime originalDate = match.MatchDate;
|
||||
DateTime newDate = originalDate.AddDays(5);
|
||||
|
||||
UpdateMatchRequest request = new() { MatchDate = newDate };
|
||||
IMapper mapper = CreateMapper();
|
||||
|
||||
mapper.Map(request, match);
|
||||
|
||||
Assert.Equal(3, match.Round);
|
||||
Assert.Equal(newDate, match.MatchDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The round flows through to the response DTOs so the frontend can group
|
||||
/// the fixture by matchday (HU-63).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_ToDetailedMatchResponse_ExposesRound()
|
||||
{
|
||||
Match match = CreateMatch(homeTeam: null, visitorTeam: null);
|
||||
match.Round = 7;
|
||||
IMapper mapper = CreateMapper();
|
||||
|
||||
DetailedMatchResponse response = mapper.Map<DetailedMatchResponse>(match);
|
||||
|
||||
Assert.Equal(7, response.Round);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The match's tournament (via Stage.Division.TournamentId) flows through
|
||||
/// to the response DTO so the public match page can navigate back to its
|
||||
/// tournament instead of an orphaned listing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_ToDetailedMatchResponse_ExposesTournamentIdFromStageDivision()
|
||||
{
|
||||
Guid tournamentId = Guid.NewGuid();
|
||||
Match match = CreateMatch(homeTeam: null, visitorTeam: null);
|
||||
match.Stage = new Stage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
Name = "Fase de grupos",
|
||||
Slug = $"fase-{Guid.NewGuid()}",
|
||||
StageType = Domain.Enums.StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = match.MatchDate,
|
||||
EndDate = match.MatchDate,
|
||||
DivisionId = Guid.NewGuid(),
|
||||
Division = new Division
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
Name = "Zona A",
|
||||
Slug = $"zona-a-{Guid.NewGuid()}",
|
||||
TournamentId = tournamentId,
|
||||
Tournament = new Tournament
|
||||
{
|
||||
Id = tournamentId,
|
||||
CreatedBy = "system",
|
||||
Name = "Apertura",
|
||||
Slug = "apertura",
|
||||
Description = "Torneo de prueba",
|
||||
TeamRegistrationDeadline = match.MatchDate,
|
||||
StartDate = match.MatchDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
},
|
||||
Stages = [],
|
||||
},
|
||||
Matches = [],
|
||||
};
|
||||
IMapper mapper = CreateMapper();
|
||||
|
||||
DetailedMatchResponse response = mapper.Map<DetailedMatchResponse>(match);
|
||||
|
||||
Assert.Equal(tournamentId, response.TournamentId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The team's ClubId flows through to the response DTO so the frontend can
|
||||
/// link a team back to its club.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_ToTeamResponse_ExposesClubId()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<TeamProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
Guid clubId = Guid.NewGuid();
|
||||
Team team = CreateTeam("River Plate");
|
||||
team.ClubId = clubId;
|
||||
|
||||
TeamResponse response = mapper.Map<TeamResponse>(team);
|
||||
|
||||
Assert.Equal(clubId, response.ClubId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-110: QualifiersPerGroup round-trips through the division mappings —
|
||||
/// CreateDivisionRequest -> Division -> DivisionResponse — by AutoMapper's
|
||||
/// name convention, so the wizard's value reaches the entity and is echoed
|
||||
/// back in the response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_DivisionQualifiersPerGroup_RoundTripsThroughRequestAndResponse()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<DivisionProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
CreateDivisionRequest request = new()
|
||||
{
|
||||
Name = "Copa Club12",
|
||||
TournamentId = Guid.NewGuid(),
|
||||
IsCrossDivisionCup = true,
|
||||
QualifiersPerGroup = 2,
|
||||
};
|
||||
|
||||
Division division = mapper.Map<Division>(request);
|
||||
Assert.Equal(2, division.QualifiersPerGroup);
|
||||
|
||||
DivisionResponse response = mapper.Map<DivisionResponse>(division);
|
||||
Assert.Equal(2, response.QualifiersPerGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An omitted QualifiersPerGroup defaults to 1 on the request DTO and
|
||||
/// survives the mapping unchanged, leaving every existing division's
|
||||
/// seeding behavior intact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_DivisionQualifiersPerGroup_DefaultsToOne()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<DivisionProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
CreateDivisionRequest request = new()
|
||||
{
|
||||
Name = "Primera",
|
||||
TournamentId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
Division division = mapper.Map<Division>(request);
|
||||
|
||||
Assert.Equal(1, division.QualifiersPerGroup);
|
||||
}
|
||||
|
||||
private static Division CreateDivision(params DivisionPlayoffMapping[] mappings)
|
||||
{
|
||||
return new Division
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
Name = "Primera",
|
||||
Slug = "primera",
|
||||
Tournament = null!,
|
||||
Stages = [],
|
||||
PlayoffMappings = mappings,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-45: a division WITH playoff mappings exposes them as ordered
|
||||
/// qualification ranges on the response — top cup first (Order 0), cup name
|
||||
/// carried from the mapping destination — so the public standings table can
|
||||
/// highlight the qualifying rows.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_DivisionWithPlayoffMappings_ExposesOrderedQualificationRanges()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<DivisionProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
Division division = CreateDivision(
|
||||
new DivisionPlayoffMapping { FromPosition = 5, ToPosition = 8, Destination = "Copa Plata", CreatedBy = "system" },
|
||||
new DivisionPlayoffMapping { FromPosition = 1, ToPosition = 4, Destination = "Copa Oro", CreatedBy = "system" });
|
||||
|
||||
DivisionResponse response = mapper.Map<DivisionResponse>(division);
|
||||
|
||||
Assert.NotNull(response.QualificationRanges);
|
||||
Assert.Collection(
|
||||
response.QualificationRanges!,
|
||||
top =>
|
||||
{
|
||||
Assert.Equal(1, top.FromPosition);
|
||||
Assert.Equal(4, top.ToPosition);
|
||||
Assert.Equal("Copa Oro", top.CupName);
|
||||
Assert.Equal(0, top.Order);
|
||||
},
|
||||
next =>
|
||||
{
|
||||
Assert.Equal(5, next.FromPosition);
|
||||
Assert.Equal(8, next.ToPosition);
|
||||
Assert.Equal("Copa Plata", next.CupName);
|
||||
Assert.Equal(1, next.Order);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A division WITHOUT playoff mappings exposes an empty qualification-range
|
||||
/// list, so the public standings table simply renders no highlight/legend.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_DivisionWithoutPlayoffMappings_ExposesEmptyQualificationRanges()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<DivisionProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
Division division = CreateDivision();
|
||||
|
||||
DivisionResponse response = mapper.Map<DivisionResponse>(division);
|
||||
|
||||
Assert.NotNull(response.QualificationRanges);
|
||||
Assert.Empty(response.QualificationRanges!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A TeamStaff member maps to TeamStaffResponse exposing Role as its
|
||||
/// string name (not the numeric enum value) and TeamName resolved from
|
||||
/// the loaded Team navigation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_ToTeamStaffResponse_ExposesRoleAsStringAndTeamName()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<TeamStaffProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
Team team = CreateTeam("River Plate");
|
||||
TeamStaff staff = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
TeamId = team.Id,
|
||||
Team = team,
|
||||
TournamentId = Guid.NewGuid(),
|
||||
FullName = "Carlos Gómez",
|
||||
Role = TeamStaffRole.Coach,
|
||||
};
|
||||
|
||||
TeamStaffResponse response = mapper.Map<TeamStaffResponse>(staff);
|
||||
|
||||
Assert.Equal("Coach", response.Role);
|
||||
Assert.Equal("River Plate", response.TeamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the Team navigation was not loaded, TeamName degrades to null
|
||||
/// instead of throwing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Map_ToTeamStaffResponse_WithNoTeamLoaded_TeamNameIsNull()
|
||||
{
|
||||
MapperConfiguration configuration = new(cfg => cfg.AddProfile<TeamStaffProfile>(), NullLoggerFactory.Instance);
|
||||
IMapper mapper = configuration.CreateMapper();
|
||||
|
||||
TeamStaff staff = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
TeamId = Guid.NewGuid(),
|
||||
Team = null,
|
||||
TournamentId = Guid.NewGuid(),
|
||||
FullName = "Javier Coronel",
|
||||
Role = TeamStaffRole.AssistantCoach,
|
||||
};
|
||||
|
||||
TeamStaffResponse response = mapper.Map<TeamStaffResponse>(staff);
|
||||
|
||||
Assert.Equal("AssistantCoach", response.Role);
|
||||
Assert.Null(response.TeamName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants.Stage;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization (approval) test for MatchService.CreateAutomatedMatchesAsync's
|
||||
/// knockout and final-stage match generation. Written BEFORE the batch1 cleanup refactor (magic-number
|
||||
/// extraction, CS1998 fix) to prove those edits stay behavior-preserving: match counts, stage
|
||||
/// assignment (via MatchType.Playoff), and date bounds must remain identical.
|
||||
/// </summary>
|
||||
public class AutomatedMatchGenerationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public AutomatedMatchGenerationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public static readonly TheoryData<StageType, int> KnockoutStageCases = new()
|
||||
{
|
||||
{ StageType.QuarterFinal, KnockoutMatchCount.QuarterFinal },
|
||||
{ StageType.SemiFinal, KnockoutMatchCount.SemiFinal },
|
||||
{ StageType.Final, 1 },
|
||||
{ StageType.ThirdPlace, 1 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(KnockoutStageCases))]
|
||||
public async Task CreateAutomatedMatchesAsync_GeneratesExpectedMatchCountForKnockoutStage(
|
||||
StageType stageType, int expectedMatchCount)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Stage stage = await SeedStageAsync(db, stageType);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stage.Id);
|
||||
|
||||
Assert.Equal(expectedMatchCount, matches.Count);
|
||||
Assert.All(matches, match => Assert.Equal(MatchType.Playoff, match.Type));
|
||||
Assert.All(matches, match => Assert.InRange(match.MatchDate, stage.StartDate, stage.EndDate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnockoutMatchCount_MatchesPriorLiterals()
|
||||
{
|
||||
Assert.Equal(4, KnockoutMatchCount.QuarterFinal);
|
||||
Assert.Equal(2, KnockoutMatchCount.SemiFinal);
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, StageType stageType)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date;
|
||||
DateTime endDate = startDate.AddDays(14);
|
||||
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Characterization test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Tests StartupExtensions.AddBackupConfig's storage-target
|
||||
/// branching by inspecting the resulting ServiceDescriptor for
|
||||
/// IBackupStorage — never calling BuildServiceProvider
|
||||
/// or resolving anything. This keeps the test free of real Supabase network
|
||||
/// I/O (a SupabaseBackupStorage registration is type-based, so
|
||||
/// its ServiceDescriptor.ImplementationType is inspectable
|
||||
/// without constructing it) and free of local-filesystem side effects (a
|
||||
/// LocalDirectoryBackupStorage registration is factory-based and is
|
||||
/// never invoked here either).
|
||||
/// </summary>
|
||||
public sealed class AddBackupConfigStorageSelectionTests
|
||||
{
|
||||
private static IServiceCollection BuildServices(string storageTarget)
|
||||
{
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["Backup:Enabled"] = "false",
|
||||
["Backup:StorageTarget"] = storageTarget,
|
||||
["Backup:LocalStoragePath"] = "backups",
|
||||
["Backup:PgDumpPath"] = "pg_dump",
|
||||
};
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
ServiceCollection services = new();
|
||||
services.AddBackupConfig(configuration);
|
||||
return services;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBackupConfig_StorageTargetSupabase_RegistersSupabaseBackupStorage()
|
||||
{
|
||||
IServiceCollection services = BuildServices("Supabase");
|
||||
|
||||
ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(IBackupStorage));
|
||||
|
||||
Assert.Equal(typeof(SupabaseBackupStorage), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("supabase")]
|
||||
[InlineData("SUPABASE")]
|
||||
[InlineData("SupaBase")]
|
||||
public void AddBackupConfig_StorageTargetSupabase_CaseInsensitive_RegistersSupabaseBackupStorage(string storageTarget)
|
||||
{
|
||||
IServiceCollection services = BuildServices(storageTarget);
|
||||
|
||||
ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(IBackupStorage));
|
||||
|
||||
Assert.Equal(typeof(SupabaseBackupStorage), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local storage is registered via a factory (it needs the plain
|
||||
/// LocalStoragePath string, not a DI-resolvable type), so this asserts
|
||||
/// it is NOT the type-based Supabase registration rather than invoking
|
||||
/// the factory, which would touch the local filesystem.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Local")]
|
||||
[InlineData("")]
|
||||
[InlineData("SomethingElse")]
|
||||
public void AddBackupConfig_StorageTargetNotSupabase_RegistersLocalDirectoryBackupStorage_NotSupabase(string storageTarget)
|
||||
{
|
||||
IServiceCollection services = BuildServices(storageTarget);
|
||||
|
||||
ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(IBackupStorage));
|
||||
|
||||
Assert.NotEqual(typeof(SupabaseBackupStorage), descriptor.ImplementationType);
|
||||
Assert.Null(descriptor.ImplementationType);
|
||||
Assert.NotNull(descriptor.ImplementationFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using API.Controllers;
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Pure unit tests for BackupController's outcome-to-status-code
|
||||
/// mapping (design.md's "Controllers return an explicit outcome, not
|
||||
/// exception-mapped status codes" decision) — GET/POST/DELETE against a fake
|
||||
/// IBackupOperationsService, no HTTP pipeline
|
||||
/// involved. Non-Admin/anonymous 401/403 gating (which only takes effect via
|
||||
/// [Authorize] in the real MVC pipeline) is covered separately by
|
||||
/// BackupAuthorizationTests.
|
||||
/// </summary>
|
||||
public class BackupControllerTests
|
||||
{
|
||||
private static BackupController CreateSut(IBackupOperationsService? operations = null)
|
||||
{
|
||||
return new BackupController(operations ?? new FakeBackupOperationsService());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsOkWithCatalogRecords()
|
||||
{
|
||||
BackupRecord record = new()
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = "a.sql",
|
||||
SizeBytes = 10,
|
||||
Origin = BackupOrigin.Manual,
|
||||
};
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextListResult = [record],
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
ActionResult<IReadOnlyList<BackupRecordResponse>> result = await sut.GetAll(CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
IReadOnlyList<BackupRecordResponse> body = Assert.IsAssignableFrom<IReadOnlyList<BackupRecordResponse>>(ok.Value);
|
||||
Assert.Single(body);
|
||||
Assert.Equal("a.sql", body[0].StoragePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_EmptyCatalog_ReturnsOkWithEmptyList()
|
||||
{
|
||||
BackupController sut = CreateSut();
|
||||
|
||||
ActionResult<IReadOnlyList<BackupRecordResponse>> result = await sut.GetAll(CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
IReadOnlyList<BackupRecordResponse> body = Assert.IsAssignableFrom<IReadOnlyList<BackupRecordResponse>>(ok.Value);
|
||||
Assert.Empty(body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Completed_ReturnsOkWithRecord()
|
||||
{
|
||||
BackupRecordResponse expected = new(Guid.NewGuid(), DateTime.UtcNow, 42, "Manual", "a.sql");
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Completed, expected, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Create(CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result);
|
||||
BackupRecordResponse body = Assert.IsType<BackupRecordResponse>(ok.Value);
|
||||
Assert.Equal(expected, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Busy_ReturnsConflict()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Create(CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Failed_ReturnsInternalServerError()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Failed, null, "boom"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Create(CancellationToken.None);
|
||||
|
||||
ObjectResult obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Completed_ReturnsNoContent()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.Completed, null, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NotFound_ReturnsNotFound()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.NotFound, null, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Busy_ReturnsConflict()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Failed_ReturnsInternalServerError()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.Failed, null, "boom"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
ObjectResult obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// threat-matrix "Restore of foreign/uploaded dumps": Restore's only
|
||||
/// input parameter is the route Guid — no [FromBody] parameter
|
||||
/// exists on the action, so no request body is ever bound/deserialized
|
||||
/// into a path or dump payload. Confirming the exact id reaches
|
||||
/// IBackupOperationsService.RestoreBackupAsync is the closest
|
||||
/// unit-level proof of that (no separate body-binding surface to probe).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Restore_Completed_ReturnsOkWithRecord_PassesOnlyRouteId()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
BackupRecordResponse expected = new(Guid.NewGuid(), DateTime.UtcNow, 99, "Job", "safety.sql");
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.Completed, expected, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(id, CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result);
|
||||
BackupRecordResponse body = Assert.IsType<BackupRecordResponse>(ok.Value);
|
||||
Assert.Equal(expected, body);
|
||||
Assert.Equal(id, operations.LastRestoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_NotFound_ReturnsNotFound()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.NotFound, null, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Busy_ReturnsConflict()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Failed_ReturnsInternalServerError()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.Failed, null, "boom"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
ObjectResult obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Application.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for BackupOperationLock: the process-wide single-flight
|
||||
/// guard shared by manual (BackupController) and scheduled
|
||||
/// (DatabaseBackupHostedService) backup/restore attempts. Covers
|
||||
/// backup-catalog#Single-Shared-Write-Path.
|
||||
/// </summary>
|
||||
public class BackupOperationLockTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WaitAsync_SecondCallWhileHeld_ReturnsFalse()
|
||||
{
|
||||
BackupOperationLock sut = new();
|
||||
|
||||
bool first = await sut.WaitAsync(TimeSpan.Zero);
|
||||
bool second = await sut.WaitAsync(TimeSpan.Zero);
|
||||
|
||||
Assert.True(first, "First acquisition of an unheld lock must succeed.");
|
||||
Assert.False(second, "A second acquisition attempt while the lock is held must fail immediately.");
|
||||
|
||||
sut.Release();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_AfterRelease_AllowsAcquisitionAgain()
|
||||
{
|
||||
BackupOperationLock sut = new();
|
||||
bool first = await sut.WaitAsync(TimeSpan.Zero);
|
||||
sut.Release();
|
||||
|
||||
bool acquiredAgain = await sut.WaitAsync(TimeSpan.Zero);
|
||||
|
||||
Assert.True(first);
|
||||
Assert.True(acquiredAgain, "Once released, the lock must be acquirable again.");
|
||||
|
||||
sut.Release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for BackupOperationsService — the one shared write
|
||||
/// path used by both BackupController (manual) and
|
||||
/// DatabaseBackupHostedService (scheduled, via a DI scope). All
|
||||
/// dependencies are fakes; no real pg_dump, storage I/O, or database is
|
||||
/// involved. Covers backup-catalog#Single-Shared-Write-Path,
|
||||
/// backup-catalog#Failed-Backups-Are-Not-Catalogued,
|
||||
/// backup-catalog#Delete-Removes-Both-Stored-File-and-Catalog-Record, and
|
||||
/// scheduled-database-backups#Keep-Last-N-Retention-Pruning (shared
|
||||
/// Manual+Job pool).
|
||||
/// </summary>
|
||||
public class BackupOperationsServiceTests
|
||||
{
|
||||
private static BackupOptions Options(int retentionCount = 7)
|
||||
{
|
||||
return new BackupOptions { RetentionCount = retentionCount };
|
||||
}
|
||||
|
||||
private static BackupOperationsService CreateSut(
|
||||
FakeBackupCatalog? catalog = null,
|
||||
FakeBackupStorage? storage = null,
|
||||
FakeDatabaseBackupService? backupService = null,
|
||||
FakeDatabaseRestoreService? restoreService = null,
|
||||
IBackupRetentionPolicy? retentionPolicy = null,
|
||||
BackupOptions? options = null,
|
||||
BackupOperationLock? operationLock = null,
|
||||
IMaintenanceModeState? maintenanceModeState = null,
|
||||
FakeAuditService? auditService = null,
|
||||
ILogger<BackupOperationsService>? logger = null)
|
||||
{
|
||||
return new BackupOperationsService(
|
||||
catalog ?? new FakeBackupCatalog(),
|
||||
storage ?? new FakeBackupStorage(),
|
||||
backupService ?? new FakeDatabaseBackupService(),
|
||||
restoreService ?? new FakeDatabaseRestoreService(),
|
||||
retentionPolicy ?? new KeepLastNRetentionPolicy(),
|
||||
options ?? Options(),
|
||||
operationLock ?? new BackupOperationLock(),
|
||||
maintenanceModeState ?? new MaintenanceModeState(),
|
||||
auditService ?? new FakeAuditService(),
|
||||
logger ?? NullLogger<BackupOperationsService>.Instance);
|
||||
}
|
||||
|
||||
private static BackupRecord NewRecord(string storagePath, BackupOrigin origin, DateTime dateCreated)
|
||||
{
|
||||
return new BackupRecord
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = storagePath,
|
||||
SizeBytes = 1,
|
||||
Origin = origin,
|
||||
DateCreated = dateCreated,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_Succeeds_AddsCatalogRecord()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog);
|
||||
|
||||
BackupOperationResult result = await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.NotNull(result.Record);
|
||||
Assert.Equal("Manual", result.Record!.Origin);
|
||||
Assert.Equal(1, catalog.AddCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_ConcurrentCalls_SecondReturnsBusy_NoSecondCatalogRow()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeDatabaseBackupService backupService = new()
|
||||
{
|
||||
Gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously),
|
||||
};
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, backupService: backupService);
|
||||
|
||||
Task<BackupOperationResult> firstCall = sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
bool startedFirstCall = await TestTiming.WaitUntilAsync(() => backupService.CallCount >= 1, TimeSpan.FromSeconds(2));
|
||||
Assert.True(startedFirstCall, "Expected the first call to reach the (gated) dump step before starting the second.");
|
||||
|
||||
BackupOperationResult second = await sut.CreateBackupAsync(BackupOrigin.Job);
|
||||
|
||||
backupService.Gate.SetResult(true);
|
||||
BackupOperationResult first = await firstCall;
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Busy, second.Outcome);
|
||||
Assert.Equal(BackupOperationOutcome.Completed, first.Outcome);
|
||||
Assert.Equal(1, catalog.AddCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_DumpFails_NoCatalogRecordWritten()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeDatabaseBackupService backupService = new() { FailFirstCalls = 1 };
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, backupService: backupService);
|
||||
|
||||
BackupOperationResult result = await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(0, catalog.AddCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBackupAsync_StorageFileMissing_CatalogRowStillRemoved_WarningLogged()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new() { DeleteException = new FileNotFoundException("missing") };
|
||||
CapturingLogger<BackupOperationsService> logger = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, storage: storage, logger: logger);
|
||||
BackupRecord seeded = await catalog.AddAsync(NewRecord("backups/to-delete.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
BackupOperationResult result = await sut.DeleteBackupAsync(seeded.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.Null(await catalog.GetByIdAsync(seeded.Id));
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBackupAsync_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
BackupOperationsService sut = CreateSut();
|
||||
|
||||
BackupOperationResult result = await sut.DeleteBackupAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.NotFound, result.Outcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// scheduled-database-backups#Keep-Last-N-Retention-Pruning: retention
|
||||
/// now reads the catalog (Manual+Job combined), not
|
||||
/// IBackupStorage.ListAsync(), so pruning applies across both
|
||||
/// origins with no per-origin cap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_RetentionAppliesAcrossSharedManualAndJobPool_PrunesOldestRegardlessOfOrigin()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, storage: storage, options: Options(retentionCount: 2));
|
||||
|
||||
DateTime baseline = DateTime.UtcNow.AddDays(-1);
|
||||
await catalog.AddAsync(NewRecord("job-old.sql", BackupOrigin.Job, baseline));
|
||||
await catalog.AddAsync(NewRecord("manual-mid.sql", BackupOrigin.Manual, baseline.AddHours(1)));
|
||||
|
||||
BackupOperationResult result = await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
IReadOnlyList<BackupRecord> remaining = await catalog.ListNewestFirstAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.DoesNotContain(remaining, r => r.StoragePath == "job-old.sql");
|
||||
Assert.Contains(remaining, r => r.StoragePath == "manual-mid.sql");
|
||||
Assert.Contains(storage.DeletedNames, n => n == "job-old.sql");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithinRetentionLimit_PrunesNothing()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, storage: storage, options: Options(retentionCount: 5));
|
||||
|
||||
await catalog.AddAsync(NewRecord("job-old.sql", BackupOrigin.Job, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
IReadOnlyList<BackupRecord> remaining = await catalog.ListNewestFirstAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.Empty(storage.DeletedNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// database-restore#Automatic-Pre-Restore-Safety-Backup: every restore
|
||||
/// takes an automatic backup of the current state first, catalogued with
|
||||
/// Origin = Job and applyRetention: false — so it is kept
|
||||
/// even if the catalog is already at (or past) RetentionCount.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_TakesSafetyBackupWithJobOriginAndNoRetention_EvenPastRetentionLimit()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
MaintenanceModeState maintenanceModeState = new();
|
||||
BackupOperationsService sut = CreateSut(
|
||||
catalog: catalog,
|
||||
storage: storage,
|
||||
options: Options(retentionCount: 1),
|
||||
maintenanceModeState: maintenanceModeState);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.NotNull(result.Record);
|
||||
Assert.Equal("Job", result.Record!.Origin);
|
||||
|
||||
// RetentionCount is 1, but the safety backup uses applyRetention: false,
|
||||
// so both the pre-existing target AND the new safety backup survive.
|
||||
IReadOnlyList<BackupRecord> all = await catalog.ListNewestFirstAsync();
|
||||
Assert.Equal(2, all.Count);
|
||||
BackupRecord safety = Assert.Single(all, r => r.Id != target.Id);
|
||||
Assert.Equal(BackupOrigin.Job, safety.Origin);
|
||||
Assert.False(maintenanceModeState.IsActive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-101: a successful restore must be auditable — AuditAction.BackupRestore
|
||||
/// existed in the enum but nothing ever logged it (a real gap found while
|
||||
/// auditing historias-de-usuario.md against the actual code).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_Succeeds_LogsBackupRestoreAuditEntry()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeAuditService auditService = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, auditService: auditService);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.Contains(AuditAction.BackupRestore, auditService.LoggedActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
BackupOperationsService sut = CreateSut();
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.NotFound, result.Outcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// database-restore#Restore-Failure-Is-Logged-and-Isolated +
|
||||
/// threat-matrix "Temp-file handling during restore": a restore failure
|
||||
/// must still clear maintenance mode, delete the temp dump file, and
|
||||
/// never throw out of RestoreBackupAsync (no host crash).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_RestoreServiceThrows_MaintenanceExited_TempFileDeleted_NoCrash()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
FakeDatabaseRestoreService restoreService = new() { ExceptionToThrow = new BackupExecutionException("psql failed") };
|
||||
MaintenanceModeState maintenanceModeState = new();
|
||||
BackupOperationsService sut = CreateSut(
|
||||
catalog: catalog,
|
||||
storage: storage,
|
||||
restoreService: restoreService,
|
||||
maintenanceModeState: maintenanceModeState);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Failed, result.Outcome);
|
||||
Assert.False(maintenanceModeState.IsActive);
|
||||
Assert.NotNull(restoreService.CapturedDumpFilePath);
|
||||
Assert.False(File.Exists(restoreService.CapturedDumpFilePath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// threat-matrix "Denial of service via repeated restore": the same
|
||||
/// single-flight BackupOperationLock used by create/delete also guards
|
||||
/// restore, so a concurrent restore attempt returns Busy (409 at the
|
||||
/// controller) instead of running alongside another restore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_ConcurrentCalls_SecondReturnsBusy_OnlyOneRestoreRuns()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
FakeDatabaseBackupService backupService = new()
|
||||
{
|
||||
Gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously),
|
||||
};
|
||||
FakeDatabaseRestoreService restoreService = new();
|
||||
BackupOperationsService sut = CreateSut(
|
||||
catalog: catalog, storage: storage, backupService: backupService, restoreService: restoreService);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
Task<BackupOperationResult> firstCall = sut.RestoreBackupAsync(target.Id);
|
||||
bool startedFirstCall = await TestTiming.WaitUntilAsync(() => backupService.CallCount >= 1, TimeSpan.FromSeconds(2));
|
||||
Assert.True(startedFirstCall, "Expected the first restore to reach the (gated) safety-backup dump step before starting the second.");
|
||||
|
||||
BackupOperationResult second = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
backupService.Gate.SetResult(true);
|
||||
BackupOperationResult first = await firstCall;
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Busy, second.Outcome);
|
||||
Assert.Equal(BackupOperationOutcome.Completed, first.Outcome);
|
||||
Assert.Equal(1, restoreService.CallCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using API.BackgroundServices;
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DatabaseBackupHostedService: interval-trigger
|
||||
/// logic, the Backup:Enabled gate, and failure isolation. Since the
|
||||
/// PR2 refactor, the service resolves IBackupOperationsService from a
|
||||
/// DI scope per tick and no longer owns its own single-flight flag — that
|
||||
/// guard now lives in BackupOperationLock, shared with the manual
|
||||
/// endpoint, and is exercised at that layer
|
||||
/// (BackupOperationsServiceTests). All tests use
|
||||
/// DatabaseBackupHostedService.IntervalOverride (a short,
|
||||
/// deterministic interval) instead of sleeping for real
|
||||
/// IntervalHours-scale durations, and poll via TestTiming
|
||||
/// rather than fixed sleeps to avoid flakiness.
|
||||
/// </summary>
|
||||
public class DatabaseBackupHostedServiceTests
|
||||
{
|
||||
private static BackupOptions EnabledOptions()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Enabled = true,
|
||||
IntervalHours = 24,
|
||||
RetentionCount = 7,
|
||||
};
|
||||
}
|
||||
|
||||
private static IServiceScopeFactory ScopeFactoryFor(IBackupOperationsService operations)
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddSingleton(operations);
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IntervalElapses_TriggersOneBackupAttempt()
|
||||
{
|
||||
FakeBackupOperationsService operations = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), NullLogger<DatabaseBackupHostedService>.Instance)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(30),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
bool triggered = await TestTiming.WaitUntilAsync(() => operations.CreateCallCount >= 1, TimeSpan.FromSeconds(2));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(triggered, "Expected at least one backup attempt after the interval elapsed.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 100ms delay is well short of the 5s interval used here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IntervalNotYetElapsed_NoBackupAttemptTriggered()
|
||||
{
|
||||
FakeBackupOperationsService operations = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), NullLogger<DatabaseBackupHostedService>.Instance)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, operations.CreateCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Disabled_NeverCallsOperationsService()
|
||||
{
|
||||
BackupOptions options = new() { Enabled = false, IntervalHours = 24, RetentionCount = 7 };
|
||||
FakeBackupOperationsService operations = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), options, NullLogger<DatabaseBackupHostedService>.Instance)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(150); // several would-be intervals, to prove no scheduling ever starts
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, operations.CreateCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proves the hosted service no longer guards overlapping ticks itself —
|
||||
/// it simply calls CreateBackupAsync every tick and tolerates a
|
||||
/// Busy outcome (from the shared BackupOperationLock)
|
||||
/// without throwing or stalling later ticks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OperationReturnsBusy_LogsAndContinuesTicking_NoThrow()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
CapturingLogger<DatabaseBackupHostedService> logger = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), logger)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
bool calledTwice = await TestTiming.WaitUntilAsync(() => operations.CreateCallCount >= 2, TimeSpan.FromSeconds(2));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(calledTwice, "A Busy outcome must not stop later ticks from also attempting a backup.");
|
||||
Assert.Contains(
|
||||
logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("progress", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_BackupFails_LoggedAndHostSurvives_NextTickStillRuns()
|
||||
{
|
||||
FakeBackupOperationsService operations = new() { FailFirstCalls = 1 };
|
||||
CapturingLogger<DatabaseBackupHostedService> logger = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), logger)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(25),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
bool secondCallHappened = await TestTiming.WaitUntilAsync(() => operations.CreateCallCount >= 2, TimeSpan.FromSeconds(2));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(secondCallHappened, "A failed attempt must not stop the host from ticking again.");
|
||||
Assert.Contains(
|
||||
logger.Entries,
|
||||
e => e.Level == LogLevel.Error && e.Message.Contains("backup", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies EfBackupCatalog's CRUD surface (IBackupCatalog) against a real
|
||||
/// EF Core context backed by SQLite in-memory, via
|
||||
/// CustomWebApplicationFactory (same harness used by
|
||||
/// DataMaintenanceServiceTests). Covers spec
|
||||
/// backup-catalog#Catalog-Powers-the-Admin-Backup-Listing: AddAsync
|
||||
/// persists a record and assigns an Id, GetByIdAsync resolves a single
|
||||
/// record (or null when missing), ListNewestFirstAsync orders the full
|
||||
/// catalog by creation date descending, and RemoveAsync deletes a record
|
||||
/// and is idempotent when the id is already gone.
|
||||
/// </summary>
|
||||
public sealed class EfBackupCatalogTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public EfBackupCatalogTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
private static BackupRecord NewRecord(string storagePath, BackupOrigin origin, DateTime dateCreated)
|
||||
{
|
||||
return new BackupRecord
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = storagePath,
|
||||
SizeBytes = 1024,
|
||||
Origin = origin,
|
||||
DateCreated = dateCreated,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_PersistsRecord_AndAssignsId()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord record = NewRecord("backups/manual-1.sql", BackupOrigin.Manual, DateTime.UtcNow);
|
||||
|
||||
BackupRecord added = await catalog.AddAsync(record);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, added.Id);
|
||||
|
||||
BackupRecord? persisted = await db.BackupRecords.FindAsync(added.Id);
|
||||
Assert.NotNull(persisted);
|
||||
Assert.Equal("backups/manual-1.sql", persisted!.StoragePath);
|
||||
Assert.Equal(BackupOrigin.Manual, persisted.Origin);
|
||||
Assert.Equal(1024, persisted.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsMatchingRecord()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord added = await catalog.AddAsync(NewRecord("backups/job-1.sql", BackupOrigin.Job, DateTime.UtcNow));
|
||||
|
||||
BackupRecord? found = await catalog.GetByIdAsync(added.Id);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(added.Id, found!.Id);
|
||||
Assert.Equal(BackupOrigin.Job, found.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenRecordDoesNotExist()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord? found = await catalog.GetByIdAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListNewestFirstAsync_OrdersRecordsByDateCreatedDescending()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
// xUnit does not guarantee fact execution order and
|
||||
// CustomWebApplicationFactory shares one database per class, so
|
||||
// earlier facts in this class may have left rows behind — establish
|
||||
// a known-empty fixture before asserting exact ordering.
|
||||
List<BackupRecord> existing = await db.BackupRecords.ToListAsync();
|
||||
db.BackupRecords.RemoveRange(existing);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
DateTime baseline = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
BackupRecord oldest = await catalog.AddAsync(NewRecord("backups/oldest.sql", BackupOrigin.Job, baseline));
|
||||
BackupRecord middle = await catalog.AddAsync(NewRecord("backups/middle.sql", BackupOrigin.Manual, baseline.AddHours(1)));
|
||||
BackupRecord newest = await catalog.AddAsync(NewRecord("backups/newest.sql", BackupOrigin.Job, baseline.AddHours(2)));
|
||||
|
||||
IReadOnlyList<BackupRecord> result = await catalog.ListNewestFirstAsync();
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(newest.Id, result[0].Id);
|
||||
Assert.Equal(middle.Id, result[1].Id);
|
||||
Assert.Equal(oldest.Id, result[2].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_DeletesRecord()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord added = await catalog.AddAsync(NewRecord("backups/to-delete.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
await catalog.RemoveAsync(added.Id);
|
||||
|
||||
BackupRecord? persisted = await db.BackupRecords.FindAsync(added.Id);
|
||||
Assert.Null(persisted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_IsIdempotent_WhenRecordAlreadyMissing()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
Guid missingId = Guid.NewGuid();
|
||||
|
||||
// Should not throw even though nothing exists at this id.
|
||||
await catalog.RemoveAsync(missingId);
|
||||
|
||||
BackupRecord? stillMissing = await catalog.GetByIdAsync(missingId);
|
||||
Assert.Null(stillMissing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A single log call captured by CapturingLogger{T}.
|
||||
/// </summary>
|
||||
public sealed record CapturingLogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal ILogger{T} test double that records every log entry
|
||||
/// so tests can assert a failure was actually logged (spec requirement),
|
||||
/// without pulling in a logging test-framework dependency.
|
||||
/// </summary>
|
||||
public sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly List<CapturingLogEntry> _entries = [];
|
||||
|
||||
public IReadOnlyList<CapturingLogEntry> Entries => _entries;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
||||
{
|
||||
return NullScope.Instance;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (_entries)
|
||||
{
|
||||
_entries.Add(new CapturingLogEntry(logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.AuditLogs.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IAuditService. Records every call so tests can assert on
|
||||
/// what got logged without a real repository/database.
|
||||
/// </summary>
|
||||
public sealed class FakeAuditService : IAuditService
|
||||
{
|
||||
public List<AuditAction> LoggedActions { get; } = [];
|
||||
|
||||
public Task LogAsync(
|
||||
AuditAction action,
|
||||
string? targetType = null,
|
||||
string? targetId = null,
|
||||
string? targetName = null,
|
||||
string? detail = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
LoggedActions.Add(action);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PaginatedResponse<AuditLog>> GetAuditLogsAsync(AuditLogFilteredRequest filter)
|
||||
{
|
||||
return Task.FromResult(new PaginatedResponse<AuditLog>
|
||||
{
|
||||
Page = filter.PageNumber,
|
||||
PageSize = filter.PageSize,
|
||||
TotalCount = 0,
|
||||
Items = [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory test double for IBackupCatalog. Lets
|
||||
/// BackupOperationsServiceTests and BackupControllerTests exercise
|
||||
/// the shared create/delete/retention logic without a real database.
|
||||
/// </summary>
|
||||
public sealed class FakeBackupCatalog : IBackupCatalog
|
||||
{
|
||||
private readonly List<BackupRecord> _records = [];
|
||||
|
||||
public int AddCallCount { get; private set; }
|
||||
public int RemoveCallCount { get; private set; }
|
||||
|
||||
public Task<BackupRecord> AddAsync(BackupRecord record, CancellationToken ct = default)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (record.Id == Guid.Empty)
|
||||
{
|
||||
record.Id = Guid.NewGuid();
|
||||
}
|
||||
|
||||
_records.Add(record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<BackupRecord?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_records.Find(r => r.Id == id));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<BackupRecord> ordered = _records
|
||||
.OrderByDescending(r => r.DateCreated)
|
||||
.ToList();
|
||||
return Task.FromResult(ordered);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
RemoveCallCount++;
|
||||
_records.RemoveAll(r => r.Id == id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IBackupOperationsService. Used both by
|
||||
/// BackupControllerTests (configuring the exact outcome an endpoint
|
||||
/// call should map to an HTTP status) and by
|
||||
/// DatabaseBackupHostedServiceTests (using Gate/FailFirstCalls to
|
||||
/// simulate a slow/failing scheduled attempt).
|
||||
/// </summary>
|
||||
public sealed class FakeBackupOperationsService : IBackupOperationsService
|
||||
{
|
||||
private int _createCallCount;
|
||||
|
||||
public int CreateCallCount => _createCallCount;
|
||||
|
||||
/// <summary>
|
||||
/// When set, CreateBackupAsync awaits this before returning —
|
||||
/// simulates a still-running attempt for concurrency-related tests.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<bool>? Gate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first N calls to CreateBackupAsync return
|
||||
/// Failed instead of NextCreateResult.
|
||||
/// </summary>
|
||||
public int FailFirstCalls { get; set; }
|
||||
|
||||
public BackupOperationResult NextCreateResult { get; set; } =
|
||||
new(BackupOperationOutcome.Completed, new BackupRecordResponse(Guid.NewGuid(), DateTime.UtcNow, 0, "Manual", "fake.sql"), null);
|
||||
|
||||
public BackupOperationResult NextDeleteResult { get; set; } =
|
||||
new(BackupOperationOutcome.Completed, null, null);
|
||||
|
||||
public BackupOperationResult NextRestoreResult { get; set; } =
|
||||
new(BackupOperationOutcome.Completed, new BackupRecordResponse(Guid.NewGuid(), DateTime.UtcNow, 0, "Job", "safety.sql"), null);
|
||||
|
||||
public Guid? LastRestoreId { get; private set; }
|
||||
|
||||
public IReadOnlyList<BackupRecord> NextListResult { get; set; } = [];
|
||||
|
||||
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(NextListResult);
|
||||
}
|
||||
|
||||
public async Task<BackupOperationResult> CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default)
|
||||
{
|
||||
int call = Interlocked.Increment(ref _createCallCount);
|
||||
|
||||
if (Gate is not null)
|
||||
{
|
||||
await Gate.Task;
|
||||
}
|
||||
|
||||
return call <= FailFirstCalls
|
||||
? new BackupOperationResult(BackupOperationOutcome.Failed, null, $"Simulated failure on call {call}.")
|
||||
: NextCreateResult;
|
||||
}
|
||||
|
||||
public Task<BackupOperationResult> DeleteBackupAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(NextDeleteResult);
|
||||
}
|
||||
|
||||
public Task<BackupOperationResult> RestoreBackupAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
LastRestoreId = id;
|
||||
return Task.FromResult(NextRestoreResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IBackupStorage. Records call counts and
|
||||
/// deleted names so hosted-service tests can assert on storage/retention
|
||||
/// interaction without any real I/O.
|
||||
/// </summary>
|
||||
public sealed class FakeBackupStorage : IBackupStorage
|
||||
{
|
||||
private int _storeCallCount;
|
||||
private int _listCallCount;
|
||||
private int _deleteCallCount;
|
||||
|
||||
public int StoreCallCount => _storeCallCount;
|
||||
public int ListCallCount => _listCallCount;
|
||||
public int DeleteCallCount => _deleteCallCount;
|
||||
|
||||
public IReadOnlyList<BackupFile> FilesToList { get; set; } = Array.Empty<BackupFile>();
|
||||
public List<string> DeletedNames { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When set, DeleteAsync throws this instead of succeeding —
|
||||
/// simulates the stored file already being missing out-of-band.
|
||||
/// </summary>
|
||||
public Exception? DeleteException { get; set; }
|
||||
|
||||
public Task StoreAsync(string name, Stream content, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _storeCallCount);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BackupFile>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _listCallCount);
|
||||
return Task.FromResult(FilesToList);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _deleteCallCount);
|
||||
lock (DeletedNames)
|
||||
{
|
||||
DeletedNames.Add(name);
|
||||
}
|
||||
|
||||
if (DeleteException is not null)
|
||||
{
|
||||
throw DeleteException;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When set, OpenReadAsync throws this instead of succeeding —
|
||||
/// simulates a missing/unreadable stored file.
|
||||
/// </summary>
|
||||
public Exception? OpenReadException { get; set; }
|
||||
|
||||
public Stream ContentToOpen { get; set; } = new MemoryStream();
|
||||
|
||||
public Task<Stream> OpenReadAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
if (OpenReadException is not null)
|
||||
{
|
||||
throw OpenReadException;
|
||||
}
|
||||
|
||||
return Task.FromResult(ContentToOpen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IDatabaseBackupService. Supports simulating
|
||||
/// a slow/still-running dump (via Gate) for single-flight tests,
|
||||
/// and simulating the first N calls failing (via FailFirstCalls)
|
||||
/// for failure-isolation tests.
|
||||
/// </summary>
|
||||
public sealed class FakeDatabaseBackupService : IDatabaseBackupService
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => _callCount;
|
||||
|
||||
/// <summary>
|
||||
/// When set, CreateDumpAsync awaits this before returning/throwing.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<bool>? Gate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first N calls throw BackupExecutionException; subsequent calls succeed.
|
||||
/// </summary>
|
||||
public int FailFirstCalls { get; set; }
|
||||
|
||||
public string DumpContent { get; set; } = "-- fake dump --";
|
||||
|
||||
public async Task<Stream> CreateDumpAsync(CancellationToken ct = default)
|
||||
{
|
||||
int call = Interlocked.Increment(ref _callCount);
|
||||
|
||||
if (Gate is not null)
|
||||
{
|
||||
await Gate.Task;
|
||||
}
|
||||
|
||||
return call <= FailFirstCalls
|
||||
? throw new BackupExecutionException($"Simulated backup failure on call {call}.")
|
||||
: (Stream) new MemoryStream(Encoding.UTF8.GetBytes(DumpContent));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IDatabaseRestoreService. No real psql binary
|
||||
/// involved — records the dump file path it was invoked with (so tests can
|
||||
/// assert the temp file cleanup happens after the call) and can be
|
||||
/// configured to throw to simulate a failed restore (spec
|
||||
/// database-restore#Restore-Failure-Is-Logged-and-Isolated).
|
||||
/// </summary>
|
||||
public sealed class FakeDatabaseRestoreService : IDatabaseRestoreService
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => _callCount;
|
||||
|
||||
public string? CapturedDumpFilePath { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, RestoreAsync throws this instead of succeeding.
|
||||
/// </summary>
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public Task RestoreAsync(string dumpFilePath, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
CapturedDumpFilePath = dumpFilePath;
|
||||
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IProcessRunner. Records exactly what it was
|
||||
/// invoked with (file name, argument vector, environment variables) so tests
|
||||
/// can assert on those without a real subprocess, and returns a
|
||||
/// pre-configured ProcessResult.
|
||||
/// </summary>
|
||||
public sealed class FakeProcessRunner : IProcessRunner
|
||||
{
|
||||
public string? CapturedFileName { get; private set; }
|
||||
public IReadOnlyList<string>? CapturedArgs { get; private set; }
|
||||
public IReadOnlyDictionary<string, string>? CapturedEnvironmentVariables { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public ProcessResult ResultToReturn { get; set; } = new(0, string.Empty, string.Empty);
|
||||
|
||||
public Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> args,
|
||||
IReadOnlyDictionary<string, string>? environmentVariables = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
CapturedFileName = fileName;
|
||||
CapturedArgs = args;
|
||||
CapturedEnvironmentVariables = environmentVariables;
|
||||
|
||||
return Task.FromResult(ResultToReturn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Application.Utils.Helper.SupabaseHelper;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for ISupabaseRawStorage. Records the exact
|
||||
/// object paths (and, per HU medical-records-storage-eligibility, the target
|
||||
/// bucket) passed to each raw call and can be configured to throw (simulating
|
||||
/// a network/auth error from the real Supabase client), so
|
||||
/// SupabaseBackupStorage and SupabaseMedicalRecordStorage can be unit tested
|
||||
/// without a real SupabaseHelper (whose constructor performs real network
|
||||
/// initialization) or any network call — per this change's spec Non-Goal on
|
||||
/// actual Supabase upload verification.
|
||||
/// </summary>
|
||||
public sealed class FakeSupabaseRawStorage : ISupabaseRawStorage
|
||||
{
|
||||
public List<string> UploadedPaths { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The <c>bucket</c> argument passed to each <see cref="UploadRawAsync"/>
|
||||
/// call, in order — <see langword="null"/> when the caller did not pass a
|
||||
/// bucket (i.e. relied on the configured default).
|
||||
/// </summary>
|
||||
public List<string?> UploadedBuckets { get; } = [];
|
||||
|
||||
public List<string> RemovedPaths { get; } = [];
|
||||
public string? LastListedPrefix { get; private set; }
|
||||
public string? LastDownloadedPath { get; private set; }
|
||||
|
||||
/// <summary>The <c>bucket</c> argument passed to the last <see cref="DownloadRawAsync"/> call.</summary>
|
||||
public string? DownloadedBucket { get; private set; }
|
||||
|
||||
public IReadOnlyList<SupabaseStorageEntry> EntriesToList { get; set; } = Array.Empty<SupabaseStorageEntry>();
|
||||
|
||||
public byte[] BytesToDownload { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// When set, every raw call throws this exception instead of succeeding —
|
||||
/// simulates a network error, auth error, or any other Supabase client
|
||||
/// failure.
|
||||
/// </summary>
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public Task UploadRawAsync(string objectPath, Stream content, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
UploadedPaths.Add(objectPath);
|
||||
UploadedBuckets.Add(bucket);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SupabaseStorageEntry>> ListRawAsync(string prefix, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
LastListedPrefix = prefix;
|
||||
return Task.FromResult(EntriesToList);
|
||||
}
|
||||
|
||||
public Task RemoveRawAsync(string objectPath, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
RemovedPaths.Add(objectPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<byte[]> DownloadRawAsync(string objectPath, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
LastDownloadedPath = objectPath;
|
||||
DownloadedBucket = bucket;
|
||||
return Task.FromResult(BytesToDownload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// No-op IDisposable returned from ILogger.BeginScope, shared by every
|
||||
/// CapturingLogger{T} instance regardless of T.
|
||||
/// </summary>
|
||||
public sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Polls a condition instead of sleeping for a fixed real-time duration, so
|
||||
/// timing-sensitive hosted-service tests stay fast on quick machines and
|
||||
/// resilient (non-flaky) on slow/loaded CI runners.
|
||||
/// </summary>
|
||||
internal static class TestTiming
|
||||
{
|
||||
public static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
return condition();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the pure keep-last-N retention decision. No I/O — the
|
||||
/// policy only selects which BackupFile entries to delete
|
||||
/// given an already-known list and a retain count.
|
||||
/// </summary>
|
||||
public class KeepLastNRetentionPolicyTests
|
||||
{
|
||||
private static readonly KeepLastNRetentionPolicy Policy = new();
|
||||
|
||||
private static BackupFile File(string name, int minutesAgo)
|
||||
{
|
||||
return new(name, DateTimeOffset.UtcNow.AddMinutes(-minutesAgo));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_CountWithinLimit_SelectsNone()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 30),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
File("backup-3", minutesAgo: 10),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_CountEqualsLimit_SelectsNone()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 30),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 2);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entries are oldest-to-newest by minutesAgo: backup-5 (50m) ... backup-1 (10m).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SelectForDeletion_CountExceedsLimit_SelectsOldestExcess_RetainsNewestN()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 10),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
File("backup-3", minutesAgo: 30),
|
||||
File("backup-4", minutesAgo: 40),
|
||||
File("backup-5", minutesAgo: 50),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 2);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(
|
||||
new[] { "backup-3", "backup-4", "backup-5" },
|
||||
result.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
|
||||
Assert.DoesNotContain(result, f => f.Name is "backup-1" or "backup-2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per the documented tie-break rule (design.md Open Questions), among
|
||||
/// entries with identical timestamps the lexically-smallest name
|
||||
/// (ordinal) is retained.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SelectForDeletion_IdenticalTimestampsAtBoundary_IsDeterministicAndOrderIndependent()
|
||||
{
|
||||
DateTimeOffset tiedTimestamp = DateTimeOffset.UtcNow.AddMinutes(-10);
|
||||
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
new BackupFile("backup-b", tiedTimestamp),
|
||||
new BackupFile("backup-a", tiedTimestamp),
|
||||
new BackupFile("backup-c", tiedTimestamp),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> firstRun = Policy.SelectForDeletion(existing, retainCount: 1);
|
||||
|
||||
List<BackupFile> reordered = [existing[2], existing[0], existing[1]];
|
||||
IReadOnlyList<BackupFile> secondRun = Policy.SelectForDeletion(reordered, retainCount: 1);
|
||||
|
||||
Assert.Equal(2, firstRun.Count);
|
||||
Assert.Equal(
|
||||
firstRun.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal),
|
||||
secondRun.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal));
|
||||
|
||||
Assert.DoesNotContain(firstRun, f => f.Name == "backup-a");
|
||||
Assert.Contains(firstRun, f => f.Name == "backup-b");
|
||||
Assert.Contains(firstRun, f => f.Name == "backup-c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_EmptyList_SelectsNone()
|
||||
{
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion([], retainCount: 7);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_RetainCountZero_SelectsAll()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 10),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 0);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Tests LocalDirectoryBackupStorage against a real temporary
|
||||
/// directory (safe: local filesystem only, no external dependency), plus an
|
||||
/// end-to-end integration with the real KeepLastNRetentionPolicy
|
||||
/// to prove retention actually deletes the correct files when invoked
|
||||
/// through the storage adapter.
|
||||
/// </summary>
|
||||
public sealed class LocalDirectoryBackupStorageTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly LocalDirectoryBackupStorage _storage;
|
||||
|
||||
public LocalDirectoryBackupStorageTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "club12-backup-tests-" + Guid.NewGuid().ToString("N"));
|
||||
_storage = new LocalDirectoryBackupStorage(_tempDir, NullLogger<LocalDirectoryBackupStorage>.Instance);
|
||||
}
|
||||
|
||||
private static Stream ContentStream(string text)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ThenListAsync_ReturnsStoredFile()
|
||||
{
|
||||
await _storage.StoreAsync("backup-1.sql", ContentStream("dump-1"));
|
||||
|
||||
IReadOnlyList<BackupFile> files = await _storage.ListAsync();
|
||||
|
||||
Assert.Single(files);
|
||||
Assert.Equal("backup-1.sql", files[0].Name);
|
||||
Assert.Equal("dump-1", await File.ReadAllTextAsync(Path.Combine(_tempDir, "backup-1.sql")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesFile_ListNoLongerContainsIt()
|
||||
{
|
||||
await _storage.StoreAsync("backup-1.sql", ContentStream("dump-1"));
|
||||
await _storage.StoreAsync("backup-2.sql", ContentStream("dump-2"));
|
||||
|
||||
await _storage.DeleteAsync("backup-1.sql");
|
||||
IReadOnlyList<BackupFile> files = await _storage.ListAsync();
|
||||
|
||||
Assert.Single(files);
|
||||
Assert.Equal("backup-2.sql", files[0].Name);
|
||||
Assert.False(File.Exists(Path.Combine(_tempDir, "backup-1.sql")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_StoredFile_ReturnsReadableStreamWithContent()
|
||||
{
|
||||
await _storage.StoreAsync("backup-1.sql", ContentStream("dump-1"));
|
||||
|
||||
await using Stream stream = await _storage.OpenReadAsync("backup-1.sql");
|
||||
using StreamReader reader = new(stream);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal("dump-1", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A catalog row's StoragePath is expected to be server-generated
|
||||
/// and safe, but OpenReadAsync must still independently
|
||||
/// re-validate it via the same ResolveSafePath guard used by
|
||||
/// StoreAsync/DeleteAsync — a malformed/malicious catalog value must
|
||||
/// never reach a file read (threat: storage path traversal).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_TraversalName_ThrowsArgumentException_NoFileOpened()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _storage.OpenReadAsync("../../etc/passwd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_RootedPathName_ThrowsArgumentException()
|
||||
{
|
||||
string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/passwd";
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _storage.OpenReadAsync(rooted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_NameEscapingConfiguredDirectory_ThrowsArgumentException()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _storage.StoreAsync("../escape.sql", ContentStream("evil")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RootedPathName_ThrowsArgumentException()
|
||||
{
|
||||
string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/evil.sql";
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _storage.StoreAsync(rooted, ContentStream("evil")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NameEscapingConfiguredDirectory_ThrowsArgumentException()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _storage.DeleteAsync("../../escape.sql"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// retainCount = 2; 4 files stored (N+2). One tied pair straddles the
|
||||
/// retention boundary to exercise the documented tie-break rule (newest
|
||||
/// timestamp first, then lexically-smallest name retained).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RetentionIntegration_StoresNPlus2_RetainsNewestN_DeletesExactlyTwoPerTieBreak()
|
||||
{
|
||||
const int retainCount = 2;
|
||||
DateTime tied = DateTime.UtcNow.AddMinutes(-10);
|
||||
|
||||
await StoreWithTimestamp("file-newest.sql", DateTime.UtcNow.AddMinutes(-5));
|
||||
await StoreWithTimestamp("file-tied-a.sql", tied);
|
||||
await StoreWithTimestamp("file-tied-b.sql", tied);
|
||||
await StoreWithTimestamp("file-oldest.sql", DateTime.UtcNow.AddMinutes(-40));
|
||||
|
||||
IReadOnlyList<BackupFile> existing = await _storage.ListAsync();
|
||||
Assert.Equal(4, existing.Count);
|
||||
|
||||
KeepLastNRetentionPolicy retention = new();
|
||||
IReadOnlyList<BackupFile> toDelete = retention.SelectForDeletion(existing, retainCount);
|
||||
|
||||
Assert.Equal(2, toDelete.Count);
|
||||
Assert.Equal(
|
||||
new[] { "file-oldest.sql", "file-tied-b.sql" },
|
||||
toDelete.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
|
||||
|
||||
foreach (BackupFile stale in toDelete)
|
||||
{
|
||||
await _storage.DeleteAsync(stale.Name);
|
||||
}
|
||||
|
||||
IReadOnlyList<BackupFile> remaining = await _storage.ListAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.Contains(remaining, f => f.Name == "file-newest.sql");
|
||||
Assert.Contains(remaining, f => f.Name == "file-tied-a.sql");
|
||||
}
|
||||
|
||||
private async Task StoreWithTimestamp(string name, DateTime timestampUtc)
|
||||
{
|
||||
await _storage.StoreAsync(name, ContentStream("dump-content-for-" + name));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_tempDir, name), timestampUtc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using API.Utils.Middlewares;
|
||||
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MaintenanceModeMiddleware (design.md's "Maintenance
|
||||
/// gate is middleware placed after UseCors, before
|
||||
/// UseAuthentication" decision — covers every request shape,
|
||||
/// including unmatched routes and Swagger, unlike an MVC filter). Exercises
|
||||
/// InvokeAsync directly against a DefaultHttpContext and a real
|
||||
/// MaintenanceModeState — no HTTP pipeline/host required.
|
||||
/// </summary>
|
||||
public class MaintenanceModeMiddlewareTests
|
||||
{
|
||||
private sealed class CallCounter
|
||||
{
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private static (MaintenanceModeMiddleware Middleware, CallCounter Counter) CreateSut(IMaintenanceModeState state)
|
||||
{
|
||||
CallCounter counter = new();
|
||||
RequestDelegate next = _ =>
|
||||
{
|
||||
counter.Count++;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
return (new MaintenanceModeMiddleware(next, state), counter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// threat-matrix "Routing gate bypass (maintenance 503)": /api/backups,
|
||||
/// /swagger, and an unmatched route all must return 503 while
|
||||
/// maintenance is active — the middleware runs before endpoint routing,
|
||||
/// so this must not depend on any route being matched.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/api/backups")]
|
||||
[InlineData("/swagger")]
|
||||
[InlineData("/this-route-does-not-exist")]
|
||||
public async Task InvokeAsync_MaintenanceActive_NonAllowedPath_Returns503_DoesNotCallNext(string path)
|
||||
{
|
||||
MaintenanceModeState state = new();
|
||||
state.Enter("restore in progress");
|
||||
(MaintenanceModeMiddleware sut, CallCounter counter) = CreateSut(state);
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Path = path;
|
||||
|
||||
await sut.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/health")]
|
||||
[InlineData("/health/ready")]
|
||||
[InlineData("/api/maintenance")]
|
||||
public async Task InvokeAsync_MaintenanceActive_AllowedPath_CallsNext(string path)
|
||||
{
|
||||
MaintenanceModeState state = new();
|
||||
state.Enter("restore in progress");
|
||||
(MaintenanceModeMiddleware sut, CallCounter counter) = CreateSut(state);
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Path = path;
|
||||
|
||||
await sut.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
Assert.NotEqual(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MaintenanceInactive_CallsNext_ForAnyPath()
|
||||
{
|
||||
MaintenanceModeState state = new();
|
||||
(MaintenanceModeMiddleware sut, CallCounter counter) = CreateSut(state);
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Path = "/api/backups";
|
||||
|
||||
await sut.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
Assert.NotEqual(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MaintenanceModeState — pure in-memory process
|
||||
/// state (design.md: "Maintenance state lives in Application/Backup, not
|
||||
/// Infrastructure", registered as a singleton). Covers the
|
||||
/// Enter/Exit transitions and the IsActive/Reason/EnteredAtUtc
|
||||
/// shape MaintenanceStatusResponse projects.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModeStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void InitialState_IsNotActive_ReasonAndEnteredAtUtcAreNull()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
|
||||
Assert.False(state.IsActive);
|
||||
Assert.Null(state.Reason);
|
||||
Assert.Null(state.EnteredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_SetsIsActiveTrue_AndReason_AndEnteredAtUtc()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||
|
||||
state.Enter("Restoring backup abc123");
|
||||
|
||||
DateTimeOffset after = DateTimeOffset.UtcNow;
|
||||
Assert.True(state.IsActive);
|
||||
Assert.Equal("Restoring backup abc123", state.Reason);
|
||||
Assert.NotNull(state.EnteredAtUtc);
|
||||
Assert.InRange(state.EnteredAtUtc!.Value, before, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exit_AfterEnter_ClearsIsActive_ReasonAndEnteredAtUtc()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
state.Enter("Restoring backup abc123");
|
||||
|
||||
state.Exit();
|
||||
|
||||
Assert.False(state.IsActive);
|
||||
Assert.Null(state.Reason);
|
||||
Assert.Null(state.EnteredAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The manual escape hatch (DELETE api/maintenance) must be able
|
||||
/// to clear a stuck window even when no restore is in flight — Exit()
|
||||
/// has no precondition on a prior Enter().
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Exit_WithoutPriorEnter_DoesNotThrow_StaysInactive()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
|
||||
state.Exit();
|
||||
|
||||
Assert.False(state.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_TwiceWithDifferentReasons_LatestReasonWins()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
|
||||
state.Enter("First reason");
|
||||
state.Enter("Second reason");
|
||||
|
||||
Assert.True(state.IsActive);
|
||||
Assert.Equal("Second reason", state.Reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PgDumpBackupService using a
|
||||
/// FakeProcessRunner — no real pg_dump binary involved.
|
||||
/// Covers: correct argument-vector construction from the connection string,
|
||||
/// subprocess argument-injection resistance, and failure handling
|
||||
/// (non-zero exit / missing binary → logged, handled exception, not a crash).
|
||||
/// </summary>
|
||||
public class PgDumpBackupServiceTests
|
||||
{
|
||||
private static IConfiguration BuildConfiguration(string connectionString, string? pgDumpPath = null)
|
||||
{
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["ConnectionStrings:DbConnection"] = connectionString,
|
||||
};
|
||||
if (pgDumpPath is not null)
|
||||
{
|
||||
values["Backup:PgDumpPath"] = pgDumpPath;
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_SuccessfulExit_ReturnsStdOutAsStream()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(0, "-- pg_dump output --", string.Empty),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=secret");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await using Stream dump = await service.CreateDumpAsync();
|
||||
|
||||
using StreamReader reader = new(dump);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
Assert.Equal("-- pg_dump output --", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The password must never appear in the argument vector, since it would
|
||||
/// otherwise leak via `ps`/task list; it is instead passed through the
|
||||
/// PGPASSWORD environment variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_BuildsArgumentVectorFromConnectionString_PasswordNeverInArgs()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=db.internal;Port=5433;Database=club12;Username=app_user;Password=s3cret");
|
||||
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("db.internal", runner.CapturedArgs!);
|
||||
Assert.Contains("5433", runner.CapturedArgs!);
|
||||
Assert.Contains("app_user", runner.CapturedArgs!);
|
||||
Assert.Contains("club12", runner.CapturedArgs!);
|
||||
Assert.DoesNotContain("s3cret", runner.CapturedArgs!);
|
||||
Assert.NotNull(runner.CapturedEnvironmentVariables);
|
||||
Assert.Equal("s3cret", runner.CapturedEnvironmentVariables!["PGPASSWORD"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// maliciousDbName is crafted to look like a shell-injection payload and
|
||||
/// must survive as one literal argument-vector element, never concatenated
|
||||
/// into a shell command string that a shell could re-tokenize/expand. ';'
|
||||
/// and '=' are reserved separators in the ADO connection-string format
|
||||
/// itself — a different boundary than the subprocess argument vector under
|
||||
/// test here — so the payload avoids those two characters and instead uses
|
||||
/// shell metacharacters: $(), backticks, pipes, and &.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_ArgumentInjectionAttempt_PassedAsLiteralArgVectorElement()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
const string maliciousDbName = "club12$(touch pwned)`whoami`|evil&";
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
$"Host=localhost;Port=5432;Database={maliciousDbName};Username=app;Password=x");
|
||||
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.Contains(maliciousDbName, runner.CapturedArgs!);
|
||||
Assert.All(runner.CapturedArgs!, a => Assert.DoesNotContain("&&", a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restoring with psql -f against a Supabase-managed database can
|
||||
/// fail on ownership/role statements captured by a plain pg_dump;
|
||||
/// these flags move that safety onto the dump side (design.md's
|
||||
/// "Keep plain-SQL dumps; restore with psql" decision).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_IncludesCleanAndOwnershipSafetyFlags()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("--clean", runner.CapturedArgs!);
|
||||
Assert.Contains("--if-exists", runner.CapturedArgs!);
|
||||
Assert.Contains("--no-owner", runner.CapturedArgs!);
|
||||
Assert.Contains("--no-privileges", runner.CapturedArgs!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// pg_dump with no schema restriction captures the WHOLE database,
|
||||
/// including Supabase-platform-owned tables/views/functions the app's
|
||||
/// connection role never owns (e.g. a "vector_indexes" table from
|
||||
/// Supabase's own tooling) — --clean's DROP for those then fails with
|
||||
/// "must be owner of table X" on restore. The app's own data lives in
|
||||
/// exactly two schemas: "public" (ASP.NET Core Identity's default,
|
||||
/// unconfigured schema) and "Club12" (every domain entity, via
|
||||
/// EntityConstants.Schema) — restricting the dump to just those excludes
|
||||
/// every Supabase-managed schema at once, rather than reacting to each
|
||||
/// foreign object name as it surfaces one restore attempt at a time.
|
||||
/// The "Club12" pattern MUST be double-quoted (as a literal, embedded in
|
||||
/// the arg-vector element itself — no shell is involved, so no outer
|
||||
/// single-quoting is needed): pg_dump's -n pattern matching folds an
|
||||
/// unquoted pattern to lowercase before comparing, and the real schema
|
||||
/// is mixed-case, so an unquoted "Club12" pattern silently matches
|
||||
/// nothing and pg_dump dumps zero tables from it — confirmed by
|
||||
/// inspecting a real dump taken with the unquoted form, which contained
|
||||
/// only "public" content, not one line of "Club12".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_RestrictsDumpToAppOwnedSchemas()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
IReadOnlyList<string> args = runner.CapturedArgs!;
|
||||
Assert.Contains("public", args);
|
||||
Assert.Contains("\"Club12\"", args);
|
||||
Assert.Equal(2, args.Count(a => a == "-n"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supabase-managed databases carry platform-internal event triggers
|
||||
/// (PostgREST's schema-cache-reload hooks, pgsodium's mask-update hook,
|
||||
/// etc.) that a plain pg_dump captures because event triggers are
|
||||
/// database-wide, not schema-scoped — no `-n`/`--exclude-schema` filters
|
||||
/// them out. On restore, `--clean`'s `DROP EVENT TRIGGER` for one of
|
||||
/// these fails with "must be owner of event trigger", since the app's
|
||||
/// connection role never owns Supabase's own infrastructure objects. The
|
||||
/// app never defines its own event triggers, so any EVENT TRIGGER
|
||||
/// statement in the dump is guaranteed to be Supabase-owned and safe to
|
||||
/// drop from the dump entirely (not just the one named in the incident —
|
||||
/// psql aborts at the first one, so others further down the dump would
|
||||
/// never even surface).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_StripsEventTriggerStatements_SupabaseInternalObjectsNotOwnedByAppRole()
|
||||
{
|
||||
const string rawDump = """
|
||||
SET statement_timeout = 0;
|
||||
DROP EVENT TRIGGER IF EXISTS pgrst_drop_watch;
|
||||
DROP EVENT TRIGGER IF EXISTS pgrst_ddl_watch;
|
||||
CREATE TABLE "Club12"."BackupRecords" (
|
||||
"Id" uuid NOT NULL
|
||||
);
|
||||
CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop
|
||||
EXECUTE FUNCTION extensions.pgrst_drop_watch();
|
||||
CREATE EVENT TRIGGER pgrst_ddl_watch ON ddl_command_end
|
||||
EXECUTE FUNCTION extensions.pgrst_ddl_watch();
|
||||
COMMENT ON EVENT TRIGGER pgrst_drop_watch IS 'notify PostgREST of DDL changes';
|
||||
INSERT INTO "Club12"."BackupRecords" ("Id") VALUES ('11111111-1111-1111-1111-111111111111');
|
||||
""";
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, rawDump, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await using Stream dump = await service.CreateDumpAsync();
|
||||
|
||||
using StreamReader reader = new(dump);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
Assert.DoesNotContain("EVENT TRIGGER", content, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("CREATE TABLE \"Club12\".\"BackupRecords\"", content);
|
||||
Assert.Contains("INSERT INTO \"Club12\".\"BackupRecords\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_NonZeroExitCode_ThrowsBackupExecutionException_NotUncaught()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(1, string.Empty, "pg_dump: error: connection failed"),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.CreateDumpAsync());
|
||||
|
||||
Assert.Contains("exit code 1", ex.Message);
|
||||
Assert.Contains("connection failed", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates what ProcessRunner returns when Process.Start fails for a
|
||||
/// missing executable: sentinel exit code -1, detail in StdErr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_MissingBinary_ThrowsHandledExceptionWithActionableMessage()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(
|
||||
-1, string.Empty, "Failed to start process 'pg_dump': The system cannot find the file specified."),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x",
|
||||
pgDumpPath: "pg_dump");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.CreateDumpAsync());
|
||||
|
||||
Assert.Contains("pg_dump", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("PATH", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_MissingConnectionString_ThrowsBackupExecutionException()
|
||||
{
|
||||
FakeProcessRunner runner = new();
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(
|
||||
[]).Build();
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<BackupExecutionException>(() => service.CreateDumpAsync());
|
||||
|
||||
Assert.Equal(0, runner.CallCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the real ProcessRunner adapter (not a fake) —
|
||||
/// proves the missing-binary path degrades to a failed
|
||||
/// ProcessResult instead of throwing an unhandled exception,
|
||||
/// and that a real successful invocation is captured correctly.
|
||||
/// </summary>
|
||||
public class ProcessRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_MissingExecutable_ReturnsFailedResult_DoesNotThrow()
|
||||
{
|
||||
ProcessRunner runner = new();
|
||||
|
||||
ProcessResult result = await runner.RunAsync(
|
||||
"club12-definitely-not-a-real-executable-name",
|
||||
args: []);
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.False(string.IsNullOrEmpty(result.StdErr));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "dotnet --version" is used because it is a safe, always-available
|
||||
/// executable in this test environment (the test host itself runs via
|
||||
/// dotnet), exits 0, and writes a version string to stdout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SuccessfulProcess_CapturesExitCodeAndStdOut()
|
||||
{
|
||||
ProcessRunner runner = new();
|
||||
|
||||
ProcessResult result = await runner.RunAsync("dotnet", args: ["--version"]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.StdOut));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PsqlDatabaseRestoreService using a
|
||||
/// FakeProcessRunner — no real psql binary involved.
|
||||
/// Covers: correct argument-vector construction (design.md's
|
||||
/// "psql -v ON_ERROR_STOP=1 -f <tmp>" decision — plain-SQL restore, not
|
||||
/// pg_restore), subprocess argument-injection resistance for a
|
||||
/// dump-file path crafted to look like shell/psql-flag injection, and
|
||||
/// failure handling (non-zero exit / missing binary → logged, handled
|
||||
/// exception, not a crash).
|
||||
/// </summary>
|
||||
public class PsqlDatabaseRestoreServiceTests
|
||||
{
|
||||
private static IConfiguration BuildConfiguration(string connectionString, string? psqlPath = null)
|
||||
{
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["ConnectionStrings:DbConnection"] = connectionString,
|
||||
};
|
||||
if (psqlPath is not null)
|
||||
{
|
||||
values["Backup:PsqlPath"] = psqlPath;
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_SuccessfulExit_CompletesWithoutThrowing()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=secret");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync("/tmp/backup-restore-test.sql");
|
||||
|
||||
Assert.Equal(1, runner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The password must never appear in the argument vector, since it would
|
||||
/// otherwise leak via `ps`/task list; it is instead passed through the
|
||||
/// PGPASSWORD environment variable — identical convention to
|
||||
/// PgDumpBackupService.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreAsync_BuildsArgumentVectorFromConnectionString_PasswordNeverInArgs()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=db.internal;Port=5433;Database=club12;Username=app_user;Password=s3cret");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync("/tmp/backup-restore-test.sql");
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("db.internal", runner.CapturedArgs!);
|
||||
Assert.Contains("5433", runner.CapturedArgs!);
|
||||
Assert.Contains("app_user", runner.CapturedArgs!);
|
||||
Assert.Contains("club12", runner.CapturedArgs!);
|
||||
Assert.DoesNotContain("s3cret", runner.CapturedArgs!);
|
||||
Assert.NotNull(runner.CapturedEnvironmentVariables);
|
||||
Assert.Equal("s3cret", runner.CapturedEnvironmentVariables!["PGPASSWORD"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the exact arg vector for design.md's chosen restore invocation:
|
||||
/// "-v", "ON_ERROR_STOP=1", "-f", <dumpFilePath> — ON_ERROR_STOP=1 is what
|
||||
/// turns the first SQL error inside the dump into a non-zero psql exit
|
||||
/// code instead of silently continuing past it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreAsync_UsesPsqlWithOnErrorStopAndDumpFilePathFlag()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x", psqlPath: "/usr/bin/psql");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync("/tmp/backup-restore-test.sql");
|
||||
|
||||
Assert.Equal("/usr/bin/psql", runner.CapturedFileName);
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("-v", runner.CapturedArgs!);
|
||||
Assert.Contains("ON_ERROR_STOP=1", runner.CapturedArgs!);
|
||||
Assert.Contains("-f", runner.CapturedArgs!);
|
||||
Assert.Contains("/tmp/backup-restore-test.sql", runner.CapturedArgs!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// maliciousDumpPath is crafted to look like a subprocess argument-injection
|
||||
/// payload (extra flags via ';'/'--', shell metacharacters) and must
|
||||
/// survive as one literal argument-vector element passed to psql's -f
|
||||
/// flag, never reinterpreted/split — arguments go through
|
||||
/// ProcessStartInfo.ArgumentList (never a concatenated shell command
|
||||
/// string), matching PgDumpBackupService's existing defense.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/tmp/evil;rm -rf /.sql")]
|
||||
[InlineData("/tmp/evil --set=malicious.sql")]
|
||||
[InlineData("/tmp/evil`whoami`.sql")]
|
||||
public async Task RestoreAsync_DumpFilePathInjectionAttempt_PassedAsLiteralArgVectorElement(string maliciousDumpPath)
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync(maliciousDumpPath);
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Single(runner.CapturedArgs!, a => a == maliciousDumpPath);
|
||||
Assert.Equal(1, runner.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_NonZeroExitCode_ThrowsBackupExecutionException_NotUncaught()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(1, string.Empty, "psql: error: syntax error at or near \"BOGUS\""),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.RestoreAsync("/tmp/backup-restore-test.sql"));
|
||||
|
||||
Assert.Contains("exit code 1", ex.Message);
|
||||
Assert.Contains("syntax error", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates what ProcessRunner returns when Process.Start fails for a
|
||||
/// missing executable: sentinel exit code -1, detail in StdErr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreAsync_MissingBinary_ThrowsHandledExceptionWithActionableMessage()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(
|
||||
-1, string.Empty, "Failed to start process 'psql': The system cannot find the file specified."),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x", psqlPath: "psql");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.RestoreAsync("/tmp/backup-restore-test.sql"));
|
||||
|
||||
Assert.Contains("psql", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("PATH", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_MissingConnectionString_ThrowsBackupExecutionException()
|
||||
{
|
||||
FakeProcessRunner runner = new();
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection([]).Build();
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.RestoreAsync("/tmp/backup-restore-test.sql"));
|
||||
|
||||
Assert.Equal(0, runner.CallCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SupabaseBackupStorage against a
|
||||
/// FakeSupabaseRawStorage — no real Supabase client, no
|
||||
/// network call (per this change's spec Non-Goal: actual Supabase upload is
|
||||
/// staging/manual verification only). Covers: the backups/
|
||||
/// object-path builder, traversal rejection, list translation, and wrapping
|
||||
/// of raw-storage failures (network/auth errors) into
|
||||
/// BackupExecutionException — the same failure type
|
||||
/// PgDumpBackupService throws and DatabaseBackupHostedService
|
||||
/// already catches and logs without crashing the host.
|
||||
/// </summary>
|
||||
public sealed class SupabaseBackupStorageTests
|
||||
{
|
||||
private static Stream ContentStream(string text)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ValidName_UploadsUnderBackupsPrefix()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await storage.StoreAsync("backup-1.sql", ContentStream("dump"));
|
||||
|
||||
Assert.Single(raw.UploadedPaths);
|
||||
Assert.Equal("backups/backup-1.sql", raw.UploadedPaths[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape.sql")]
|
||||
[InlineData("../../escape.sql")]
|
||||
[InlineData("nested/../../escape.sql")]
|
||||
public async Task StoreAsync_RelativeTraversalName_ThrowsArgumentException_NoUploadAttempted(string maliciousName)
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.StoreAsync(maliciousName, ContentStream("evil")));
|
||||
|
||||
Assert.Empty(raw.UploadedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RootedPathName_ThrowsArgumentException_NoUploadAttempted()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/evil.sql";
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.StoreAsync(rooted, ContentStream("evil")));
|
||||
|
||||
Assert.Empty(raw.UploadedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_ValidName_DownloadsFromBackupsPrefix_ReturnsStreamWithContent()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { BytesToDownload = Encoding.UTF8.GetBytes("dump-1") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await using Stream stream = await storage.OpenReadAsync("backup-1.sql");
|
||||
using StreamReader reader = new(stream);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal("backups/backup-1.sql", raw.LastDownloadedPath);
|
||||
Assert.Equal("dump-1", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A catalog row's StoragePath is expected to be server-generated
|
||||
/// and safe, but OpenReadAsync must still independently
|
||||
/// re-validate it via the same ToObjectPath guard used by
|
||||
/// StoreAsync/DeleteAsync — a malformed/malicious catalog value must
|
||||
/// never reach a raw download call (threat: storage path traversal).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_TraversalName_ThrowsArgumentException_NoDownloadAttempted()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.OpenReadAsync("../../etc/passwd"));
|
||||
|
||||
Assert.Null(raw.LastDownloadedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_RawStorageThrows_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new InvalidOperationException("network unreachable") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => storage.OpenReadAsync("backup-1.sql"));
|
||||
|
||||
Assert.Contains("backup-1.sql", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ValidName_RemovesUnderBackupsPrefix()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await storage.DeleteAsync("backup-1.sql");
|
||||
|
||||
Assert.Single(raw.RemovedPaths);
|
||||
Assert.Equal("backups/backup-1.sql", raw.RemovedPaths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_TraversalName_ThrowsArgumentException_NoRemoveAttempted()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.DeleteAsync("../escape.sql"));
|
||||
|
||||
Assert.Empty(raw.RemovedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_TranslatesRawEntries_UsingBackupsPrefix()
|
||||
{
|
||||
DateTimeOffset updated = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
FakeSupabaseRawStorage raw = new()
|
||||
{
|
||||
EntriesToList =
|
||||
[
|
||||
new("backup-1.sql", updated),
|
||||
new("backup-2.sql", null),
|
||||
],
|
||||
};
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
IReadOnlyList<BackupFile> files = await storage.ListAsync();
|
||||
|
||||
Assert.Equal("backups/", raw.LastListedPrefix);
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains(files, f => f.Name == "backup-1.sql" && f.Timestamp == updated);
|
||||
Assert.Contains(files, f => f.Name == "backup-2.sql");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RawStorageThrowsNetworkError_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new HttpRequestException("network unreachable") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => storage.StoreAsync("backup-1.sql", ContentStream("dump")));
|
||||
|
||||
Assert.Contains("backup-1.sql", ex.Message);
|
||||
Assert.IsType<HttpRequestException>(ex.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_RawStorageThrowsAuthError_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new InvalidOperationException("401 unauthorized") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(() => storage.ListAsync());
|
||||
|
||||
Assert.Contains("401 unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RawStorageThrows_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new InvalidOperationException("boom") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<BackupExecutionException>(() => storage.DeleteAsync("backup-1.sql"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Domain.Enums;
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves every api/backups endpoint is staff-only (Admin or Owner),
|
||||
/// mirroring the pattern in DataMaintenanceAuthorizationTests.cs. Only the
|
||||
/// negative paths (anonymous/Guest) and the safe read (GET, staff) are
|
||||
/// exercised via the real HTTP pipeline — POST/DELETE as staff would invoke
|
||||
/// the real BackupOperationsService (pg_dump), which has no
|
||||
/// binary available in this test environment; that outcome-mapping behavior
|
||||
/// is covered instead by the pure unit tests in
|
||||
/// Backup/BackupControllerTests.cs.
|
||||
/// </summary>
|
||||
public class BackupAuthorizationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public BackupAuthorizationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBackups_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/backups");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBackups_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/backups");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin)]
|
||||
[InlineData(Roles.Owner)]
|
||||
public async Task GetBackups_StaffRole_Succeeds(string role)
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/backups");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackup_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/backups", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackup_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
string role = Roles.Guest;
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/backups", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBackup_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.DeleteAsync($"api/backups/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBackup_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
string role = Roles.Guest;
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.DeleteAsync($"api/backups/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync($"api/backups/{Guid.NewGuid()}/restore", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackup_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
string role = Roles.Guest;
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync($"api/backups/{Guid.NewGuid()}/restore", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.BlogPosts.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HU-16: public blog reads only ever expose published posts, while
|
||||
/// Admin/Owner callers (modelled here by the includeUnpublished flag the
|
||||
/// controller sets from the caller's role) also see drafts.
|
||||
/// </summary>
|
||||
public class BlogPostDraftVisibilityTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public BlogPostDraftVisibilityTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicListHidesDrafts_AdminSeesThem()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService service = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
// Unique author so the filtered listing only ever sees these two posts,
|
||||
// regardless of what other tests left in the shared database.
|
||||
string author = $"author-{Guid.NewGuid():N}";
|
||||
|
||||
db.BlogPosts.Add(NewPost(author, "Published post", isPublished: true));
|
||||
db.BlogPosts.Add(NewPost(author, "Draft post", isPublished: false));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
GetBlogPostsFilteredRequest filter = new() { Author = author, PageSize = 50 };
|
||||
|
||||
PaginatedResponse<BlogPost> publicView = await service.GetAllBlogPostsAsync(filter);
|
||||
Assert.Single(publicView.Items);
|
||||
Assert.All(publicView.Items, post => Assert.True(post.IsPublished));
|
||||
|
||||
PaginatedResponse<BlogPost> adminView = await service.GetAllBlogPostsAsync(filter, includeUnpublished: true);
|
||||
Assert.Equal(2, adminView.Items.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicDetailHidesDraft_AdminResolvesIt()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService service = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
string slug = $"draft-{Guid.NewGuid():N}";
|
||||
BlogPost draft = NewPost($"author-{Guid.NewGuid():N}", "Hidden draft", isPublished: false);
|
||||
draft.Slug = slug;
|
||||
db.BlogPosts.Add(draft);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Public caller: a draft is treated as not found.
|
||||
Assert.Null(await service.GetBlogPostByIdOrSlugAsync(slug));
|
||||
|
||||
// Admin/Owner caller: the draft resolves.
|
||||
BlogPost? resolved = await service.GetBlogPostByIdOrSlugAsync(slug, includeUnpublished: true);
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(slug, resolved!.Slug);
|
||||
}
|
||||
|
||||
private static BlogPost NewPost(string author, string title, bool isPublished)
|
||||
{
|
||||
return new BlogPost
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "test",
|
||||
Author = author,
|
||||
Title = title,
|
||||
Slug = $"{title.ToLowerInvariant().Replace(' ', '-')}-{Guid.NewGuid():N}",
|
||||
MarkdownText = "Body.",
|
||||
IsPublished = isPublished,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Application.DTOs.Abstract.Request;
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.BlogPosts.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the paginated blog-post list served by <c>GET /api/blog-posts</c>
|
||||
/// defaults to newest-first ordering by <see cref="EntityBase.DateCreated"/>.
|
||||
/// The home "Últimas noticias" section, the public <c>/blog</c> list and the
|
||||
/// admin Novedades list all send no sort parameter, so the backend default is
|
||||
/// the effective order — it must surface the most recently created posts first,
|
||||
/// not the oldest. Each test tags its posts with a unique author so the shared
|
||||
/// fixture database cannot leak rows between tests.
|
||||
/// </summary>
|
||||
public class BlogPostOrderingTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public BlogPostOrderingTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRequest_DefaultOrdering_IsDateCreatedDescending()
|
||||
{
|
||||
GetBlogPostsFilteredRequest request = new();
|
||||
|
||||
Assert.Equal(nameof(EntityBase.DateCreated), request.OrderBy);
|
||||
Assert.Equal(SortOrder.Descending, request.Order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllBlogPostsAsync_NoExplicitSort_ReturnsNewestCreatedFirst()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IBlogPostService service = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
string author = $"author-{Guid.NewGuid():N}";
|
||||
DateTime oldest = new(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime newest = new(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime middle = new(2026, 2, 5, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Inserted out of chronological order so an insertion-order result
|
||||
// would come back oldest, newest, middle.
|
||||
db.BlogPosts.Add(NewPost(author, "Old news", oldest));
|
||||
db.BlogPosts.Add(NewPost(author, "New news", newest));
|
||||
db.BlogPosts.Add(NewPost(author, "Mid news", middle));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
PaginatedResponse<BlogPost> result = await service.GetAllBlogPostsAsync(
|
||||
new GetBlogPostsFilteredRequest { Author = author, PageSize = 50 });
|
||||
|
||||
List<DateTime> dates = [.. result.Items.Select(post => post.DateCreated)];
|
||||
|
||||
Assert.Equal([newest, middle, oldest], dates);
|
||||
for (int i = 1; i < dates.Count; i++)
|
||||
{
|
||||
Assert.True(dates[i] <= dates[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllBlogPostsAsync_ExplicitAscendingSort_OverridesDefault()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IBlogPostService service = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
string author = $"author-{Guid.NewGuid():N}";
|
||||
DateTime oldest = new(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime newest = new(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime middle = new(2026, 2, 5, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
db.BlogPosts.Add(NewPost(author, "New news", newest));
|
||||
db.BlogPosts.Add(NewPost(author, "Old news", oldest));
|
||||
db.BlogPosts.Add(NewPost(author, "Mid news", middle));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
PaginatedResponse<BlogPost> result = await service.GetAllBlogPostsAsync(
|
||||
new GetBlogPostsFilteredRequest
|
||||
{
|
||||
Author = author,
|
||||
PageSize = 50,
|
||||
OrderBy = nameof(EntityBase.DateCreated),
|
||||
Order = SortOrder.Ascending,
|
||||
});
|
||||
|
||||
List<DateTime> dates = [.. result.Items.Select(post => post.DateCreated)];
|
||||
|
||||
Assert.Equal([oldest, middle, newest], dates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllBlogPostsAsync_PublicCaller_StillHidesDrafts_Regression()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IBlogPostService service = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
string author = $"author-{Guid.NewGuid():N}";
|
||||
db.BlogPosts.Add(NewPost(author, "Published", new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
BlogPost draft = NewPost(author, "Draft", new DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc));
|
||||
draft.IsPublished = false;
|
||||
db.BlogPosts.Add(draft);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
PaginatedResponse<BlogPost> result = await service.GetAllBlogPostsAsync(
|
||||
new GetBlogPostsFilteredRequest { Author = author, PageSize = 50 });
|
||||
|
||||
Assert.Single(result.Items);
|
||||
Assert.All(result.Items, post => Assert.True(post.IsPublished));
|
||||
}
|
||||
|
||||
private static BlogPost NewPost(string author, string title, DateTime createdAt)
|
||||
{
|
||||
return new BlogPost
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "test",
|
||||
DateCreated = createdAt,
|
||||
Author = author,
|
||||
Title = title,
|
||||
Slug = $"{title.ToLowerInvariant().Replace(' ', '-')}-{Guid.NewGuid():N}",
|
||||
MarkdownText = "Body.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies BlogPostService's slug support: CreateBlogPostAsync generates a
|
||||
/// unique slug from the post's Title, and GetBlogPostByIdOrSlugAsync resolves
|
||||
/// a post by either its GUID id or its slug. Exercised at the service layer
|
||||
/// (resolved directly from DI) rather than through BlogPostController,
|
||||
/// because the controller's constructor-injected SupabaseHelper eagerly
|
||||
/// opens a Supabase Realtime websocket connection and cannot boot in a
|
||||
/// sandboxed test environment — see SupabaseDependentControllerNotFoundTests'
|
||||
/// doc comment for the same testability gap on this controller's not-found
|
||||
/// case.
|
||||
/// </summary>
|
||||
public class BlogPostServiceSlugTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public BlogPostServiceSlugTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBlogPostAsync_GeneratesSlugFromTitle()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService blogPostService = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
BlogPost created = await blogPostService.CreateBlogPostAsync(new BlogPost
|
||||
{
|
||||
Author = "Autor de Prueba",
|
||||
Title = $"Título con Ñandú {Guid.NewGuid():N}",
|
||||
MarkdownText = "contenido",
|
||||
CreatedBy = "test",
|
||||
Slug = null!,
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(created.Slug));
|
||||
Assert.DoesNotContain(' ', created.Slug);
|
||||
Assert.Equal(created.Slug, created.Slug.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBlogPostAsync_DuplicateTitle_AppendsSuffixToSlug()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService blogPostService = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
string sharedTitle = $"Mismo Titulo {Guid.NewGuid():N}";
|
||||
|
||||
BlogPost first = await blogPostService.CreateBlogPostAsync(new BlogPost
|
||||
{
|
||||
Author = "Autor Uno",
|
||||
Title = sharedTitle,
|
||||
MarkdownText = "contenido uno",
|
||||
CreatedBy = "test",
|
||||
Slug = null!,
|
||||
});
|
||||
|
||||
BlogPost second = await blogPostService.CreateBlogPostAsync(new BlogPost
|
||||
{
|
||||
Author = "Autor Dos",
|
||||
Title = sharedTitle,
|
||||
MarkdownText = "contenido dos",
|
||||
CreatedBy = "test",
|
||||
Slug = null!,
|
||||
});
|
||||
|
||||
Assert.NotEqual(first.Slug, second.Slug);
|
||||
Assert.Equal($"{first.Slug}-2", second.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlogPostByIdOrSlugAsync_ResolvesByGuidId()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService blogPostService = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
BlogPost created = await blogPostService.CreateBlogPostAsync(new BlogPost
|
||||
{
|
||||
Author = "Autor",
|
||||
Title = $"Post por id {Guid.NewGuid():N}",
|
||||
MarkdownText = "contenido",
|
||||
CreatedBy = "test",
|
||||
Slug = null!,
|
||||
});
|
||||
|
||||
BlogPost? found = await blogPostService.GetBlogPostByIdOrSlugAsync(created.Id.ToString());
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(created.Id, found!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlogPostByIdOrSlugAsync_ResolvesBySlug()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService blogPostService = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
BlogPost created = await blogPostService.CreateBlogPostAsync(new BlogPost
|
||||
{
|
||||
Author = "Autor",
|
||||
Title = $"Post por slug {Guid.NewGuid():N}",
|
||||
MarkdownText = "contenido",
|
||||
CreatedBy = "test",
|
||||
Slug = null!,
|
||||
});
|
||||
|
||||
BlogPost? found = await blogPostService.GetBlogPostByIdOrSlugAsync(created.Slug);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(created.Id, found!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlogPostByIdOrSlugAsync_UnknownSlug_ReturnsNull()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IBlogPostService blogPostService = scope.ServiceProvider.GetRequiredService<IBlogPostService>();
|
||||
|
||||
BlogPost? found = await blogPostService.GetBlogPostByIdOrSlugAsync($"unknown-slug-{Guid.NewGuid():N}");
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using API.AutoMapperProfiles;
|
||||
using API.Controllers;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.BlogPosts.Request;
|
||||
using Application.DTOs.BlogPosts.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the blog-post view counter (QA wave 1, Bug 3): the counter must
|
||||
/// increment exactly ONCE per genuine PUBLIC read of a post, and must NOT be
|
||||
/// inflated by an Admin/Owner opening the post to preview or edit it. Exercised
|
||||
/// as a direct-controller test because BlogPostController takes a
|
||||
/// constructor-injected SupabaseHelper (live-connection ctor) that the
|
||||
/// not-found branch and this read path never touch — the same documented host
|
||||
/// testability gap as SupabaseDependentControllerNotFoundTests.
|
||||
/// </summary>
|
||||
public class BlogPostViewCounterTests
|
||||
{
|
||||
private static readonly IMapper Mapper = new MapperConfiguration(
|
||||
cfg => cfg.AddProfile<BlogPostProfile>(), NullLoggerFactory.Instance).CreateMapper();
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlogPostById_PublicView_IncrementsViewsExactlyOnce()
|
||||
{
|
||||
RecordingBlogPostService service = new(Published(views: 7));
|
||||
BlogPostController controller = WithUser(new BlogPostController(service, null!, Mapper), roles: []);
|
||||
|
||||
ActionResult<BlogPostResponse> result = await controller.GetBlogPostById("some-post");
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
BlogPostResponse response = Assert.IsType<BlogPostResponse>(ok.Value);
|
||||
|
||||
Assert.Equal(8, response.Views);
|
||||
Assert.Equal(1, service.UpdateCount);
|
||||
Assert.Equal(8, service.LastPersistedViews);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlogPostById_AdminView_DoesNotIncrementViews()
|
||||
{
|
||||
RecordingBlogPostService service = new(Published(views: 7));
|
||||
BlogPostController controller = WithUser(
|
||||
new BlogPostController(service, null!, Mapper), roles: [Roles.Admin]);
|
||||
|
||||
ActionResult<BlogPostResponse> result = await controller.GetBlogPostById("some-post");
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
BlogPostResponse response = Assert.IsType<BlogPostResponse>(ok.Value);
|
||||
|
||||
Assert.Equal(7, response.Views);
|
||||
Assert.Equal(0, service.UpdateCount);
|
||||
}
|
||||
|
||||
private static BlogPost Published(int views) => new()
|
||||
{
|
||||
Author = "Club 12",
|
||||
Title = "A public post",
|
||||
Slug = "a-public-post",
|
||||
MarkdownText = "body",
|
||||
Views = views,
|
||||
IsPublished = true,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
private static BlogPostController WithUser(BlogPostController controller, string[] roles)
|
||||
{
|
||||
List<Claim> claims = [.. roles.Select(role => new Claim(ClaimTypes.Role, role))];
|
||||
ClaimsPrincipal user = new(new ClaimsIdentity(claims, roles.Length > 0 ? "TestAuth" : null));
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = user },
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private sealed class RecordingBlogPostService(BlogPost post) : IBlogPostService
|
||||
{
|
||||
public int UpdateCount { get; private set; }
|
||||
public int LastPersistedViews { get; private set; }
|
||||
|
||||
public Task<BlogPost?> GetBlogPostByIdOrSlugAsync(string idOrSlug, bool includeUnpublished = false)
|
||||
=> Task.FromResult<BlogPost?>(post);
|
||||
|
||||
public Task UpdateBlogPostAsync(BlogPost blogPostEntity)
|
||||
{
|
||||
UpdateCount++;
|
||||
LastPersistedViews = blogPostEntity.Views;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<BlogPost> CreateBlogPostAsync(BlogPost blogPostEntity)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public Task<BlogPost?> GetBlogPostByIdAsync(System.Guid blogPostId)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public Task DeleteBlogPostAsync(System.Guid id) => throw new System.NotImplementedException();
|
||||
|
||||
public Task<PaginatedResponse<BlogPost>> GetAllBlogPostsAsync(
|
||||
GetBlogPostsFilteredRequest filter, bool includeUnpublished = false)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Helper.Playoff;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers StageService.CommitDrawAsync's private re-draw guard
|
||||
/// (EnsureBracketDrawableAsync): a bracket may be drawn or re-drawn only
|
||||
/// while every real match of that Stage.DivisionId + Stage.BracketName is
|
||||
/// unplayed, byes and empty slots never count as played, and parallel
|
||||
/// brackets under different BracketName values lock independently.
|
||||
/// </summary>
|
||||
public class BracketRedrawGuardTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public BracketRedrawGuardTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBracketDrawableAsync_NoPlayedMatches_Allowed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
|
||||
List<Match> committed = await stageService.CommitDrawAsync(
|
||||
stage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]);
|
||||
|
||||
Assert.Equal(2, committed.Count);
|
||||
}
|
||||
|
||||
public static readonly TheoryData<bool, int?, int?, MatchStatus> PlayedTriggers = new()
|
||||
{
|
||||
{ true, null, null, MatchStatus.Scheduled },
|
||||
{ false, 10, 8, MatchStatus.Scheduled },
|
||||
{ false, null, null, MatchStatus.Played },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(PlayedTriggers))]
|
||||
public async Task EnsureBracketDrawableAsync_OneMatchFinishedOrScored_Rejected(
|
||||
bool isFinished, int? homeScore, int? visitorScore, MatchStatus status)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedRealMatchAsync(db, stage, teams[0], teams[1], isFinished, homeScore, visitorScore, status);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBracketDrawableAsync_ByeMatchesDoNotCountAsPlayed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 3);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
|
||||
List<Guid> firstOrder = [.. teams.Select(t => t.Id)];
|
||||
await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: firstOrder);
|
||||
|
||||
List<Guid> reversedOrder = [.. firstOrder.AsEnumerable().Reverse()];
|
||||
List<Match> reDrawn = await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: reversedOrder);
|
||||
|
||||
Assert.Equal(2, reDrawn.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBracketDrawableAsync_ParallelBracketsLockIndependently()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
|
||||
Stage goldStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa de Oro", bestOf: 1);
|
||||
await SeedRealMatchAsync(db, goldStage, teams[0], teams[1], isFinished: true, homeScore: null, visitorScore: null, status: MatchStatus.Scheduled);
|
||||
await SeedEmptyMatchAsync(db, goldStage);
|
||||
|
||||
Stage silverStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa de Plata", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, silverStage);
|
||||
await SeedEmptyMatchAsync(db, silverStage);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => stageService.CommitDrawAsync(goldStage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]));
|
||||
|
||||
List<Match> silverCommitted = await stageService.CommitDrawAsync(
|
||||
silverStage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]);
|
||||
|
||||
Assert.Equal(2, silverCommitted.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_ReDraw_ResetsPriorSeedingAndSeries()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
await SeedEmptyMatchAsync(db, stage);
|
||||
|
||||
List<Guid> firstOrder = [teams[0].Id, teams[1].Id, teams[2].Id, teams[3].Id];
|
||||
await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: firstOrder);
|
||||
|
||||
List<Guid> firstSeriesIds = await db.MatchSeries.Where(s => s.StageId == stage.Id).Select(s => s.Id).ToListAsync();
|
||||
Assert.Equal(2, firstSeriesIds.Count);
|
||||
|
||||
List<Guid> secondOrder = [teams[3].Id, teams[2].Id, teams[1].Id, teams[0].Id];
|
||||
List<Match> reCommitted = await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: secondOrder);
|
||||
|
||||
List<MatchSeries> secondSeries = await db.MatchSeries.Where(s => s.StageId == stage.Id).ToListAsync();
|
||||
Assert.Equal(2, secondSeries.Count);
|
||||
Assert.DoesNotContain(secondSeries, s => firstSeriesIds.Contains(s.Id));
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> expectedPairs = PlayoffSeeder.SeedPairs(secondOrder);
|
||||
List<Match> ordered = [.. reCommitted.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
Assert.Equal(expectedPairs[i].HomeTeamId, ordered[i].HomeTeamId);
|
||||
Assert.Equal(expectedPairs[i].VisitorTeamId, ordered[i].VisitorTeamId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Bracket re-draw guard test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAndRegisterAsync(
|
||||
ApplicationDBContext db, Tournament tournament, Division division, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration
|
||||
{
|
||||
TeamId = team.Id,
|
||||
DivisionId = division.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, string? bracketName, int bestOf)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
BracketName = bracketName,
|
||||
BestOf = bestOf,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedEmptyMatchAsync(ApplicationDBContext db, Stage stage)
|
||||
{
|
||||
int existingCount = await db.Matches.CountAsync(m => m.StageId == stage.Id);
|
||||
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate.AddMinutes(existingCount),
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedRealMatchAsync(
|
||||
ApplicationDBContext db, Stage stage, Team home, Team visitor,
|
||||
bool isFinished, int? homeScore, int? visitorScore, MatchStatus status)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = isFinished,
|
||||
Status = status,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
using Application.DTOs.Champions.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers IChampionService podium computation: a division WITH a playoff is
|
||||
/// crowned by its top cup's Final (winner 1st, loser 2nd) and its third-place
|
||||
/// match (3rd); a group-only division is crowned by its standings top three;
|
||||
/// an undecided Final yields no champion; and a best-of-N Final is decided by
|
||||
/// its MatchSeries winner. Also covers the multi-cup "top cup" selection and
|
||||
/// the finished-tournament champions history.
|
||||
/// </summary>
|
||||
public class ChampionServiceTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public ChampionServiceTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_PlayoffDivision_ReturnsFinalWinnerRunnerUpAndThird()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: null);
|
||||
Stage thirdPlaceStage = await SeedStageAsync(db, division, tournament, StageType.ThirdPlace, bracketName: null);
|
||||
|
||||
// teams[0] beats teams[1] in the Final; teams[2] beats teams[3] for third place.
|
||||
await SeedFinishedMatchAsync(db, finalStage, teams[0], teams[1], 88, 70);
|
||||
await SeedFinishedMatchAsync(db, thirdPlaceStage, teams[2], teams[3], 60, 55);
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.True(podium!.HasPlayoff);
|
||||
Assert.Equal(teams[0].Id, podium.First!.TeamId);
|
||||
Assert.Equal(teams[1].Id, podium.Second!.TeamId);
|
||||
Assert.Equal(teams[2].Id, podium.Third!.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_GroupOnlyDivision_ReturnsStandingsTopThree()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null);
|
||||
|
||||
// A ladder round-robin where the lower index always wins yields the exact
|
||||
// standings teams[0] > teams[1] > teams[2] > teams[3].
|
||||
for (int i = 0; i < teams.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < teams.Count; j++)
|
||||
{
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[i], teams[j], 90, 80);
|
||||
}
|
||||
}
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.False(podium!.HasPlayoff);
|
||||
Assert.Equal(teams[0].Id, podium.First!.TeamId);
|
||||
Assert.Equal(teams[1].Id, podium.Second!.TeamId);
|
||||
Assert.Equal(teams[2].Id, podium.Third!.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_FinalOnlyCupWithNoThirdPlaceStage_FallsBackToStandingsThird()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
|
||||
// A round-robin group stage decides the full standings...
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null);
|
||||
for (int i = 0; i < teams.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < teams.Count; j++)
|
||||
{
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[i], teams[j], 90, 80);
|
||||
}
|
||||
}
|
||||
|
||||
// ...and a bare Final (no SemiFinal, no ThirdPlace stage) crowns the
|
||||
// top two into a "Copa de Oro"-style title decider — there is no
|
||||
// bracket round to draw a real third place from, so it must fall
|
||||
// back to the group standings' position 3 (teams[2]) instead of
|
||||
// leaving the podium's third place empty.
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, finalStage, teams[0], teams[1], 88, 70);
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.True(podium!.HasPlayoff);
|
||||
Assert.Equal(teams[0].Id, podium.First!.TeamId);
|
||||
Assert.Equal(teams[1].Id, podium.Second!.TeamId);
|
||||
Assert.Equal(teams[2].Id, podium.Third!.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_DeepBracketWithNoThirdPlaceStage_KeepsThirdEmpty()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
|
||||
// A real bracket (SemiFinal -> Final) that opted out of a third-place
|
||||
// match is a deliberate two-team podium — it must NOT be papered over
|
||||
// with a standings-based third, even though standings would have one.
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null);
|
||||
for (int i = 0; i < teams.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < teams.Count; j++)
|
||||
{
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[i], teams[j], 90, 80);
|
||||
}
|
||||
}
|
||||
|
||||
Stage semiStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, semiStage, teams[0], teams[3], 70, 60);
|
||||
await SeedFinishedMatchAsync(db, semiStage, teams[1], teams[2], 65, 62);
|
||||
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, finalStage, teams[0], teams[1], 88, 70);
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.True(podium!.HasPlayoff);
|
||||
Assert.Equal(teams[0].Id, podium.First!.TeamId);
|
||||
Assert.Equal(teams[1].Id, podium.Second!.TeamId);
|
||||
Assert.Null(podium.Third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_UndecidedFinal_ReturnsNoChampion()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: null);
|
||||
await SeedUnfinishedMatchAsync(db, finalStage, teams[0], teams[1]);
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.True(podium!.HasPlayoff);
|
||||
Assert.Null(podium.First);
|
||||
Assert.Null(podium.Second);
|
||||
Assert.Null(podium.Third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_BestOfSeriesFinal_UsesSeriesWinner()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: null, bestOf: 3);
|
||||
|
||||
// The series is won by teams[1] even though the (only recorded) individual
|
||||
// game shows teams[0] winning — the podium must trust the series winner.
|
||||
await SeedDecidedSeriesAsync(db, finalStage, teams[1], teams[0], bestOf: 3);
|
||||
await SeedFinishedMatchAsync(db, finalStage, teams[0], teams[1], 80, 70);
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.True(podium!.HasPlayoff);
|
||||
Assert.Equal(teams[1].Id, podium.First!.TeamId);
|
||||
Assert.Equal(teams[0].Id, podium.Second!.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionPodiumAsync_MultipleCups_CrownsTopCupFinalWinner()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
|
||||
// Position 1 maps to "Copa Oro", so it is the top cup; "Copa Plata" is secondary.
|
||||
await SeedMappingAsync(db, division, 1, 2, "Copa Oro");
|
||||
await SeedMappingAsync(db, division, 3, 4, "Copa Plata");
|
||||
|
||||
Stage goldFinal = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Oro");
|
||||
Stage silverFinal = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Plata");
|
||||
|
||||
await SeedFinishedMatchAsync(db, goldFinal, teams[0], teams[1], 88, 70);
|
||||
await SeedFinishedMatchAsync(db, silverFinal, teams[2], teams[3], 66, 60);
|
||||
|
||||
PodiumResponse? podium = await championService.GetDivisionPodiumAsync(division.Id);
|
||||
|
||||
Assert.NotNull(podium);
|
||||
Assert.True(podium!.HasPlayoff);
|
||||
Assert.Equal(teams[0].Id, podium.First!.TeamId);
|
||||
Assert.Equal(teams[1].Id, podium.Second!.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChampionsHistoryAsync_OnlyFinishedTournaments_ReturnsDecidedChampions()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
// A finished tournament with a decided playoff division.
|
||||
Tournament finished = await SeedTournamentAsync(db, TournamentStatus.Finished);
|
||||
Division finishedDivision = await SeedDivisionAsync(db, finished);
|
||||
List<Team> finishedTeams = await SeedTeamsAsync(db, finished, 2);
|
||||
Stage finishedFinal = await SeedStageAsync(db, finishedDivision, finished, StageType.Final, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, finishedFinal, finishedTeams[0], finishedTeams[1], 88, 70);
|
||||
|
||||
// An ongoing tournament must never appear in the history.
|
||||
Tournament ongoing = await SeedTournamentAsync(db, TournamentStatus.Ongoing);
|
||||
Division ongoingDivision = await SeedDivisionAsync(db, ongoing);
|
||||
List<Team> ongoingTeams = await SeedTeamsAsync(db, ongoing, 2);
|
||||
Stage ongoingFinal = await SeedStageAsync(db, ongoingDivision, ongoing, StageType.Final, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, ongoingFinal, ongoingTeams[0], ongoingTeams[1], 90, 80);
|
||||
|
||||
List<ChampionHistoryResponse> history = await championService.GetChampionsHistoryAsync(seasonId: null);
|
||||
|
||||
Assert.Contains(history, entry =>
|
||||
entry.TournamentId == finished.Id
|
||||
&& entry.DivisionName == finishedDivision.Name
|
||||
&& entry.ChampionTeam.TeamId == finishedTeams[0].Id);
|
||||
Assert.DoesNotContain(history, entry => entry.TournamentId == ongoing.Id);
|
||||
|
||||
// A single-bracket division crowns one champion with no sub-cup label.
|
||||
ChampionHistoryResponse single = history.Single(entry =>
|
||||
entry.TournamentId == finished.Id && entry.DivisionName == finishedDivision.Name);
|
||||
Assert.Null(single.CupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChampionsHistoryAsync_MultipleCups_ReturnsOneChampionPerCup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Finished);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
|
||||
// Two sub-cups: Copa Oro (top tier, seeded from position 1) and Copa Plata.
|
||||
await SeedMappingAsync(db, division, 1, 2, "Copa Oro");
|
||||
await SeedMappingAsync(db, division, 3, 4, "Copa Plata");
|
||||
|
||||
Stage goldFinal = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Oro");
|
||||
Stage silverFinal = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Plata");
|
||||
|
||||
await SeedFinishedMatchAsync(db, goldFinal, teams[0], teams[1], 88, 70);
|
||||
await SeedFinishedMatchAsync(db, silverFinal, teams[2], teams[3], 66, 60);
|
||||
|
||||
List<ChampionHistoryResponse> history = await championService.GetChampionsHistoryAsync(seasonId: null);
|
||||
|
||||
List<ChampionHistoryResponse> divisionChampions = [.. history
|
||||
.Where(entry => entry.TournamentId == tournament.Id && entry.DivisionName == division.Name)];
|
||||
|
||||
// Both cups crown a champion: Copa Oro → teams[0], Copa Plata → teams[2].
|
||||
Assert.Equal(2, divisionChampions.Count);
|
||||
Assert.Contains(divisionChampions, e => e.CupName == "Copa Oro" && e.ChampionTeam.TeamId == teams[0].Id);
|
||||
Assert.Contains(divisionChampions, e => e.CupName == "Copa Plata" && e.ChampionTeam.TeamId == teams[2].Id);
|
||||
|
||||
// The top cup (Copa Oro) is listed before the lower tier (Copa Plata).
|
||||
Assert.Equal("Copa Oro", divisionChampions[0].CupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChampionsHistoryAsync_CarriesSeasonYear()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IChampionService championService = scope.ServiceProvider.GetRequiredService<IChampionService>();
|
||||
|
||||
Season season = await SeedSeasonAsync(db, year: 2026);
|
||||
Tournament inSeason = await SeedTournamentAsync(db, TournamentStatus.Finished, seasonId: season.Id);
|
||||
Division seasonDivision = await SeedDivisionAsync(db, inSeason);
|
||||
List<Team> seasonTeams = await SeedTeamsAsync(db, inSeason, 2);
|
||||
Stage seasonFinal = await SeedStageAsync(db, seasonDivision, inSeason, StageType.Final, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, seasonFinal, seasonTeams[0], seasonTeams[1], 88, 70);
|
||||
|
||||
// A finished tournament with no season at all.
|
||||
Tournament noSeason = await SeedTournamentAsync(db, TournamentStatus.Finished);
|
||||
Division noSeasonDivision = await SeedDivisionAsync(db, noSeason);
|
||||
List<Team> noSeasonTeams = await SeedTeamsAsync(db, noSeason, 2);
|
||||
Stage noSeasonFinal = await SeedStageAsync(db, noSeasonDivision, noSeason, StageType.Final, bracketName: null);
|
||||
await SeedFinishedMatchAsync(db, noSeasonFinal, noSeasonTeams[0], noSeasonTeams[1], 90, 80);
|
||||
|
||||
List<ChampionHistoryResponse> history = await championService.GetChampionsHistoryAsync(seasonId: null);
|
||||
|
||||
Assert.Equal(2026, history.Single(entry => entry.TournamentId == inSeason.Id).SeasonYear);
|
||||
Assert.Null(history.Single(entry => entry.TournamentId == noSeason.Id).SeasonYear);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(
|
||||
ApplicationDBContext db,
|
||||
TournamentStatus status = TournamentStatus.OpenForRegistration,
|
||||
Guid? seasonId = null)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Champion test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Status = status,
|
||||
SeasonId = seasonId,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Season> SeedSeasonAsync(ApplicationDBContext db, int? year)
|
||||
{
|
||||
Season season = new()
|
||||
{
|
||||
Name = $"Temporada {year?.ToString() ?? Guid.NewGuid().ToString("N")[..6]}",
|
||||
Slug = $"temporada-{Guid.NewGuid()}",
|
||||
Year = year,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Seasons.Add(season);
|
||||
await db.SaveChangesAsync();
|
||||
return season;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task SeedMappingAsync(ApplicationDBContext db, Division division, int from, int to, string destination)
|
||||
{
|
||||
db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping
|
||||
{
|
||||
DivisionId = division.Id,
|
||||
FromPosition = from,
|
||||
ToPosition = to,
|
||||
Destination = destination,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, string? bracketName, int bestOf = 1)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"{stageType}-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
IsElimination = stageType != StageType.Group,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
BracketName = bracketName,
|
||||
BestOf = bestOf,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedFinishedMatchAsync(
|
||||
ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = true,
|
||||
WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedUnfinishedMatchAsync(ApplicationDBContext db, Stage stage, Team home, Team visitor)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedDecidedSeriesAsync(
|
||||
ApplicationDBContext db, Stage stage, Team winner, Team loser, int bestOf)
|
||||
{
|
||||
db.MatchSeries.Add(new MatchSeries
|
||||
{
|
||||
StageId = stage.Id,
|
||||
HomeTeamId = winner.Id,
|
||||
VisitorTeamId = loser.Id,
|
||||
BestOf = bestOf,
|
||||
WinningTeamId = winner.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
using Application.DTOs.Club.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the stable cross-season club identity (HU-99): the idempotent
|
||||
/// backfill collapses same-named season teams onto a single
|
||||
/// <see cref="Club"/>, and club history resolves every per-season team through
|
||||
/// the existing <see cref="TeamTournamentRegistration"/> join. Exercises the
|
||||
/// real <see cref="IClubService"/> through DI scopes against the
|
||||
/// CustomWebApplicationFactory's SQLite-backed ApplicationDBContext, matching
|
||||
/// this project's established integration-test style.
|
||||
/// </summary>
|
||||
public class ClubTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public ClubTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two same-named teams from different seasons (the "Colón SF 2026" /
|
||||
/// "Colón SF 2027" case) are linked to ONE club, while a distinctly-named
|
||||
/// team gets its own — so a club's identity is stable across seasons.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Backfill_LinksSameNamedTeamsToOneClub_AndSeparatesDistinctNames()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
string sharedName = $"Colón SF {Guid.NewGuid()}";
|
||||
Team season2026 = await SeedTeamAsync(seedDb, name: sharedName);
|
||||
Team season2027 = await SeedTeamAsync(seedDb, name: sharedName);
|
||||
Team otherClub = await SeedTeamAsync(seedDb, name: $"Unión {Guid.NewGuid()}");
|
||||
|
||||
await BackfillAsync();
|
||||
|
||||
Team linked2026 = await ReadTeamAsync(season2026.Id);
|
||||
Team linked2027 = await ReadTeamAsync(season2027.Id);
|
||||
Team linkedOther = await ReadTeamAsync(otherClub.Id);
|
||||
|
||||
Assert.NotNull(linked2026.ClubId);
|
||||
Assert.NotNull(linked2027.ClubId);
|
||||
Assert.NotNull(linkedOther.ClubId);
|
||||
|
||||
// Same name → same stable club.
|
||||
Assert.Equal(linked2026.ClubId, linked2027.ClubId);
|
||||
// Different name → different club.
|
||||
Assert.NotEqual(linked2026.ClubId, linkedOther.ClubId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The backfill is idempotent: a second run creates no new clubs and links
|
||||
/// no additional teams (every team already carries a ClubId).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Backfill_IsIdempotent_SecondRunIsNoOp()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
string sharedName = $"Central {Guid.NewGuid()}";
|
||||
await SeedTeamAsync(seedDb, name: sharedName);
|
||||
await SeedTeamAsync(seedDb, name: sharedName);
|
||||
|
||||
// First run links the two teams and creates exactly one club for them.
|
||||
ClubBackfillResult first = await BackfillAsync();
|
||||
Assert.True(first.TeamsLinked >= 2);
|
||||
|
||||
int clubsAfterFirst = await CountClubsAsync();
|
||||
|
||||
// Second run must change nothing.
|
||||
ClubBackfillResult second = await BackfillAsync();
|
||||
|
||||
Assert.Equal(0, second.ClubsCreated);
|
||||
Assert.Equal(0, second.TeamsLinked);
|
||||
Assert.Equal(clubsAfterFirst, await CountClubsAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Club history returns the per-season teams that belong to the club, each
|
||||
/// with the tournaments (seasons) it was registered in.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetClubHistory_ReturnsPerSeasonTeamsWithTheirSeasons()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament season2026 = await SeedTournamentAsync(seedDb, "Season 2026");
|
||||
Tournament season2027 = await SeedTournamentAsync(seedDb, "Season 2027");
|
||||
|
||||
string sharedName = $"Racing {Guid.NewGuid()}";
|
||||
Team team2026 = await SeedTeamAsync(seedDb, name: sharedName, tournamentId: season2026.Id);
|
||||
await SeedTournamentRegistrationAsync(seedDb, team2026, season2026);
|
||||
Team team2027 = await SeedTeamAsync(seedDb, name: sharedName, tournamentId: season2027.Id);
|
||||
await SeedTournamentRegistrationAsync(seedDb, team2027, season2027);
|
||||
|
||||
await BackfillAsync();
|
||||
|
||||
Team linked = await ReadTeamAsync(team2026.Id);
|
||||
ClubHistoryResponse? history = await GetHistoryAsync(linked.ClubId!.Value.ToString());
|
||||
|
||||
Assert.NotNull(history);
|
||||
// Both season teams belong to the same club.
|
||||
Assert.Equal(2, history!.Teams.Count);
|
||||
Assert.Contains(history.Teams, t => t.TeamId == team2026.Id);
|
||||
Assert.Contains(history.Teams, t => t.TeamId == team2027.Id);
|
||||
|
||||
// Each per-season team surfaces the season it was registered in.
|
||||
ClubTeamSeasonResponse entry2026 = history.Teams.Single(t => t.TeamId == team2026.Id);
|
||||
Assert.Contains(entry2026.Seasons, s => s.TournamentId == season2026.Id);
|
||||
|
||||
ClubTeamSeasonResponse entry2027 = history.Teams.Single(t => t.TeamId == team2027.Id);
|
||||
Assert.Contains(entry2027.Seasons, s => s.TournamentId == season2027.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Club history returns each team's seasons ordered by the tournament's
|
||||
/// start date, most recent first, and every season entry carries that
|
||||
/// start date so the history page can sort a flattened row list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetClubHistory_OrdersSeasonsByStartDateDescending()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
DateTime older = new(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime newer = new(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
Tournament seasonOlder = await SeedTournamentAsync(seedDb, "Ordering Older", older);
|
||||
Tournament seasonNewer = await SeedTournamentAsync(seedDb, "Ordering Newer", newer);
|
||||
|
||||
Team team = await SeedTeamAsync(
|
||||
seedDb, name: $"Sorting FC {Guid.NewGuid()}", tournamentId: seasonOlder.Id);
|
||||
// Registered in the OLDER tournament first, then the newer one — so
|
||||
// insertion order is the opposite of the expected result.
|
||||
await SeedTournamentRegistrationAsync(seedDb, team, seasonOlder);
|
||||
await SeedTournamentRegistrationAsync(seedDb, team, seasonNewer);
|
||||
|
||||
await BackfillAsync();
|
||||
|
||||
Team linked = await ReadTeamAsync(team.Id);
|
||||
ClubHistoryResponse? history = await GetHistoryAsync(linked.ClubId!.Value.ToString());
|
||||
|
||||
Assert.NotNull(history);
|
||||
ClubTeamSeasonResponse entry = history!.Teams.Single(t => t.TeamId == team.Id);
|
||||
|
||||
Assert.Equal([newer, older], entry.Seasons.Select(season => season.StartDate));
|
||||
Assert.Equal(newer, entry.Seasons.Single(s => s.TournamentId == seasonNewer.Id).StartDate);
|
||||
Assert.Equal(older, entry.Seasons.Single(s => s.TournamentId == seasonOlder.Id).StartDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A newly created team is linked to a club immediately — the roster
|
||||
/// import feature (HU-53) must have a club history to search from day
|
||||
/// one, not only after someone remembers to run the bulk backfill.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateTeam_IsLinkedToAClub_Immediately()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITeamService teamService = scope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
|
||||
Team created = await teamService.CreateTeamAsync(NewUnsavedTeam($"Newell's {Guid.NewGuid()}"));
|
||||
|
||||
Team persisted = await ReadTeamAsync(created.Id);
|
||||
Assert.NotNull(persisted.ClubId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A second team created later with the same name joins the SAME club as
|
||||
/// the first, instead of getting its own — mirroring the backfill's
|
||||
/// same-name-collapses-onto-one-club rule (HU-99).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateTeam_WithNameOfExistingClub_JoinsTheSameClub()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITeamService teamService = scope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
|
||||
string sharedName = $"Boca Paraná {Guid.NewGuid()}";
|
||||
Team first = await teamService.CreateTeamAsync(NewUnsavedTeam(sharedName));
|
||||
Team second = await teamService.CreateTeamAsync(NewUnsavedTeam(sharedName));
|
||||
|
||||
Team linkedFirst = await ReadTeamAsync(first.Id);
|
||||
Team linkedSecond = await ReadTeamAsync(second.Id);
|
||||
|
||||
Assert.NotNull(linkedFirst.ClubId);
|
||||
Assert.Equal(linkedFirst.ClubId, linkedSecond.ClubId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linking a squad club to a parent institution club shows up both ways:
|
||||
/// the squad's history carries the parent, and the parent's history lists
|
||||
/// the squad among its children.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LinkClubToParent_SetsParentAndListsChildOnBothSides()
|
||||
{
|
||||
Club parent = await SeedClubAsync($"Echagüe {Guid.NewGuid()}");
|
||||
Club child = await SeedClubAsync($"Echagüe B {Guid.NewGuid()}");
|
||||
|
||||
ClubHistoryResponse childHistory = await LinkParentAsync(child.Id, parent.Id);
|
||||
|
||||
Assert.NotNull(childHistory.ParentClub);
|
||||
Assert.Equal(parent.Id, childHistory.ParentClub!.Id);
|
||||
|
||||
ClubHistoryResponse? parentHistory = await GetHistoryAsync(parent.Id.ToString());
|
||||
Assert.NotNull(parentHistory);
|
||||
Assert.Contains(parentHistory!.ChildClubs, c => c.Id == child.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A club can share a parent institution with a squad of the OTHER gender
|
||||
/// category — club linking is purely an institutional grouping and has no
|
||||
/// notion of tournament category, since that lives on the Tournament, not
|
||||
/// the Club.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LinkClubToParent_AllowsSquadsOfDifferentTournamentCategories()
|
||||
{
|
||||
Club parent = await SeedClubAsync($"Echagüe {Guid.NewGuid()}");
|
||||
Club masculineSquad = await SeedClubAsync($"Echagüe Primera {Guid.NewGuid()}");
|
||||
Club feminineSquad = await SeedClubAsync($"Echagüe Femenino {Guid.NewGuid()}");
|
||||
|
||||
await LinkParentAsync(masculineSquad.Id, parent.Id);
|
||||
await LinkParentAsync(feminineSquad.Id, parent.Id);
|
||||
|
||||
ClubHistoryResponse? parentHistory = await GetHistoryAsync(parent.Id.ToString());
|
||||
Assert.NotNull(parentHistory);
|
||||
Assert.Contains(parentHistory!.ChildClubs, c => c.Id == masculineSquad.Id);
|
||||
Assert.Contains(parentHistory.ChildClubs, c => c.Id == feminineSquad.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinkClubToParent_RejectsLinkingAClubToItself()
|
||||
{
|
||||
Club club = await SeedClubAsync($"Self {Guid.NewGuid()}");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => LinkParentAsync(club.Id, club.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tree stays flat, one level deep: a club that is already a squad of
|
||||
/// some institution can't itself become a parent to another club.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LinkClubToParent_RejectsWhenTargetParentIsItselfASquad()
|
||||
{
|
||||
Club grandparent = await SeedClubAsync($"Grandparent {Guid.NewGuid()}");
|
||||
Club parent = await SeedClubAsync($"Parent {Guid.NewGuid()}");
|
||||
Club child = await SeedClubAsync($"Child {Guid.NewGuid()}");
|
||||
|
||||
await LinkParentAsync(parent.Id, grandparent.Id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => LinkParentAsync(child.Id, parent.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reverse of the previous rule: a club that already has squads of its
|
||||
/// own can't become a squad of another club.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LinkClubToParent_RejectsWhenChildAlreadyHasItsOwnSquads()
|
||||
{
|
||||
Club institution = await SeedClubAsync($"Institution {Guid.NewGuid()}");
|
||||
Club squad = await SeedClubAsync($"Squad {Guid.NewGuid()}");
|
||||
await LinkParentAsync(squad.Id, institution.Id);
|
||||
|
||||
Club otherInstitution = await SeedClubAsync($"Other {Guid.NewGuid()}");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => LinkParentAsync(institution.Id, otherInstitution.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnlinkClubParent_ClearsTheParentLink()
|
||||
{
|
||||
Club parent = await SeedClubAsync($"Parent {Guid.NewGuid()}");
|
||||
Club child = await SeedClubAsync($"Child {Guid.NewGuid()}");
|
||||
await LinkParentAsync(child.Id, parent.Id);
|
||||
|
||||
ClubHistoryResponse unlinked = await UnlinkParentAsync(child.Id);
|
||||
|
||||
Assert.Null(unlinked.ParentClub);
|
||||
|
||||
ClubHistoryResponse? parentHistory = await GetHistoryAsync(parent.Id.ToString());
|
||||
Assert.DoesNotContain(parentHistory!.ChildClubs, c => c.Id == child.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renaming a club changes its display name but never its slug, so the
|
||||
/// club's public URL (used to canonicalize the history page) stays
|
||||
/// stable across a rename.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RenameClub_ChangesNameButNeverTheSlug()
|
||||
{
|
||||
Club club = await SeedClubAsync($"Echagüe A {Guid.NewGuid()}");
|
||||
string originalSlug = club.Slug;
|
||||
|
||||
ClubHistoryResponse renamed = await RenameClubAsync(club.Id, "Echagüe");
|
||||
|
||||
Assert.Equal("Echagüe", renamed.Name);
|
||||
Assert.Equal(originalSlug, renamed.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameClub_RejectsABlankName()
|
||||
{
|
||||
Club club = await SeedClubAsync($"Club {Guid.NewGuid()}");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => RenameClubAsync(club.Id, " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteClub_RemovesAClubWithNoTeamsOrSquads()
|
||||
{
|
||||
Club club = await SeedClubAsync($"Empty {Guid.NewGuid()}");
|
||||
|
||||
await DeleteClubAsync(club.Id);
|
||||
|
||||
Assert.Null(await GetHistoryAsync(club.Id.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deleting a club with per-season teams would orphan their history, so it is rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeleteClub_RejectsWhenItHasTeams()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Team team = await SeedTeamAsync(db, name: $"Has Team {Guid.NewGuid()}");
|
||||
await BackfillAsync();
|
||||
Team linked = await ReadTeamAsync(team.Id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => DeleteClubAsync(linked.ClubId!.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deleting a club with linked squad clubs would orphan them, so it is rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeleteClub_RejectsWhenItHasChildClubs()
|
||||
{
|
||||
Club parent = await SeedClubAsync($"Parent {Guid.NewGuid()}");
|
||||
Club child = await SeedClubAsync($"Child {Guid.NewGuid()}");
|
||||
await LinkParentAsync(child.Id, parent.Id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => DeleteClubAsync(parent.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllClubs_ReturnsEveryClubOrderedByName()
|
||||
{
|
||||
string suffix = Guid.NewGuid().ToString();
|
||||
await SeedClubAsync($"Zeta {suffix}");
|
||||
await SeedClubAsync($"Alfa {suffix}");
|
||||
|
||||
IReadOnlyList<ClubSummaryResponse> clubs = await GetAllClubsAsync();
|
||||
|
||||
List<ClubSummaryResponse> matching = [.. clubs.Where(c => c.Name.EndsWith(suffix))];
|
||||
Assert.Equal(2, matching.Count);
|
||||
Assert.Equal(
|
||||
matching.Select(c => c.Name).OrderBy(name => name, StringComparer.Ordinal),
|
||||
matching.Select(c => c.Name));
|
||||
}
|
||||
|
||||
private async Task<Club> SeedClubAsync(string name)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Club club = new()
|
||||
{
|
||||
Name = name,
|
||||
Slug = $"club-{Guid.NewGuid()}",
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return club;
|
||||
}
|
||||
|
||||
private async Task<ClubHistoryResponse> LinkParentAsync(Guid childClubId, Guid parentClubId)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
return await clubService.LinkClubToParentAsync(childClubId, parentClubId);
|
||||
}
|
||||
|
||||
private async Task<ClubHistoryResponse> UnlinkParentAsync(Guid childClubId)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
return await clubService.UnlinkClubParentAsync(childClubId);
|
||||
}
|
||||
|
||||
private async Task<ClubHistoryResponse> RenameClubAsync(Guid clubId, string name)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
return await clubService.RenameClubAsync(clubId, name);
|
||||
}
|
||||
|
||||
private async Task DeleteClubAsync(Guid clubId)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
await clubService.DeleteClubAsync(clubId);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ClubSummaryResponse>> GetAllClubsAsync()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
return [.. await clubService.GetAllClubsAsync()];
|
||||
}
|
||||
|
||||
private static Team NewUnsavedTeam(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
// CreateTeamAsync overwrites this with a generated unique slug.
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
private async Task<ClubBackfillResult> BackfillAsync()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
return await clubService.BackfillClubsAsync();
|
||||
}
|
||||
|
||||
private async Task<ClubHistoryResponse?> GetHistoryAsync(string idOrSlug)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IClubService clubService = scope.ServiceProvider.GetRequiredService<IClubService>();
|
||||
return await clubService.GetClubHistoryAsync(idOrSlug);
|
||||
}
|
||||
|
||||
private async Task<Team> ReadTeamAsync(Guid teamId)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
return await db.Teams.AsNoTracking().SingleAsync(t => t.Id == teamId);
|
||||
}
|
||||
|
||||
private async Task<int> CountClubsAsync()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
return await db.Clubs.AsNoTracking().CountAsync();
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, string name, Guid? tournamentId = null)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = name,
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(
|
||||
ApplicationDBContext db, string name, DateTime? startDate = null)
|
||||
{
|
||||
DateTime start = startDate ?? DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = $"{name} description",
|
||||
Name = $"{name}-{Guid.NewGuid()}",
|
||||
Slug = $"{name.ToLowerInvariant().Replace(' ', '-')}-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = start.AddDays(-1),
|
||||
StartDate = start,
|
||||
Status = TournamentStatus.OpenForRegistration,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task SeedTournamentRegistrationAsync(
|
||||
ApplicationDBContext db, Team team, Tournament tournament)
|
||||
{
|
||||
TeamTournamentRegistration registration = new()
|
||||
{
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournament.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.TeamTournamentRegistrations.Add(registration);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Application.DTOs.Auth.Request;
|
||||
using Application.DTOs.Player.Request;
|
||||
using Application.DTOs.User.Request;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the email / phone DataAnnotations on the request DTOs that collect
|
||||
/// contact data. The [ApiController] filter turns any of these validation
|
||||
/// failures into a 400, so exercising the attributes directly is enough to
|
||||
/// guarantee the endpoints reject malformed input and accept plausible input.
|
||||
/// The accepted phone shape mirrors the frontend validator
|
||||
/// (src/modules/core/utils/validators.ts).
|
||||
/// </summary>
|
||||
public class ContactFieldValidationTests
|
||||
{
|
||||
private static IReadOnlyList<ValidationResult> Validate(object model)
|
||||
{
|
||||
List<ValidationResult> results = new();
|
||||
Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool MemberInvalid(object model, string memberName)
|
||||
{
|
||||
foreach (ValidationResult result in Validate(model))
|
||||
{
|
||||
foreach (string member in result.MemberNames)
|
||||
{
|
||||
if (member == memberName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("no-at-sign.com")]
|
||||
[InlineData("@no-local.com")]
|
||||
public void RegisterUserRequest_InvalidEmail_IsRejected(string email)
|
||||
{
|
||||
RegisterUserRequest request = new()
|
||||
{
|
||||
Email = email,
|
||||
Username = "validuser",
|
||||
Role = "ADMIN",
|
||||
};
|
||||
|
||||
Assert.True(MemberInvalid(request, nameof(RegisterUserRequest.Email)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc12345")] // letters not allowed
|
||||
[InlineData("123")] // too few digits
|
||||
[InlineData("12345678901234567")] // too many digits
|
||||
[InlineData("+541123456789")] // +54 country code not accepted — local-only app
|
||||
[InlineData("01123456789")] // 0 long-distance prefix not accepted
|
||||
public void RegisterUserRequest_InvalidPhone_IsRejected(string phone)
|
||||
{
|
||||
RegisterUserRequest request = new()
|
||||
{
|
||||
Email = "user@example.com",
|
||||
Username = "validuser",
|
||||
Phone = phone,
|
||||
Role = "ADMIN",
|
||||
};
|
||||
|
||||
Assert.True(MemberInvalid(request, nameof(RegisterUserRequest.Phone)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)] // optional — absent is fine
|
||||
[InlineData("11 2345-6789")]
|
||||
[InlineData("1123456789")]
|
||||
public void RegisterUserRequest_ValidContactData_IsAccepted(string? phone)
|
||||
{
|
||||
RegisterUserRequest request = new()
|
||||
{
|
||||
Email = "user@example.com",
|
||||
Username = "validuser",
|
||||
Phone = phone,
|
||||
Role = "ADMIN",
|
||||
};
|
||||
|
||||
Assert.False(MemberInvalid(request, nameof(RegisterUserRequest.Email)));
|
||||
Assert.False(MemberInvalid(request, nameof(RegisterUserRequest.Phone)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("bad-phone")]
|
||||
[InlineData("12")]
|
||||
[InlineData("+541123456789")]
|
||||
public void CreatePlayerRequest_InvalidPhone_IsRejected(string phone)
|
||||
{
|
||||
CreatePlayerRequest request = NewPlayer(phone);
|
||||
|
||||
Assert.True(MemberInvalid(request, nameof(CreatePlayerRequest.PhoneNumber)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1123456789")]
|
||||
[InlineData("343 555-1234")]
|
||||
public void CreatePlayerRequest_ValidPhone_IsAccepted(string phone)
|
||||
{
|
||||
CreatePlayerRequest request = NewPlayer(phone);
|
||||
|
||||
Assert.False(MemberInvalid(request, nameof(CreatePlayerRequest.PhoneNumber)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateUserRequest_InvalidEmailAndPhone_AreRejected()
|
||||
{
|
||||
UpdateUserRequest request = new()
|
||||
{
|
||||
Email = "nope",
|
||||
Phone = "letters-here",
|
||||
};
|
||||
|
||||
Assert.True(MemberInvalid(request, nameof(UpdateUserRequest.Email)));
|
||||
Assert.True(MemberInvalid(request, nameof(UpdateUserRequest.Phone)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateUserRequest_NullOptionalFields_AreAccepted()
|
||||
{
|
||||
UpdateUserRequest request = new();
|
||||
|
||||
Assert.Empty(Validate(request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3435551234")] // 10-digit national number
|
||||
[InlineData("343 555-1234")]
|
||||
public void UpdateUserRequest_ArgentinePhoneShapes_AreAccepted(string phone)
|
||||
{
|
||||
UpdateUserRequest request = new() { Phone = phone };
|
||||
|
||||
Assert.False(MemberInvalid(request, nameof(UpdateUserRequest.Phone)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("93435551234")] // 11 digits — the 9 mobile marker is not accepted
|
||||
[InlineData("03435551234")] // 11 digits — the 0 trunk prefix is not accepted
|
||||
[InlineData("543435551234")] // 12 digits — the 54 country code is not accepted
|
||||
[InlineData("5493435551234")] // 13 digits — +54 9 is not accepted
|
||||
public void UpdateUserRequest_ImplausibleArgentinePhoneShapes_AreRejected(string phone)
|
||||
{
|
||||
UpdateUserRequest request = new() { Phone = phone };
|
||||
|
||||
Assert.True(MemberInvalid(request, nameof(UpdateUserRequest.Phone)));
|
||||
}
|
||||
|
||||
private static CreatePlayerRequest NewPlayer(string phone) => new()
|
||||
{
|
||||
FirstName = "Test",
|
||||
LastName = "Player",
|
||||
DocumentNumber = "12345678",
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
PhoneNumber = phone,
|
||||
SocialSecurity = "OSDE",
|
||||
TeamId = Guid.NewGuid(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Application.Utils.Helper.Playoff;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers CrossCupGroupSeeder (HU-110): pooling the top-K qualifiers of each
|
||||
/// internal group of a multi-group cross-division cup and ordering the pool
|
||||
/// into a single seed list by group-stage strength
|
||||
/// (Points → Wins → PointsDifference → PointsFor → TeamId), so a group winner
|
||||
/// with a stronger record seeds ahead of a weaker one.
|
||||
/// </summary>
|
||||
public class CrossCupGroupSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveSeedOrder_DefaultOnePerGroup_PoolsWinnersOrderedByStrength()
|
||||
{
|
||||
Guid a1 = Guid.NewGuid();
|
||||
Guid b1 = Guid.NewGuid();
|
||||
Guid c1 = Guid.NewGuid();
|
||||
|
||||
// Group A winner strongest (bigger point difference), then B, then C
|
||||
// (fewer points). Each list's runner-up must be dropped by K = 1.
|
||||
IReadOnlyList<Position> groupA = [Pos(a1, points: 6, wins: 3, diff: 30, pointsFor: 100), Pos(Guid.NewGuid(), 4, 2, 10, 80)];
|
||||
IReadOnlyList<Position> groupB = [Pos(b1, points: 6, wins: 3, diff: 20, pointsFor: 90), Pos(Guid.NewGuid(), 4, 2, 5, 70)];
|
||||
IReadOnlyList<Position> groupC = [Pos(c1, points: 4, wins: 2, diff: 40, pointsFor: 120), Pos(Guid.NewGuid(), 2, 1, 0, 60)];
|
||||
|
||||
List<Guid> order = CrossCupGroupSeeder.ResolveSeedOrder([groupA, groupB, groupC], qualifiersPerGroup: 1);
|
||||
|
||||
Assert.Equal([a1, b1, c1], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSeedOrder_TieOnPointsWinsAndDifference_BreaksByPointsFor()
|
||||
{
|
||||
Guid strong = Guid.NewGuid();
|
||||
Guid weak = Guid.NewGuid();
|
||||
|
||||
IReadOnlyList<Position> groupA = [Pos(weak, points: 6, wins: 3, diff: 20, pointsFor: 80)];
|
||||
IReadOnlyList<Position> groupB = [Pos(strong, points: 6, wins: 3, diff: 20, pointsFor: 95)];
|
||||
|
||||
List<Guid> order = CrossCupGroupSeeder.ResolveSeedOrder([groupA, groupB], qualifiersPerGroup: 1);
|
||||
|
||||
Assert.Equal([strong, weak], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSeedOrder_KGreaterThanOne_PoolsTopKPerGroup()
|
||||
{
|
||||
Guid a1 = Guid.NewGuid();
|
||||
Guid a2 = Guid.NewGuid();
|
||||
Guid b1 = Guid.NewGuid();
|
||||
Guid b2 = Guid.NewGuid();
|
||||
|
||||
IReadOnlyList<Position> groupA =
|
||||
[
|
||||
Pos(a1, points: 6, wins: 3, diff: 90, pointsFor: 300),
|
||||
Pos(a2, points: 4, wins: 2, diff: 30, pointsFor: 270),
|
||||
Pos(Guid.NewGuid(), 2, 1, -30, 240),
|
||||
];
|
||||
IReadOnlyList<Position> groupB =
|
||||
[
|
||||
Pos(b1, points: 6, wins: 3, diff: 30, pointsFor: 270),
|
||||
Pos(b2, points: 4, wins: 2, diff: 10, pointsFor: 260),
|
||||
Pos(Guid.NewGuid(), 2, 1, -10, 250),
|
||||
];
|
||||
|
||||
List<Guid> order = CrossCupGroupSeeder.ResolveSeedOrder([groupA, groupB], qualifiersPerGroup: 2);
|
||||
|
||||
// Pool: a1(6,90), b1(6,30), a2(4,30), b2(4,10) — ordered by strength.
|
||||
Assert.Equal([a1, b1, a2, b2], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSeedOrder_FewerThanTwoQualifiers_Throws()
|
||||
{
|
||||
IReadOnlyList<Position> onlyGroup = [Pos(Guid.NewGuid(), points: 6, wins: 3, diff: 30, pointsFor: 100)];
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => CrossCupGroupSeeder.ResolveSeedOrder([onlyGroup], qualifiersPerGroup: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSeedOrder_QualifiersPerGroupLessThanOne_Throws()
|
||||
{
|
||||
IReadOnlyList<Position> groupA = [Pos(Guid.NewGuid(), 6, 3, 30, 100)];
|
||||
IReadOnlyList<Position> groupB = [Pos(Guid.NewGuid(), 6, 3, 30, 100)];
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => CrossCupGroupSeeder.ResolveSeedOrder([groupA, groupB], qualifiersPerGroup: 0));
|
||||
}
|
||||
|
||||
private static Position Pos(Guid teamId, int points, int wins, int diff, int pointsFor)
|
||||
{
|
||||
return new Position
|
||||
{
|
||||
TeamId = teamId,
|
||||
TeamName = $"Team-{teamId}",
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
MatchesPlayed = wins,
|
||||
Wins = wins,
|
||||
Losses = 0,
|
||||
PointsFor = pointsFor,
|
||||
PointsAgainst = pointsFor - diff,
|
||||
PointsDifference = diff,
|
||||
Points = points,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants.Stage;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end backend proof for HU-110 (wave B): a cross-division cup with
|
||||
/// SEVERAL internal <see cref="StageType.Group"/> stages can be built and run
|
||||
/// entirely through the real services on the real (SQLite) database — create
|
||||
/// the group stages (guard relaxation), assign teams, generate + finish each
|
||||
/// round-robin, then generate the bracket's first-round matches (dynamic
|
||||
/// sizing) and seed them from the pooled top-K qualifiers.
|
||||
///
|
||||
/// Regression: a regular division and a single-group cross cup keep the fixed
|
||||
/// per-stage-type bracket size, so only the multi-group cross cup is resized.
|
||||
/// </summary>
|
||||
public class CrossCupMultiGroupBracketGenerationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public CrossCupMultiGroupBracketGenerationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups of 4, 4 and 3 with K = 1. Pooled winners a0 (Diff +120), b0
|
||||
/// (Diff +60) and c0 (4 pts) order a0 > b0 > c0. Three seeds pad to a
|
||||
/// 4-team bracket, so the whole pipeline must generate exactly 2 first-round
|
||||
/// matches and seed a0 into the BYE, b0 vs c0 into the other.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CrossCupMultiGroup_KOne_GeneratesTwoBracketMatchesAndSeedsPooledQualifiers()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedCrossCupDivisionAsync(db, tournament, qualifiersPerGroup: 1);
|
||||
|
||||
List<Team> groupA = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 60);
|
||||
List<Team> groupB = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 80);
|
||||
List<Team> groupC = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 3, winnerScore: 90, loserScore: 85);
|
||||
|
||||
Stage bracket = await CreateBracketStageAsync(stageService, division, tournament);
|
||||
|
||||
List<Match> generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id);
|
||||
|
||||
// Dynamic sizing: 3 pooled qualifiers -> next power of two (4) / 2 = 2.
|
||||
Assert.Equal(2, generated.Count);
|
||||
|
||||
List<Match> seeded = await stageService.SeedKnockoutStageAsync(bracket.Id);
|
||||
List<Match> ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
|
||||
// Seed 1 (a0) gets the BYE.
|
||||
Assert.Equal(groupA[0].Id, ordered[0].HomeTeamId);
|
||||
Assert.Null(ordered[0].VisitorTeamId);
|
||||
Assert.True(ordered[0].IsFinished);
|
||||
Assert.Equal(groupA[0].Id, ordered[0].WinningTeamId);
|
||||
|
||||
// Seed 2 (b0) vs seed 3 (c0).
|
||||
Assert.Equal(groupB[0].Id, ordered[1].HomeTeamId);
|
||||
Assert.Equal(groupC[0].Id, ordered[1].VisitorTeamId);
|
||||
Assert.False(ordered[1].IsFinished);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups of 4, 4 and 3 with K = 2 pool six qualifiers, so the bracket must
|
||||
/// grow to 8 slots = 4 first-round matches — four more than a plain
|
||||
/// <see cref="StageType.SemiFinal"/>'s fixed two — proving the bracket is
|
||||
/// sized from the pooled qualifiers, not the stage type. The two top seeds
|
||||
/// (a0, b0) receive the two byes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CrossCupMultiGroup_KTwo_SizesBracketFromPooledQualifiers_NotStageType()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedCrossCupDivisionAsync(db, tournament, qualifiersPerGroup: 2);
|
||||
|
||||
List<Team> groupA = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 60);
|
||||
List<Team> groupB = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 80);
|
||||
List<Team> groupC = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 3, winnerScore: 90, loserScore: 85);
|
||||
|
||||
Stage bracket = await CreateBracketStageAsync(stageService, division, tournament);
|
||||
|
||||
List<Match> generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id);
|
||||
|
||||
// 6 pooled qualifiers -> next power of two (8) / 2 = 4, not SemiFinal's fixed 2.
|
||||
Assert.Equal(4, generated.Count);
|
||||
Assert.NotEqual(KnockoutMatchCount.SemiFinal, generated.Count);
|
||||
|
||||
List<Match> seeded = await stageService.SeedKnockoutStageAsync(bracket.Id);
|
||||
List<Match> ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
|
||||
// Order: a0, b0, a1, b1, c0, c1. Byes to the top two seeds.
|
||||
// Bracket order [1,8,4,5,2,7,3,6] -> (a0,BYE)(b1,c0)(b0,BYE)(a1,c1).
|
||||
Assert.Equal(groupA[0].Id, ordered[0].HomeTeamId);
|
||||
Assert.Null(ordered[0].VisitorTeamId);
|
||||
|
||||
Assert.Equal(groupB[1].Id, ordered[1].HomeTeamId);
|
||||
Assert.Equal(groupC[0].Id, ordered[1].VisitorTeamId);
|
||||
|
||||
Assert.Equal(groupB[0].Id, ordered[2].HomeTeamId);
|
||||
Assert.Null(ordered[2].VisitorTeamId);
|
||||
|
||||
Assert.Equal(groupA[1].Id, ordered[3].HomeTeamId);
|
||||
Assert.Equal(groupC[1].Id, ordered[3].VisitorTeamId);
|
||||
|
||||
// Exactly the two top seeds walk over.
|
||||
List<Match> byes = [.. ordered.Where(m => m.VisitorTeamId is null)];
|
||||
Assert.Equal(2, byes.Count);
|
||||
Assert.Contains(byes, m => m.HomeTeamId == groupA[0].Id);
|
||||
Assert.Contains(byes, m => m.HomeTeamId == groupB[0].Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: a regular (non-cross-cup) division keeps the fixed
|
||||
/// per-stage-type bracket size — a SemiFinal still generates exactly two
|
||||
/// first-round matches, untouched by the multi-group sizing branch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RegularDivision_KeepsFixedSemiFinalBracketSize()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedCrossCupDivisionAsync(db, tournament, qualifiersPerGroup: 1, isCrossDivisionCup: false);
|
||||
|
||||
// A single regular group is enough; the sizing branch is only entered
|
||||
// for a cross cup with more than one group.
|
||||
await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 90, loserScore: 80);
|
||||
|
||||
Stage bracket = await CreateBracketStageAsync(stageService, division, tournament);
|
||||
|
||||
List<Match> generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id);
|
||||
|
||||
Assert.Equal(KnockoutMatchCount.SemiFinal, generated.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: a cross cup with a SINGLE group is not a multi-group cup, so
|
||||
/// it keeps the fixed per-stage-type bracket size too.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SingleGroupCrossCup_KeepsFixedSemiFinalBracketSize()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedCrossCupDivisionAsync(db, tournament, qualifiersPerGroup: 2);
|
||||
|
||||
await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 90, loserScore: 80);
|
||||
|
||||
Stage bracket = await CreateBracketStageAsync(stageService, division, tournament);
|
||||
|
||||
List<Match> generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id);
|
||||
|
||||
Assert.Equal(KnockoutMatchCount.SemiFinal, generated.Count);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Cross-cup multi-group bracket-generation test",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedCrossCupDivisionAsync(
|
||||
ApplicationDBContext db, Tournament tournament, int qualifiersPerGroup, bool isCrossDivisionCup = true)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = isCrossDivisionCup,
|
||||
QualifiersPerGroup = qualifiersPerGroup,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
return division;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Group stage through the real service (exercising the relaxed
|
||||
/// multi-group guard), assigns <paramref name="size"/> fresh teams, generates
|
||||
/// its round-robin fixture, then finishes every match so the lower-ordered
|
||||
/// team of each pair wins by <paramref name="winnerScore"/>-<paramref
|
||||
/// name="loserScore"/>. Returns the teams best-first, so <c>teams[0]</c> wins
|
||||
/// the group.
|
||||
/// </summary>
|
||||
private static async Task<List<Team>> BuildAndPlayGroupAsync(
|
||||
ApplicationDBContext db,
|
||||
IStageService stageService,
|
||||
IMatchService matchService,
|
||||
Division division,
|
||||
Tournament tournament,
|
||||
int size,
|
||||
int winnerScore,
|
||||
int loserScore)
|
||||
{
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, size);
|
||||
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration
|
||||
{
|
||||
TeamId = team.Id,
|
||||
DivisionId = division.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage groupStage = await stageService.CreateStageAsync(new Stage
|
||||
{
|
||||
Slug = string.Empty,
|
||||
Name = $"Grupo-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(14),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await stageService.AssignTeamsToStageAsync(groupStage, [.. teams.Select(t => t.Id)]);
|
||||
|
||||
await matchService.CreateAutomatedMatchesAsync(groupStage.Id);
|
||||
|
||||
Dictionary<Guid, int> rankByTeamId = teams
|
||||
.Select((team, index) => (team.Id, index))
|
||||
.ToDictionary(pair => pair.Id, pair => pair.index);
|
||||
|
||||
List<Match> groupMatches = [.. await db.Matches.Where(m => m.StageId == groupStage.Id).ToListAsync()];
|
||||
|
||||
foreach (Match match in groupMatches)
|
||||
{
|
||||
bool homeWins = rankByTeamId[match.HomeTeamId!.Value] < rankByTeamId[match.VisitorTeamId!.Value];
|
||||
|
||||
match.HomeScore = homeWins ? winnerScore : loserScore;
|
||||
match.VisitorScore = homeWins ? loserScore : winnerScore;
|
||||
match.IsFinished = true;
|
||||
match.WinningTeamId = homeWins ? match.HomeTeamId : match.VisitorTeamId;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> CreateBracketStageAsync(IStageService stageService, Division division, Tournament tournament)
|
||||
{
|
||||
return await stageService.CreateStageAsync(new Stage
|
||||
{
|
||||
Slug = string.Empty,
|
||||
Name = $"Semifinal-{Guid.NewGuid()}",
|
||||
StageType = StageType.SemiFinal,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate.AddDays(20),
|
||||
EndDate = tournament.StartDate.AddDays(27),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return teams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Infrastructure.Identity;
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Boots the real API host for integration tests, replacing the
|
||||
/// production Npgsql-backed ApplicationDBContext and
|
||||
/// IdentityAppDbContext registrations with SQLite in-memory
|
||||
/// connections. No production code path is altered by this factory; it only
|
||||
/// intercepts service registration for the test host.
|
||||
///
|
||||
/// The host's own startup code (ExecuteMigrationsAndSeedAsync) still
|
||||
/// runs for real and calls Database.MigrateAsync() against these
|
||||
/// SQLite connections — replaying the checked-in migration history (written
|
||||
/// for Npgsql) against SQLite is unreliable (schema-qualified raw SQL,
|
||||
/// provider-specific column types). To keep the harness reliable, schema is
|
||||
/// instead built once via EnsureCreated() directly from the current
|
||||
/// model, and the EF migrations-history table is pre-seeded so the host's
|
||||
/// own MigrateAsync() call sees nothing pending and becomes a no-op.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _appConnection;
|
||||
private readonly SqliteConnection _identityConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Sets these environment variables first because startup config binding
|
||||
/// (ConnectionStrings, AllowedOrigins, JWT, Smtp) throws before the host is
|
||||
/// even built otherwise — these values are read from configuration before
|
||||
/// WebApplicationFactory gets a chance to override services. The two SQLite
|
||||
/// connections are then kept open for the lifetime of the factory, since an
|
||||
/// in-memory SQLite database is destroyed as soon as its connection closes.
|
||||
/// </summary>
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings__DbConnection", "Host=localhost;Database=club12_test;Username=test;Password=test");
|
||||
Environment.SetEnvironmentVariable("AllowedOrigins__0", "http://localhost:5173");
|
||||
Environment.SetEnvironmentVariable("JWT__Key", "test-signing-key-at-least-32-characters-long");
|
||||
Environment.SetEnvironmentVariable("JWT__Issuer", "club12-tests");
|
||||
Environment.SetEnvironmentVariable("JWT__Audience", "club12-tests");
|
||||
Environment.SetEnvironmentVariable("Smtp__Host", "localhost");
|
||||
Environment.SetEnvironmentVariable("Smtp__Port", "25");
|
||||
Environment.SetEnvironmentVariable("Smtp__Username", "test");
|
||||
Environment.SetEnvironmentVariable("Smtp__Password", "test");
|
||||
Environment.SetEnvironmentVariable("Smtp__UseSsl", "false");
|
||||
// appsettings.json ships with Seed:Enabled=true, which makes the host's
|
||||
// ExecuteMigrationsAndSeedAsync run the full sample seed — that path
|
||||
// constructs SupabaseHelper, whose Supabase client ctor throws on the
|
||||
// null ProjectUrl the test host has no config for, so the host never
|
||||
// builds. Tests seed their own data; keep the sample seed off.
|
||||
Environment.SetEnvironmentVariable("Seed__Enabled", "false");
|
||||
|
||||
_appConnection = new SqliteConnection("DataSource=:memory:");
|
||||
_appConnection.Open();
|
||||
|
||||
_identityConnection = new SqliteConnection("DataSource=:memory:");
|
||||
_identityConnection.Open();
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
ReplaceDbContext<ApplicationDBContext>(services, _appConnection);
|
||||
ReplaceDbContext<IdentityAppDbContext>(services, _identityConnection);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ReplaceDbContext<TContext>(IServiceCollection services, SqliteConnection connection)
|
||||
where TContext : DbContext
|
||||
{
|
||||
ServiceDescriptor? descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<TContext>));
|
||||
if (descriptor is not null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
services.AddDbContext<TContext>(options => options.UseSqlite(connection));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
using IServiceScope scope = provider.CreateScope();
|
||||
TContext db = scope.ServiceProvider.GetRequiredService<TContext>();
|
||||
|
||||
db.Database.EnsureCreated();
|
||||
MarkAllMigrationsAsApplied(db);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-seeds the EF Core migrations-history table so that the host's own
|
||||
/// startup Database.MigrateAsync() call finds nothing pending and
|
||||
/// performs no schema changes against the SQLite database we already
|
||||
/// built via DatabaseFacade.EnsureCreated.
|
||||
/// </summary>
|
||||
private static void MarkAllMigrationsAsApplied(DbContext db)
|
||||
{
|
||||
IHistoryRepository historyRepository = db.GetService<IHistoryRepository>();
|
||||
|
||||
if (!historyRepository.Exists())
|
||||
{
|
||||
db.Database.ExecuteSqlRaw(historyRepository.GetCreateScript());
|
||||
}
|
||||
|
||||
foreach (string migrationId in db.Database.GetMigrations())
|
||||
{
|
||||
string insertScript = historyRepository.GetInsertScript(
|
||||
new HistoryRow(migrationId, ProductInfo.GetVersion()));
|
||||
db.Database.ExecuteSqlRaw(insertScript);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only hook for readiness-check tests: closes the SQLite
|
||||
/// connection backing ApplicationDBContext and repoints it at a
|
||||
/// nonexistent path with `Mode=ReadWrite` (no auto-create), so any
|
||||
/// subsequent reopen attempt fails with SQLITE_CANTOPEN instead of
|
||||
/// silently succeeding. A plain Close()/Dispose() is not enough here:
|
||||
/// unlike most ADO.NET providers, SqliteConnection happily reopens an
|
||||
/// `:memory:` connection after Dispose() by creating a brand-new, empty
|
||||
/// in-memory database — which would make CanConnectAsync report
|
||||
/// "reachable" again instead of simulating a real outage. Call this
|
||||
/// AFTER the host has booted (e.g. after CreateClient()) so schema
|
||||
/// creation already ran against the original in-memory database.
|
||||
/// </summary>
|
||||
public void BreakDatabaseConnection()
|
||||
{
|
||||
_appConnection.Close();
|
||||
_appConnection.ConnectionString =
|
||||
"Data Source=./health-endpoint-tests-unreachable/does-not-exist.db;Mode=ReadWrite";
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
_appConnection.Dispose();
|
||||
_identityConnection.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves both data-maintenance endpoints are staff-only (Admin or Owner),
|
||||
/// mirroring the pattern in AuthorizationGatingTests.cs.
|
||||
/// </summary>
|
||||
public class DataMaintenanceAuthorizationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DataMaintenanceAuthorizationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/seed", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/seed", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin)]
|
||||
[InlineData(Roles.Owner)]
|
||||
public async Task Seed_StaffRole_Succeeds(string role)
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
// Wipe first: this class shares one CustomWebApplicationFactory (and
|
||||
// therefore one database) across every [Fact] via IClassFixture, and
|
||||
// xUnit does not guarantee execution order — another fact in this
|
||||
// class may have already seeded, which would make this seed call
|
||||
// return 409 instead of 200.
|
||||
await client.PostAsync("api/data-maintenance/wipe", null);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/seed", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wipe_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/wipe", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wipe_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/wipe", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin)]
|
||||
[InlineData(Roles.Owner)]
|
||||
public async Task Wipe_StaffRole_Succeeds(string role)
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/wipe", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_OnNonEmptyDatabase_ReturnsConflict()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
// Wipe first, same reasoning as Seed_AdminRole_Succeeds above —
|
||||
// guarantees the first seed call below actually starts empty
|
||||
// regardless of what other facts in this class already did.
|
||||
await client.PostAsync("api/data-maintenance/wipe", null);
|
||||
await client.PostAsync("api/data-maintenance/seed", null);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync("api/data-maintenance/seed", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal(2, await db.Tournaments.CountAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
using Application.DTOs.DataMaintenance.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Linq;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DataMaintenanceService.WipeSampleDataAsync and
|
||||
/// SeedSampleDataAsync (Task 3 adds the seed-side facts to this same
|
||||
/// class). CustomWebApplicationFactory shares one SQLite in-memory
|
||||
/// database across every [Fact] in this class via IClassFixture, and
|
||||
/// xUnit does not guarantee fact execution order — every fact below
|
||||
/// establishes its own precondition explicitly (calling WipeSampleDataAsync
|
||||
/// first) instead of assuming the database starts empty, so the suite
|
||||
/// passes regardless of run order.
|
||||
/// </summary>
|
||||
public class DataMaintenanceServiceTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DataMaintenanceServiceTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WipeSampleDataAsync_RemovesAllTournamentDomainData()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
// Clear whatever other facts in this class may have left behind
|
||||
// before establishing this fact's own known fixture.
|
||||
await service.WipeSampleDataAsync();
|
||||
|
||||
(int tournaments, int divisions, int teams, int players, int matches, int sanctions) =
|
||||
await BuildAndSaveSampleFixtureAsync(db);
|
||||
|
||||
DataWipeResult result = await service.WipeSampleDataAsync();
|
||||
|
||||
Assert.Equal(tournaments, result.Tournaments);
|
||||
Assert.Equal(divisions, result.Divisions);
|
||||
Assert.Equal(teams, result.Teams);
|
||||
Assert.Equal(players, result.Players);
|
||||
Assert.Equal(matches, result.Matches);
|
||||
Assert.Equal(1, result.MatchSeries);
|
||||
Assert.Equal(sanctions, result.PlayerSanctions);
|
||||
Assert.Equal(1, result.BlogPosts);
|
||||
Assert.Equal(1, result.Venues);
|
||||
|
||||
Assert.Equal(0, await db.Tournaments.CountAsync());
|
||||
Assert.Equal(0, await db.Divisions.CountAsync());
|
||||
Assert.Equal(0, await db.Teams.CountAsync());
|
||||
Assert.Equal(0, await db.Players.CountAsync());
|
||||
Assert.Equal(0, await db.Matches.CountAsync());
|
||||
Assert.Equal(0, await db.MatchSeries.CountAsync());
|
||||
Assert.Equal(0, await db.PlayerSanctions.CountAsync());
|
||||
Assert.Equal(0, await db.PlayersStatistics.CountAsync());
|
||||
Assert.Equal(0, await db.Scorers.CountAsync());
|
||||
Assert.Equal(0, await db.Venues.CountAsync());
|
||||
Assert.Equal(0, await db.BlogPosts.CountAsync());
|
||||
Assert.Equal(0, await db.StageTeamMatches.CountAsync());
|
||||
Assert.Equal(0, await db.PlayerTeamRegistrations.CountAsync());
|
||||
Assert.Equal(0, await db.Stages.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WipeSampleDataAsync_LeavesIdentityUntouched()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
Infrastructure.Identity.IdentityAppDbContext identityDb =
|
||||
scope.ServiceProvider.GetRequiredService<Infrastructure.Identity.IdentityAppDbContext>();
|
||||
|
||||
int usersBefore = await identityDb.Users.CountAsync();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
|
||||
int usersAfter = await identityDb.Users.CountAsync();
|
||||
|
||||
Assert.Equal(usersBefore, usersAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds one small tournament directly via SampleTournamentBuilder
|
||||
/// (Task 1) plus one BlogPost and one MatchSeries row that the builder
|
||||
/// itself doesn't create, so this task's wipe tests don't need
|
||||
/// DataMaintenanceService.SeedSampleDataAsync — which doesn't exist
|
||||
/// yet (Task 3) — to have something real to delete.
|
||||
/// </summary>
|
||||
private static async Task<(int Tournaments, int Divisions, int Teams, int Players, int Matches, int PlayerSanctions)>
|
||||
BuildAndSaveSampleFixtureAsync(ApplicationDBContext db)
|
||||
{
|
||||
List<Venue> venues =
|
||||
[
|
||||
new() { Slug = $"venue-{Guid.NewGuid()}", CreatedBy = "test", Name = "Cancha de prueba", Address = "Calle Falsa 123" },
|
||||
];
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition definition = new(
|
||||
Name: "Torneo de prueba",
|
||||
Description: "Fixture de prueba para WipeSampleDataAsync.",
|
||||
TeamRegistrationDeadline: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StartDate: new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageStartDate: new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageEndDate: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Divisions:
|
||||
[
|
||||
new(
|
||||
"Primera",
|
||||
["Equipo A", "Equipo B", "Equipo C", "Equipo D"],
|
||||
["EQA", "EQB", "EQC", "EQD"],
|
||||
["#111111", "#222222", "#333333", "#444444"]),
|
||||
]);
|
||||
|
||||
int playerCounter = 0;
|
||||
SampleTournamentBuilder.BuildResult result = SampleTournamentBuilder.Build(definition, venues, ref playerCounter);
|
||||
|
||||
db.Tournaments.Add(result.Tournament);
|
||||
db.PlayerSanctions.AddRange(result.Sanctions);
|
||||
|
||||
Team homeTeam = result.Tournament.Teams.ElementAt(0);
|
||||
Team visitorTeam = result.Tournament.Teams.ElementAt(1);
|
||||
Stage stage = result.Tournament.Divisions.ElementAt(0).Stages.ElementAt(0);
|
||||
|
||||
db.MatchSeries.Add(new MatchSeries
|
||||
{
|
||||
CreatedBy = "test",
|
||||
StageId = Guid.Empty,
|
||||
Stage = stage,
|
||||
HomeTeamId = Guid.Empty,
|
||||
HomeTeam = homeTeam,
|
||||
VisitorTeamId = Guid.Empty,
|
||||
VisitorTeam = visitorTeam,
|
||||
BestOf = 3,
|
||||
});
|
||||
|
||||
db.BlogPosts.Add(new BlogPost
|
||||
{
|
||||
CreatedBy = "test",
|
||||
Author = "Test",
|
||||
Title = "Post de prueba",
|
||||
Slug = Application.Utils.Helper.Slug.SlugGenerator.GenerateSlug($"Post de prueba {Guid.NewGuid()}"),
|
||||
MarkdownText = "Contenido de prueba.",
|
||||
Views = 0,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
int teamCount = result.Tournament.Teams.Count;
|
||||
int playerCount = result.Tournament.Teams.Sum(t => t.Players.Count);
|
||||
int matchCount = result.Tournament.Divisions.Sum(d => d.Stages.Sum(s => s.Matches.Count));
|
||||
|
||||
return (1, 1, teamCount, playerCount, matchCount, result.Sanctions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_OnEmptyDatabase_Creates2DistinctTournaments()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
|
||||
DataSeedResult result = await service.SeedSampleDataAsync();
|
||||
|
||||
// Main "Apertura": Primera(8) + Segunda(8) + Copa Club 12 (cross cup) = 3 divisions,
|
||||
// 16 teams, 128 players. Historical "Clausura": Primera(4) + Reserva(4) = 2 divisions,
|
||||
// 8 teams, 64 players. Totals: 5 divisions, 24 teams, 192 players.
|
||||
Assert.Equal(2, result.Tournaments);
|
||||
Assert.Equal(5, result.Divisions);
|
||||
Assert.Equal(24, result.Teams);
|
||||
Assert.Equal(192, result.Players);
|
||||
Assert.Equal(6, result.BlogPosts);
|
||||
Assert.True(result.Matches > 0);
|
||||
Assert.True(result.PlayerSanctions >= 5);
|
||||
|
||||
List<Tournament> tournaments = await db.Tournaments.ToListAsync();
|
||||
Assert.Equal(2, tournaments.Count);
|
||||
Assert.NotEqual(tournaments[0].Name, tournaments[1].Name);
|
||||
Assert.NotEqual(tournaments[0].Slug, tournaments[1].Slug);
|
||||
|
||||
Assert.True(await db.Scorers.CountAsync() > 0);
|
||||
Assert.True(await db.PlayersStatistics.CountAsync() > 0);
|
||||
Assert.Equal(6, await db.Venues.CountAsync());
|
||||
Assert.True(await db.Matches.AnyAsync(m => m.IsFinished && m.HomeScore != null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_MainDivisions_AreNamedAndHaveEightTeamsEach()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
foreach (string divisionName in new[] { "Primera División", "Segunda División" })
|
||||
{
|
||||
Division division = await db.Divisions
|
||||
.Include(d => d.Stages).ThenInclude(s => s.StageTeamMatches)
|
||||
.SingleAsync(d => d.Name == divisionName);
|
||||
|
||||
Stage groupStage = Assert.Single(division.Stages, s => s.StageType == StageType.Group);
|
||||
Assert.Equal(8, groupStage.StageTeamMatches.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_MainDivisions_HaveCopaOroAndCopaPlataMappingsAndBrackets()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
foreach (string divisionName in new[] { "Primera División", "Segunda División" })
|
||||
{
|
||||
Division division = await db.Divisions
|
||||
.Include(d => d.Stages)
|
||||
.Include(d => d.PlayoffMappings)
|
||||
.SingleAsync(d => d.Name == divisionName);
|
||||
|
||||
// Position-range mappings (HU-45): 1-4 -> Copa Oro, 5-8 -> Copa Plata.
|
||||
DivisionPlayoffMapping oro = Assert.Single(division.PlayoffMappings, m => m.Destination == "Copa Oro");
|
||||
Assert.Equal(1, oro.FromPosition);
|
||||
Assert.Equal(4, oro.ToPosition);
|
||||
DivisionPlayoffMapping plata = Assert.Single(division.PlayoffMappings, m => m.Destination == "Copa Plata");
|
||||
Assert.Equal(5, plata.FromPosition);
|
||||
Assert.Equal(8, plata.ToPosition);
|
||||
|
||||
// Each cup has a best-of-3 SemiFinal + Final bracket carrying its BracketName.
|
||||
foreach (string cup in new[] { "Copa Oro", "Copa Plata" })
|
||||
{
|
||||
List<Stage> cupStages = [.. division.Stages.Where(s => s.BracketName == cup)];
|
||||
Assert.Contains(cupStages, s => s.StageType == StageType.SemiFinal);
|
||||
Assert.Contains(cupStages, s => s.StageType == StageType.Final);
|
||||
Assert.All(cupStages, s => Assert.Equal(3, s.BestOf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_FinalStages_AreFinishedWithAWinner()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
List<Stage> finalStages = await db.Stages
|
||||
.Where(s => s.StageType == StageType.Final)
|
||||
.Include(s => s.Matches)
|
||||
.Include(s => s.MatchSeries).ThenInclude(ms => ms.Matches)
|
||||
.ToListAsync();
|
||||
|
||||
// 2 cups x 2 main divisions + 1 cross cup + 2 historical divisions = 7 finals.
|
||||
Assert.Equal(7, finalStages.Count);
|
||||
|
||||
foreach (Stage finalStage in finalStages)
|
||||
{
|
||||
if (finalStage.BestOf > 1)
|
||||
{
|
||||
// A BestOf > 1 final is a REAL MatchSeries (Copa Oro/Copa
|
||||
// Plata for both main divisions), decided with between the
|
||||
// minimum-to-clinch and BestOf finished games — not one
|
||||
// collapsed match.
|
||||
MatchSeries series = Assert.Single(finalStage.MatchSeries);
|
||||
Assert.NotNull(series.WinningTeamId);
|
||||
Assert.Equal(
|
||||
series.WinningTeamId,
|
||||
Application.Utils.Helper.Series.SeriesDecisionCalculator.DetermineWinner(series));
|
||||
|
||||
int gamesToWin = (series.BestOf / 2) + 1;
|
||||
Assert.InRange(series.Matches.Count, gamesToWin, series.BestOf);
|
||||
Assert.All(series.Matches, m => Assert.True(m.IsFinished));
|
||||
Assert.Equal(series.Matches.Count, finalStage.Matches.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
Match finalMatch = Assert.Single(finalStage.Matches);
|
||||
Assert.True(finalMatch.IsFinished);
|
||||
Assert.NotNull(finalMatch.WinningTeamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_CrossDivisionCup_ExistsWithFourGroupsAndQualifiersPerGroup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
Division crossCup = await db.Divisions
|
||||
.Include(d => d.Stages).ThenInclude(s => s.StageTeamMatches)
|
||||
.SingleAsync(d => d.IsCrossDivisionCup);
|
||||
|
||||
Assert.Equal("Copa Club 12", crossCup.Name);
|
||||
Assert.Equal(1, crossCup.QualifiersPerGroup);
|
||||
|
||||
List<Stage> groups = [.. crossCup.Stages.Where(s => s.StageType == StageType.Group)];
|
||||
Assert.Equal(4, groups.Count);
|
||||
Assert.All(groups, g => Assert.Equal(4, g.StageTeamMatches.Count));
|
||||
|
||||
// The pooled bracket exists (SemiFinal + Final).
|
||||
Assert.Contains(crossCup.Stages, s => s.StageType == StageType.SemiFinal);
|
||||
Assert.Contains(crossCup.Stages, s => s.StageType == StageType.Final);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_GroupMatches_HaveJornadasWithEveryTeamOncePerRound()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
List<Match> groupMatches = await db.Matches
|
||||
.Where(m => m.Stage.StageType == StageType.Group)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.NotEmpty(groupMatches);
|
||||
// Every group match belongs to a jornada.
|
||||
Assert.All(groupMatches, m => Assert.NotNull(m.Round));
|
||||
|
||||
// For the Primera División group: 7 jornadas, each with every one of the
|
||||
// 8 teams appearing exactly once (4 matches, 8 distinct team slots).
|
||||
Division primera = await db.Divisions
|
||||
.Include(d => d.Stages).ThenInclude(s => s.Matches)
|
||||
.SingleAsync(d => d.Name == "Primera División");
|
||||
Stage primeraGroup = primera.Stages.Single(s => s.StageType == StageType.Group);
|
||||
|
||||
List<IGrouping<int, Match>> rounds = [.. primeraGroup.Matches.GroupBy(m => m.Round!.Value)];
|
||||
Assert.Equal(7, rounds.Count);
|
||||
foreach (IGrouping<int, Match> round in rounds)
|
||||
{
|
||||
List<Guid?> teamSlots =
|
||||
[
|
||||
.. round.Select(m => m.HomeTeamId),
|
||||
.. round.Select(m => m.VisitorTeamId),
|
||||
];
|
||||
Assert.Equal(8, teamSlots.Count);
|
||||
Assert.Equal(8, teamSlots.Distinct().Count());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_ZoneAndCrossCupJornadas_FallOnDifferentWeekdays()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
// Regular zone group matches are played on Sundays.
|
||||
Division primera = await db.Divisions
|
||||
.Include(d => d.Stages).ThenInclude(s => s.Matches)
|
||||
.SingleAsync(d => d.Name == "Primera División");
|
||||
Stage primeraGroup = primera.Stages.Single(s => s.StageType == StageType.Group);
|
||||
Assert.All(primeraGroup.Matches, m => Assert.Equal(DayOfWeek.Sunday, m.MatchDate.DayOfWeek));
|
||||
|
||||
// Cross-cup group matches are played on Wednesdays (HU-111), so they can
|
||||
// never collide with the zone Sundays.
|
||||
Division crossCup = await db.Divisions
|
||||
.Include(d => d.Stages).ThenInclude(s => s.Matches)
|
||||
.SingleAsync(d => d.IsCrossDivisionCup);
|
||||
List<Match> crossGroupMatches = [.. crossCup.Stages
|
||||
.Where(s => s.StageType == StageType.Group)
|
||||
.SelectMany(s => s.Matches)];
|
||||
Assert.NotEmpty(crossGroupMatches);
|
||||
Assert.All(crossGroupMatches, m => Assert.Equal(DayOfWeek.Wednesday, m.MatchDate.DayOfWeek));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_SeedsVariedSanctionsAndSeveralSpanishNews()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
List<PlayerSanction> sanctions = await db.PlayerSanctions.ToListAsync();
|
||||
Assert.True(sanctions.Count >= 5);
|
||||
// Variety: at least one Team sanction, one appeal pending, one served/rejected.
|
||||
Assert.Contains(sanctions, s => s.SubjectType == SanctionSubjectType.Team && s.TeamId != null);
|
||||
Assert.Contains(sanctions, s => s.SubjectType == SanctionSubjectType.Player && s.PlayerId != null);
|
||||
Assert.Contains(sanctions, s => s.AppealStatus == SanctionAppealStatus.Pending);
|
||||
// Active sanctions actually flag their player as sanctioned.
|
||||
Assert.True(await db.Players.AnyAsync(p => p.IsSanctioned));
|
||||
|
||||
// News: several posts, all with Spanish content, mixing published and draft.
|
||||
List<BlogPost> posts = await db.BlogPosts.ToListAsync();
|
||||
Assert.True(posts.Count >= 5);
|
||||
Assert.Contains(posts, p => p.IsPublished);
|
||||
Assert.Contains(posts, p => !p.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_ProducesCoherentStandingsBracketsAndOneTeamOneZone()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
// Standings over the finished group matches are complete: every one of
|
||||
// the 8 teams has played all 7 of its games.
|
||||
Division primera = await db.Divisions
|
||||
.Include(d => d.Stages).ThenInclude(s => s.Matches).ThenInclude(m => m.HomeTeam)
|
||||
.Include(d => d.Stages).ThenInclude(s => s.Matches).ThenInclude(m => m.VisitorTeam)
|
||||
.Include(d => d.Stages).ThenInclude(s => s.MatchSeries)
|
||||
.SingleAsync(d => d.Name == "Primera División");
|
||||
Stage primeraGroup = primera.Stages.Single(s => s.StageType == StageType.Group);
|
||||
|
||||
List<Domain.Entities.Models.Position> standings =
|
||||
Application.Utils.Helper.Standings.PositionCalculator.CalculatePositions(primeraGroup.Matches);
|
||||
Assert.Equal(8, standings.Count);
|
||||
foreach (Domain.Entities.Models.Position position in standings)
|
||||
{
|
||||
Assert.Equal(7, position.MatchesPlayed);
|
||||
}
|
||||
|
||||
// Copa Oro's semifinal teams are exactly the top 4 of those standings.
|
||||
// The SemiFinal is BestOf=3, so it's a real MatchSeries per pairing
|
||||
// (one row per pairing) rather than Matches (which now holds every
|
||||
// individual game — 2 or 3 per pairing — so team ids repeat there).
|
||||
List<Guid> top4 = [.. standings.Take(4).Select(p => p.TeamId)];
|
||||
Stage oroSemi = primera.Stages.Single(s => s.BracketName == "Copa Oro" && s.StageType == StageType.SemiFinal);
|
||||
Assert.Equal(2, oroSemi.MatchSeries.Count);
|
||||
List<Guid> oroSemiTeams =
|
||||
[
|
||||
.. oroSemi.MatchSeries.Select(s => s.HomeTeamId),
|
||||
.. oroSemi.MatchSeries.Select(s => s.VisitorTeamId),
|
||||
];
|
||||
Assert.Equal([.. top4.OrderBy(x => x)], [.. oroSemiTeams.OrderBy(x => x)]);
|
||||
|
||||
// Every main-tournament team belongs to exactly one regular zone (one
|
||||
// StageTeamMatch across the two divisions' group stages) AND to exactly
|
||||
// one cross-cup group.
|
||||
Tournament apertura = await db.Tournaments.SingleAsync(t => t.Name == "Torneo Apertura 2026");
|
||||
List<Guid> divisionIds = await db.Divisions
|
||||
.Where(d => d.TournamentId == apertura.Id && !d.IsCrossDivisionCup)
|
||||
.Select(d => d.Id).ToListAsync();
|
||||
Guid crossCupId = await db.Divisions
|
||||
.Where(d => d.TournamentId == apertura.Id && d.IsCrossDivisionCup)
|
||||
.Select(d => d.Id).SingleAsync();
|
||||
|
||||
List<Guid> teamIds = await db.Teams.Where(t => t.TournamentId == apertura.Id).Select(t => t.Id).ToListAsync();
|
||||
Assert.Equal(16, teamIds.Count);
|
||||
|
||||
foreach (Guid teamId in teamIds)
|
||||
{
|
||||
int zoneGroupCount = await db.StageTeamMatches.CountAsync(stm =>
|
||||
stm.TeamId == teamId
|
||||
&& stm.Stage!.StageType == StageType.Group
|
||||
&& divisionIds.Contains(stm.Stage.DivisionId));
|
||||
Assert.Equal(1, zoneGroupCount);
|
||||
|
||||
int crossGroupCount = await db.StageTeamMatches.CountAsync(stm =>
|
||||
stm.TeamId == teamId
|
||||
&& stm.Stage!.StageType == StageType.Group
|
||||
&& stm.Stage.DivisionId == crossCupId);
|
||||
Assert.Equal(1, crossGroupCount);
|
||||
}
|
||||
|
||||
// Season registrations exist for every team and player of the season.
|
||||
Assert.Equal(16, await db.Set<TeamTournamentRegistration>().CountAsync(r => r.TournamentId == apertura.Id));
|
||||
Assert.Equal(128, await db.Set<PlayerTeamRegistration>().CountAsync(r => r.TournamentId == apertura.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_OnEmptyDatabase_EveryGroupStageMatchIsFinished()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
List<Match> groupMatches = await db.Matches
|
||||
.Where(m => m.Stage.StageType == StageType.Group)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.NotEmpty(groupMatches);
|
||||
Assert.All(groupMatches, m => Assert.True(m.IsFinished));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutPlayoffs_ProducesOnlyGroupStagePerDivision()
|
||||
{
|
||||
List<Venue> venues =
|
||||
[
|
||||
new() { Slug = $"venue-{Guid.NewGuid()}", CreatedBy = "test", Name = "Cancha de prueba", Address = "Calle Falsa 123" },
|
||||
];
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition definition = new(
|
||||
Name: "Torneo sin playoffs",
|
||||
Description: "Fixture de prueba para el camino de arranque (DataSeeder) sin playoffs.",
|
||||
TeamRegistrationDeadline: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StartDate: new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageStartDate: new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageEndDate: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Divisions:
|
||||
[
|
||||
new(
|
||||
"Primera",
|
||||
["Equipo A", "Equipo B", "Equipo C", "Equipo D"],
|
||||
["EQA", "EQB", "EQC", "EQD"],
|
||||
["#111111", "#222222", "#333333", "#444444"]),
|
||||
new(
|
||||
"Reserva",
|
||||
["Equipo E", "Equipo F", "Equipo G", "Equipo H"],
|
||||
["EQE", "EQF", "EQG", "EQH"],
|
||||
["#555555", "#666666", "#777777", "#888888"]),
|
||||
]);
|
||||
|
||||
int playerCounter = 0;
|
||||
SampleTournamentBuilder.BuildResult result = SampleTournamentBuilder.Build(definition, venues, ref playerCounter);
|
||||
|
||||
foreach (Division division in result.Tournament.Divisions)
|
||||
{
|
||||
Stage stage = Assert.Single(division.Stages);
|
||||
Assert.Equal(StageType.Group, stage.StageType);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedSampleDataAsync_OnNonEmptyDatabase_ThrowsInvalidOperationException()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SeedSampleDataAsync());
|
||||
Assert.Equal(2, await db.Tournaments.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WipeSampleDataAsync_ThenSeedSampleDataAsync_SucceedsAgain()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService<IDataMaintenanceService>();
|
||||
|
||||
await service.WipeSampleDataAsync();
|
||||
await service.SeedSampleDataAsync();
|
||||
await service.WipeSampleDataAsync();
|
||||
|
||||
DataSeedResult result = await service.SeedSampleDataAsync();
|
||||
|
||||
Assert.Equal(2, result.Tournaments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the docker-compose topology and .env.example contract for the
|
||||
/// self-hosted Postgres change: a network-private `db` service the backend
|
||||
/// waits on, and the matching database keys in the example env file.
|
||||
///
|
||||
/// Plain-text assertions on the repo-root files — API.Tests ships no YAML
|
||||
/// parser and adding one for these checks is not warranted.
|
||||
/// </summary>
|
||||
public class DeploymentContractTests
|
||||
{
|
||||
private static string RepoRoot()
|
||||
{
|
||||
DirectoryInfo? dir = new(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "docker-compose.yml")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
private static string ComposeYaml() => File.ReadAllText(Path.Combine(RepoRoot(), "docker-compose.yml"));
|
||||
|
||||
private static string EnvExample() => File.ReadAllText(Path.Combine(RepoRoot(), ".env.example"));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the text of a single top-level compose service block (the lines
|
||||
/// from ` <name>:` up to the next 2-space-indented key or a column-0 key).
|
||||
/// </summary>
|
||||
private static string ServiceBlock(string yaml, string serviceName)
|
||||
{
|
||||
string[] lines = yaml.Replace("\r\n", "\n").Split('\n');
|
||||
int start = Array.FindIndex(lines, l => l.StartsWith($" {serviceName}:", StringComparison.Ordinal));
|
||||
Assert.True(start >= 0, $"compose service '{serviceName}' not found");
|
||||
|
||||
List<string> block = [lines[start]];
|
||||
for (int i = start + 1; i < lines.Length; i++)
|
||||
{
|
||||
string line = lines[i];
|
||||
bool isNextTopLevelKey = line.Length > 0 && !char.IsWhiteSpace(line[0]);
|
||||
bool isNextServiceKey = Regex.IsMatch(line, @"^ \S");
|
||||
if (isNextTopLevelKey || isNextServiceKey)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
block.Add(line);
|
||||
}
|
||||
|
||||
return string.Join('\n', block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_DefinesPinnedPostgresDbService()
|
||||
{
|
||||
string db = ServiceBlock(ComposeYaml(), "db");
|
||||
|
||||
Assert.Contains("image: postgres:17-alpine", db, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_DbServicePublishesNoHostPort()
|
||||
{
|
||||
string db = ServiceBlock(ComposeYaml(), "db");
|
||||
|
||||
Assert.DoesNotContain("ports:", db, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_DbServiceHasHealthcheckAndMemoryLimit()
|
||||
{
|
||||
string db = ServiceBlock(ComposeYaml(), "db");
|
||||
|
||||
Assert.Contains("healthcheck:", db, StringComparison.Ordinal);
|
||||
Assert.Contains("pg_isready", db, StringComparison.Ordinal);
|
||||
Assert.Contains("memory:", db, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_BackendWaitsForDbHealth()
|
||||
{
|
||||
string backend = ServiceBlock(ComposeYaml(), "backend");
|
||||
|
||||
Assert.Contains("depends_on:", backend, StringComparison.Ordinal);
|
||||
Assert.Matches(new Regex(@"db:\s*\n\s*condition:\s*service_healthy"), backend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvExample_DeclaresPostgresImageKeys()
|
||||
{
|
||||
string env = EnvExample();
|
||||
|
||||
Assert.Contains("POSTGRES_USER=", env, StringComparison.Ordinal);
|
||||
Assert.Contains("POSTGRES_PASSWORD=", env, StringComparison.Ordinal);
|
||||
Assert.Contains("POSTGRES_DB=", env, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvExample_ConnectionStringTargetsTheInternalServiceWithoutTls()
|
||||
{
|
||||
string line = EnvExample()
|
||||
.Replace("\r\n", "\n")
|
||||
.Split('\n')
|
||||
.Single(l => l.StartsWith("ConnectionStrings__DbConnection=", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains("Host=db", line, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("SSL Mode=Disable", line, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gitignore_ExcludesDotEnv()
|
||||
{
|
||||
string[] gitignore = File.ReadAllLines(Path.Combine(RepoRoot(), ".gitignore"))
|
||||
.Select(l => l.Trim())
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(gitignore, l => l is ".env" or "/.env" or "*.env");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Helper.Standings;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies IDivisionService.GetGroupStandingsByDivisionIdAsync returns one
|
||||
/// standings table PER Group stage — the shape a multi-group cross-division
|
||||
/// cup (HU-110) needs so the public tournament view can render one POSICIONES
|
||||
/// table per internal group ("Grupo 1".."Grupo N") instead of a single
|
||||
/// aggregate. A regular zone (one Group stage) still yields exactly one table.
|
||||
/// </summary>
|
||||
public class DivisionGroupStandingsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionGroupStandingsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGroupStandingsByDivisionIdAsync_MultipleGroupStages_ReturnsOneTablePerGroup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
|
||||
List<Team> group1Teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
List<Team> group2Teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
|
||||
Stage group1 = await SeedGroupStageAsync(db, division, tournament, "Grupo 1", order: 0);
|
||||
Stage group2 = await SeedGroupStageAsync(db, division, tournament, "Grupo 2", order: 1);
|
||||
|
||||
await SeedFinishedMatchAsync(db, group1, group1Teams[0], group1Teams[1], 90, 80);
|
||||
await SeedFinishedMatchAsync(db, group2, group2Teams[0], group2Teams[1], 70, 65);
|
||||
|
||||
List<GroupStandings> groups = await divisionService.GetGroupStandingsByDivisionIdAsync(division.Id);
|
||||
|
||||
Assert.Equal(2, groups.Count);
|
||||
|
||||
GroupStandings first = groups[0];
|
||||
Assert.Equal(group1.Id, first.StageId);
|
||||
Assert.Equal("Grupo 1", first.StageName);
|
||||
Assert.Equal(2, first.Positions.Count);
|
||||
Assert.All(first.Positions, p =>
|
||||
Assert.Contains(p.TeamId, group1Teams.Select(t => t.Id)));
|
||||
|
||||
GroupStandings second = groups[1];
|
||||
Assert.Equal(group2.Id, second.StageId);
|
||||
Assert.Equal("Grupo 2", second.StageName);
|
||||
Assert.Equal(2, second.Positions.Count);
|
||||
Assert.All(second.Positions, p =>
|
||||
Assert.Contains(p.TeamId, group2Teams.Select(t => t.Id)));
|
||||
|
||||
// Distinct team count across ALL group stages is 4, not one group's 2.
|
||||
int distinctTeams = groups.SelectMany(g => g.Positions).Select(p => p.TeamId).Distinct().Count();
|
||||
Assert.Equal(4, distinctTeams);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGroupStandingsByDivisionIdAsync_SingleGroupStage_ReturnsOneTable()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Stage groupStage = await SeedGroupStageAsync(db, division, tournament, "Fase de Grupos", order: 0);
|
||||
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], 90, 80);
|
||||
|
||||
List<GroupStandings> groups = await divisionService.GetGroupStandingsByDivisionIdAsync(division.Id);
|
||||
|
||||
GroupStandings single = Assert.Single(groups);
|
||||
Assert.Equal(groupStage.Id, single.StageId);
|
||||
Assert.Equal(2, single.Positions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGroupStandingsByDivisionIdAsync_NoGroupStage_ReturnsEmptyList()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
|
||||
List<GroupStandings> groups = await divisionService.GetGroupStandingsByDivisionIdAsync(division.Id);
|
||||
|
||||
Assert.Empty(groups);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Group standings test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedGroupStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, string name, int order)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = name,
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
IsElimination = false,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
Order = order,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedFinishedMatchAsync(
|
||||
ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeam = visitor,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = true,
|
||||
WinningTeam = homeScore > visitorScore ? home : visitor,
|
||||
WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the divisions LIST endpoint (GET /api/divisions) populates each
|
||||
/// division's Positions, the field the admin divisions table reads for its
|
||||
/// per-division team counter — HU-32/HU-33. The single-division detail
|
||||
/// endpoint already filled Positions; the list endpoint did not, so the
|
||||
/// counter always rendered 0 (and any "no hay equipos" gate driven by it
|
||||
/// stayed off) even for a fully-populated division.
|
||||
/// </summary>
|
||||
public class DivisionListPositionsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionListPositionsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFilteredDivisions_PopulatesPositions_ForDivisionWithFinishedGroupMatches()
|
||||
{
|
||||
Guid tournamentId;
|
||||
Guid divisionId;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage groupStage = await SeedGroupStageAsync(db, division, tournament);
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], homeScore: 90, visitorScore: 80);
|
||||
|
||||
tournamentId = tournament.Id;
|
||||
divisionId = division.Id;
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
HttpResponseMessage response = await client.GetAsync($"api/divisions?tournamentId={tournamentId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
PaginatedDivisionsDto? body = await response.Content.ReadFromJsonAsync<PaginatedDivisionsDto>();
|
||||
Assert.NotNull(body);
|
||||
|
||||
DivisionDto seededDivision = Assert.Single(body!.Items, d => d.Id == divisionId);
|
||||
Assert.NotNull(seededDivision.Positions);
|
||||
Assert.Equal(2, seededDivision.Positions!.Count);
|
||||
}
|
||||
|
||||
private sealed record PositionDto(Guid TeamId);
|
||||
private sealed record DivisionDto(Guid Id, List<PositionDto>? Positions);
|
||||
private sealed record PaginatedDivisionsDto(List<DivisionDto> Items);
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Division list positions test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedGroupStageAsync(ApplicationDBContext db, Division division, Tournament tournament)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Group-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
IsElimination = false,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedFinishedMatchAsync(
|
||||
ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeam = visitor,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = true,
|
||||
WinningTeam = homeScore > visitorScore ? home : visitor,
|
||||
WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Application.DTOs.Divisions.Request;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves DivisionRosterController's HTTP wiring: every route requires Owner
|
||||
/// or Admin (mirroring SeasonControllerAuthorizationTests), and staff round
|
||||
/// trips through roster enroll/unenroll, sub-group rebuild, auto-distribute,
|
||||
/// and manual reassignment reach the underlying service correctly.
|
||||
/// </summary>
|
||||
public class DivisionRosterControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionRosterControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRoster_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division division = await SeedDivisionAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
HttpResponseMessage response = await client.GetAsync($"api/divisions/{division.Id}/roster");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRoster_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division division = await SeedDivisionAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
HttpResponseMessage response = await client.GetAsync($"api/divisions/{division.Id}/roster");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeams_StaffRole_ReturnsOkWithUpdatedRoster()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division division = await SeedDivisionAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, division.TournamentId, 2);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{division.Id}/roster",
|
||||
new EnrollTeamsRequest { TeamIds = [.. teams.Select(t => t.Id)] });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
List<TeamBody>? roster = await response.Content.ReadFromJsonAsync<List<TeamBody>>();
|
||||
Assert.NotNull(roster);
|
||||
Assert.Equal(2, roster!.Count);
|
||||
Assert.All(teams, t => Assert.Contains(roster, r => r.Id == t.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnenrollTeams_StaffRole_RemovesTeamFromRoster()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division division = await SeedDivisionAsync(db);
|
||||
Team team = (await SeedTeamsAsync(db, division.TournamentId, 1))[0];
|
||||
await SeedRegistrationAsync(db, division, team);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
HttpRequestMessage request = new(HttpMethod.Delete, $"api/divisions/{division.Id}/roster")
|
||||
{
|
||||
Content = JsonContent.Create(new UnenrollTeamsRequest { TeamIds = [team.Id] }),
|
||||
};
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(
|
||||
r => r.DivisionId == division.Id && r.TeamId == team.Id);
|
||||
Assert.Equal(0, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RebuildSubGroups_StaffRole_ReturnsOkWithNewStages()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division division = await SeedDivisionAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{division.Id}/sub-groups/rebuild",
|
||||
new RebuildSubGroupsRequest { SubGroupCount = 1 });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
List<StageBody>? stages = await response.Content.ReadFromJsonAsync<List<StageBody>>();
|
||||
Assert.NotNull(stages);
|
||||
Assert.Single(stages!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoDistributeRoster_StaffRole_ReturnsNoContent()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division division = await SeedDivisionAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
$"api/divisions/{division.Id}/roster/auto-distribute", content: null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReassignTeamToSubGroup_StagesInDifferentDivisions_ReturnsConflict()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division divisionA = await SeedDivisionAsync(db);
|
||||
Division divisionB = await SeedDivisionAsync(db, divisionA.TournamentId);
|
||||
Stage fromStage = await SeedStageAsync(db, divisionA);
|
||||
Stage toStage = await SeedStageAsync(db, divisionB);
|
||||
Team team = (await SeedTeamsAsync(db, divisionA.TournamentId, 1))[0];
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{divisionA.Id}/sub-groups/reassign",
|
||||
new ReassignTeamToSubGroupRequest { TeamId = team.Id, FromStageId = fromStage.Id, ToStageId = toStage.Id });
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Roster controller test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Guid? tournamentId = null)
|
||||
{
|
||||
Tournament tournament = tournamentId.HasValue
|
||||
? await db.Tournaments.SingleAsync(t => t.Id == tournamentId.Value)
|
||||
: await SeedTournamentAsync(db);
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Grupo-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = DateTime.UtcNow.Date.AddDays(30),
|
||||
EndDate = DateTime.UtcNow.Date.AddDays(60),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Guid tournamentId, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task SeedRegistrationAsync(ApplicationDBContext db, Division division, Team team)
|
||||
{
|
||||
db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration
|
||||
{
|
||||
TeamId = team.Id,
|
||||
DivisionId = division.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private sealed record TeamBody(Guid Id);
|
||||
|
||||
private sealed record StageBody(Guid Id, string Name);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers IDivisionRosterService: roster CRUD, the idempotent enroll skip, the
|
||||
/// one-regular-zone-plus-optional-cross-cup conflict rule, and the cascade
|
||||
/// unenroll that removes a team's stage placements before its registration.
|
||||
/// </summary>
|
||||
public class DivisionRosterServiceTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionRosterServiceTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeamsAsync_NewTeams_CreatesRegistrations()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
|
||||
List<DivisionTeamRegistration> created = await rosterService.EnrollTeamsAsync(
|
||||
division.Id, [.. teams.Select(t => t.Id)]);
|
||||
|
||||
Assert.Equal(2, created.Count);
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(r => r.DivisionId == division.Id);
|
||||
Assert.Equal(2, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeamsAsync_AlreadyRegisteredTeam_IsIdempotent_NoDuplicate()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, division, team);
|
||||
|
||||
await rosterService.EnrollTeamsAsync(division.Id, [team.Id]);
|
||||
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(
|
||||
r => r.DivisionId == division.Id && r.TeamId == team.Id);
|
||||
Assert.Equal(1, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeamsAsync_TeamAlreadyInAnotherRegularDivision_ThrowsAndCreatesNoRegistration()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division divisionA = await SeedDivisionAsync(db, tournament);
|
||||
Division divisionB = await SeedDivisionAsync(db, tournament);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, divisionA, team);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => rosterService.EnrollTeamsAsync(divisionB.Id, [team.Id]));
|
||||
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(r => r.DivisionId == divisionB.Id);
|
||||
Assert.Equal(0, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeamsAsync_TeamInRegularDivisionPlusCrossCupDivision_BothRegistrationsSucceed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division regularDivision = await SeedDivisionAsync(db, tournament);
|
||||
Division cupDivision = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, regularDivision, team);
|
||||
|
||||
await rosterService.EnrollTeamsAsync(cupDivision.Id, [team.Id]);
|
||||
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(r => r.TeamId == team.Id);
|
||||
Assert.Equal(2, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeamsAsync_SecondCrossCupRegistration_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division firstCup = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true);
|
||||
Division secondCup = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, firstCup, team);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => rosterService.EnrollTeamsAsync(secondCup.Id, [team.Id]));
|
||||
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(r => r.DivisionId == secondCup.Id);
|
||||
Assert.Equal(0, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollTeamsAsync_TournamentStructureLocked_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => rosterService.EnrollTeamsAsync(division.Id, [team.Id]));
|
||||
|
||||
int rowCount = await db.DivisionTeamRegistrations.CountAsync(r => r.DivisionId == division.Id);
|
||||
Assert.Equal(0, rowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnenrollTeamsAsync_TeamStillPlacedInStage_RemovesPlacementThenRegistration()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, StageType.Group, "Grupo A");
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, division, team);
|
||||
await SeedStageTeamMatchAsync(db, stage, team);
|
||||
|
||||
await rosterService.UnenrollTeamsAsync(division.Id, [team.Id]);
|
||||
|
||||
int placementCount = await db.StageTeamMatches.CountAsync(stm => stm.TeamId == team.Id);
|
||||
int registrationCount = await db.DivisionTeamRegistrations.CountAsync(
|
||||
r => r.DivisionId == division.Id && r.TeamId == team.Id);
|
||||
Assert.Equal(0, placementCount);
|
||||
Assert.Equal(0, registrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnenrollTeamsAsync_TeamNotPlaced_RemovesRegistrationOnly()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, division, team);
|
||||
|
||||
await rosterService.UnenrollTeamsAsync(division.Id, [team.Id]);
|
||||
|
||||
int registrationCount = await db.DivisionTeamRegistrations.CountAsync(
|
||||
r => r.DivisionId == division.Id && r.TeamId == team.Id);
|
||||
Assert.Equal(0, registrationCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The roster edit lock mirrors StageService's structure lock so a locked
|
||||
/// tournament can't be unenrolled from underneath its already-generated matches.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnenrollTeamsAsync_TournamentStructureLocked_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Team team = (await SeedTeamsAsync(db, tournament, 1))[0];
|
||||
await SeedRegistrationAsync(db, division, team);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => rosterService.UnenrollTeamsAsync(division.Id, [team.Id]));
|
||||
|
||||
int registrationCount = await db.DivisionTeamRegistrations.CountAsync(
|
||||
r => r.DivisionId == division.Id && r.TeamId == team.Id);
|
||||
Assert.Equal(1, registrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRosterAsync_ReturnsAllEnrolledTeams_IncludingUnplacedOnes()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService<IDivisionRosterService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, StageType.Group, "Grupo A");
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
await SeedRegistrationAsync(db, division, teams[0]);
|
||||
await SeedRegistrationAsync(db, division, teams[1]);
|
||||
await SeedStageTeamMatchAsync(db, stage, teams[0]);
|
||||
|
||||
List<Team> roster = await rosterService.GetRosterAsync(division.Id);
|
||||
|
||||
Assert.Equal(2, roster.Count);
|
||||
Assert.Contains(roster, t => t.Id == teams[0].Id);
|
||||
Assert.Contains(roster, t => t.Id == teams[1].Id);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(
|
||||
ApplicationDBContext db, TournamentStatus status = TournamentStatus.Scheduled)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Roster service test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Status = status,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(
|
||||
ApplicationDBContext db, Tournament tournament, bool isCrossDivisionCup = false)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = isCrossDivisionCup,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, StageType stageType, string name)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"{name}-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = division.Tournament!.StartDate,
|
||||
EndDate = division.Tournament!.StartDate.AddDays(30),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task SeedRegistrationAsync(ApplicationDBContext db, Division division, Team team)
|
||||
{
|
||||
db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration
|
||||
{
|
||||
TeamId = team.Id,
|
||||
DivisionId = division.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedStageTeamMatchAsync(ApplicationDBContext db, Stage stage, Team team)
|
||||
{
|
||||
db.StageTeamMatches.Add(new StageTeamMatch
|
||||
{
|
||||
StageId = stage.Id,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Division's slug support: DivisionService.CreateDivisionAsync
|
||||
/// generates a unique slug from the division's Name, GetSimpleDivisionByIdOrSlugAsync
|
||||
/// resolves a division by either its GUID id or its slug, and
|
||||
/// GET api/divisions/{idOrSlug}/detail resolves by either form. DivisionController
|
||||
/// has no SupabaseHelper dependency, so this can run as a real HTTP round trip
|
||||
/// through CustomWebApplicationFactory.
|
||||
/// </summary>
|
||||
public class DivisionSlugTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionSlugTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDivisionAsync_GeneratesSlugFromName()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
|
||||
Division created = await divisionService.CreateDivisionAsync(new Division
|
||||
{
|
||||
Name = "Zona Ñandú",
|
||||
Slug = null!,
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(created.Slug));
|
||||
Assert.DoesNotContain(' ', created.Slug);
|
||||
Assert.Equal(created.Slug, created.Slug.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDivisionAsync_DuplicateName_AppendsSuffixToSlug()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
string sharedName = "Zona " + Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
Division first = await divisionService.CreateDivisionAsync(new Division
|
||||
{
|
||||
Name = sharedName,
|
||||
Slug = null!,
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
Division second = await divisionService.CreateDivisionAsync(new Division
|
||||
{
|
||||
Name = sharedName,
|
||||
Slug = null!,
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
Assert.NotEqual(first.Slug, second.Slug);
|
||||
Assert.Equal($"{first.Slug}-2", second.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionById_BySlug_Returns200WithMatchingDivision()
|
||||
{
|
||||
Guid createdId;
|
||||
string createdSlug;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division created = await divisionService.CreateDivisionAsync(new Division
|
||||
{
|
||||
Name = "Zona " + Guid.NewGuid().ToString("N")[..12],
|
||||
Slug = null!,
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
createdId = created.Id;
|
||||
createdSlug = created.Slug;
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage byId = await client.GetAsync($"api/divisions/{createdId}/detail");
|
||||
HttpResponseMessage bySlug = await client.GetAsync($"api/divisions/{createdSlug}/detail");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, byId.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, bySlug.StatusCode);
|
||||
|
||||
DivisionIdResponse? bySlugBody = await bySlug.Content.ReadFromJsonAsync<DivisionIdResponse>();
|
||||
Assert.NotNull(bySlugBody);
|
||||
Assert.Equal(createdId, bySlugBody!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDivisionById_UnknownSlug_Returns404()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/divisions/unknown-slug-{Guid.NewGuid():N}/detail");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record DivisionIdResponse(Guid Id, string Slug);
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date;
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Division slug characterization tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Status = TournamentStatus.OpenForRegistration,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies IDivisionService.GetPositionsByDivisionIdAsync actually computes
|
||||
/// standings from real Group-stage match results — the gap that previously
|
||||
/// left DivisionResponse.Positions always null/empty, since no code path
|
||||
/// ever populated it.
|
||||
/// </summary>
|
||||
public class DivisionStandingsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionStandingsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPositionsByDivisionIdAsync_FinishedGroupStageMatches_ReturnsComputedStandings()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage groupStage = await SeedGroupStageAsync(db, division, tournament);
|
||||
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], homeScore: 90, visitorScore: 80);
|
||||
|
||||
List<Position> positions = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
|
||||
|
||||
Assert.Equal(2, positions.Count);
|
||||
Position winner = Assert.Single(positions, p => p.TeamId == teams[0].Id);
|
||||
Assert.Equal(1, winner.Wins);
|
||||
Assert.Equal(2, winner.Points);
|
||||
Position loser = Assert.Single(positions, p => p.TeamId == teams[1].Id);
|
||||
Assert.Equal(1, loser.Losses);
|
||||
Assert.Equal(1, loser.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPositionsByDivisionIdAsync_ElimintationStageMatchesOnly_AreExcludedFromStandings()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage finalStage = await SeedEliminationStageAsync(db, division, tournament, StageType.Final);
|
||||
|
||||
await SeedFinishedMatchAsync(db, finalStage, teams[0], teams[1], homeScore: 90, visitorScore: 80);
|
||||
|
||||
List<Position> positions = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
|
||||
|
||||
Assert.Empty(positions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPositionsByDivisionIdAsync_NoGroupStage_ReturnsEmptyList()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
|
||||
List<Position> positions = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
|
||||
|
||||
Assert.Empty(positions);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Standings test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedGroupStageAsync(ApplicationDBContext db, Division division, Tournament tournament)
|
||||
{
|
||||
return await SeedEliminationStageAsync(db, division, tournament, StageType.Group, isElimination: false);
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedEliminationStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, bool isElimination = true)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"{stageType}-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
IsElimination = isElimination,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedFinishedMatchAsync(
|
||||
ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeam = visitor,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = true,
|
||||
WinningTeam = homeScore > visitorScore ? home : visitor,
|
||||
WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the backfill rule the AddDivisionTeamRegistrationAndStageDrawnAt
|
||||
/// migration's raw SQL implements: exactly one DivisionTeamRegistration row
|
||||
/// per distinct (TeamId, DivisionId) pair derived from StageTeamMatch ->
|
||||
/// Stage.DivisionId, deduplicated on the pair, never on TeamId alone, and
|
||||
/// safe to re-run. The migration's SQL is PostgreSQL-only (schema-qualified
|
||||
/// identifiers, gen_random_uuid) and cannot execute against this project's
|
||||
/// SQLite-backed test harness, so this test replicates the same join and
|
||||
/// NOT EXISTS guard as an EF/LINQ query against real seeded StageTeamMatch
|
||||
/// rows, mirroring the established pattern in
|
||||
/// TeamTournamentRegistrationTests.Backfill_JoinLogic_RecoversHistoricalAndCurrentParticipationIdempotently.
|
||||
/// </summary>
|
||||
public class DivisionTeamRegistrationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionTeamRegistrationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backfill_TeamInTwoSubGroupsOfOneDivision_CollapsesToOneRegistration()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage groupA = await SeedStageAsync(db, division, StageType.Group, "Grupo A");
|
||||
Stage groupB = await SeedStageAsync(db, division, StageType.Group, "Grupo B");
|
||||
Team team = await SeedTeamAsync(db, tournament);
|
||||
await SeedStageTeamMatchAsync(db, groupA, team);
|
||||
await SeedStageTeamMatchAsync(db, groupB, team);
|
||||
|
||||
await RunBackfillAsync(db);
|
||||
|
||||
List<DivisionTeamRegistration> registrations = await ReadRegistrationsAsync(db, team.Id);
|
||||
DivisionTeamRegistration only = Assert.Single(registrations);
|
||||
Assert.Equal(division.Id, only.DivisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backfill_TeamInGroupAndSameDivisionBracket_CollapsesToOneRegistration()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage groupStage = await SeedStageAsync(db, division, StageType.Group, "Fase de grupos");
|
||||
Stage bracketStage = await SeedStageAsync(db, division, StageType.SemiFinal, "Semifinal");
|
||||
Team team = await SeedTeamAsync(db, tournament);
|
||||
await SeedStageTeamMatchAsync(db, groupStage, team);
|
||||
await SeedStageTeamMatchAsync(db, bracketStage, team);
|
||||
|
||||
await RunBackfillAsync(db);
|
||||
|
||||
List<DivisionTeamRegistration> registrations = await ReadRegistrationsAsync(db, team.Id);
|
||||
DivisionTeamRegistration only = Assert.Single(registrations);
|
||||
Assert.Equal(division.Id, only.DivisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backfill_CrossDivisionCupTeam_ProducesTwoRegistrations_NotCollapsed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division regularDivision = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false);
|
||||
Division cupDivision = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true);
|
||||
Stage regularStage = await SeedStageAsync(db, regularDivision, StageType.Group, "Fase de grupos");
|
||||
Stage cupStage = await SeedStageAsync(db, cupDivision, StageType.Group, "Grupo 1");
|
||||
Team team = await SeedTeamAsync(db, tournament);
|
||||
await SeedStageTeamMatchAsync(db, regularStage, team);
|
||||
await SeedStageTeamMatchAsync(db, cupStage, team);
|
||||
|
||||
await RunBackfillAsync(db);
|
||||
|
||||
List<DivisionTeamRegistration> registrations = await ReadRegistrationsAsync(db, team.Id);
|
||||
Assert.Equal(2, registrations.Count);
|
||||
Assert.Contains(registrations, r => r.DivisionId == regularDivision.Id);
|
||||
Assert.Contains(registrations, r => r.DivisionId == cupDivision.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backfill_ReRunAgainstAlreadyBackfilledData_CreatesNoDuplicates()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage groupA = await SeedStageAsync(db, division, StageType.Group, "Grupo A");
|
||||
Stage groupB = await SeedStageAsync(db, division, StageType.Group, "Grupo B");
|
||||
Team team = await SeedTeamAsync(db, tournament);
|
||||
await SeedStageTeamMatchAsync(db, groupA, team);
|
||||
await SeedStageTeamMatchAsync(db, groupB, team);
|
||||
|
||||
await RunBackfillAsync(db);
|
||||
await RunBackfillAsync(db);
|
||||
await RunBackfillAsync(db);
|
||||
|
||||
List<DivisionTeamRegistration> registrations = await ReadRegistrationsAsync(db, team.Id);
|
||||
Assert.Single(registrations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replicates the migration's INSERT ... SELECT ... GROUP BY (TeamId,
|
||||
/// DivisionId) ... WHERE NOT EXISTS: computes every distinct
|
||||
/// (TeamId, DivisionId) pair implied by StageTeamMatch -> Stage.DivisionId,
|
||||
/// skips pairs already present, and inserts exactly the missing ones.
|
||||
/// Deduplicating on the tuple, not on TeamId alone, is what keeps a
|
||||
/// cross-division-cup team's two registrations distinct.
|
||||
/// </summary>
|
||||
private static async Task RunBackfillAsync(ApplicationDBContext db)
|
||||
{
|
||||
List<(Guid TeamId, Guid DivisionId)> sourcePairs = await db.StageTeamMatches
|
||||
.Join(db.Stages, stm => stm.StageId, s => s.Id, (stm, s) => new { stm.TeamId, s.DivisionId })
|
||||
.Select(x => new ValueTuple<Guid, Guid>(x.TeamId, x.DivisionId))
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
HashSet<(Guid TeamId, Guid DivisionId)> existingPairs = [.. await db.DivisionTeamRegistrations
|
||||
.Select(r => new ValueTuple<Guid, Guid>(r.TeamId, r.DivisionId))
|
||||
.ToListAsync()];
|
||||
|
||||
foreach ((Guid teamId, Guid divisionId) in sourcePairs)
|
||||
{
|
||||
if (existingPairs.Contains((teamId, divisionId)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration
|
||||
{
|
||||
TeamId = teamId,
|
||||
DivisionId = divisionId,
|
||||
CreatedBy = "System",
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<List<DivisionTeamRegistration>> ReadRegistrationsAsync(ApplicationDBContext db, Guid teamId)
|
||||
{
|
||||
return await db.DivisionTeamRegistrations
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TeamId == teamId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Backfill test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(
|
||||
ApplicationDBContext db, Tournament tournament, bool isCrossDivisionCup = false)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
IsCrossDivisionCup = isCrossDivisionCup,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, StageType stageType, string name)
|
||||
{
|
||||
DateTime start = DateTime.UtcNow.Date.AddDays(10);
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"{name}-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = start,
|
||||
EndDate = start.AddDays(30),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task SeedStageTeamMatchAsync(ApplicationDBContext db, Stage stage, Team team)
|
||||
{
|
||||
StageTeamMatch stageTeamMatch = new()
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Stage = stage,
|
||||
TeamId = team.Id,
|
||||
Team = team,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.StageTeamMatches.Add(stageTeamMatch);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies IDivisionService.TryAssignTournamentAsync — the capability that
|
||||
/// lets a division (and everything under it: stages, matches, and team
|
||||
/// assignments, none of which carry their own tournament reference) be
|
||||
/// moved to a different tournament after creation. Exercised through the
|
||||
/// same two-step flow the controller uses: mutate in memory via
|
||||
/// TryAssignTournamentAsync, then persist via UpdateDivisionAsync.
|
||||
/// </summary>
|
||||
public class DivisionTournamentReassignmentTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public DivisionTournamentReassignmentTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAssignTournamentAsync_TargetTournamentExists_MovesDivisionAndItsStages()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament sourceTournament = await SeedTournamentAsync(seedDb);
|
||||
Tournament targetTournament = await SeedTournamentAsync(seedDb);
|
||||
Division division = await SeedDivisionAsync(seedDb, sourceTournament);
|
||||
Stage stage = await SeedStageAsync(seedDb, division, sourceTournament);
|
||||
|
||||
using (IServiceScope actScope = _factory.Services.CreateScope())
|
||||
{
|
||||
IDivisionService divisionService = actScope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division tracked = await actDb.Divisions.SingleAsync(d => d.Id == division.Id);
|
||||
|
||||
bool reassigned = await divisionService.TryAssignTournamentAsync(tracked, targetTournament.Id);
|
||||
Assert.True(reassigned);
|
||||
|
||||
await divisionService.UpdateDivisionAsync(tracked);
|
||||
}
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division? persisted = await verifyDb.Divisions
|
||||
.AsNoTracking()
|
||||
.Include(d => d.Stages)
|
||||
.SingleOrDefaultAsync(d => d.Id == division.Id);
|
||||
|
||||
Assert.NotNull(persisted);
|
||||
Assert.Equal(targetTournament.Id, persisted!.TournamentId);
|
||||
Assert.Contains(persisted.Stages, s => s.Id == stage.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAssignTournamentAsync_SameTournament_IsNoopAndReturnsTrue()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(seedDb);
|
||||
Division division = await SeedDivisionAsync(seedDb, tournament);
|
||||
|
||||
using IServiceScope actScope = _factory.Services.CreateScope();
|
||||
IDivisionService divisionService = actScope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division tracked = await actDb.Divisions.SingleAsync(d => d.Id == division.Id);
|
||||
|
||||
bool reassigned = await divisionService.TryAssignTournamentAsync(tracked, tournament.Id);
|
||||
|
||||
Assert.True(reassigned);
|
||||
Assert.Equal(tournament.Id, tracked.TournamentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAssignTournamentAsync_TargetTournamentDoesNotExist_ReturnsFalseAndLeavesDivisionUnchanged()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament sourceTournament = await SeedTournamentAsync(seedDb);
|
||||
Division division = await SeedDivisionAsync(seedDb, sourceTournament);
|
||||
|
||||
using IServiceScope actScope = _factory.Services.CreateScope();
|
||||
IDivisionService divisionService = actScope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Division tracked = await actDb.Divisions.SingleAsync(d => d.Id == division.Id);
|
||||
|
||||
bool reassigned = await divisionService.TryAssignTournamentAsync(tracked, Guid.NewGuid());
|
||||
|
||||
Assert.False(reassigned);
|
||||
Assert.Equal(sourceTournament.Id, tracked.TournamentId);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Division-reassignment test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Status = TournamentStatus.OpenForRegistration,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division, Tournament tournament)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies slug-based lookup (QA wave 1, Bug 5) for the entities not already
|
||||
/// covered by a dedicated slug test — Team, Tournament and Match. Each service
|
||||
/// path must treat a value that does NOT parse as a GUID as a slug and find the
|
||||
/// row, while still resolving the same entity by its GUID id. Exercised at the
|
||||
/// service layer (TeamController depends on the live SupabaseHelper, so the team
|
||||
/// case cannot round-trip through the HTTP host).
|
||||
/// </summary>
|
||||
public class EntitySlugLookupTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public EntitySlugLookupTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Team_IsResolvedBySlug_AndById()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
ITeamService service = scope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Team team = await SeedTeamAsync(db, tournament.Id);
|
||||
|
||||
Team? bySlug = await service.GetTeamByIdOrSlugAsync(team.Slug);
|
||||
Team? byId = await service.GetTeamByIdOrSlugAsync(team.Id.ToString());
|
||||
|
||||
Assert.NotNull(bySlug);
|
||||
Assert.Equal(team.Id, bySlug!.Id);
|
||||
Assert.NotNull(byId);
|
||||
Assert.Equal(team.Id, byId!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tournament_IsResolvedBySlug_AndById()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
ITournamentService service = scope.ServiceProvider.GetRequiredService<ITournamentService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
|
||||
Tournament? bySlug = await service.GetTournamentByIdOrSlugAsync(tournament.Slug);
|
||||
Tournament? byId = await service.GetTournamentByIdOrSlugAsync(tournament.Id.ToString());
|
||||
|
||||
Assert.NotNull(bySlug);
|
||||
Assert.Equal(tournament.Id, bySlug!.Id);
|
||||
Assert.NotNull(byId);
|
||||
Assert.Equal(tournament.Id, byId!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_IsResolvedBySlug_AndById()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService service = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Team home = await SeedTeamAsync(db, tournament.Id);
|
||||
Match match = await SeedMatchAsync(db, tournament, home);
|
||||
|
||||
Match? bySlug = await service.GetMatchByIdOrSlugAsync(match.Slug);
|
||||
Match? byId = await service.GetMatchByIdOrSlugAsync(match.Id.ToString());
|
||||
|
||||
Assert.NotNull(bySlug);
|
||||
Assert.Equal(match.Id, bySlug!.Id);
|
||||
Assert.NotNull(byId);
|
||||
Assert.Equal(match.Id, byId!.Id);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(10);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Slug lookup test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Guid tournamentId)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedMatchAsync(ApplicationDBContext db, Tournament tournament, Team home)
|
||||
{
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Id = divisionId,
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Id = stageId,
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(30),
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = tournament.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Round = 1,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = false,
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
return match;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using API.Utils;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="FileExtensions.IsValidPdfFile"/>, the gate on medical-record
|
||||
/// uploads (HU-55/HU-56): only a non-empty, actual PDF may be stored, so the
|
||||
/// same file an admin uploads is exactly what a later download returns
|
||||
/// (round-tripped byte-for-byte, verified separately in
|
||||
/// MedicalRecordStorageTests). This had no direct test coverage before.
|
||||
/// </summary>
|
||||
public class FileExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsValidPdfFile_NullFile_ReturnsFalse()
|
||||
{
|
||||
// Deliberately passing null to verify the guard clause it exercises.
|
||||
IFormFile nullFile = null!;
|
||||
|
||||
Assert.False(nullFile.IsValidPdfFile());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidPdfFile_EmptyFile_ReturnsFalse()
|
||||
{
|
||||
// Correct extension and content type, but zero bytes — must still be
|
||||
// rejected. This is the exact "no vacío" requirement.
|
||||
FormFile emptyFile = MakeFile("ficha.pdf", string.Empty, "application/pdf");
|
||||
|
||||
Assert.False(emptyFile.IsValidPdfFile());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidPdfFile_NonPdfExtension_ReturnsFalse()
|
||||
{
|
||||
FormFile wordFile = MakeFile("ficha.docx", "some content", "application/pdf");
|
||||
|
||||
Assert.False(wordFile.IsValidPdfFile());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidPdfFile_MismatchedContentType_ReturnsFalse()
|
||||
{
|
||||
// Right extension, but the browser/client reports a different type —
|
||||
// a renamed non-PDF file wearing a .pdf extension.
|
||||
FormFile mislabeled = MakeFile("ficha.pdf", "not really a pdf", "image/png");
|
||||
|
||||
Assert.False(mislabeled.IsValidPdfFile());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidPdfFile_ValidNonEmptyPdf_ReturnsTrue()
|
||||
{
|
||||
FormFile validPdf = MakeFile("ficha.pdf", "%PDF-1.4 body", "application/pdf");
|
||||
|
||||
Assert.True(validPdf.IsValidPdfFile());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidPdfFile_MissingContentType_StillAcceptedByExtension_ReturnsTrue()
|
||||
{
|
||||
// Some clients omit Content-Type on the multipart part; the extension
|
||||
// alone is enough when no type was declared at all.
|
||||
FormFile noContentType = MakeFile("ficha.pdf", "%PDF-1.4 body", contentType: string.Empty);
|
||||
|
||||
Assert.True(noContentType.IsValidPdfFile());
|
||||
}
|
||||
|
||||
private static FormFile MakeFile(string fileName, string content, string contentType)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(content);
|
||||
MemoryStream stream = new(bytes);
|
||||
|
||||
return new FormFile(stream, 0, stream.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = contentType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using Application.DTOs.Divisions.Request;
|
||||
using Application.DTOs.Tournament.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HU-38: the compound POST /api/tournaments/full path
|
||||
/// (TournamentService.CreateFullTournamentAsync) persists a tournament and its
|
||||
/// whole division/stage graph in ONE transaction. On any failure the whole
|
||||
/// thing rolls back, so no partial tournament rows survive.
|
||||
/// </summary>
|
||||
public class FullTournamentCreationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public FullTournamentCreationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
private static CreateFullStageRequest GroupStage() => new()
|
||||
{
|
||||
Name = "Fase de Grupos",
|
||||
StageType = StageType.Group,
|
||||
StartDate = DateTime.UtcNow.Date.AddDays(30),
|
||||
EndDate = DateTime.UtcNow.Date.AddDays(51),
|
||||
RoundRobinLegs = 1,
|
||||
};
|
||||
|
||||
private static CreateFullTournamentRequest BuildRequest(
|
||||
string name, TournamentCategory tournamentCategory, TournamentCategory secondDivisionCategory)
|
||||
{
|
||||
DateTime start = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
return new CreateFullTournamentRequest
|
||||
{
|
||||
Name = name,
|
||||
Description = "HU-38 compound create",
|
||||
StartDate = start,
|
||||
TeamRegistrationDeadline = start.AddDays(-1),
|
||||
Category = tournamentCategory,
|
||||
Divisions =
|
||||
[
|
||||
new CreateFullDivisionRequest
|
||||
{
|
||||
Name = $"{name} - Zona A",
|
||||
Category = tournamentCategory,
|
||||
PointsForWin = 3,
|
||||
PointsForLoss = 0,
|
||||
PlayoffMappings =
|
||||
[
|
||||
new PlayoffMappingRequest { FromPosition = 1, ToPosition = 4, Destination = "Copa Oro" },
|
||||
],
|
||||
Stages = [GroupStage()],
|
||||
},
|
||||
new CreateFullDivisionRequest
|
||||
{
|
||||
Name = $"{name} - Zona B",
|
||||
Category = secondDivisionCategory,
|
||||
Stages = [GroupStage()],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFullTournamentAsync_PersistsTournamentWithDivisionsAndStagesAtomically()
|
||||
{
|
||||
string name = $"Full-OK-{Guid.NewGuid():N}";
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ITournamentService tournamentService =
|
||||
scope.ServiceProvider.GetRequiredService<ITournamentService>();
|
||||
|
||||
Tournament created = await tournamentService.CreateFullTournamentAsync(
|
||||
BuildRequest(name, TournamentCategory.Masculine, TournamentCategory.Masculine));
|
||||
|
||||
Assert.NotEqual(Guid.Empty, created.Id);
|
||||
Assert.Equal(TournamentStatus.OpenForRegistration, created.Status);
|
||||
}
|
||||
|
||||
// Verify in a FRESH scope/context that the whole graph is on disk.
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await db.Tournaments
|
||||
.Include(t => t.Divisions)
|
||||
.SingleAsync(t => t.Name == name);
|
||||
|
||||
Assert.Equal(2, tournament.Divisions.Count);
|
||||
|
||||
foreach (Division division in tournament.Divisions)
|
||||
{
|
||||
Assert.Equal(tournament.Id, division.TournamentId);
|
||||
|
||||
int stageCount = await db.Stages.CountAsync(s => s.DivisionId == division.Id);
|
||||
Assert.Equal(1, stageCount);
|
||||
}
|
||||
|
||||
// The first division's playoff mapping was persisted too.
|
||||
List<Guid> divisionIds = tournament.Divisions.Select(d => d.Id).ToList();
|
||||
int mappingCount = await db.DivisionPlayoffMappings.CountAsync(m => divisionIds.Contains(m.DivisionId));
|
||||
Assert.Equal(1, mappingCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A division name past DivisionFieldLengths.NameMaxLength must fail model validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateFullDivisionRequest_NameOverMaxLength_FailsValidation()
|
||||
{
|
||||
CreateFullDivisionRequest divisionRequest = new()
|
||||
{
|
||||
Name = new string('a', Application.Utils.Constants.Validation.DivisionFieldLengths.NameMaxLength + 1),
|
||||
Stages = [],
|
||||
};
|
||||
|
||||
List<System.ComponentModel.DataAnnotations.ValidationResult> results = [];
|
||||
bool isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject(
|
||||
divisionRequest,
|
||||
new System.ComponentModel.DataAnnotations.ValidationContext(divisionRequest),
|
||||
results,
|
||||
validateAllProperties: true);
|
||||
|
||||
Assert.False(isValid);
|
||||
Assert.Contains(results, r => r.MemberNames.Contains(nameof(CreateFullDivisionRequest.Name)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage.Order is assigned from each stage's position in the request's own Stages list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateFullTournamentAsync_AssignsStageOrderFromRequestSequence()
|
||||
{
|
||||
string name = $"Full-Order-{Guid.NewGuid():N}";
|
||||
DateTime start = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
CreateFullTournamentRequest request = new()
|
||||
{
|
||||
Name = name,
|
||||
Description = "order check",
|
||||
StartDate = start,
|
||||
TeamRegistrationDeadline = start.AddDays(-1),
|
||||
Category = TournamentCategory.Masculine,
|
||||
Divisions =
|
||||
[
|
||||
new CreateFullDivisionRequest
|
||||
{
|
||||
Name = $"{name} - Zona",
|
||||
Category = TournamentCategory.Masculine,
|
||||
PointsForWin = 2,
|
||||
PointsForLoss = 1,
|
||||
PlayoffMappings =
|
||||
[
|
||||
new PlayoffMappingRequest { FromPosition = 1, ToPosition = 4, Destination = "Final Zona" },
|
||||
],
|
||||
Stages =
|
||||
[
|
||||
GroupStage(),
|
||||
new CreateFullStageRequest { Name = "Final Zona - Semifinal", StageType = StageType.SemiFinal, IsElimination = true, StartDate = start.AddDays(21), EndDate = start.AddDays(28), BracketName = "Final Zona", BestOf = 1 },
|
||||
new CreateFullStageRequest { Name = "Final Zona - Tercer Puesto", StageType = StageType.ThirdPlace, IsElimination = true, StartDate = start.AddDays(28), EndDate = start.AddDays(35), BracketName = "Final Zona", BestOf = 1 },
|
||||
new CreateFullStageRequest { Name = "Final Zona - Final", StageType = StageType.Final, IsElimination = true, StartDate = start.AddDays(28), EndDate = start.AddDays(35), BracketName = "Final Zona", BestOf = 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService<ITournamentService>();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
await tournamentService.CreateFullTournamentAsync(request);
|
||||
|
||||
Division division = await db.Divisions.SingleAsync(d => d.Name == $"{name} - Zona");
|
||||
List<Stage> stages = [.. (await db.Stages.Where(s => s.DivisionId == division.Id).ToListAsync())
|
||||
.OrderBy(s => s.Order)];
|
||||
|
||||
Assert.Equal(
|
||||
["Fase de Grupos", "Final Zona - Semifinal", "Final Zona - Tercer Puesto", "Final Zona - Final"],
|
||||
[.. stages.Select(s => s.Name)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFullTournamentAsync_RollsBackFully_WhenADivisionIsInvalid()
|
||||
{
|
||||
string name = $"Full-Fail-{Guid.NewGuid():N}";
|
||||
|
||||
int tournamentsBefore;
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
tournamentsBefore = await db.Tournaments.CountAsync();
|
||||
}
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ITournamentService tournamentService =
|
||||
scope.ServiceProvider.GetRequiredService<ITournamentService>();
|
||||
|
||||
// Second division's category mismatches the tournament (HU-48), which
|
||||
// throws mid-way through the transaction — after the tournament and
|
||||
// first division were already inserted.
|
||||
CreateFullTournamentRequest request =
|
||||
BuildRequest(name, TournamentCategory.Masculine, TournamentCategory.Feminine);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => tournamentService.CreateFullTournamentAsync(request));
|
||||
}
|
||||
|
||||
// Nothing partial survives: no tournament, no divisions, no stages.
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Assert.False(await db.Tournaments.AnyAsync(t => t.Name == name));
|
||||
Assert.Equal(tournamentsBefore, await db.Tournaments.CountAsync());
|
||||
Assert.False(await db.Divisions.AnyAsync(d => d.Name.StartsWith(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using API.Utils;
|
||||
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
public class GlobalExceptionHandlerTests
|
||||
{
|
||||
private sealed class FakeExceptionHandlerFeature : IExceptionHandlerFeature
|
||||
{
|
||||
public FakeExceptionHandlerFeature(Exception error)
|
||||
{
|
||||
Error = error;
|
||||
Path = "/api/test";
|
||||
Endpoint = null;
|
||||
RouteValues = null;
|
||||
}
|
||||
|
||||
public Exception Error { get; }
|
||||
public string Path { get; }
|
||||
public Microsoft.AspNetCore.Http.Endpoint? Endpoint { get; }
|
||||
public Microsoft.AspNetCore.Routing.RouteValueDictionary? RouteValues { get; }
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> InvokeAsync(Exception exception)
|
||||
{
|
||||
GlobalExceptionHandler handler = new(NullLogger<GlobalExceptionHandler>.Instance);
|
||||
|
||||
ServiceCollection services = new();
|
||||
services.AddProblemDetails();
|
||||
services.AddLogging();
|
||||
using ServiceProvider serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
DefaultHttpContext httpContext = new()
|
||||
{
|
||||
RequestServices = serviceProvider,
|
||||
};
|
||||
httpContext.Response.Body = new MemoryStream();
|
||||
httpContext.Features.Set<IExceptionHandlerFeature>(new FakeExceptionHandlerFeature(exception));
|
||||
|
||||
bool handled = await handler.TryHandleAsync(httpContext, exception, CancellationToken.None);
|
||||
Assert.True(handled);
|
||||
|
||||
httpContext.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using StreamReader reader = new(httpContext.Response.Body);
|
||||
string body = await reader.ReadToEndAsync();
|
||||
return JsonDocument.Parse(body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryHandleAsync_UnmappedException_DoesNotExposeRawExceptionMessage()
|
||||
{
|
||||
InvalidCastException sensitiveException = new("Connection string 'Host=db;Password=Sup3rSecret' is invalid");
|
||||
|
||||
using JsonDocument document = await InvokeAsync(sensitiveException);
|
||||
|
||||
string detail = document.RootElement.GetProperty("detail").GetString() ?? string.Empty;
|
||||
|
||||
Assert.DoesNotContain("Sup3rSecret", detail);
|
||||
Assert.DoesNotContain(sensitiveException.Message, detail);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, document.RootElement.GetProperty("status").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryHandleAsync_ArgumentNullException_StillExposesValidationMessage()
|
||||
{
|
||||
#pragma warning disable S3928
|
||||
ArgumentNullException validationException = new("teamId", "Team id is required.");
|
||||
#pragma warning restore S3928
|
||||
|
||||
using JsonDocument document = await InvokeAsync(validationException);
|
||||
|
||||
string detail = document.RootElement.GetProperty("detail").GetString() ?? string.Empty;
|
||||
|
||||
Assert.Contains(validationException.Message, detail);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, document.RootElement.GetProperty("status").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Net;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the two container-probe endpoints defined by the
|
||||
/// service-health-endpoint spec: `/health` (liveness — process is up, no
|
||||
/// dependency checks) and `/health/ready` (readiness — DB connectivity via
|
||||
/// AddDbContextCheck<ApplicationDBContext>). Both endpoints must be
|
||||
/// anonymous. Real HTTP round trips through CustomWebApplicationFactory,
|
||||
/// same pattern as SmokeTests/AuthorizationGatingTests.
|
||||
/// </summary>
|
||||
public class HealthEndpointsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public HealthEndpointsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_ReturnsOk()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses a dedicated factory instance (not the shared class fixture) so
|
||||
/// breaking its DB connection cannot leak into other tests in this
|
||||
/// class. CreateClient() first forces the host to boot (schema created,
|
||||
/// migrations "applied") before the connection is broken.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Health_ReturnsOk_EvenWithDatabaseUnreachable()
|
||||
{
|
||||
using CustomWebApplicationFactory brokenDbFactory = new();
|
||||
HttpClient client = brokenDbFactory.CreateClient();
|
||||
brokenDbFactory.BreakDatabaseConnection();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsOk_WhenDatabaseReachable()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsServiceUnavailable_WhenDatabaseUnreachable()
|
||||
{
|
||||
using CustomWebApplicationFactory brokenDbFactory = new();
|
||||
HttpClient client = brokenDbFactory.CreateClient();
|
||||
brokenDbFactory.BreakDatabaseConnection();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/health")]
|
||||
[InlineData("/health/ready")]
|
||||
public async Task HealthEndpoints_AllowAnonymousAccess(string path)
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync(path);
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Covers the service-health-endpoint spec scenario "Repeated readiness
|
||||
/// failures do not affect the process": with the DB unreachable for an
|
||||
/// extended period, every /health/ready call must keep returning 503
|
||||
/// (not throw, hang, or crash the host), and /health must keep returning
|
||||
/// 200 in between those failing calls on the SAME factory/host instance —
|
||||
/// proving the process itself stays up and responsive throughout, not
|
||||
/// just that a single call degrades gracefully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthReady_RepeatedFailures_KeepReturning503_WithoutAffectingLiveness()
|
||||
{
|
||||
using CustomWebApplicationFactory brokenDbFactory = new();
|
||||
HttpClient client = brokenDbFactory.CreateClient();
|
||||
brokenDbFactory.BreakDatabaseConnection();
|
||||
|
||||
HttpResponseMessage ready1 = await client.GetAsync("/health/ready");
|
||||
HttpResponseMessage live1 = await client.GetAsync("/health");
|
||||
HttpResponseMessage ready2 = await client.GetAsync("/health/ready");
|
||||
HttpResponseMessage live2 = await client.GetAsync("/health");
|
||||
HttpResponseMessage ready3 = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, ready1.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, ready2.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, ready3.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, live1.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, live2.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using Application.DTOs.Scorer.Request;
|
||||
using Application.DTOs.Scorer.Response;
|
||||
using Application.DTOs.Statistics.Response;
|
||||
using Application.Interfaces.Repositories;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Historical statistics across seasons (HU-85 / HU-87 / HU-88).
|
||||
///
|
||||
/// "Season" is defined as the calendar year of a tournament's StartDate — the
|
||||
/// simplest value derivable from the existing model with no schema change. A
|
||||
/// person is joined across seasons by their stable PlayerId: Player.DocumentNumber
|
||||
/// is uniquely indexed, so one real person is exactly one Player row reused
|
||||
/// across seasons (season scoping lives in PlayerTeamRegistration, not in
|
||||
/// duplicate Player rows). These tests seed the same Player scoring in two
|
||||
/// tournaments of different years and assert the season, all-time, card, and
|
||||
/// history aggregations.
|
||||
///
|
||||
/// Every query is scoped by PlayerId (or by the player's own tournaments) so the
|
||||
/// class-shared SQLite database (IClassFixture) never leaks another test's rows.
|
||||
/// </summary>
|
||||
public class HistoricalStatisticsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public HistoricalStatisticsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeasonRanking_GroupsOnlyThatYearsTournaments()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 10, points2027: 7);
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items2026, int total2026) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { PlayerId = seed.PlayerId, Season = 2026 });
|
||||
ScorerByPlayerResponse in2026 = Assert.Single(items2026);
|
||||
Assert.Equal(1, total2026);
|
||||
Assert.Equal(10, in2026.Points);
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items2027, _) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { PlayerId = seed.PlayerId, Season = 2027 });
|
||||
ScorerByPlayerResponse in2027 = Assert.Single(items2027);
|
||||
Assert.Equal(7, in2027.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllTimeRanking_SumsSamePersonAcrossSeasons()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 10, points2027: 7);
|
||||
|
||||
// No TournamentId and no Season -> all-time: the same PlayerId aggregates
|
||||
// every point across both seasons (10 + 7).
|
||||
(IEnumerable<ScorerByPlayerResponse> allTime, _) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { PlayerId = seed.PlayerId });
|
||||
ScorerByPlayerResponse person = Assert.Single(allTime);
|
||||
Assert.Equal(17, person.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PerTournamentRanking_StillWorks()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 10, points2027: 7);
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items, int total) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { TournamentId = seed.Tournament2026Id });
|
||||
ScorerByPlayerResponse only = Assert.Single(items);
|
||||
Assert.Equal(1, total);
|
||||
Assert.Equal(seed.PlayerId, only.PlayerId);
|
||||
Assert.Equal(10, only.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlayerCard_ReturnsPerSeasonAndOverallTotalsAndAverages()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStatisticsService statisticsService = scope.ServiceProvider.GetRequiredService<IStatisticsService>();
|
||||
|
||||
// 2026: two matches (6 + 4 = 10 over 2 games). 2027: one match (7 over 1 game).
|
||||
SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 6, points2027: 7);
|
||||
await AddPointsAsync(db, seed.PlayerId, await SeedMatchInSeasonAsync(db, seed, season2026: true), points: 4);
|
||||
|
||||
PlayerStatisticCardResponse? card = await statisticsService.GetPlayerCardAsync(seed.PlayerId);
|
||||
|
||||
Assert.NotNull(card);
|
||||
Assert.Equal(17, card!.TotalPoints);
|
||||
Assert.Equal(3, card.GamesPlayed);
|
||||
Assert.Equal(5.67, card.AveragePoints);
|
||||
|
||||
List<SeasonStatLineResponse> seasons = [.. card.Seasons];
|
||||
Assert.Equal(2, seasons.Count);
|
||||
// Most recent season first.
|
||||
Assert.Equal(2027, seasons[0].Season);
|
||||
Assert.Equal(7, seasons[0].TotalPoints);
|
||||
Assert.Equal(1, seasons[0].GamesPlayed);
|
||||
Assert.Equal(7.0, seasons[0].AveragePoints);
|
||||
|
||||
Assert.Equal(2026, seasons[1].Season);
|
||||
Assert.Equal(10, seasons[1].TotalPoints);
|
||||
Assert.Equal(2, seasons[1].GamesPlayed);
|
||||
Assert.Equal(5.0, seasons[1].AveragePoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlayerHistory_ReturnsPerSeasonTeamStatsAndSanctions()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStatisticsService statisticsService = scope.ServiceProvider.GetRequiredService<IStatisticsService>();
|
||||
|
||||
SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 10, points2027: 7);
|
||||
await AddSanctionAsync(db, seed.PlayerId, seed.Match2026Id, "Technical foul", duration: 1);
|
||||
|
||||
PlayerHistoryResponse? history = await statisticsService.GetPlayerHistoryAsync(seed.PlayerId);
|
||||
|
||||
Assert.NotNull(history);
|
||||
List<PlayerHistorySeasonResponse> seasons = [.. history!.Seasons];
|
||||
Assert.Equal(2, seasons.Count);
|
||||
|
||||
PlayerHistorySeasonResponse recent = seasons[0];
|
||||
Assert.Equal(2027, recent.Season);
|
||||
Assert.Equal(seed.Team2027Id, recent.TeamId);
|
||||
Assert.Equal(7, recent.TotalPoints);
|
||||
Assert.Equal(1, recent.GamesPlayed);
|
||||
Assert.Empty(recent.Sanctions);
|
||||
|
||||
PlayerHistorySeasonResponse older = seasons[1];
|
||||
Assert.Equal(2026, older.Season);
|
||||
Assert.Equal(seed.Team2026Id, older.TeamId);
|
||||
Assert.Equal(10, older.TotalPoints);
|
||||
Assert.Equal(1, older.GamesPlayed);
|
||||
PlayerHistorySanctionResponse sanction = Assert.Single(older.Sanctions);
|
||||
Assert.Equal("Technical foul", sanction.Description);
|
||||
Assert.Equal(seed.Match2026Id, sanction.MatchId);
|
||||
}
|
||||
|
||||
// ---- seeding helpers ----
|
||||
|
||||
private sealed record SeededPlayer(
|
||||
Guid PlayerId,
|
||||
Guid Tournament2026Id,
|
||||
Guid Tournament2027Id,
|
||||
Guid Team2026Id,
|
||||
Guid Team2027Id,
|
||||
Guid Match2026Id,
|
||||
Guid Match2027Id);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds ONE player (a single Player row, stable PlayerId) registered to a
|
||||
/// team in a 2026 tournament and another team in a 2027 tournament, scoring
|
||||
/// the given points in one match of each season.
|
||||
/// </summary>
|
||||
private static async Task<SeededPlayer> SeedPlayerAcrossTwoSeasonsAsync(
|
||||
ApplicationDBContext db, int points2026, int points2027)
|
||||
{
|
||||
Tournament t2026 = await SeedTournamentAsync(db, 2026);
|
||||
Tournament t2027 = await SeedTournamentAsync(db, 2027);
|
||||
|
||||
Team team2026 = await SeedTeamAsync(db, t2026.Id);
|
||||
Team team2027 = await SeedTeamAsync(db, t2027.Id);
|
||||
|
||||
Player player = await SeedPlayerAsync(db, team2026);
|
||||
await SeedRegistrationAsync(db, player.Id, team2026.Id, t2026.Id);
|
||||
await SeedRegistrationAsync(db, player.Id, team2027.Id, t2027.Id);
|
||||
|
||||
Match match2026 = await SeedMatchAsync(db, await SeedStageAsync(db, t2026), team2026.Id);
|
||||
Match match2027 = await SeedMatchAsync(db, await SeedStageAsync(db, t2027), team2027.Id);
|
||||
|
||||
await AddPointsAsync(db, player.Id, match2026.Id, points2026);
|
||||
await AddPointsAsync(db, player.Id, match2027.Id, points2027);
|
||||
|
||||
return new SeededPlayer(player.Id, t2026.Id, t2027.Id, team2026.Id, team2027.Id, match2026.Id, match2027.Id);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedMatchInSeasonAsync(ApplicationDBContext db, SeededPlayer seed, bool season2026)
|
||||
{
|
||||
Guid tournamentId = season2026 ? seed.Tournament2026Id : seed.Tournament2027Id;
|
||||
Guid teamId = season2026 ? seed.Team2026Id : seed.Team2027Id;
|
||||
Tournament tournament = await db.Tournaments.FindAsync(tournamentId)
|
||||
?? throw new InvalidOperationException("seed tournament missing");
|
||||
Match match = await SeedMatchAsync(db, await SeedStageAsync(db, tournament), teamId);
|
||||
return match.Id;
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db, int year)
|
||||
{
|
||||
DateTime startDate = new(year, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = $"Season {year} tournament",
|
||||
Name = $"Tournament-{year}-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{year}-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(14),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Guid? tournamentId)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedPlayerAsync(ApplicationDBContext db, Team team)
|
||||
{
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Cross",
|
||||
LastName = "Season",
|
||||
SecondName = null,
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
private static async Task SeedRegistrationAsync(ApplicationDBContext db, Guid playerId, Guid teamId, Guid tournamentId)
|
||||
{
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = playerId,
|
||||
TeamId = teamId,
|
||||
TournamentId = tournamentId,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedMatchAsync(ApplicationDBContext db, Stage stage, Guid homeTeamId)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
HomeTeamId = homeTeamId,
|
||||
VisitorTeamId = null,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
private static async Task AddPointsAsync(ApplicationDBContext db, Guid playerId, Guid matchId, int points)
|
||||
{
|
||||
db.PlayersStatistics.Add(new PlayerStatistic
|
||||
{
|
||||
PlayerId = playerId,
|
||||
MatchId = matchId,
|
||||
Value = points,
|
||||
Type = StatisticType.Points,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task AddSanctionAsync(ApplicationDBContext db, Guid playerId, Guid matchId, string description, int duration)
|
||||
{
|
||||
db.PlayerSanctions.Add(new PlayerSanction
|
||||
{
|
||||
PlayerId = playerId,
|
||||
MatchId = matchId,
|
||||
Match = null!,
|
||||
Description = description,
|
||||
Duration = duration,
|
||||
IssuedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
SubjectType = SanctionSubjectType.Player,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Application.DTOs.Auth.Request;
|
||||
using Application.DTOs.Auth.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Infrastructure.Identity;
|
||||
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HU-09 (invite by email + magic activation link) and HU-10 (self-service
|
||||
/// password reset by magic link). The email sender is replaced with a capturing
|
||||
/// fake so no real SMTP delivery happens: the tests assert the token
|
||||
/// issue/consume + endpoint logic only. IdentityAuthenticationService is
|
||||
/// constructed directly against a real UserManager/IAuthService resolved from
|
||||
/// the host and an in-memory configuration carrying the frontend link URLs.
|
||||
/// </summary>
|
||||
public class MagicLinkAccountFlowTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MagicLinkAccountFlowTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
/// <summary>Capturing IEmailService test double — records the last link of each kind.</summary>
|
||||
private sealed class CapturingEmailService : IEmailService
|
||||
{
|
||||
public string? WelcomeLink { get; private set; }
|
||||
public string? ResetLink { get; private set; }
|
||||
public string? MagicLink { get; private set; }
|
||||
|
||||
public Task SendWelcomeSetPasswordAsync(string toEmail, string toUsername, string setPasswordLink, CancellationToken ct = default)
|
||||
{
|
||||
WelcomeLink = setPasswordLink;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendPasswordResetAsync(string toEmail, string toUsername, string resetLink, CancellationToken ct = default)
|
||||
{
|
||||
ResetLink = resetLink;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendMagicLinkAsync(string toEmail, string toUsername, string magicLink, CancellationToken ct = default)
|
||||
{
|
||||
MagicLink = magicLink;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfig() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Frontend:ActivationUrl"] = "http://localhost:3001/auth/activate",
|
||||
["Frontend:PasswordResetUrl"] = "http://localhost:3001/auth/password-reset",
|
||||
})
|
||||
.Build();
|
||||
|
||||
private static (IdentityAuthenticationService service, UserManager<ApplicationUser> userManager, CapturingEmailService email)
|
||||
BuildService(IServiceScope scope)
|
||||
{
|
||||
UserManager<ApplicationUser> userManager =
|
||||
scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
IAuthService authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
|
||||
CapturingEmailService email = new();
|
||||
|
||||
IdentityAuthenticationService service = new(userManager, authService, email, BuildConfig());
|
||||
return (service, userManager, email);
|
||||
}
|
||||
|
||||
private static InviteUserRequest NewInvite() => new()
|
||||
{
|
||||
Email = $"invitee-{Guid.NewGuid():N}@club12.test",
|
||||
Role = "ADMIN",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- HU-09
|
||||
|
||||
[Fact]
|
||||
public async Task InviteUserAsync_CreatesUserWithoutPassword_AndIssuesActivationLink()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
(IdentityAuthenticationService service, UserManager<ApplicationUser> userManager, CapturingEmailService email) =
|
||||
BuildService(scope);
|
||||
|
||||
InviteUserRequest request = NewInvite();
|
||||
|
||||
InviteUserResponse response = await service.InviteUserAsync(request, "OWNER", Guid.NewGuid());
|
||||
|
||||
Assert.NotEqual(Guid.Empty, response.UserId);
|
||||
Assert.Equal(request.Email, response.Email);
|
||||
|
||||
ApplicationUser? created = await userManager.FindByEmailAsync(request.Email);
|
||||
Assert.NotNull(created);
|
||||
|
||||
// The whole point of HU-09: no password is set at creation time.
|
||||
Assert.False(await userManager.HasPasswordAsync(created!));
|
||||
Assert.True(created!.MustChangePassword);
|
||||
|
||||
// An activation link (carrying a real token) was emailed.
|
||||
Assert.NotNull(email.WelcomeLink);
|
||||
Assert.Contains("token=", email.WelcomeLink!);
|
||||
Assert.Contains("http://localhost:3001/auth/activate", email.WelcomeLink!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAccountAsync_SetsPasswordAndEnablesLogin()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
(IdentityAuthenticationService service, UserManager<ApplicationUser> userManager, _) = BuildService(scope);
|
||||
|
||||
InviteUserRequest invite = NewInvite();
|
||||
await service.InviteUserAsync(invite, "OWNER", Guid.NewGuid());
|
||||
|
||||
ApplicationUser user = (await userManager.FindByEmailAsync(invite.Email))!;
|
||||
string token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
const string newPassword = "Activ8-Me!2026";
|
||||
|
||||
TokenResponse tokens = await service.ActivateAccountAsync(new ActivateAccountRequest
|
||||
{
|
||||
Email = invite.Email,
|
||||
Token = token,
|
||||
NewPassword = newPassword,
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(tokens.AccessToken));
|
||||
|
||||
ApplicationUser activated = (await userManager.FindByEmailAsync(invite.Email))!;
|
||||
Assert.True(await userManager.HasPasswordAsync(activated));
|
||||
Assert.False(activated.MustChangePassword);
|
||||
|
||||
// Login now succeeds with the password the user just set.
|
||||
TokenResponse loginTokens = await service.LoginAsync(new Application.DTOs.Auth.Request.LogInUserRequest
|
||||
{
|
||||
Email = invite.Email,
|
||||
Password = newPassword,
|
||||
});
|
||||
Assert.False(string.IsNullOrWhiteSpace(loginTokens.AccessToken));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HU-10
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPasswordReset_ThenConfirm_ChangesThePassword()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
(IdentityAuthenticationService service, UserManager<ApplicationUser> userManager, CapturingEmailService email) =
|
||||
BuildService(scope);
|
||||
|
||||
// Arrange: an active, password-holding account (invite + activate).
|
||||
InviteUserRequest invite = NewInvite();
|
||||
await service.InviteUserAsync(invite, "OWNER", Guid.NewGuid());
|
||||
ApplicationUser user = (await userManager.FindByEmailAsync(invite.Email))!;
|
||||
const string oldPassword = "Old-Password!2026";
|
||||
await service.ActivateAccountAsync(new ActivateAccountRequest
|
||||
{
|
||||
Email = invite.Email,
|
||||
Token = await userManager.GeneratePasswordResetTokenAsync(user),
|
||||
NewPassword = oldPassword,
|
||||
});
|
||||
|
||||
// Act 1: self-service request emails a reset magic link.
|
||||
await service.RequestPasswordResetAsync(new RequestPasswordResetRequest { Email = invite.Email });
|
||||
Assert.NotNull(email.ResetLink);
|
||||
Assert.Contains("token=", email.ResetLink!);
|
||||
|
||||
// Act 2: consume a reset token to set a brand-new password.
|
||||
const string newPassword = "New-Password!2026";
|
||||
await service.ConfirmPasswordResetAsync(new PasswordResetConfirmRequest
|
||||
{
|
||||
Email = invite.Email,
|
||||
Token = await userManager.GeneratePasswordResetTokenAsync(
|
||||
(await userManager.FindByEmailAsync(invite.Email))!),
|
||||
NewPassword = newPassword,
|
||||
});
|
||||
|
||||
// Assert: new password works, old one no longer does.
|
||||
TokenResponse tokens = await service.LoginAsync(new Application.DTOs.Auth.Request.LogInUserRequest
|
||||
{
|
||||
Email = invite.Email,
|
||||
Password = newPassword,
|
||||
});
|
||||
Assert.False(string.IsNullOrWhiteSpace(tokens.AccessToken));
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||
service.LoginAsync(new Application.DTOs.Auth.Request.LogInUserRequest
|
||||
{
|
||||
Email = invite.Email,
|
||||
Password = oldPassword,
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPasswordReset_UnknownEmail_DoesNotThrowAndSendsNothing()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
(IdentityAuthenticationService service, _, CapturingEmailService email) = BuildService(scope);
|
||||
|
||||
await service.RequestPasswordResetAsync(new RequestPasswordResetRequest
|
||||
{
|
||||
Email = $"nobody-{Guid.NewGuid():N}@club12.test",
|
||||
});
|
||||
|
||||
// No user enumeration: silent no-op, no email sent.
|
||||
Assert.Null(email.ResetLink);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves every api/maintenance endpoint is staff-only (Admin or Owner),
|
||||
/// mirroring the pattern in BackupAuthorizationTests.cs and
|
||||
/// DataMaintenanceAuthorizationTests.cs. The DELETE (escape-hatch)
|
||||
/// tests explicitly put the shared IMaintenanceModeState into an
|
||||
/// active window first — threat-matrix "Escape-hatch abuse": the
|
||||
/// middleware allow-lists this path so the request reaches MVC, but
|
||||
/// [Authorize(Roles = AdminOrOwner)] must still reject anonymous/Guest
|
||||
/// callers even while maintenance is active.
|
||||
/// </summary>
|
||||
public class MaintenanceAuthorizationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MaintenanceAuthorizationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/maintenance");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_GuestRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/maintenance");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin)]
|
||||
[InlineData(Roles.Owner)]
|
||||
public async Task GetStatus_StaffRole_Succeeds(string role)
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync("api/maintenance");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExitMaintenance_AnonymousWhileActive_ReturnsUnauthorized()
|
||||
{
|
||||
IMaintenanceModeState state = _factory.Services.GetRequiredService<IMaintenanceModeState>();
|
||||
state.Enter("test: restore in progress");
|
||||
try
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.DeleteAsync("api/maintenance");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExitMaintenance_GuestRoleWhileActive_ReturnsForbidden()
|
||||
{
|
||||
string role = Roles.Guest;
|
||||
IMaintenanceModeState state = _factory.Services.GetRequiredService<IMaintenanceModeState>();
|
||||
state.Enter("test: restore in progress");
|
||||
try
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.DeleteAsync("api/maintenance");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin)]
|
||||
[InlineData(Roles.Owner)]
|
||||
public async Task ExitMaintenance_StaffRoleWhileActive_Succeeds_AndClearsState(string role)
|
||||
{
|
||||
IMaintenanceModeState state = _factory.Services.GetRequiredService<IMaintenanceModeState>();
|
||||
state.Enter("test: restore in progress");
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(role);
|
||||
|
||||
HttpResponseMessage response = await client.DeleteAsync("api/maintenance");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.False(state.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using Application.DTOs.Match.Request;
|
||||
using Application.DTOs.Match.Response;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the venue schedule conflict guard (already shipped on
|
||||
/// <see cref="Application.Services.MatchService.HasVenueScheduleConflictAsync"/>
|
||||
/// and wired into <c>UpdateMatchDate</c>) is uniformly enforced on the two
|
||||
/// previously-unguarded write paths: <c>POST /api/matches</c> and
|
||||
/// <c>PUT /api/matches/{id}/suspend</c>.
|
||||
/// </summary>
|
||||
public class MatchControllerVenueConflictTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchControllerVenueConflictTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMatch_CollidingVenueAndTime_ReturnsBadRequestWithConflictMessage()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, existingDate, teamA.Id, teamB.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
CreateMatchRequest request = new()
|
||||
{
|
||||
MatchDate = existingDate.AddHours(1),
|
||||
HomeTeamId = teamC.Id,
|
||||
VisitorTeamId = teamD.Id,
|
||||
StageId = stage.Id,
|
||||
VenueId = venue.Id,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/matches", request);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains(ErrorMessages.Match.VenueScheduleConflict, body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare-string BadRequest body serializes as a raw JSON string, which the
|
||||
/// frontend's error handler cannot parse into a display message (it only reads a
|
||||
/// `detail`/`title` field off a JSON object) — silently falling back to a generic
|
||||
/// error instead of showing the real conflict reason. This proves the fix: the
|
||||
/// response is a proper ProblemDetails object with the message under `detail`.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateMatch_CollidingVenueAndTime_ReturnsProblemDetailsShapeWithDetailField()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, existingDate, teamA.Id, teamB.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
CreateMatchRequest request = new()
|
||||
{
|
||||
MatchDate = existingDate.AddHours(1),
|
||||
HomeTeamId = teamC.Id,
|
||||
VisitorTeamId = teamD.Id,
|
||||
StageId = stage.Id,
|
||||
VenueId = venue.Id,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/matches", request);
|
||||
ProblemDetailsBody? problem = await response.Content.ReadFromJsonAsync<ProblemDetailsBody>();
|
||||
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal(ErrorMessages.Match.VenueScheduleConflict, problem!.Detail);
|
||||
}
|
||||
|
||||
private sealed record ProblemDetailsBody(string? Title, string? Detail, int? Status);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMatch_NullVenue_SameCollidingDate_Succeeds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, existingDate, teamA.Id, teamB.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
CreateMatchRequest request = new()
|
||||
{
|
||||
MatchDate = existingDate,
|
||||
HomeTeamId = teamC.Id,
|
||||
VisitorTeamId = teamD.Id,
|
||||
StageId = stage.Id,
|
||||
VenueId = null,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/matches", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMatch_ExactlyTwoHoursAfterExistingMatch_SameVenue_Succeeds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, existingDate, teamA.Id, teamB.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
CreateMatchRequest request = new()
|
||||
{
|
||||
MatchDate = existingDate.AddHours(2),
|
||||
HomeTeamId = teamC.Id,
|
||||
VisitorTeamId = teamD.Id,
|
||||
StageId = stage.Id,
|
||||
VenueId = venue.Id,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/matches", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMatch_SameTime_DifferentVenue_Succeeds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venueA, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
Venue venueB = await SeedVenueAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venueA.Id, existingDate, teamA.Id, teamB.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
CreateMatchRequest request = new()
|
||||
{
|
||||
MatchDate = existingDate,
|
||||
HomeTeamId = teamC.Id,
|
||||
VisitorTeamId = teamD.Id,
|
||||
StageId = stage.Id,
|
||||
VenueId = venueB.Id,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/matches", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendMatch_CollidingNewDateAtItsOwnVenue_ReturnsBadRequestAndLeavesDateUnchanged()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime matchMDate = new(2026, 9, 6, 10, 0, 0, DateTimeKind.Utc);
|
||||
DateTime matchNDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, matchMDate, teamA.Id, teamB.Id);
|
||||
Match matchN = await SeedMatchAsync(db, stage, venue.Id, matchNDate, teamC.Id, teamD.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
SuspendMatchRequest request = new() { MatchDate = matchMDate.AddHours(1) };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync($"api/matches/{matchN.Id}/suspend", request);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains(ErrorMessages.Match.VenueScheduleConflict, body);
|
||||
|
||||
Match? reloaded = await db.Matches.AsNoTracking().FirstOrDefaultAsync(m => m.Id == matchN.Id);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal(matchNDate, reloaded!.MatchDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendMatch_NonCollidingNewDate_Succeeds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime matchMDate = new(2026, 9, 6, 10, 0, 0, DateTimeKind.Utc);
|
||||
DateTime matchNDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, matchMDate, teamA.Id, teamB.Id);
|
||||
Match matchN = await SeedMatchAsync(db, stage, venue.Id, matchNDate, teamC.Id, teamD.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
DateTime newDate = matchNDate.AddDays(1);
|
||||
SuspendMatchRequest request = new() { MatchDate = newDate };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync($"api/matches/{matchN.Id}/suspend", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Match? reloaded = await db.Matches.AsNoTracking().FirstOrDefaultAsync(m => m.Id == matchN.Id);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal(newDate, reloaded!.MatchDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendMatch_NoVenue_SucceedsRegardlessOfDate()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD) = await SeedFixtureAsync(db);
|
||||
DateTime matchMDate = new(2026, 9, 6, 10, 0, 0, DateTimeKind.Utc);
|
||||
DateTime matchNDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, matchMDate, teamA.Id, teamB.Id);
|
||||
Match matchN = await SeedMatchAsync(db, stage, venueId: null, matchNDate, teamC.Id, teamD.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
SuspendMatchRequest request = new() { MatchDate = matchMDate.AddMinutes(30) };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync($"api/matches/{matchN.Id}/suspend", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UpdateMatchDate's two other rejection guards (CannotUpdateStartedOrFinished,
|
||||
/// TeamsNotAssignedToStage) had the exact same bare-string BadRequest anti-pattern
|
||||
/// as the venue-conflict guard — fixed alongside it for consistency, since a live
|
||||
/// QA pass hit the TeamsNotAssignedToStage one specifically and got a generic
|
||||
/// frontend error instead of this message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateMatchDate_TeamsNotAssignedToStage_ReturnsProblemDetailsShapeWithDetailField()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, _, _) = await SeedFixtureAsync(db);
|
||||
DateTime matchDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
// Deliberately no StageTeamMatch rows seeded for teamA/teamB, so neither is assigned to the stage.
|
||||
Match match = await SeedMatchAsync(db, stage, venue.Id, matchDate, teamA.Id, teamB.Id);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
UpdateMatchRequest request = new() { MatchDate = matchDate.AddHours(1) };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync($"api/matches/{match.Id}", request);
|
||||
ProblemDetailsBody? problem = await response.Content.ReadFromJsonAsync<ProblemDetailsBody>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal(ErrorMessages.Match.TeamsNotAssignedToStage, problem!.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMatchDate_MatchAlreadyFinished_ReturnsProblemDetailsShapeWithDetailField()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(Stage stage, Venue venue, Team teamA, Team teamB, _, _) = await SeedFixtureAsync(db);
|
||||
DateTime matchDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
Match match = await SeedMatchAsync(db, stage, venue.Id, matchDate, teamA.Id, teamB.Id);
|
||||
match.IsFinished = true;
|
||||
db.Matches.Update(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
UpdateMatchRequest request = new() { MatchDate = matchDate.AddHours(1) };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync($"api/matches/{match.Id}", request);
|
||||
ProblemDetailsBody? problem = await response.Content.ReadFromJsonAsync<ProblemDetailsBody>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal(ErrorMessages.Match.CannotUpdateStartedOrFinished, problem!.Detail);
|
||||
}
|
||||
|
||||
private static async Task<Venue> SeedVenueAsync(ApplicationDBContext db)
|
||||
{
|
||||
Venue venue = new()
|
||||
{
|
||||
Name = $"Venue-{Guid.NewGuid()}",
|
||||
Slug = $"venue-{Guid.NewGuid()}",
|
||||
Address = "Test Address 123",
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Venues.Add(venue);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return venue;
|
||||
}
|
||||
|
||||
private static async Task<(Stage stage, Venue venue, Team teamA, Team teamB, Team teamC, Team teamD)> SeedFixtureAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime start = DateTime.UtcNow.Date;
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Venue conflict controller test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = start.AddDays(-1),
|
||||
StartDate = start,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = start,
|
||||
EndDate = start.AddDays(60),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
|
||||
List<Team> teams = [.. Enumerable.Range(0, 4).Select(i => new Team
|
||||
{
|
||||
Name = $"Team-{i}-{Guid.NewGuid()}",
|
||||
Slug = $"team-{i}-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = $"T{i:D2}",
|
||||
LogoUrl = "https://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
})];
|
||||
|
||||
db.Teams.AddRange(teams);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
|
||||
return (stage, venue, teams[0], teams[1], teams[2], teams[3]);
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedMatchAsync(
|
||||
ApplicationDBContext db, Stage stage, Guid? venueId, DateTime matchDate, Guid homeTeamId, Guid visitorTeamId)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
Type = MatchType.Regular,
|
||||
IsFinished = false,
|
||||
StageId = stage.Id,
|
||||
VenueId = venueId,
|
||||
MatchDate = matchDate,
|
||||
HomeTeamId = homeTeamId,
|
||||
VisitorTeamId = visitorTeamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return match;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Application.DTOs.Match.Request;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Rescheduling a match (edit date/venue) must NEVER clear its teams. A prior
|
||||
/// bug had HomeTeamId/VisitorTeamId on UpdateMatchRequest, so the convention
|
||||
/// map wrote their (unsent) null values over the entity and wiped both teams.
|
||||
/// </summary>
|
||||
public class MatchRescheduleMappingTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchRescheduleMappingTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingRescheduleRequest_KeepsTeamsAndUpdatesDateAndVenue()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IMapper mapper = scope.ServiceProvider.GetRequiredService<IMapper>();
|
||||
|
||||
Guid homeTeamId = Guid.NewGuid();
|
||||
Guid visitorTeamId = Guid.NewGuid();
|
||||
Guid newVenueId = Guid.NewGuid();
|
||||
DateTime newDate = DateTime.UtcNow.Date.AddDays(7);
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
Slug = "team-a-vs-team-b-2026-10-04",
|
||||
Type = Domain.Enums.MatchType.Regular,
|
||||
IsFinished = false,
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
HomeTeamId = homeTeamId,
|
||||
VisitorTeamId = visitorTeamId,
|
||||
MatchDate = DateTime.UtcNow.Date,
|
||||
};
|
||||
|
||||
// Exactly what MatchController.UpdateMatchDate binds and maps: only the
|
||||
// calendar date and the venue — no team ids.
|
||||
UpdateMatchRequest request = new() { MatchDate = newDate, VenueId = newVenueId };
|
||||
mapper.Map(request, match);
|
||||
|
||||
// Teams survive the reschedule.
|
||||
Assert.Equal(homeTeamId, match.HomeTeamId);
|
||||
Assert.Equal(visitorTeamId, match.VisitorTeamId);
|
||||
// Date and venue were applied.
|
||||
Assert.Equal(newDate, match.MatchDate);
|
||||
Assert.Equal(newVenueId, match.VenueId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
using Application.DTOs.PlayerStatistic.Request;
|
||||
using Application.DTOs.Scorer.Request;
|
||||
using Application.DTOs.Scorer.Response;
|
||||
using Application.Interfaces.Repositories;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioural tests for the MATCH RESULT / GOLEADORES batch:
|
||||
/// HU-69 (match status), HU-70 (no draws — a played match needs a winner),
|
||||
/// HU-71 (planilla coherence: players' points must equal the team score, and
|
||||
/// players must be eligible roster members), HU-72 (the ranking reflects the
|
||||
/// real loaded points), and HU-73 (walkover). Exercised through the real
|
||||
/// services/repositories against the SQLite-backed integration host.
|
||||
/// </summary>
|
||||
public class MatchResultAndSheetTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchResultAndSheetTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
// ---------- HU-70: no draws ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultAsync_GroupStageTie_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 70, visitorScore: 70));
|
||||
|
||||
Assert.Contains("no puede terminar empatado", ex.Message);
|
||||
|
||||
// Nothing persisted: the match stays scheduled and unfinished.
|
||||
Match reloaded = await ReloadMatchAsync(db, seeded.Match.Id);
|
||||
Assert.False(reloaded.IsFinished);
|
||||
Assert.Equal(MatchStatus.Scheduled, reloaded.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultAsync_PlayoffTie_IsRejectedRequiringAWinner()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Final);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 88, visitorScore: 88));
|
||||
|
||||
Assert.Contains("tiempo suplementario", ex.Message);
|
||||
}
|
||||
|
||||
// ---------- HU-69: status transitions + winner (no silent visitor) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultAsync_DecisiveHomeWin_MarksPlayedAndPicksHomeWinner()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
|
||||
Match? updated = await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 91, visitorScore: 80);
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.True(updated!.IsFinished);
|
||||
Assert.Equal(MatchStatus.Played, updated.Status);
|
||||
// The winner is the higher-scoring team, NOT a silent visitor default.
|
||||
Assert.Equal(seeded.HomeTeam.Id, updated.WinningTeamId);
|
||||
|
||||
Match reloaded = await ReloadMatchAsync(db, seeded.Match.Id);
|
||||
Assert.Equal(MatchStatus.Played, reloaded.Status);
|
||||
Assert.Equal(seeded.HomeTeam.Id, reloaded.WinningTeamId);
|
||||
Assert.Equal(91, reloaded.HomeScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultAsync_NonExistentMatch_ReturnsNull()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Match? updated = await matchService.LoadMatchResultAsync(Guid.NewGuid(), 10, 20);
|
||||
|
||||
Assert.Null(updated);
|
||||
}
|
||||
|
||||
// ---------- HU-73: walkover ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadWalkOverAsync_AwardsRegulationDefaultAndCountsInStandings()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
|
||||
Match? updated = await matchService.LoadWalkOverAsync(
|
||||
seeded.Match.Id, presentTeamId: seeded.VisitorTeam.Id, presentTeamScore: null);
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(MatchStatus.WalkOver, updated!.Status);
|
||||
Assert.True(updated.IsFinished);
|
||||
Assert.Equal(seeded.VisitorTeam.Id, updated.WinningTeamId);
|
||||
Assert.Equal(20, updated.VisitorScore);
|
||||
Assert.Equal(0, updated.HomeScore);
|
||||
|
||||
// The walkover impacts standings just like a normal finished result.
|
||||
List<Position> positions = await divisionService.GetPositionsByDivisionIdAsync(seeded.DivisionId);
|
||||
Position winner = Assert.Single(positions, p => p.TeamId == seeded.VisitorTeam.Id);
|
||||
Assert.Equal(1, winner.Wins);
|
||||
Assert.Equal(2, winner.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadWalkOverAsync_TeamNotInMatch_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchService.LoadWalkOverAsync(seeded.Match.Id, presentTeamId: Guid.NewGuid(), presentTeamScore: null));
|
||||
}
|
||||
|
||||
// ---------- HU-71 + HU-72: coherent sheet feeds the ranking ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_SumEqualsScore_PersistsAndRankingReflectsPoints()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player scorerA = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Alpha");
|
||||
Player scorerB = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Bravo");
|
||||
|
||||
await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 90, visitorScore: 80);
|
||||
|
||||
LoadMatchSheetRequest request = new()
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
TeamId = seeded.HomeTeam.Id,
|
||||
Scores =
|
||||
[
|
||||
new PlayerScoreEntry { PlayerId = scorerA.Id, Points = 55 },
|
||||
new PlayerScoreEntry { PlayerId = scorerB.Id, Points = 35 },
|
||||
],
|
||||
};
|
||||
|
||||
List<PlayerStatistic> created = await statisticService.LoadTeamMatchSheetAsync(request);
|
||||
Assert.Equal(2, created.Count);
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items, _) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id });
|
||||
|
||||
List<ScorerByPlayerResponse> ranking = [.. items];
|
||||
Assert.Equal(55, Assert.Single(ranking, r => r.PlayerId == scorerA.Id).Points);
|
||||
Assert.Equal(35, Assert.Single(ranking, r => r.PlayerId == scorerB.Id).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_Reload_ReplacesPreviousSheet()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player scorer = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Solo");
|
||||
|
||||
await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 40, visitorScore: 30);
|
||||
|
||||
await statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
TeamId = seeded.HomeTeam.Id,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = scorer.Id, Points = 40 }],
|
||||
});
|
||||
|
||||
// Correct the score, then reload the sheet — the previous rows are replaced.
|
||||
await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 44, visitorScore: 30);
|
||||
await statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
TeamId = seeded.HomeTeam.Id,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = scorer.Id, Points = 44 }],
|
||||
});
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items, _) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id });
|
||||
|
||||
Assert.Equal(44, Assert.Single(items, r => r.PlayerId == scorer.Id).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_SumDiffersFromScore_IsRejectedWithDifference()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player scorer = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Alpha");
|
||||
|
||||
await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 90, visitorScore: 80);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
TeamId = seeded.HomeTeam.Id,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = scorer.Id, Points = 85 }],
|
||||
}));
|
||||
|
||||
// 90 (team score) - 85 (players sum) = difference of 5.
|
||||
Assert.Contains("diferencia de 5", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_PlayerNotOnRoster_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
// A player with no PlayerTeamRegistration for this season.
|
||||
Player stranger = await SeedPlayerAsync(db, seeded.HomeTeam, "Stranger");
|
||||
|
||||
await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 10, visitorScore: 8);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
TeamId = seeded.HomeTeam.Id,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = stranger.Id, Points = 10 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no está en el plantel", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_SanctionedPlayer_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player sanctioned = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Banned", isSanctioned: true);
|
||||
|
||||
await matchService.LoadMatchResultAsync(seeded.Match.Id, homeScore: 12, visitorScore: 9);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
TeamId = seeded.HomeTeam.Id,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = sanctioned.Id, Points = 12 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no está habilitado", ex.Message);
|
||||
}
|
||||
|
||||
// ---------- HU-72: result derived from both teams' sheets in one shot ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_ValidSheets_DerivesScoreAndFinishesMatch()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player homeA = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Alpha");
|
||||
Player homeB = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Bravo");
|
||||
Player visitorA = await SeedRosterPlayerAsync(db, seeded.VisitorTeam, seeded.TournamentId, "Charlie");
|
||||
|
||||
Match? updated = await statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores =
|
||||
[
|
||||
new PlayerScoreEntry { PlayerId = homeA.Id, Points = 55 },
|
||||
new PlayerScoreEntry { PlayerId = homeB.Id, Points = 35 },
|
||||
],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorA.Id, Points = 80 }],
|
||||
WentToOvertime = true,
|
||||
});
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(90, updated!.HomeScore);
|
||||
Assert.Equal(80, updated.VisitorScore);
|
||||
Assert.True(updated.IsFinished);
|
||||
Assert.Equal(MatchStatus.Played, updated.Status);
|
||||
Assert.Equal(seeded.HomeTeam.Id, updated.WinningTeamId);
|
||||
Assert.True(updated.WentToOvertime);
|
||||
|
||||
Match reloaded = await ReloadMatchAsync(db, seeded.Match.Id);
|
||||
Assert.Equal(90, reloaded.HomeScore);
|
||||
Assert.Equal(80, reloaded.VisitorScore);
|
||||
Assert.True(reloaded.WentToOvertime);
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items, _) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id });
|
||||
List<ScorerByPlayerResponse> ranking = [.. items];
|
||||
Assert.Equal(55, Assert.Single(ranking, r => r.PlayerId == homeA.Id).Points);
|
||||
Assert.Equal(35, Assert.Single(ranking, r => r.PlayerId == homeB.Id).Points);
|
||||
Assert.Equal(80, Assert.Single(ranking, r => r.PlayerId == visitorA.Id).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_TiedSums_IsRejectedAndPersistsNothing()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player homePlayer = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Alpha");
|
||||
Player visitorPlayer = await SeedRosterPlayerAsync(db, seeded.VisitorTeam, seeded.TournamentId, "Bravo");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores = [new PlayerScoreEntry { PlayerId = homePlayer.Id, Points = 70 }],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorPlayer.Id, Points = 70 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no puede terminar empatado", ex.Message);
|
||||
|
||||
Match reloaded = await ReloadMatchAsync(db, seeded.Match.Id);
|
||||
Assert.False(reloaded.IsFinished);
|
||||
Assert.Equal(MatchStatus.Scheduled, reloaded.Status);
|
||||
Assert.Empty(await db.PlayersStatistics.Where(s => s.MatchId == seeded.Match.Id).ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_PlayerNotOnRoster_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player stranger = await SeedPlayerAsync(db, seeded.HomeTeam, "Stranger");
|
||||
Player visitorPlayer = await SeedRosterPlayerAsync(db, seeded.VisitorTeam, seeded.TournamentId, "Bravo");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores = [new PlayerScoreEntry { PlayerId = stranger.Id, Points = 10 }],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorPlayer.Id, Points = 8 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no está en el plantel", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_SanctionedPlayer_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player sanctioned = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Banned", isSanctioned: true);
|
||||
Player visitorPlayer = await SeedRosterPlayerAsync(db, seeded.VisitorTeam, seeded.TournamentId, "Bravo");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores = [new PlayerScoreEntry { PlayerId = sanctioned.Id, Points = 12 }],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorPlayer.Id, Points = 9 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no está habilitado", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_SeveralIneligiblePlayersAcrossBothTeams_ListsEveryOneByName()
|
||||
{
|
||||
// Regression: rejecting on the FIRST ineligible player found (and
|
||||
// naming it by raw player id) forced the admin to fix and resubmit
|
||||
// the sheet once per offender. The error must instead name every
|
||||
// ineligible/sanctioned player, by their real name, grouped by team.
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player homeSanctioned = await SeedRosterPlayerAsync(
|
||||
db, seeded.HomeTeam, seeded.TournamentId, "Banned", isSanctioned: true);
|
||||
Player homeNotHabilitado = await SeedRosterPlayerAsync(
|
||||
db, seeded.HomeTeam, seeded.TournamentId, "Pending", medicalStatus: MedicalRecordStatus.Pending);
|
||||
Player visitorSanctioned = await SeedRosterPlayerAsync(
|
||||
db, seeded.VisitorTeam, seeded.TournamentId, "Suspended", isSanctioned: true);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
HomeScores =
|
||||
[
|
||||
new PlayerScoreEntry { PlayerId = homeSanctioned.Id, Points = 10 },
|
||||
new PlayerScoreEntry { PlayerId = homeNotHabilitado.Id, Points = 5 },
|
||||
],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorSanctioned.Id, Points = 8 }],
|
||||
MatchId = seeded.Match.Id,
|
||||
}));
|
||||
|
||||
Assert.Contains(homeSanctioned.FullName, ex.Message);
|
||||
Assert.Contains(homeNotHabilitado.FullName, ex.Message);
|
||||
Assert.Contains(visitorSanctioned.FullName, ex.Message);
|
||||
Assert.Contains(seeded.HomeTeam.Name, ex.Message);
|
||||
Assert.Contains(seeded.VisitorTeam.Name, ex.Message);
|
||||
Assert.DoesNotContain(homeSanctioned.Id.ToString(), ex.Message);
|
||||
Assert.DoesNotContain(homeNotHabilitado.Id.ToString(), ex.Message);
|
||||
Assert.DoesNotContain(visitorSanctioned.Id.ToString(), ex.Message);
|
||||
}
|
||||
|
||||
// ---------- HU-73 (owner's rule): <4 habilitados forces a walkover ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_TeamWithFewerThanFourHabilitados_RequiresWalkOver()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
// Only 3 habilitado players on the home roster (2 fillers + the
|
||||
// scoring player below) — every one of them individually eligible,
|
||||
// but the TEAM still can't legally play.
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group, fillerHabilitadoPerTeam: 2);
|
||||
Player homePlayer = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Alpha");
|
||||
Player visitorPlayer = await SeedRosterPlayerAsync(db, seeded.VisitorTeam, seeded.TournamentId, "Bravo");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores = [new PlayerScoreEntry { PlayerId = homePlayer.Id, Points = 10 }],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorPlayer.Id, Points = 8 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("mínimo 4", ex.Message);
|
||||
Assert.Contains("walkover", ex.Message);
|
||||
|
||||
// Nothing persisted — the match stays scheduled, unfinished.
|
||||
Match reloaded = await ReloadMatchAsync(db, seeded.Match.Id);
|
||||
Assert.False(reloaded.IsFinished);
|
||||
Assert.Equal(MatchStatus.Scheduled, reloaded.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_NonExistentMatch_ReturnsNull()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Match? updated = await statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = Guid.NewGuid(),
|
||||
HomeScores = [],
|
||||
VisitorScores = [],
|
||||
});
|
||||
|
||||
Assert.Null(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadMatchResultFromSheetsAsync_Correction_ReplacesBothTeamsPreviousSheetsAndScore()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService<IScorerRepository>();
|
||||
|
||||
Seeded seeded = await SeedMatchAsync(db, StageType.Group);
|
||||
Player homePlayer = await SeedRosterPlayerAsync(db, seeded.HomeTeam, seeded.TournamentId, "Alpha");
|
||||
Player visitorPlayer = await SeedRosterPlayerAsync(db, seeded.VisitorTeam, seeded.TournamentId, "Bravo");
|
||||
|
||||
await statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores = [new PlayerScoreEntry { PlayerId = homePlayer.Id, Points = 40 }],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorPlayer.Id, Points = 30 }],
|
||||
});
|
||||
|
||||
Match? corrected = await statisticService.LoadMatchResultFromSheetsAsync(new LoadMatchResultFromSheetsRequest
|
||||
{
|
||||
MatchId = seeded.Match.Id,
|
||||
HomeScores = [new PlayerScoreEntry { PlayerId = homePlayer.Id, Points = 44 }],
|
||||
VisitorScores = [new PlayerScoreEntry { PlayerId = visitorPlayer.Id, Points = 30 }],
|
||||
});
|
||||
|
||||
Assert.NotNull(corrected);
|
||||
Assert.Equal(44, corrected!.HomeScore);
|
||||
|
||||
(IEnumerable<ScorerByPlayerResponse> items, _) =
|
||||
await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id });
|
||||
List<ScorerByPlayerResponse> ranking = [.. items];
|
||||
Assert.Equal(44, Assert.Single(ranking, r => r.PlayerId == homePlayer.Id).Points);
|
||||
Assert.Single(ranking, r => r.PlayerId == homePlayer.Id);
|
||||
}
|
||||
|
||||
// ---------- seeding ----------
|
||||
|
||||
private sealed record Seeded(Tournament Tournament, Guid TournamentId, Guid DivisionId, Team HomeTeam, Team VisitorTeam, Match Match);
|
||||
|
||||
private static async Task<Seeded> SeedMatchAsync(
|
||||
ApplicationDBContext db, StageType stageType, int fillerHabilitadoPerTeam = 4)
|
||||
{
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Team home = await SeedTeamAsync(db, tournament.Id);
|
||||
Team visitor = await SeedTeamAsync(db, tournament.Id);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, stageType);
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = stageType == StageType.Group ? MatchType.Regular : MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeam = visitor,
|
||||
VisitorTeamId = visitor.Id,
|
||||
IsFinished = false,
|
||||
Status = MatchStatus.Scheduled,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Baseline habilitado padding (owner's walkover-threshold rule, HU-73):
|
||||
// every team here needs >= 4 habilitado players to even load a normal
|
||||
// result, independent of whichever specific player(s) each test seeds
|
||||
// to exercise a DIFFERENT rule (roster membership, sanctions, medical
|
||||
// approval). None of these fillers are ever listed in a score sheet.
|
||||
// A caller testing the threshold itself can lower this below 4.
|
||||
for (int i = 0; i < fillerHabilitadoPerTeam; i++)
|
||||
{
|
||||
await SeedRosterPlayerAsync(db, home, tournament.Id, $"Filler{i}");
|
||||
await SeedRosterPlayerAsync(db, visitor, tournament.Id, $"Filler{i}");
|
||||
}
|
||||
|
||||
return new Seeded(tournament, tournament.Id, division.Id, home, visitor, match);
|
||||
}
|
||||
|
||||
private static async Task<Match> ReloadMatchAsync(ApplicationDBContext db, Guid matchId)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
return await db.Matches.FindAsync(matchId)
|
||||
?? throw new InvalidOperationException("Seeded match disappeared.");
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Match-result test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Guid tournamentId)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division, Tournament tournament, StageType stageType)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"{stageType}-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
IsElimination = stageType != StageType.Group,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedPlayerAsync(ApplicationDBContext db, Team team, string lastName, bool isSanctioned = false)
|
||||
{
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Test",
|
||||
LastName = lastName,
|
||||
SecondName = null,
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = isSanctioned,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedRosterPlayerAsync(
|
||||
ApplicationDBContext db, Team team, Guid tournamentId, string lastName,
|
||||
bool isSanctioned = false, MedicalRecordStatus medicalStatus = MedicalRecordStatus.Approved)
|
||||
{
|
||||
Player player = await SeedPlayerAsync(db, team, lastName, isSanctioned);
|
||||
|
||||
// File-backed habilitación (medical-records-storage-eligibility): an
|
||||
// Approved registration must also carry a real stored file reference
|
||||
// to read as habilitado, so this default-Approved fixture stores one.
|
||||
bool approved = medicalStatus == MedicalRecordStatus.Approved;
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournamentId,
|
||||
MedicalRecordStatus = medicalStatus,
|
||||
MedicalRecordFileUrl = approved ? $"{team.Id}/{player.Id}/{Guid.NewGuid()}.pdf" : null,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Application.DTOs.Match.Response;
|
||||
using Application.DTOs.Player.Response;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A match's roster (<see cref="TeamDetailedMatchResponse.Players"/>) must
|
||||
/// reflect each player's REAL season-scoped medical-record status. Found live:
|
||||
/// <c>CreateMap<Player, PublicPlayerResponse>()</c> has no member mapping
|
||||
/// for MedicalRecordStatus/IsHabilitado/JerseyNumber — those live on
|
||||
/// <see cref="PlayerTeamRegistration"/>, not <see cref="Player"/> — so every
|
||||
/// roster entry silently defaulted to not-habilitado, even for players who
|
||||
/// genuinely are (HU-57/HU-62). Fixed via an AfterMap step on
|
||||
/// <c>CreateMap<Match, DetailedMatchResponse>()</c> that resolves each
|
||||
/// roster player's matching registration for the match's tournament.
|
||||
/// </summary>
|
||||
public class MatchRosterEligibilityMappingTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchRosterEligibilityMappingTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingMatchToDetailedResponse_ResolvesRosterHabilitadoFromRegistration()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IMapper mapper = scope.ServiceProvider.GetRequiredService<IMapper>();
|
||||
|
||||
Guid tournamentId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Team home = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = "Home",
|
||||
Slug = $"home-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "HOM",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player habilitado = NewPlayer(home, "Habilitado");
|
||||
habilitado.PlayerTeamRegistrations =
|
||||
[
|
||||
new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = habilitado.Id,
|
||||
TeamId = teamId,
|
||||
TournamentId = tournamentId,
|
||||
MedicalRecordStatus = MedicalRecordStatus.Approved,
|
||||
MedicalRecordFileUrl = $"{teamId}/{habilitado.Id}/ficha.pdf",
|
||||
JerseyNumber = 7,
|
||||
CreatedBy = "test",
|
||||
},
|
||||
];
|
||||
|
||||
Player pending = NewPlayer(home, "Pending");
|
||||
pending.PlayerTeamRegistrations =
|
||||
[
|
||||
new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = pending.Id,
|
||||
TeamId = teamId,
|
||||
TournamentId = tournamentId,
|
||||
MedicalRecordStatus = MedicalRecordStatus.Pending,
|
||||
CreatedBy = "test",
|
||||
},
|
||||
];
|
||||
|
||||
home.Players = [habilitado, pending];
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Id = tournamentId,
|
||||
Description = "Test",
|
||||
Name = "Test tournament",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = DateTime.UtcNow.Date,
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Name = "Division",
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournamentId,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Name = "Group",
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = DateTime.UtcNow.Date,
|
||||
EndDate = DateTime.UtcNow.Date.AddDays(30),
|
||||
Division = division,
|
||||
DivisionId = division.Id,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
Type = MatchType.Regular,
|
||||
IsFinished = false,
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
MatchDate = DateTime.UtcNow.Date,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
DetailedMatchResponse response = mapper.Map<DetailedMatchResponse>(match);
|
||||
|
||||
PublicPlayerResponse mappedHabilitado = Assert.Single(
|
||||
response.HomeTeam!.Players, p => p.Id == habilitado.Id);
|
||||
Assert.True(mappedHabilitado.IsHabilitado);
|
||||
Assert.Equal(MedicalRecordStatus.Approved, mappedHabilitado.MedicalRecordStatus);
|
||||
Assert.Equal(7, mappedHabilitado.JerseyNumber);
|
||||
|
||||
PublicPlayerResponse mappedPending = Assert.Single(
|
||||
response.HomeTeam!.Players, p => p.Id == pending.Id);
|
||||
Assert.False(mappedPending.IsHabilitado);
|
||||
Assert.Equal(MedicalRecordStatus.Pending, mappedPending.MedicalRecordStatus);
|
||||
}
|
||||
|
||||
private static Player NewPlayer(Team team, string lastName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FirstName = "Test",
|
||||
LastName = lastName,
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using API.AutoMapperProfiles;
|
||||
|
||||
using Application.DTOs.Match.Response;
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the public match detail attributes each scorer to its own team (via
|
||||
/// the player's TeamId, since Scorer has no TeamId) and aggregates points per
|
||||
/// player — so the match scoreboard can list "goleadores del partido" per side.
|
||||
/// </summary>
|
||||
public class MatchScorersMappingTests
|
||||
{
|
||||
private static IMapper CreateMapper()
|
||||
{
|
||||
MapperConfiguration configuration = new(
|
||||
cfg =>
|
||||
{
|
||||
cfg.AddProfile<MatchProfile>();
|
||||
cfg.AddProfile<TeamProfile>();
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
return configuration.CreateMapper();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetailedMatch_AttributesScorersToTheirTeam_AndAggregatesPerPlayer()
|
||||
{
|
||||
Team home = CreateTeam("Home");
|
||||
Team visitor = CreateTeam("Visitor");
|
||||
Player homePlayer = CreatePlayer(home, "Perez", "Juan");
|
||||
Player visitorPlayer = CreatePlayer(visitor, "Gomez", "Luis");
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
MatchDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Type = Domain.Enums.MatchType.Regular,
|
||||
Slug = "home-vs-visitor",
|
||||
IsFinished = true,
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeam = visitor,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = 20,
|
||||
VisitorScore = 10,
|
||||
Scorers =
|
||||
[
|
||||
new Scorer { CreatedBy = "system", PlayerId = homePlayer.Id, Player = homePlayer, Points = 8, MatchId = Guid.Empty },
|
||||
new Scorer { CreatedBy = "system", PlayerId = homePlayer.Id, Player = homePlayer, Points = 12, MatchId = Guid.Empty },
|
||||
new Scorer { CreatedBy = "system", PlayerId = visitorPlayer.Id, Player = visitorPlayer, Points = 10, MatchId = Guid.Empty },
|
||||
],
|
||||
};
|
||||
|
||||
DetailedMatchResponse response = CreateMapper().Map<DetailedMatchResponse>(match);
|
||||
|
||||
Assert.Single(response.HomeTeam!.Scorers);
|
||||
Assert.Equal(homePlayer.Id, response.HomeTeam!.Scorers[0].PlayerId);
|
||||
Assert.Equal(20, response.HomeTeam!.Scorers[0].Points); // 8 + 12 aggregated per player
|
||||
|
||||
Assert.Single(response.VisitorTeam!.Scorers);
|
||||
Assert.Equal(visitorPlayer.Id, response.VisitorTeam!.Scorers[0].PlayerId);
|
||||
Assert.Equal(10, response.VisitorTeam!.Scorers[0].Points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A not-yet-seeded bracket slot (both teams still unknown) must map both
|
||||
/// sides to null so the frontend renders "A definir" — regression test for
|
||||
/// a ForPath(dest.HomeTeam!.Score, ...) mapping that used to force
|
||||
/// AutoMapper to instantiate a fake HomeTeam (Guid.Empty id, null name)
|
||||
/// just to have somewhere to put the score, even with no team assigned.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DetailedMatch_WithNoTeamsAssignedYet_MapsBothSidesToNull()
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
MatchDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Type = Domain.Enums.MatchType.Playoff,
|
||||
Slug = "",
|
||||
IsFinished = false,
|
||||
HomeTeam = null,
|
||||
HomeTeamId = null,
|
||||
VisitorTeam = null,
|
||||
VisitorTeamId = null,
|
||||
HomeScore = null,
|
||||
VisitorScore = null,
|
||||
Scorers = [],
|
||||
};
|
||||
|
||||
DetailedMatchResponse response = CreateMapper().Map<DetailedMatchResponse>(match);
|
||||
|
||||
Assert.Null(response.HomeTeam);
|
||||
Assert.Null(response.VisitorTeam);
|
||||
}
|
||||
|
||||
private static Team CreateTeam(string name)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
Name = name,
|
||||
Slug = $"{name}-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = name[..Math.Min(3, name.Length)].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.com/logo.png",
|
||||
ShirtColor = "red",
|
||||
Players = [],
|
||||
};
|
||||
}
|
||||
|
||||
private static Player CreatePlayer(Team team, string lastName, string firstName)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "system",
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
Slug = $"{lastName}-{firstName}-{Guid.NewGuid()}",
|
||||
DocumentNumber = "12345678",
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "SS-0001",
|
||||
TeamId = team.Id,
|
||||
Team = team,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers best-of-N playoff series: creation guardrails (teams must be
|
||||
/// distinct, assigned to the stage, and not duplicated), game scheduling
|
||||
/// limits (cannot exceed BestOf, cannot add to a decided series), and
|
||||
/// winner determination once one team wins the majority of games.
|
||||
/// </summary>
|
||||
public class MatchSeriesServiceTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchSeriesServiceTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeriesAsync_ValidTeams_CopiesBestOfFromStage()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, bestOf: 3);
|
||||
await AssignTeamToStageAsync(db, stage, teams[0]);
|
||||
await AssignTeamToStageAsync(db, stage, teams[1]);
|
||||
|
||||
MatchSeries series = await matchSeriesService.CreateSeriesAsync(stage.Id, teams[0].Id, teams[1].Id);
|
||||
|
||||
Assert.Equal(3, series.BestOf);
|
||||
Assert.Null(series.WinningTeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeriesAsync_SameTeamTwice_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 1);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, bestOf: 1);
|
||||
await AssignTeamToStageAsync(db, stage, teams[0]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchSeriesService.CreateSeriesAsync(stage.Id, teams[0].Id, teams[0].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeriesAsync_TeamNotAssignedToStage_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, bestOf: 1);
|
||||
await AssignTeamToStageAsync(db, stage, teams[0]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchSeriesService.CreateSeriesAsync(stage.Id, teams[0].Id, teams[1].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeriesAsync_DuplicatePairing_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, bestOf: 1);
|
||||
await AssignTeamToStageAsync(db, stage, teams[0]);
|
||||
await AssignTeamToStageAsync(db, stage, teams[1]);
|
||||
|
||||
await matchSeriesService.CreateSeriesAsync(stage.Id, teams[0].Id, teams[1].Id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchSeriesService.CreateSeriesAsync(stage.Id, teams[1].Id, teams[0].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddGameToSeriesAsync_AssignsSequentialGameNumbers()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
MatchSeries series = await SeedDecidableSeriesAsync(db, matchSeriesService, bestOf: 3);
|
||||
|
||||
Match firstGame = await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(1), null);
|
||||
Match secondGame = await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(2), null);
|
||||
|
||||
Assert.Equal(1, firstGame.GameNumber);
|
||||
Assert.Equal(2, secondGame.GameNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddGameToSeriesAsync_ExceedsBestOf_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
MatchSeries series = await SeedDecidableSeriesAsync(db, matchSeriesService, bestOf: 1);
|
||||
|
||||
await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(1), null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(2), null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateSeriesWinnerAsync_MajorityOfGamesWon_SetsWinner()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
MatchSeries series = await SeedDecidableSeriesAsync(db, matchSeriesService, bestOf: 3);
|
||||
|
||||
Match game1 = await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(1), null);
|
||||
Match game2 = await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(2), null);
|
||||
|
||||
await FinishGameAsync(db, game1, series.HomeTeamId);
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(series.Id);
|
||||
|
||||
MatchSeries? afterOneWin = await matchSeriesService.GetSeriesByIdAsync(series.Id);
|
||||
Assert.Null(afterOneWin!.WinningTeamId);
|
||||
|
||||
await FinishGameAsync(db, game2, series.HomeTeamId);
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(series.Id);
|
||||
|
||||
MatchSeries? afterTwoWins = await matchSeriesService.GetSeriesByIdAsync(series.Id);
|
||||
Assert.Equal(series.HomeTeamId, afterTwoWins!.WinningTeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddGameToSeriesAsync_SeriesAlreadyDecided_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
MatchSeries series = await SeedDecidableSeriesAsync(db, matchSeriesService, bestOf: 1);
|
||||
|
||||
Match game = await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(1), null);
|
||||
await FinishGameAsync(db, game, series.HomeTeamId);
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(series.Id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(2), null));
|
||||
}
|
||||
|
||||
private static async Task<MatchSeries> SeedDecidableSeriesAsync(ApplicationDBContext db, IMatchSeriesService matchSeriesService, int bestOf)
|
||||
{
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 2);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament, bestOf);
|
||||
await AssignTeamToStageAsync(db, stage, teams[0]);
|
||||
await AssignTeamToStageAsync(db, stage, teams[1]);
|
||||
|
||||
return await matchSeriesService.CreateSeriesAsync(stage.Id, teams[0].Id, teams[1].Id);
|
||||
}
|
||||
|
||||
private static async Task FinishGameAsync(ApplicationDBContext db, Match game, Guid winningTeamId)
|
||||
{
|
||||
Match tracked = await db.Matches.FirstAsync(m => m.Id == game.Id);
|
||||
tracked.IsFinished = true;
|
||||
tracked.WinningTeamId = winningTeamId;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Series test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division, Tournament tournament, int bestOf)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.SemiFinal,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
BestOf = bestOf,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task AssignTeamToStageAsync(ApplicationDBContext db, Stage stage, Team team)
|
||||
{
|
||||
db.StageTeamMatches.Add(new StageTeamMatch
|
||||
{
|
||||
StageId = stage.Id,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for group-stage match creation in MatchService.
|
||||
/// </summary>
|
||||
public class MatchServiceGenerationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchServiceGenerationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_NoTeamsRegistered_ThrowsInvalidOperationException()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(_, Division division) = await SeedDivisionAsync(db);
|
||||
List<Stage> stages = await SeedGroupStagesAsync(db, division, groupCount: 1);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchService.CreateAutomatedMatchesAsync(stages[0].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_TeamsNotDivisibleByGroupCount_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(Tournament tournament, Division division) = await SeedDivisionAsync(db);
|
||||
await SeedTeamsAsync(db, 10, tournament.Id);
|
||||
List<Stage> stages = await SeedGroupStagesAsync(db, division, groupCount: 3);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchService.CreateAutomatedMatchesAsync(stages[0].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_FewerThanTwoTeamsPerGroup_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(Tournament tournament, Division division) = await SeedDivisionAsync(db);
|
||||
await SeedTeamsAsync(db, 2, tournament.Id);
|
||||
List<Stage> stages = await SeedGroupStagesAsync(db, division, groupCount: 2);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => matchService.CreateAutomatedMatchesAsync(stages[0].Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected match counts follow the round-robin formula teamsPerGroup *
|
||||
/// (teamsPerGroup - 1) / 2: 8 teams / 2 groups => 4 teams/group => 6
|
||||
/// matches; 16 teams / 2 groups => 8 teams/group => 28 matches.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(4, 2, 6)]
|
||||
[InlineData(8, 2, 28)]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_ValidDistribution_CreatesRoundRobinMatches(
|
||||
int teamsPerGroup, int groupCount, int expectedMatchCount)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup, groupCount);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
Assert.Equal(expectedMatchCount, matches.Count);
|
||||
Assert.All(matches, m => Assert.Equal(stages[0].Id, m.StageId));
|
||||
Assert.All(matches, m => Assert.Equal(MatchType.Regular, m.Type));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-65: a group-stage fixture is scheduled by matchday, one round per
|
||||
/// Sunday from the stage start — not by spreading matches evenly across the
|
||||
/// stage's date range. teamsPerGroup=2 produces a single round-robin match
|
||||
/// (2*1/2 = 1) in round 1, dated the first Sunday on or after the start.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_SingleMatch_IsRoundOneOnFirstSunday()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
DateTime start = DateTime.UtcNow.Date;
|
||||
DateTime end = start.AddDays(10);
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 2, groupCount: 1, start, end);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
Assert.Single(matches);
|
||||
Assert.Equal(1, matches[0].Round);
|
||||
Assert.Equal(FirstSundayOnOrAfter(start), matches[0].MatchDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-63/HU-65: teamsPerGroup=3 is odd, so the single round-robin has 3
|
||||
/// rounds (each with one match and one idle team), scheduled on 3
|
||||
/// consecutive Sundays. Round is the canonical grouping — one match per
|
||||
/// round, one round per Sunday.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_OddTeams_OneMatchPerRoundOnConsecutiveSundays()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
DateTime start = DateTime.UtcNow.Date;
|
||||
DateTime end = start.AddDays(30);
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 3, groupCount: 1, start, end);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
List<Match> ordered = [.. matches.OrderBy(m => m.Round)];
|
||||
|
||||
Assert.Equal(3, ordered.Count);
|
||||
Assert.Equal([1, 2, 3], [.. ordered.Select(m => m.Round)]);
|
||||
|
||||
DateTime firstSunday = FirstSundayOnOrAfter(start);
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
Assert.Equal(firstSunday.AddDays(7 * i), ordered[i].MatchDate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The default Sunday schedule for a round: the first Sunday on or after
|
||||
/// the start date. Mirrors RoundCalendar so tests assert against the same
|
||||
/// contract the service uses.
|
||||
/// </summary>
|
||||
private static DateTime FirstSundayOnOrAfter(DateTime start)
|
||||
{
|
||||
int daysUntilSunday = ((int)DayOfWeek.Sunday - (int)start.Date.DayOfWeek + 7) % 7;
|
||||
return start.Date.AddDays(daysUntilSunday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_SingleGroupDivision_SeedsEachMatchWithARealPairing()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(_, _, List<Stage> stages, List<Team> teams) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
Assert.Equal(6, matches.Count);
|
||||
Assert.All(matches, m =>
|
||||
{
|
||||
Assert.NotNull(m.HomeTeamId);
|
||||
Assert.NotNull(m.VisitorTeamId);
|
||||
Assert.Contains(m.HomeTeamId!.Value, teams.Select(t => t.Id));
|
||||
Assert.Contains(m.VisitorTeamId!.Value, teams.Select(t => t.Id));
|
||||
Assert.NotEqual(m.HomeTeamId, m.VisitorTeamId);
|
||||
});
|
||||
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
int appearances = matches.Count(m => m.HomeTeamId == team.Id || m.VisitorTeamId == team.Id);
|
||||
Assert.Equal(3, appearances);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces a real bug found while driving the tournament wizard
|
||||
/// end-to-end: a multi-zone tournament registers every selected team to
|
||||
/// the TOURNAMENT first, then assigns each zone's own subset to that
|
||||
/// zone's own Group stage (via AssignTeamsToStageAsync/StageTeamMatch)
|
||||
/// — exactly what submitWizard.ts does. Before this fix,
|
||||
/// ResolveGroupTeamCountAsync computed "registered teams ÷ groups in
|
||||
/// THIS division" (always ÷ 1, since each zone has exactly one Group
|
||||
/// stage), which equals the TOURNAMENT-WIDE team total, not this
|
||||
/// zone's assigned team count — so whenever the two coincidentally
|
||||
/// differed, matches were silently generated for the wrong pool
|
||||
/// (mixing in teams from every other zone) instead of the teams
|
||||
/// actually assigned to this stage.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_ExplicitAssignmentDiffersFromTournamentTotal_UsesOnlyAssignedTeams()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(Tournament tournament, Division zoneA) = await SeedDivisionAsync(db);
|
||||
Division zoneB = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(zoneB);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// 8 teams registered to the TOURNAMENT as a whole (4 per zone) —
|
||||
// the wizard's "register every selected team up front" step.
|
||||
List<Team> zoneATeams = await SeedTeamsAsync(db, 4, tournament.Id);
|
||||
List<Team> zoneBTeams = await SeedTeamsAsync(db, 4, tournament.Id);
|
||||
|
||||
List<Stage> zoneAStages = await SeedGroupStagesAsync(db, zoneA, groupCount: 1);
|
||||
List<Stage> zoneBStages = await SeedGroupStagesAsync(db, zoneB, groupCount: 1);
|
||||
Stage zoneAGroupStage = zoneAStages[0];
|
||||
|
||||
// Only zoneA's own 4 teams are explicitly assigned to zoneA's group
|
||||
// stage (zoneB's stage is left unassigned, mirroring "the other
|
||||
// zones haven't been processed by the wizard yet").
|
||||
db.StageTeamMatches.AddRange(zoneATeams.Select(t => new StageTeamMatch
|
||||
{
|
||||
StageId = zoneAGroupStage.Id,
|
||||
TeamId = t.Id,
|
||||
CreatedBy = "test",
|
||||
}));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(zoneAGroupStage.Id);
|
||||
|
||||
Assert.Equal(6, matches.Count);
|
||||
Assert.All(matches, m =>
|
||||
{
|
||||
Assert.NotNull(m.HomeTeamId);
|
||||
Assert.NotNull(m.VisitorTeamId);
|
||||
Assert.Contains(m.HomeTeamId!.Value, zoneATeams.Select(t => t.Id));
|
||||
Assert.Contains(m.VisitorTeamId!.Value, zoneATeams.Select(t => t.Id));
|
||||
Assert.DoesNotContain(m.HomeTeamId!.Value, zoneBTeams.Select(t => t.Id));
|
||||
Assert.DoesNotContain(m.VisitorTeamId!.Value, zoneBTeams.Select(t => t.Id));
|
||||
});
|
||||
|
||||
// zoneB's stage existing (unassigned) is itself part of what makes
|
||||
// this reproduce the bug: it proves the fix isn't just "there's
|
||||
// only one Group stage in the tournament".
|
||||
Assert.Single(zoneBStages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_MultipleGroupsInDivision_LeavesMatchesUnseeded()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 2);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
Assert.All(matches, m => Assert.Null(m.HomeTeamId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every match built in one CreateAutomatedMatchesAsync call is unpersisted
|
||||
/// until the whole batch is saved together, so a naive uniqueness check
|
||||
/// against the repository alone would miss collisions between matches in
|
||||
/// the same batch. Forcing a single-day stage range collapses every
|
||||
/// unseeded match's slug source ("TBD vs TBD {date}") to the same base
|
||||
/// slug, proving the batch-local dedup in AssignMatchSlugsAsync actually
|
||||
/// catches it and resolves it via the usual -2/-3 suffixing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_UnseededMatchesShareSameDate_AssignsUniqueSlugsViaSuffix()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
DateTime sameDate = DateTime.UtcNow.Date;
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 2, sameDate, sameDate);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
// Unseeded matches still carry the round structure (HU-63): 4 teams =>
|
||||
// 3 rounds of 2 unseeded ("TBD vs TBD") matches each. The two matches
|
||||
// that share a round share a Sunday date, so their base slug collides —
|
||||
// the point under test is that all in-batch collisions resolve to
|
||||
// distinct slugs. Assertions avoid pinning exact suffix values because
|
||||
// this test class shares one SQLite database across its fixture.
|
||||
Assert.Equal(6, matches.Count);
|
||||
Assert.All(matches, m => Assert.NotNull(m.Round));
|
||||
Assert.All(matches, m => Assert.StartsWith("tbd-vs-tbd-", m.Slug));
|
||||
Assert.Equal(matches.Count, matches.Select(m => m.Slug).Distinct().Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-65/HU-67: a group-stage fixture is scheduled from the start date by
|
||||
/// consecutive Sundays and does not depend on the stage end date, so an
|
||||
/// inverted range no longer aborts generation the way the old even-spread
|
||||
/// date distribution did — the match is simply placed on the first Sunday
|
||||
/// on or after the start.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_EndDateBeforeStartDate_StillSchedulesFromStartSunday()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
DateTime start = DateTime.UtcNow.Date;
|
||||
DateTime end = start.AddDays(-1);
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 2, groupCount: 1, start, end);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
Assert.Single(matches);
|
||||
Assert.Equal(1, matches[0].Round);
|
||||
Assert.Equal(FirstSundayOnOrAfter(start), matches[0].MatchDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-68/HU-67: suspending (and rescheduling) a match marks it Suspended and
|
||||
/// moves its date, but never changes its round nor the rest of the fixture's
|
||||
/// rounds. Every other match keeps the round it was generated with.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SuspendMatchAsync_MarksSuspendedAndMovesDate_ButKeepsRoundAndRestOfFixture()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1);
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
Match target = matches[0];
|
||||
int originalRound = target.Round!.Value;
|
||||
|
||||
// Snapshot every other match's round to prove the fixture is untouched.
|
||||
Dictionary<Guid, int?> otherRoundsBefore = matches
|
||||
.Where(m => m.Id != target.Id)
|
||||
.ToDictionary(m => m.Id, m => m.Round);
|
||||
|
||||
DateTime newDate = target.MatchDate.AddDays(3);
|
||||
Match? suspended = await matchService.SuspendMatchAsync(target.Id, newDate);
|
||||
|
||||
Assert.NotNull(suspended);
|
||||
Assert.Equal(MatchStatus.Suspended, suspended!.Status);
|
||||
Assert.Equal(newDate, suspended.MatchDate);
|
||||
Assert.Equal(originalRound, suspended.Round);
|
||||
|
||||
// Re-read from the database to confirm the round was persisted intact.
|
||||
Match? reloaded = await matchService.GetMatchByIdAsync(target.Id);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal(originalRound, reloaded!.Round);
|
||||
Assert.Equal(MatchStatus.Suspended, reloaded.Status);
|
||||
|
||||
List<Match> byRound = await matchService.GetStageMatchesByRoundAsync(stages[0].Id);
|
||||
foreach (Match other in byRound.Where(m => m.Id != target.Id))
|
||||
{
|
||||
Assert.Equal(otherRoundsBefore[other.Id], other.Round);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-63: a stage's matches can be fetched grouped/ordered by round so the
|
||||
/// frontend renders "Fecha 1 / Partido…, Fecha 2 / …". Ordering is by round,
|
||||
/// not calendar date.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStageMatchesByRoundAsync_OrdersMatchesByRound()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1);
|
||||
await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
List<Match> byRound = await matchService.GetStageMatchesByRoundAsync(stages[0].Id);
|
||||
|
||||
Assert.NotEmpty(byRound);
|
||||
Assert.All(byRound, m => Assert.NotNull(m.Round));
|
||||
|
||||
// 4 teams => 3 rounds of 2 matches each, delivered round 1, 2, 3.
|
||||
List<int> rounds = [.. byRound.Select(m => m.Round!.Value)];
|
||||
Assert.Equal([1, 1, 2, 2, 3, 3], rounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-111: a group-stage fixture is laid out by jornada, weekly. Every match
|
||||
/// sharing a <see cref="Match.Round"/> is dated the same calendar day, and
|
||||
/// successive rounds are exactly seven days apart. Dates only: the time
|
||||
/// component is a neutral midnight and no venue is assigned, so the admin
|
||||
/// fills real time/venue later (HU-66/HU-67).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_GroupStage_SameRoundSharesDate_RoundsAreWeekly_NeutralTimeNoVenue()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
DateTime start = new(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc); // Fixed anchor keeps the assertions deterministic.
|
||||
(_, _, List<Stage> stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1, start, start.AddDays(60));
|
||||
|
||||
List<Match> matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id);
|
||||
|
||||
// 4 teams => 3 rounds of 2 matches each.
|
||||
List<IGrouping<int, Match>> byRound = [.. matches.GroupBy(m => m.Round!.Value).OrderBy(g => g.Key)];
|
||||
Assert.Equal(3, byRound.Count);
|
||||
|
||||
// Every match in a round shares exactly one calendar date.
|
||||
Assert.All(byRound, group => Assert.Single(group.Select(m => m.MatchDate).Distinct()));
|
||||
|
||||
// Successive rounds are exactly one week apart.
|
||||
List<DateTime> roundDates = [.. byRound.Select(group => group.First().MatchDate)];
|
||||
for (int i = 0; i < roundDates.Count; i++)
|
||||
{
|
||||
Assert.Equal(roundDates[0].AddDays(7 * i), roundDates[i]);
|
||||
}
|
||||
|
||||
// Dates only: neutral midnight time, no venue.
|
||||
Assert.All(matches, m => Assert.Equal(TimeSpan.Zero, m.MatchDate.TimeOfDay));
|
||||
Assert.All(matches, m => Assert.Null(m.VenueId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-111 core anti-collision guarantee: a team belongs to its zone AND the
|
||||
/// cross-division cup, so the two fixtures must never place a jornada on the
|
||||
/// same day. Regular zones are dated on Sundays; the cross cup is shifted to
|
||||
/// a different fixed weekday (Wednesday), so no calendar date is ever shared
|
||||
/// between the two fixtures on any jornada.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAutomatedMatchesAsync_RegularZoneAndCrossCup_SameTournament_NeverShareADate()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
DateTime start = new(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc); // Fixed anchor keeps the assertions deterministic.
|
||||
|
||||
(Tournament tournament, Division zone) = await SeedDivisionAsync(db);
|
||||
Stage zoneStage = (await SeedGroupStagesAsync(db, zone, groupCount: 1, start, start.AddDays(60)))[0];
|
||||
List<Team> zoneTeams = await SeedTeamsAsync(db, 4, tournament.Id);
|
||||
await AssignTeamsToStageAsync(db, zoneStage, zoneTeams);
|
||||
|
||||
Division cup = await SeedCrossCupDivisionAsync(db, tournament);
|
||||
Stage cupStage = (await SeedGroupStagesAsync(db, cup, groupCount: 1, start, start.AddDays(60)))[0];
|
||||
List<Team> cupTeams = await SeedTeamsAsync(db, 4, tournament.Id);
|
||||
await AssignTeamsToStageAsync(db, cupStage, cupTeams);
|
||||
|
||||
List<Match> zoneMatches = await matchService.CreateAutomatedMatchesAsync(zoneStage.Id);
|
||||
List<Match> cupMatches = await matchService.CreateAutomatedMatchesAsync(cupStage.Id);
|
||||
|
||||
// Zones land on Sundays; the cross cup on a different fixed weekday.
|
||||
Assert.NotEmpty(zoneMatches);
|
||||
Assert.NotEmpty(cupMatches);
|
||||
Assert.All(zoneMatches, m => Assert.Equal(DayOfWeek.Sunday, m.MatchDate.DayOfWeek));
|
||||
Assert.All(cupMatches, m => Assert.Equal(DayOfWeek.Wednesday, m.MatchDate.DayOfWeek));
|
||||
|
||||
// The core guarantee: no jornada of either fixture ever shares a date.
|
||||
HashSet<DateTime> zoneDates = [.. zoneMatches.Select(m => m.MatchDate)];
|
||||
HashSet<DateTime> cupDates = [.. cupMatches.Select(m => m.MatchDate)];
|
||||
Assert.Empty(zoneDates.Intersect(cupDates));
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedCrossCupDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division cup = new()
|
||||
{
|
||||
Slug = $"cross-cup-{Guid.NewGuid()}",
|
||||
Name = $"Cross-Cup-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = true,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(cup);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return cup;
|
||||
}
|
||||
|
||||
private static async Task AssignTeamsToStageAsync(ApplicationDBContext db, Stage stage, List<Team> teams)
|
||||
{
|
||||
db.StageTeamMatches.AddRange(teams.Select(t => new StageTeamMatch
|
||||
{
|
||||
StageId = stage.Id,
|
||||
TeamId = t.Id,
|
||||
CreatedBy = "test",
|
||||
}));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, int count, Guid? tournamentId = null)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
teams.Add(new Team
|
||||
{
|
||||
Name = $"Team-{i}-{Guid.NewGuid()}",
|
||||
Slug = $"team-{i}-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = $"T{i:D2}",
|
||||
LogoUrl = "https://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
if (teams.Count > 0)
|
||||
{
|
||||
db.Teams.AddRange(teams);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<(Tournament tournament, Division division)> SeedDivisionAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime start = DateTime.UtcNow.Date;
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Characterization test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = start.AddDays(-1),
|
||||
StartDate = start,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (tournament, division);
|
||||
}
|
||||
|
||||
private static async Task<List<Stage>> SeedGroupStagesAsync(
|
||||
ApplicationDBContext db, Division division, int groupCount, DateTime? startDate = null, DateTime? endDate = null)
|
||||
{
|
||||
DateTime start = startDate ?? DateTime.UtcNow.Date;
|
||||
DateTime end = endDate ?? start.AddDays(14);
|
||||
|
||||
List<Stage> stages = [];
|
||||
for (int i = 0; i < groupCount; i++)
|
||||
{
|
||||
stages.Add(new Stage
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Group-{i}-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = start,
|
||||
EndDate = end,
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
db.Stages.AddRange(stages);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
private static async Task<(Tournament tournament, Division division, List<Stage> stages, List<Team> teams)>
|
||||
SeedGroupStageWithTeamsAsync(
|
||||
ApplicationDBContext db, int teamsPerGroup, int groupCount,
|
||||
DateTime? startDate = null, DateTime? endDate = null)
|
||||
{
|
||||
(Tournament tournament, Division division) = await SeedDivisionAsync(db);
|
||||
List<Team> teams = await SeedTeamsAsync(db, teamsPerGroup * groupCount, tournament.Id);
|
||||
List<Stage> stages = await SeedGroupStagesAsync(db, division, groupCount, startDate, endDate);
|
||||
|
||||
return (tournament, division, stages, teams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for the already-shipped
|
||||
/// <see cref="MatchService.HasVenueScheduleConflictAsync"/> (Phase 1, no production
|
||||
/// change): proves the exact 2-hour exclusive boundary, cross-division/tournament
|
||||
/// reach, different-venue independence, and self-exclusion before the rule is wired
|
||||
/// into <c>CreateMatch</c> and <c>SuspendMatch</c>.
|
||||
/// </summary>
|
||||
public class MatchServiceVenueScheduleConflictTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MatchServiceVenueScheduleConflictTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasVenueScheduleConflictAsync_JustUnderTwoHoursApart_SameVenue_ReturnsTrue()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
Stage stage = await SeedStageAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, existingDate);
|
||||
|
||||
bool result = await matchService.HasVenueScheduleConflictAsync(
|
||||
venue.Id, existingDate.AddMinutes(119), Guid.Empty);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasVenueScheduleConflictAsync_ExactlyTwoHoursApart_SameVenue_ReturnsFalse()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
Stage stage = await SeedStageAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venue.Id, existingDate);
|
||||
|
||||
bool result = await matchService.HasVenueScheduleConflictAsync(
|
||||
venue.Id, existingDate.AddHours(2), Guid.Empty);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasVenueScheduleConflictAsync_SameTime_DifferentVenue_ReturnsFalse()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Venue venueA = await SeedVenueAsync(db);
|
||||
Venue venueB = await SeedVenueAsync(db);
|
||||
Stage stage = await SeedStageAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stage, venueA.Id, existingDate);
|
||||
|
||||
bool result = await matchService.HasVenueScheduleConflictAsync(
|
||||
venueB.Id, existingDate, Guid.Empty);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasVenueScheduleConflictAsync_SameVenueWindow_DifferentDivisionAndTournament_ReturnsTrue()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
Stage stageOne = await SeedStageAsync(db);
|
||||
Stage stageTwo = await SeedStageAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
await SeedMatchAsync(db, stageOne, venue.Id, existingDate);
|
||||
|
||||
bool result = await matchService.HasVenueScheduleConflictAsync(
|
||||
venue.Id, existingDate.AddHours(1), Guid.Empty);
|
||||
|
||||
Assert.True(result);
|
||||
// stageTwo belongs to its own freshly seeded division/tournament, proving the
|
||||
// rule reached across division/tournament boundaries via VenueId alone.
|
||||
Assert.NotEqual(stageOne.DivisionId, stageTwo.DivisionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasVenueScheduleConflictAsync_ExcludeMatchId_ExcludesItself_ReturnsFalse()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
Stage stage = await SeedStageAsync(db);
|
||||
DateTime existingDate = new(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
Match match = await SeedMatchAsync(db, stage, venue.Id, existingDate);
|
||||
|
||||
bool result = await matchService.HasVenueScheduleConflictAsync(
|
||||
venue.Id, existingDate, match.Id);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
private static async Task<Venue> SeedVenueAsync(ApplicationDBContext db)
|
||||
{
|
||||
Venue venue = new()
|
||||
{
|
||||
Name = $"Venue-{Guid.NewGuid()}",
|
||||
Slug = $"venue-{Guid.NewGuid()}",
|
||||
Address = "Test Address 123",
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Venues.Add(venue);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return venue;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime start = DateTime.UtcNow.Date;
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Venue conflict test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = start.AddDays(-1),
|
||||
StartDate = start,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = start,
|
||||
EndDate = start.AddDays(14),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedMatchAsync(ApplicationDBContext db, Stage stage, Guid venueId, DateTime matchDate)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
Type = MatchType.Regular,
|
||||
IsFinished = false,
|
||||
StageId = stage.Id,
|
||||
VenueId = venueId,
|
||||
MatchDate = matchDate,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return match;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using API.Controllers;
|
||||
|
||||
using Application.DTOs.MedicalRecord.Response;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Interfaces.Storage;
|
||||
using Application.Utils.Helper.SupabaseHelper;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers medical-record DOWNLOAD (QA wave 1, Bug 2): the uploaded PDF is kept
|
||||
/// in a PRIVATE storage area (no public URL), so it can only be served back
|
||||
/// through the API. Before the fix there was no download path at all. These
|
||||
/// tests verify (1) the storage boundary round-trips an uploaded object back to
|
||||
/// its bytes and (2) the controller streams those bytes as a PDF with the
|
||||
/// original file name, and returns 404 when no file exists. The controller is
|
||||
/// exercised directly (its DI graph pulls in the live SupabaseHelper, the same
|
||||
/// documented host testability gap as SupabaseDependentControllerNotFoundTests).
|
||||
/// </summary>
|
||||
public class MedicalRecordDownloadTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Storage_StoreThenDownload_RoundTripsBytes()
|
||||
{
|
||||
InMemoryRawStorage raw = new();
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
SupabaseMedicalRecordStorage storage = new(raw, configuration);
|
||||
byte[] pdf = Encoding.UTF8.GetBytes("%PDF-1.4 fake medical record");
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
string objectPath = await storage.StoreAsync(
|
||||
teamId, Guid.NewGuid(), "ficha.pdf", new MemoryStream(pdf));
|
||||
|
||||
byte[] downloaded = await storage.DownloadAsync(objectPath);
|
||||
|
||||
Assert.Equal(pdf, downloaded);
|
||||
Assert.StartsWith($"{teamId}/", objectPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_WithStoredFile_StreamsPdfWithOriginalName()
|
||||
{
|
||||
byte[] pdf = Encoding.UTF8.GetBytes("%PDF-1.4 stored");
|
||||
Guid playerId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
Guid tournamentId = Guid.NewGuid();
|
||||
const string fileUrl = "medical-records/t/p/object.pdf";
|
||||
|
||||
FakeMedicalRecordService service = new(new MedicalRecordResponse
|
||||
{
|
||||
PlayerId = playerId,
|
||||
TeamId = teamId,
|
||||
TournamentId = tournamentId,
|
||||
Status = MedicalRecordStatus.Pending,
|
||||
IsHabilitado = false,
|
||||
FileUrl = fileUrl,
|
||||
FileName = "ficha-original.pdf",
|
||||
});
|
||||
FakeStorage storage = new(new Dictionary<string, byte[]> { [fileUrl] = pdf });
|
||||
|
||||
MedicalRecordController controller = new(service, storage);
|
||||
|
||||
IActionResult result = await controller.DownloadMedicalRecord(playerId, teamId, tournamentId);
|
||||
|
||||
FileContentResult file = Assert.IsType<FileContentResult>(result);
|
||||
Assert.Equal("application/pdf", file.ContentType);
|
||||
Assert.Equal("ficha-original.pdf", file.FileDownloadName);
|
||||
Assert.Equal(pdf, file.FileContents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_WhenNoFileStored_Returns404()
|
||||
{
|
||||
Guid playerId = Guid.NewGuid();
|
||||
|
||||
// Registration exists but no file was ever uploaded (FileUrl null).
|
||||
FakeMedicalRecordService service = new(new MedicalRecordResponse
|
||||
{
|
||||
PlayerId = playerId,
|
||||
TeamId = Guid.NewGuid(),
|
||||
TournamentId = Guid.NewGuid(),
|
||||
Status = MedicalRecordStatus.Pending,
|
||||
IsHabilitado = false,
|
||||
FileUrl = null,
|
||||
});
|
||||
FakeStorage storage = new([]);
|
||||
|
||||
MedicalRecordController controller = new(service, storage);
|
||||
ConfigureProblemDetailsFactory(controller);
|
||||
|
||||
IActionResult result = await controller.DownloadMedicalRecord(
|
||||
playerId, Guid.NewGuid(), Guid.NewGuid());
|
||||
|
||||
ObjectResult objectResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, objectResult.StatusCode);
|
||||
}
|
||||
|
||||
private static void ConfigureProblemDetailsFactory(ControllerBase controller)
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddMvc();
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { RequestServices = provider },
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeMedicalRecordService(MedicalRecordResponse response) : IMedicalRecordService
|
||||
{
|
||||
public Task<MedicalRecordResponse> RecordUploadAsync(
|
||||
Guid playerId, Guid teamId, Guid tournamentId, string fileReference, string fileName, string actor)
|
||||
=> Task.FromResult(response);
|
||||
|
||||
public Task<MedicalRecordResponse> ReviewAsync(
|
||||
Guid playerId, Guid teamId, Guid tournamentId, bool approve, string? reason, string actor)
|
||||
=> Task.FromResult(response);
|
||||
|
||||
public Task<MedicalRecordResponse?> GetAsync(Guid playerId, Guid teamId, Guid tournamentId)
|
||||
=> Task.FromResult<MedicalRecordResponse?>(response);
|
||||
}
|
||||
|
||||
private sealed class FakeStorage(Dictionary<string, byte[]> objects) : IMedicalRecordStorage
|
||||
{
|
||||
public Task<string> StoreAsync(
|
||||
Guid teamId, Guid playerId, string fileName, Stream content, CancellationToken ct = default)
|
||||
=> Task.FromResult("unused");
|
||||
|
||||
public Task<byte[]> DownloadAsync(string objectPath, CancellationToken ct = default)
|
||||
=> Task.FromResult(objects[objectPath]);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawStorage : ISupabaseRawStorage
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = [];
|
||||
|
||||
public async Task UploadRawAsync(string objectPath, Stream content, string? bucket = null)
|
||||
{
|
||||
using MemoryStream buffer = new();
|
||||
await content.CopyToAsync(buffer);
|
||||
_objects[objectPath] = buffer.ToArray();
|
||||
}
|
||||
|
||||
public Task<byte[]> DownloadRawAsync(string objectPath, string? bucket = null) => Task.FromResult(_objects[objectPath]);
|
||||
|
||||
public Task<IReadOnlyList<SupabaseStorageEntry>> ListRawAsync(string prefix, string? bucket = null)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public Task RemoveRawAsync(string objectPath, string? bucket = null) => throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
using Application.DTOs.MedicalRecord.Response;
|
||||
using Application.DTOs.PlayerStatistic.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioural tests for the FICHA MEDICA / eligibility batch (Epica 11):
|
||||
/// HU-57 (per team+tournament habilitado state), HU-58 (approve/reject with
|
||||
/// reason), HU-59 (a new season starts un-habilitado, never inheriting the
|
||||
/// prior one), HU-60 (only habilitado, non-sanctioned players are eligible for
|
||||
/// the planilla) and HU-62 (per-player eligibility surfaced). Exercised through
|
||||
/// the real services/repositories against the SQLite-backed integration host.
|
||||
/// </summary>
|
||||
public class MedicalRecordEligibilityTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MedicalRecordEligibilityTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
// ---------- HU-57/HU-59: default status is Pending ----------
|
||||
|
||||
[Fact]
|
||||
public async Task NewRegistration_DefaultsToPending_AndNotHabilitado()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
|
||||
MedicalRecordResponse? record = await medicalRecordService.GetAsync(fx.PlayerId, fx.TeamId, fx.TournamentId);
|
||||
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(MedicalRecordStatus.Pending, record!.Status);
|
||||
Assert.False(record.IsHabilitado);
|
||||
}
|
||||
|
||||
// ---------- HU-55: uploading a file does NOT habilitate ----------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordUpload_StoresReference_ButStaysPending()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
string fileReference = $"{fx.TeamId}/{fx.PlayerId}/{Guid.NewGuid()}.pdf";
|
||||
|
||||
MedicalRecordResponse record = await medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId,
|
||||
fileReference, "ficha.pdf", "owner@club12");
|
||||
|
||||
Assert.Equal(MedicalRecordStatus.Pending, record.Status);
|
||||
Assert.False(record.IsHabilitado);
|
||||
Assert.Equal(fileReference, record.FileUrl);
|
||||
Assert.Equal("ficha.pdf", record.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordUpload_UnknownRegistration_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => medicalRecordService.RecordUploadAsync(
|
||||
Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "ref", "ficha.pdf", "owner@club12"));
|
||||
}
|
||||
|
||||
// ---------- HU-57: once Approved the ficha cannot be re-uploaded ----------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordUpload_AfterApproval_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
string originalRef = $"{fx.TeamId}/{fx.PlayerId}/{Guid.NewGuid()}.pdf";
|
||||
|
||||
// Upload + approve so the record becomes habilitado.
|
||||
await medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId,
|
||||
originalRef, "ficha.pdf", "owner@club12");
|
||||
await medicalRecordService.ReviewAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId, approve: true, reason: null, actor: "owner@club12");
|
||||
|
||||
// A second upload on an Approved record is rejected (view/download only).
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId,
|
||||
$"{fx.TeamId}/{fx.PlayerId}/{Guid.NewGuid()}.pdf", "otra-ficha.pdf", "owner@club12"));
|
||||
|
||||
Assert.Contains("ya está aprobada", ex.Message);
|
||||
|
||||
// The originally approved ficha is untouched.
|
||||
MedicalRecordResponse? record = await medicalRecordService.GetAsync(fx.PlayerId, fx.TeamId, fx.TournamentId);
|
||||
Assert.Equal(MedicalRecordStatus.Approved, record!.Status);
|
||||
Assert.Equal(originalRef, record.FileUrl);
|
||||
Assert.Equal("ficha.pdf", record.FileName);
|
||||
}
|
||||
|
||||
// ---------- Part 2 gap: an Approved-but-never-habilitado (legacy
|
||||
// reference) row must not be stuck forever ----------
|
||||
|
||||
[Fact]
|
||||
public async Task RecordUpload_AfterApprovalWithLegacyReference_IsAllowed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
|
||||
// Approved directly on the seeded row (bypassing the write guard,
|
||||
// same shape as historical pre-relocation data) with a legacy
|
||||
// reference — this never actually habilitated the player.
|
||||
PlayerTeamRegistration registration = await db.PlayerTeamRegistrations.SingleAsync(r =>
|
||||
r.PlayerId == fx.PlayerId && r.TeamId == fx.TeamId && r.TournamentId == fx.TournamentId);
|
||||
registration.MedicalRecordStatus = MedicalRecordStatus.Approved;
|
||||
registration.MedicalRecordFileUrl = "medical-records/legacy/object.pdf";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
string newRef = $"{fx.TeamId}/{fx.PlayerId}/{Guid.NewGuid()}.pdf";
|
||||
MedicalRecordResponse record = await medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId, newRef, "ficha-nueva.pdf", "owner@club12");
|
||||
|
||||
Assert.Equal(MedicalRecordStatus.Pending, record.Status);
|
||||
Assert.Equal(newRef, record.FileUrl);
|
||||
Assert.False(record.IsHabilitado);
|
||||
|
||||
// ...and can now be properly approved against the real stored file.
|
||||
MedicalRecordResponse approved = await medicalRecordService.ReviewAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId, approve: true, reason: null, actor: "owner@club12");
|
||||
|
||||
Assert.Equal(MedicalRecordStatus.Approved, approved.Status);
|
||||
Assert.True(approved.IsHabilitado);
|
||||
}
|
||||
|
||||
// ---------- HU-58: approve -> habilitado ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_MakesHabilitado()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
|
||||
// A file must be stored before an approve is allowed (Part 2 write guard).
|
||||
await medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId,
|
||||
$"{fx.TeamId}/{fx.PlayerId}/{Guid.NewGuid()}.pdf", "ficha.pdf", "owner@club12");
|
||||
|
||||
MedicalRecordResponse record = await medicalRecordService.ReviewAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId, approve: true, reason: null, actor: "owner@club12");
|
||||
|
||||
Assert.Equal(MedicalRecordStatus.Approved, record.Status);
|
||||
Assert.True(record.IsHabilitado);
|
||||
Assert.Null(record.ReviewReason);
|
||||
Assert.NotNull(record.ReviewedAt);
|
||||
}
|
||||
|
||||
// ---------- Part 2: approve-time write guard (medical-records-storage-eligibility) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_WithNoStoredFile_IsRejected_AndRowStaysPending()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => medicalRecordService.ReviewAsync(fx.PlayerId, fx.TeamId, fx.TournamentId, approve: true, reason: null, actor: "owner@club12"));
|
||||
|
||||
Assert.Contains("no hay un archivo cargado", ex.Message);
|
||||
|
||||
MedicalRecordResponse? record = await medicalRecordService.GetAsync(fx.PlayerId, fx.TeamId, fx.TournamentId);
|
||||
Assert.Equal(MedicalRecordStatus.Pending, record!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_WithLegacyReference_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
await medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId,
|
||||
"medical-records/some/object/path.pdf", "ficha.pdf", "owner@club12");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => medicalRecordService.ReviewAsync(fx.PlayerId, fx.TeamId, fx.TournamentId, approve: true, reason: null, actor: "owner@club12"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_WithNoStoredFile_IsStillAllowed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
|
||||
MedicalRecordResponse record = await medicalRecordService.ReviewAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId, approve: false, reason: "Missing signature", actor: "owner@club12");
|
||||
|
||||
Assert.Equal(MedicalRecordStatus.Rejected, record.Status);
|
||||
}
|
||||
|
||||
// ---------- HU-58: reject -> not habilitado + reason ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_LeavesNotHabilitado_WithReason()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedRegistrationAsync(db);
|
||||
|
||||
MedicalRecordResponse record = await medicalRecordService.ReviewAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId, approve: false, reason: "Missing signature", actor: "owner@club12");
|
||||
|
||||
Assert.Equal(MedicalRecordStatus.Rejected, record.Status);
|
||||
Assert.False(record.IsHabilitado);
|
||||
Assert.Equal("Missing signature", record.ReviewReason);
|
||||
}
|
||||
|
||||
// ---------- HU-60: only approved players are eligible for the planilla ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_NotApprovedPlayer_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Fixture fx = await SeedFinishedMatchAsync(db, homeScore: 20, medicalStatus: MedicalRecordStatus.Pending);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = fx.MatchId,
|
||||
TeamId = fx.TeamId,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = fx.PlayerId, Points = 20 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no está habilitado", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_AfterApproval_Succeeds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
Fixture fx = await SeedFinishedMatchAsync(db, homeScore: 15, medicalStatus: MedicalRecordStatus.Pending);
|
||||
|
||||
// A file must be stored before an approve is allowed (Part 2 write guard),
|
||||
// and the match-sheet gate itself now requires it too.
|
||||
await medicalRecordService.RecordUploadAsync(
|
||||
fx.PlayerId, fx.TeamId, fx.TournamentId,
|
||||
$"{fx.TeamId}/{fx.PlayerId}/{Guid.NewGuid()}.pdf", "ficha.pdf", "owner");
|
||||
await medicalRecordService.ReviewAsync(fx.PlayerId, fx.TeamId, fx.TournamentId, approve: true, reason: null, actor: "owner");
|
||||
|
||||
List<PlayerStatistic> created = await statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = fx.MatchId,
|
||||
TeamId = fx.TeamId,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = fx.PlayerId, Points = 15 }],
|
||||
});
|
||||
|
||||
Assert.Single(created);
|
||||
}
|
||||
|
||||
// ---------- Part 2: match-sheet gate rejects Approved-without-file ----------
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTeamMatchSheetAsync_ApprovedWithNoStoredFile_IsRejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
// Approved directly on the seeded row (bypassing the write guard), with
|
||||
// no file reference — the exact "wrong today" shape this rule targets.
|
||||
Fixture fx = await SeedFinishedMatchAsync(db, homeScore: 20, medicalStatus: MedicalRecordStatus.Approved);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = fx.MatchId,
|
||||
TeamId = fx.TeamId,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = fx.PlayerId, Points = 20 }],
|
||||
}));
|
||||
|
||||
Assert.Contains("no está habilitado", ex.Message);
|
||||
}
|
||||
|
||||
// ---------- Part 2: season roster surfacing reflects the file-backed rule ----------
|
||||
|
||||
[Fact]
|
||||
public async Task GetTeamByIdAsync_ApprovedPlayerWithLegacyReference_ReadsAsNotHabilitado()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(seedDb);
|
||||
Team team = await SeedTeamAsync(seedDb, tournament.Id);
|
||||
Player player = await SeedPlayerAsync(seedDb, team);
|
||||
|
||||
seedDb.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournament.Id,
|
||||
MedicalRecordStatus = MedicalRecordStatus.Approved,
|
||||
MedicalRecordFileUrl = "medical-records/some/object/path.pdf",
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await seedDb.SaveChangesAsync();
|
||||
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITeamService teamService = scope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
|
||||
Team? loaded = await teamService.GetTeamByIdAsync(team.Id, tournament.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Domain.Entities.Models.Player rosterPlayer = Assert.Single(loaded!.Players, p => p.Id == player.Id);
|
||||
Assert.False(rosterPlayer.IsHabilitado);
|
||||
}
|
||||
|
||||
// ---------- HU-59: approval does NOT carry across seasons ----------
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovalInOneSeason_DoesNotHabilitateAnother()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService<IMedicalRecordService>();
|
||||
|
||||
// Season A: player approved for team A in tournament A (file stored first — Part 2 write guard).
|
||||
Fixture seasonA = await SeedFinishedMatchAsync(db, homeScore: 10, medicalStatus: MedicalRecordStatus.Pending);
|
||||
await medicalRecordService.RecordUploadAsync(
|
||||
seasonA.PlayerId, seasonA.TeamId, seasonA.TournamentId,
|
||||
$"{seasonA.TeamId}/{seasonA.PlayerId}/{Guid.NewGuid()}.pdf", "ficha.pdf", "owner");
|
||||
await medicalRecordService.ReviewAsync(seasonA.PlayerId, seasonA.TeamId, seasonA.TournamentId, approve: true, reason: null, actor: "owner");
|
||||
|
||||
// Season B: the SAME player registered to a different team in a
|
||||
// different tournament, with a fresh (Pending) medical record.
|
||||
Tournament tournamentB = await SeedTournamentAsync(db);
|
||||
Team teamB = await SeedTeamAsync(db, tournamentB.Id);
|
||||
Division divisionB = await SeedDivisionAsync(db, tournamentB);
|
||||
Stage stageB = await SeedStageAsync(db, divisionB);
|
||||
Match matchB = await SeedFinishedMatchRowAsync(db, teamB, stageB, homeScore: 10);
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = seasonA.PlayerId,
|
||||
TeamId = teamB.Id,
|
||||
TournamentId = tournamentB.Id,
|
||||
MedicalRecordStatus = MedicalRecordStatus.Pending,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await SeedHabilitadoFillersAsync(db, teamB, tournamentB.Id);
|
||||
await SeedHabilitadoFillersAsync(db, matchB.VisitorTeam!, tournamentB.Id);
|
||||
|
||||
// Season B eligibility is independent: the player is NOT eligible there.
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => statisticService.LoadTeamMatchSheetAsync(new LoadMatchSheetRequest
|
||||
{
|
||||
MatchId = matchB.Id,
|
||||
TeamId = teamB.Id,
|
||||
Scores = [new PlayerScoreEntry { PlayerId = seasonA.PlayerId, Points = 10 }],
|
||||
}));
|
||||
Assert.Contains("no está habilitado", ex.Message);
|
||||
|
||||
// ...and season B's record is still Pending while season A stays Approved.
|
||||
MedicalRecordResponse? recordB = await medicalRecordService.GetAsync(seasonA.PlayerId, teamB.Id, tournamentB.Id);
|
||||
MedicalRecordResponse? recordA = await medicalRecordService.GetAsync(seasonA.PlayerId, seasonA.TeamId, seasonA.TournamentId);
|
||||
Assert.Equal(MedicalRecordStatus.Pending, recordB!.Status);
|
||||
Assert.Equal(MedicalRecordStatus.Approved, recordA!.Status);
|
||||
}
|
||||
|
||||
// ---------- seeding ----------
|
||||
|
||||
private sealed record Fixture(Guid PlayerId, Guid TeamId, Guid TournamentId, Guid MatchId);
|
||||
|
||||
private static async Task<Fixture> SeedRegistrationAsync(ApplicationDBContext db)
|
||||
{
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Team team = await SeedTeamAsync(db, tournament.Id);
|
||||
Player player = await SeedPlayerAsync(db, team);
|
||||
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournament.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new Fixture(player.Id, team.Id, tournament.Id, Guid.Empty);
|
||||
}
|
||||
|
||||
private static async Task<Fixture> SeedFinishedMatchAsync(
|
||||
ApplicationDBContext db, int homeScore, MedicalRecordStatus medicalStatus)
|
||||
{
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Team team = await SeedTeamAsync(db, tournament.Id);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division);
|
||||
Player player = await SeedPlayerAsync(db, team);
|
||||
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournament.Id,
|
||||
MedicalRecordStatus = medicalStatus,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Match match = await SeedFinishedMatchRowAsync(db, team, stage, homeScore);
|
||||
|
||||
// Baseline habilitado padding (owner's walkover-threshold rule,
|
||||
// HU-73): these tests target the SINGLE player under test's own
|
||||
// eligibility, so both teams need >= 4 habilitado players of their
|
||||
// own to avoid tripping the team-wide walkover gate instead.
|
||||
await SeedHabilitadoFillersAsync(db, team, tournament.Id);
|
||||
await SeedHabilitadoFillersAsync(db, match.VisitorTeam!, tournament.Id);
|
||||
|
||||
return new Fixture(player.Id, team.Id, tournament.Id, match.Id);
|
||||
}
|
||||
|
||||
private static async Task SeedHabilitadoFillersAsync(
|
||||
ApplicationDBContext db, Team team, Guid tournamentId, int count = 4)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Player filler = await SeedPlayerAsync(db, team);
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = filler.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournamentId,
|
||||
MedicalRecordStatus = MedicalRecordStatus.Approved,
|
||||
MedicalRecordFileUrl = $"{team.Id}/{filler.Id}/{Guid.NewGuid()}.pdf",
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedFinishedMatchRowAsync(
|
||||
ApplicationDBContext db, Team home, Stage stage, int homeScore)
|
||||
{
|
||||
Team visitor = await SeedTeamAsync(db, home.TournamentId!.Value);
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = home,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeam = visitor,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = homeScore - 1,
|
||||
IsFinished = true,
|
||||
Status = MatchStatus.Played,
|
||||
WinningTeamId = home.Id,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Medical-record test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Guid tournamentId)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Group-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
IsElimination = false,
|
||||
StartDate = DateTime.UtcNow.Date.AddDays(30),
|
||||
EndDate = DateTime.UtcNow.Date.AddDays(37),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedPlayerAsync(ApplicationDBContext db, Team team)
|
||||
{
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Test",
|
||||
LastName = $"Player-{Guid.NewGuid():N}",
|
||||
SecondName = null,
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Application.DTOs.MedicalRecord.Response;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, fixture-free unit tests for the file-backed habilitación rule (Part 2
|
||||
/// of the medical-records-storage-eligibility change): a registration is
|
||||
/// "habilitado" only when its medical record is <c>Approved</c> AND it carries
|
||||
/// a real (non-legacy) stored file reference. Covers the single Domain
|
||||
/// predicate (<see cref="PlayerTeamRegistration.IsStoredReference"/>), the two
|
||||
/// computed <c>IsHabilitado</c> properties that consume it
|
||||
/// (<see cref="PlayerTeamRegistration"/> and the transient
|
||||
/// <see cref="Player"/> carrier), and the DTO projection
|
||||
/// (<see cref="MedicalRecordResponse.FromRegistration"/>). No DB, no fixture —
|
||||
/// these are plain object-graph assertions.
|
||||
/// </summary>
|
||||
public class MedicalRecordHabilitacionTests
|
||||
{
|
||||
// ---------- PlayerTeamRegistration.IsStoredReference ----------
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("medical-records/some/object/path.pdf")]
|
||||
public void IsStoredReference_NullWhitespaceOrLegacyPrefix_IsFalse(string? fileReference)
|
||||
{
|
||||
Assert.False(PlayerTeamRegistration.IsStoredReference(fileReference));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStoredReference_NewSchemeReference_IsTrue()
|
||||
{
|
||||
string reference = $"{Guid.NewGuid()}/{Guid.NewGuid()}/x.pdf";
|
||||
|
||||
Assert.True(PlayerTeamRegistration.IsStoredReference(reference));
|
||||
}
|
||||
|
||||
// ---------- PlayerTeamRegistration.IsHabilitado truth table ----------
|
||||
|
||||
[Fact]
|
||||
public void Registration_ApprovedWithStoredFile_IsHabilitado()
|
||||
{
|
||||
PlayerTeamRegistration registration = BuildRegistration(
|
||||
MedicalRecordStatus.Approved, $"{Guid.NewGuid()}/{Guid.NewGuid()}/ficha.pdf");
|
||||
|
||||
Assert.True(registration.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registration_ApprovedWithLegacyReference_IsNotHabilitado()
|
||||
{
|
||||
PlayerTeamRegistration registration = BuildRegistration(
|
||||
MedicalRecordStatus.Approved, "medical-records/legacy/object.pdf");
|
||||
|
||||
Assert.False(registration.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registration_ApprovedWithNullReference_IsNotHabilitado()
|
||||
{
|
||||
PlayerTeamRegistration registration = BuildRegistration(MedicalRecordStatus.Approved, null);
|
||||
|
||||
Assert.False(registration.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registration_PendingWithStoredFile_IsNotHabilitado()
|
||||
{
|
||||
PlayerTeamRegistration registration = BuildRegistration(
|
||||
MedicalRecordStatus.Pending, $"{Guid.NewGuid()}/{Guid.NewGuid()}/ficha.pdf");
|
||||
|
||||
Assert.False(registration.IsHabilitado);
|
||||
}
|
||||
|
||||
// ---------- Player.IsHabilitado truth table (transient carrier) ----------
|
||||
|
||||
[Fact]
|
||||
public void Player_ApprovedWithHasMedicalRecordFileTrue_IsHabilitado()
|
||||
{
|
||||
Player player = BuildPlayer(MedicalRecordStatus.Approved, hasMedicalRecordFile: true);
|
||||
|
||||
Assert.True(player.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Player_ApprovedWithHasMedicalRecordFileFalse_IsNotHabilitado()
|
||||
{
|
||||
Player player = BuildPlayer(MedicalRecordStatus.Approved, hasMedicalRecordFile: false);
|
||||
|
||||
Assert.False(player.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Player_PendingWithHasMedicalRecordFileTrue_IsNotHabilitado()
|
||||
{
|
||||
Player player = BuildPlayer(MedicalRecordStatus.Pending, hasMedicalRecordFile: true);
|
||||
|
||||
Assert.False(player.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Player_NoSeasonContext_DefaultsToNotHabilitado()
|
||||
{
|
||||
Player player = BuildPlayer(medicalRecordStatus: null, hasMedicalRecordFile: false);
|
||||
|
||||
Assert.False(player.IsHabilitado);
|
||||
}
|
||||
|
||||
// ---------- MedicalRecordResponse.FromRegistration ----------
|
||||
|
||||
[Fact]
|
||||
public void FromRegistration_ApprovedWithLegacyReference_IsHabilitadoFalse()
|
||||
{
|
||||
PlayerTeamRegistration registration = BuildRegistration(
|
||||
MedicalRecordStatus.Approved, "medical-records/legacy/object.pdf");
|
||||
|
||||
MedicalRecordResponse response = MedicalRecordResponse.FromRegistration(registration);
|
||||
|
||||
Assert.False(response.IsHabilitado);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromRegistration_ApprovedWithStoredFile_IsHabilitadoTrue()
|
||||
{
|
||||
PlayerTeamRegistration registration = BuildRegistration(
|
||||
MedicalRecordStatus.Approved, $"{Guid.NewGuid()}/{Guid.NewGuid()}/ficha.pdf");
|
||||
|
||||
MedicalRecordResponse response = MedicalRecordResponse.FromRegistration(registration);
|
||||
|
||||
Assert.True(response.IsHabilitado);
|
||||
}
|
||||
|
||||
private static PlayerTeamRegistration BuildRegistration(MedicalRecordStatus status, string? fileReference)
|
||||
{
|
||||
return new PlayerTeamRegistration
|
||||
{
|
||||
PlayerId = Guid.NewGuid(),
|
||||
TeamId = Guid.NewGuid(),
|
||||
TournamentId = Guid.NewGuid(),
|
||||
MedicalRecordStatus = status,
|
||||
MedicalRecordFileUrl = fileReference,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
}
|
||||
|
||||
private static Player BuildPlayer(MedicalRecordStatus? medicalRecordStatus, bool hasMedicalRecordFile)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "ABC",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
return new Player
|
||||
{
|
||||
FirstName = "Test",
|
||||
LastName = "Player",
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
MedicalRecordStatus = medicalRecordStatus,
|
||||
HasMedicalRecordFile = hasMedicalRecordFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using Application.Interfaces.Storage;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers Part 3 of medical-records-storage-eligibility: the idempotent,
|
||||
/// failure-tolerant <see cref="MedicalRecordSeedBackfiller.BackfillMedicalRecordsAsync"/>
|
||||
/// step, shared by both seed paths (the startup <c>DataSeeder</c> and the
|
||||
/// admin-triggered <c>DataMaintenanceService</c>). Constructed directly
|
||||
/// (no DI, no <c>SupabaseHelper</c> at all) since this step never touches
|
||||
/// team logos — only <see cref="IMedicalRecordStorage"/>.
|
||||
/// </summary>
|
||||
public class MedicalRecordSeedTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MedicalRecordSeedTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_NullReference_IsUploaded()
|
||||
{
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedApprovedRegistrationAsync(db, fileUrl: null);
|
||||
FakeMedicalRecordStorage storage = new();
|
||||
|
||||
await RunSeedMedicalRecordsAsync(db, storage, pdf.Path);
|
||||
|
||||
// Scoped to this test's own (TeamId, PlayerId): the shared class-level
|
||||
// fixture's DB persists rows across test methods, so asserting on the
|
||||
// storage's global call count would be flaky under xUnit's shared
|
||||
// IClassFixture instance — assert this test's own row was uploaded.
|
||||
Assert.Contains((registration.TeamId, registration.PlayerId), storage.StoredCalls);
|
||||
PlayerTeamRegistration? reloaded = await db.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.Id == registration.Id);
|
||||
Assert.NotNull(reloaded!.MedicalRecordFileUrl);
|
||||
Assert.StartsWith($"{registration.TeamId}/{registration.PlayerId}/", reloaded.MedicalRecordFileUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_LegacyReference_IsUploaded()
|
||||
{
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration =
|
||||
await SeedApprovedRegistrationAsync(db, fileUrl: "medical-records/some/object/path.pdf");
|
||||
FakeMedicalRecordStorage storage = new();
|
||||
|
||||
await RunSeedMedicalRecordsAsync(db, storage, pdf.Path);
|
||||
|
||||
Assert.Contains((registration.TeamId, registration.PlayerId), storage.StoredCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_NewSchemeReference_IsSkipped()
|
||||
{
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedApprovedRegistrationAsync(db, fileUrl: null);
|
||||
registration.MedicalRecordFileUrl = $"{registration.TeamId}/{registration.PlayerId}/{Guid.NewGuid()}.pdf";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
FakeMedicalRecordStorage storage = new();
|
||||
|
||||
await RunSeedMedicalRecordsAsync(db, storage, pdf.Path);
|
||||
|
||||
Assert.DoesNotContain((registration.TeamId, registration.PlayerId), storage.StoredCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_SecondRun_UploadsZero()
|
||||
{
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedApprovedRegistrationAsync(db, fileUrl: null);
|
||||
FakeMedicalRecordStorage firstRunStorage = new();
|
||||
await RunSeedMedicalRecordsAsync(db, firstRunStorage, pdf.Path);
|
||||
Assert.Contains((registration.TeamId, registration.PlayerId), firstRunStorage.StoredCalls);
|
||||
|
||||
FakeMedicalRecordStorage secondRunStorage = new();
|
||||
await RunSeedMedicalRecordsAsync(db, secondRunStorage, pdf.Path);
|
||||
|
||||
Assert.DoesNotContain((registration.TeamId, registration.PlayerId), secondRunStorage.StoredCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_UploadThrows_DoesNotFailTheSeed_AndLeavesRefNull()
|
||||
{
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedApprovedRegistrationAsync(db, fileUrl: null);
|
||||
FakeMedicalRecordStorage storage = new() { ExceptionToThrow = new InvalidOperationException("network unreachable") };
|
||||
|
||||
// Must complete without throwing.
|
||||
await RunSeedMedicalRecordsAsync(db, storage, pdf.Path);
|
||||
|
||||
PlayerTeamRegistration? reloaded = await db.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.Id == registration.Id);
|
||||
Assert.Null(reloaded!.MedicalRecordFileUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_SeederCreatedPendingRegistration_IsApprovedAndUploaded()
|
||||
{
|
||||
// Épica 24, ítem 10: a database seeded before the Approved-by-default
|
||||
// seed logic existed (or restored from an old backup) has every
|
||||
// registration stuck Pending. Only a SEEDER-created row (CreatedBy ==
|
||||
// AuditConstants.SystemUser) self-heals — never a real admin's.
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedRegistrationAsync(
|
||||
db, MedicalRecordStatus.Pending, createdBy: AuditConstants.SystemUser);
|
||||
FakeMedicalRecordStorage storage = new();
|
||||
|
||||
await RunSeedMedicalRecordsAsync(db, storage, pdf.Path);
|
||||
|
||||
Assert.Contains((registration.TeamId, registration.PlayerId), storage.StoredCalls);
|
||||
PlayerTeamRegistration? reloaded = await db.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.Id == registration.Id);
|
||||
Assert.Equal(MedicalRecordStatus.Approved, reloaded!.MedicalRecordStatus);
|
||||
Assert.NotNull(reloaded.MedicalRecordFileUrl);
|
||||
Assert.NotNull(reloaded.MedicalRecordReviewedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_RealAdminPendingRegistration_IsUntouched()
|
||||
{
|
||||
// The exact opposite: a Pending row created by a real admin action
|
||||
// (not the seeder) must never be silently auto-approved.
|
||||
using TempPdfFile pdf = new();
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedRegistrationAsync(
|
||||
db, MedicalRecordStatus.Pending, createdBy: "admin@club12.com");
|
||||
FakeMedicalRecordStorage storage = new();
|
||||
|
||||
await RunSeedMedicalRecordsAsync(db, storage, pdf.Path);
|
||||
|
||||
Assert.DoesNotContain((registration.TeamId, registration.PlayerId), storage.StoredCalls);
|
||||
PlayerTeamRegistration? reloaded = await db.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.Id == registration.Id);
|
||||
Assert.Equal(MedicalRecordStatus.Pending, reloaded!.MedicalRecordStatus);
|
||||
Assert.Null(reloaded.MedicalRecordFileUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMedicalRecords_MissingPdfPath_WarnsAndSkips_ZeroUploads()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration registration = await SeedApprovedRegistrationAsync(db, fileUrl: null);
|
||||
FakeMedicalRecordStorage storage = new();
|
||||
string missingPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.pdf");
|
||||
|
||||
await RunSeedMedicalRecordsAsync(db, storage, missingPath);
|
||||
|
||||
Assert.DoesNotContain((registration.TeamId, registration.PlayerId), storage.StoredCalls);
|
||||
PlayerTeamRegistration? reloaded = await db.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.Id == registration.Id);
|
||||
Assert.Null(reloaded!.MedicalRecordFileUrl);
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static Task RunSeedMedicalRecordsAsync(
|
||||
ApplicationDBContext db, IMedicalRecordStorage storage, string medicalRecordPath)
|
||||
{
|
||||
MedicalRecordSeedBackfiller backfiller = new(
|
||||
db, NullLogger<MedicalRecordSeedBackfiller>.Instance, storage);
|
||||
return backfiller.BackfillMedicalRecordsAsync(medicalRecordPath);
|
||||
}
|
||||
|
||||
private static Task<PlayerTeamRegistration> SeedApprovedRegistrationAsync(
|
||||
ApplicationDBContext db, string? fileUrl) =>
|
||||
SeedRegistrationAsync(db, MedicalRecordStatus.Approved, createdBy: "test", fileUrl);
|
||||
|
||||
private static async Task<PlayerTeamRegistration> SeedRegistrationAsync(
|
||||
ApplicationDBContext db, MedicalRecordStatus status, string createdBy, string? fileUrl = null)
|
||||
{
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Seed test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = DateTime.UtcNow.Date.AddDays(29),
|
||||
StartDate = DateTime.UtcNow.Date.AddDays(30),
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Test",
|
||||
LastName = $"Player-{Guid.NewGuid():N}",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
PlayerTeamRegistration registration = new()
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournament.Id,
|
||||
MedicalRecordStatus = status,
|
||||
MedicalRecordFileUrl = fileUrl,
|
||||
CreatedBy = createdBy,
|
||||
};
|
||||
db.PlayerTeamRegistrations.Add(registration);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
private sealed class FakeMedicalRecordStorage : IMedicalRecordStorage
|
||||
{
|
||||
public List<(Guid TeamId, Guid PlayerId)> StoredCalls { get; } = [];
|
||||
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public Task<string> StoreAsync(
|
||||
Guid teamId, Guid playerId, string fileName, Stream content, CancellationToken ct = default)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
StoredCalls.Add((teamId, playerId));
|
||||
return Task.FromResult($"{teamId}/{playerId}/{Guid.NewGuid()}{Path.GetExtension(fileName)}");
|
||||
}
|
||||
|
||||
public Task<byte[]> DownloadAsync(string objectPath, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>A small real file on disk so <c>File.Exists</c>/<c>ReadAllBytesAsync</c> succeed.</summary>
|
||||
private sealed class TempPdfFile : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"seed-test-{Guid.NewGuid()}.pdf");
|
||||
|
||||
public TempPdfFile()
|
||||
{
|
||||
File.WriteAllBytes(Path, "%PDF-1.4 fake seed medical record"u8.ToArray());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(Path))
|
||||
{
|
||||
File.Delete(Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Utils.Constants.Configuration;
|
||||
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers Part 1 of the medical-records storage relocation: the object key
|
||||
/// shape (<c>{teamId}/{playerId}/{guid}{ext}</c>, no <c>medical-records/</c>
|
||||
/// prefix, no <c>tournamentId</c> segment), bucket routing to the configured
|
||||
/// medical bucket (with a code-level default when unconfigured), and the
|
||||
/// path-traversal threat-matrix guarantee that the two leading key segments
|
||||
/// are exactly the caller-supplied Guids. <see cref="SupabaseBackupStorage"/>
|
||||
/// and image-upload regression (default bucket preserved) live in
|
||||
/// <c>SupabaseBackupStorageTests</c> and are asserted here only for the
|
||||
/// bucket-parameterized raw boundary itself.
|
||||
/// </summary>
|
||||
public class MedicalRecordStorageTests
|
||||
{
|
||||
private static IConfiguration EmptyConfiguration()
|
||||
{
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build();
|
||||
}
|
||||
|
||||
private static IConfiguration ConfigurationWithBucket(string bucketName)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{ConfigurationKeys.Supabase.Section}:{ConfigurationKeys.Supabase.MedicalRecordsBucketName}"] = bucketName,
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_KeyShape_IsTeamPlayerGuidExtension_NoLegacyPrefix()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseMedicalRecordStorage storage = new(raw, EmptyConfiguration());
|
||||
Guid teamId = Guid.NewGuid();
|
||||
Guid playerId = Guid.NewGuid();
|
||||
|
||||
string objectPath = await storage.StoreAsync(teamId, playerId, "ficha.pdf", ContentStream("%PDF-1.4"));
|
||||
|
||||
Assert.DoesNotContain("medical-records/", objectPath);
|
||||
string[] segments = objectPath.Split('/');
|
||||
Assert.Equal(3, segments.Length);
|
||||
Assert.Equal(teamId.ToString(), segments[0]);
|
||||
Assert.Equal(playerId.ToString(), segments[1]);
|
||||
Assert.EndsWith(".pdf", segments[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_DifferentTeamAndPlayer_TeamIdIsAlwaysFirstSegment()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseMedicalRecordStorage storage = new(raw, EmptyConfiguration());
|
||||
Guid teamId = Guid.NewGuid();
|
||||
Guid playerId = Guid.NewGuid();
|
||||
|
||||
string objectPath = await storage.StoreAsync(teamId, playerId, "otra.pdf", ContentStream("%PDF-1.4 b"));
|
||||
|
||||
Assert.StartsWith($"{teamId}/{playerId}/", objectPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_NoConfiguredBucket_FallsBackToDefaultMedicalRecordsBucket()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseMedicalRecordStorage storage = new(raw, EmptyConfiguration());
|
||||
|
||||
await storage.StoreAsync(Guid.NewGuid(), Guid.NewGuid(), "ficha.pdf", ContentStream("%PDF-1.4"));
|
||||
|
||||
Assert.Single(raw.UploadedBuckets);
|
||||
Assert.Equal(SupabaseMedicalRecordStorage.DefaultBucketName, raw.UploadedBuckets[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ConfiguredBucket_IsPassedExplicitly()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseMedicalRecordStorage storage = new(raw, ConfigurationWithBucket("medical-records-staging"));
|
||||
|
||||
await storage.StoreAsync(Guid.NewGuid(), Guid.NewGuid(), "ficha.pdf", ContentStream("%PDF-1.4"));
|
||||
|
||||
Assert.Equal("medical-records-staging", raw.UploadedBuckets[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAsync_TargetsTheConfiguredMedicalBucket()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { BytesToDownload = Encoding.UTF8.GetBytes("%PDF-1.4 body") };
|
||||
SupabaseMedicalRecordStorage storage = new(raw, ConfigurationWithBucket("medical-records-staging"));
|
||||
|
||||
byte[] downloaded = await storage.DownloadAsync("team/player/guid.pdf");
|
||||
|
||||
Assert.Equal("medical-records-staging", raw.DownloadedBucket);
|
||||
Assert.Equal(Encoding.UTF8.GetBytes("%PDF-1.4 body"), downloaded);
|
||||
}
|
||||
|
||||
// ---------- path-traversal threat-matrix (RED) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_KeySegments_ParseBackAsTheSuppliedGuids()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseMedicalRecordStorage storage = new(raw, EmptyConfiguration());
|
||||
Guid teamId = Guid.NewGuid();
|
||||
Guid playerId = Guid.NewGuid();
|
||||
|
||||
string objectPath = await storage.StoreAsync(teamId, playerId, "ficha.pdf", ContentStream("%PDF-1.4"));
|
||||
|
||||
string[] segments = objectPath.Split('/');
|
||||
Assert.True(Guid.TryParse(segments[0], out Guid parsedTeamId));
|
||||
Assert.True(Guid.TryParse(segments[1], out Guid parsedPlayerId));
|
||||
Assert.Equal(teamId, parsedTeamId);
|
||||
Assert.Equal(playerId, parsedPlayerId);
|
||||
}
|
||||
|
||||
// ---------- backup/image regression: default bucket preserved ----------
|
||||
|
||||
[Fact]
|
||||
public async Task BackupStorage_StillPassesNullBucket_DefaultBucketPreserved()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
Infrastructure.Backup.SupabaseBackupStorage backupStorage = new(raw);
|
||||
|
||||
await backupStorage.StoreAsync("backup-1.sql", ContentStream("dump"));
|
||||
|
||||
Assert.Single(raw.UploadedBuckets);
|
||||
Assert.Null(raw.UploadedBuckets[0]);
|
||||
}
|
||||
|
||||
private static Stream ContentStream(string text)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A division with MORE THAN ONE internal StageType.Group stage — whether a
|
||||
/// cross-division cup's pooled groups or a regular zone's own sub-groups —
|
||||
/// is seeded by pooling the top-QualifiersPerGroup teams of each group and
|
||||
/// ordering them by group-stage strength before seeding the bracket via the
|
||||
/// shared classic-seed/BYE path. A division with exactly ONE group keeps
|
||||
/// behaving as before — see <see cref="StageSeedingTests"/>.
|
||||
/// </summary>
|
||||
public class MultiGroupSeedingTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public MultiGroupSeedingTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Division_QualifiersPerGroup_DefaultsToOne()
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = "d",
|
||||
Name = "d",
|
||||
Tournament = null!,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Assert.Equal(1, division.QualifiersPerGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups of 4, 4 and 3. Group A plays 100-70 (biggest margins), Group B
|
||||
/// 90-80, Group C 90-80. With K = 1 the pooled winners are a0 (Diff +90),
|
||||
/// b0 (Diff +30) and c0 (2 wins), ordered a0 > b0 > c0. Three seeds pad to
|
||||
/// a 4-team bracket: a0 gets the BYE, b0 plays c0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedKnockoutStageAsync_MultiGroupCrossCup_PoolsTopOnePerGroupOrderedByStrength()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 1);
|
||||
|
||||
List<Team> groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 100, visitorScore: 70);
|
||||
List<Team> groupB = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 90, visitorScore: 80);
|
||||
List<Team> groupC = await SeedRoundRobinGroupAsync(db, division, tournament, size: 3, homeScore: 90, visitorScore: 80);
|
||||
|
||||
Stage bracket = await SeedStageAsync(db, division, tournament, StageType.SemiFinal);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
|
||||
List<Match> seeded = await stageService.SeedKnockoutStageAsync(bracket.Id);
|
||||
List<Match> ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
|
||||
// Seed 1 (a0) gets the BYE.
|
||||
Assert.Equal(groupA[0].Id, ordered[0].HomeTeamId);
|
||||
Assert.Null(ordered[0].VisitorTeamId);
|
||||
Assert.True(ordered[0].IsFinished);
|
||||
Assert.Equal(groupA[0].Id, ordered[0].WinningTeamId);
|
||||
|
||||
// Seed 2 (b0) vs seed 3 (c0).
|
||||
Assert.Equal(groupB[0].Id, ordered[1].HomeTeamId);
|
||||
Assert.Equal(groupC[0].Id, ordered[1].VisitorTeamId);
|
||||
Assert.False(ordered[1].IsFinished);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two groups of 4 with K = 2. Group A margins are bigger, so the pooled
|
||||
/// order is a0 (Diff +90) > b0 (Diff +30) > a1 (4 pts, Diff +30) >
|
||||
/// b1 (4 pts, Diff +10). Four seeds fill a clean 4-team bracket:
|
||||
/// 1v4 (a0 vs b1) and 2v3 (b0 vs a1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedKnockoutStageAsync_MultiGroupCrossCup_KTwo_PoolsTopTwoPerGroup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 2);
|
||||
|
||||
List<Team> groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 100, visitorScore: 70);
|
||||
List<Team> groupB = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 90, visitorScore: 80);
|
||||
|
||||
Stage bracket = await SeedStageAsync(db, division, tournament, StageType.SemiFinal);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
|
||||
List<Match> seeded = await stageService.SeedKnockoutStageAsync(bracket.Id);
|
||||
List<Match> ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
|
||||
// 1v4: a0 vs b1.
|
||||
Assert.Equal(groupA[0].Id, ordered[0].HomeTeamId);
|
||||
Assert.Equal(groupB[1].Id, ordered[0].VisitorTeamId);
|
||||
|
||||
// 2v3: b0 vs a1.
|
||||
Assert.Equal(groupB[0].Id, ordered[1].HomeTeamId);
|
||||
Assert.Equal(groupA[1].Id, ordered[1].VisitorTeamId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cross cup with a SINGLE group must keep behaving exactly as today:
|
||||
/// it uses the teams assigned to the elimination stage and orders them by
|
||||
/// the (single) group's standings — never the multi-group pool.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedKnockoutStageAsync_SingleGroupCrossCup_BehavesLikeToday()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 1);
|
||||
|
||||
List<Team> teams = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 90, visitorScore: 80);
|
||||
|
||||
Stage bracket = await SeedStageAsync(db, division, tournament, StageType.SemiFinal);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
await AssignTeamToStageAsync(db, bracket, team);
|
||||
}
|
||||
|
||||
List<Match> seeded = await stageService.SeedKnockoutStageAsync(bracket.Id);
|
||||
List<Match> ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
|
||||
// Classic single-group seeding: 1v4, 2v3.
|
||||
Assert.Equal(teams[0].Id, ordered[0].HomeTeamId);
|
||||
Assert.Equal(teams[3].Id, ordered[0].VisitorTeamId);
|
||||
Assert.Equal(teams[1].Id, ordered[1].HomeTeamId);
|
||||
Assert.Equal(teams[2].Id, ordered[1].VisitorTeamId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-group cross cup where only one group has finished results yields a
|
||||
/// single pooled qualifier, which is below the two-team seeding floor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedKnockoutStageAsync_MultiGroupCrossCup_FewerThanTwoQualifiers_Throws()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 1);
|
||||
|
||||
await SeedRoundRobinGroupAsync(db, division, tournament, size: 3, homeScore: 90, visitorScore: 80);
|
||||
// Second group with teams but NO finished matches -> empty standings.
|
||||
await SeedEmptyGroupAsync(db, division, tournament, size: 3);
|
||||
|
||||
Stage bracket = await SeedStageAsync(db, division, tournament, StageType.SemiFinal);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => stageService.SeedKnockoutStageAsync(bracket.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A regular (non-cross-cup) division split into two sub-groups pools each
|
||||
/// group's own winner into the bracket, the same mechanism a cross-division
|
||||
/// cup's pooled groups already use — the group with the bigger margin seeds
|
||||
/// higher.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedKnockoutStageAsync_MultiGroupRegularDivision_PoolsTopOnePerGroup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false, qualifiersPerGroup: 1);
|
||||
|
||||
List<Team> groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 2, homeScore: 100, visitorScore: 70);
|
||||
List<Team> groupB = await SeedRoundRobinGroupAsync(db, division, tournament, size: 2, homeScore: 90, visitorScore: 80);
|
||||
|
||||
Stage bracket = await SeedStageAsync(db, division, tournament, StageType.Final);
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
|
||||
List<Match> seeded = await stageService.SeedKnockoutStageAsync(bracket.Id);
|
||||
|
||||
Assert.Equal(groupA[0].Id, seeded[0].HomeTeamId);
|
||||
Assert.Equal(groupB[0].Id, seeded[0].VisitorTeamId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same regular multi-group pooling applies to the automatic,
|
||||
/// standings-driven cup-seeding path, not just the manual "sembrar bracket"
|
||||
/// action — a position-range mapping over the pooled order still resolves
|
||||
/// each sub-group's winner into the cup.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedPlayoffCupsAsync_MultiGroupRegularDivision_PoolsTopOnePerGroup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false, qualifiersPerGroup: 1);
|
||||
|
||||
List<Team> groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 2, homeScore: 100, visitorScore: 70);
|
||||
List<Team> groupB = await SeedRoundRobinGroupAsync(db, division, tournament, size: 2, homeScore: 90, visitorScore: 80);
|
||||
|
||||
db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping
|
||||
{
|
||||
DivisionId = division.Id,
|
||||
FromPosition = 1,
|
||||
ToPosition = 2,
|
||||
Destination = "Final Interzonal",
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage bracket = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Final Interzonal");
|
||||
await SeedEmptyMatchAsync(db, bracket);
|
||||
|
||||
Dictionary<string, List<Match>> seededByCup = await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> seeded = seededByCup["Final Interzonal"];
|
||||
Assert.Equal(groupA[0].Id, seeded[0].HomeTeamId);
|
||||
Assert.Equal(groupB[0].Id, seeded[0].VisitorTeamId);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Cross-cup multi-group test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(
|
||||
ApplicationDBContext db, Tournament tournament, bool isCrossDivisionCup, int qualifiersPerGroup)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = isCrossDivisionCup,
|
||||
QualifiersPerGroup = qualifiersPerGroup,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
return division;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a group stage with <paramref name="size"/> teams and a full
|
||||
/// single round-robin where the lower-index team is always home and wins
|
||||
/// <paramref name="homeScore"/>-<paramref name="visitorScore"/>, producing
|
||||
/// the deterministic standings teams[0] > teams[1] > ... .
|
||||
/// </summary>
|
||||
private static async Task<List<Team>> SeedRoundRobinGroupAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, int size, int homeScore, int visitorScore)
|
||||
{
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, size);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group);
|
||||
for (int i = 0; i < teams.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < teams.Count; j++)
|
||||
{
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[i], teams[j], homeScore, visitorScore);
|
||||
}
|
||||
}
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task SeedEmptyGroupAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, int size)
|
||||
{
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, size);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group);
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
await AssignTeamToStageAsync(db, groupStage, team);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, string? bracketName = null)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
BracketName = bracketName,
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedFinishedMatchAsync(ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = true,
|
||||
WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedEmptyMatchAsync(ApplicationDBContext db, Stage stage)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate.AddMinutes(db.Matches.Count(m => m.StageId == stage.Id)),
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task AssignTeamToStageAsync(ApplicationDBContext db, Stage stage, Team team)
|
||||
{
|
||||
db.StageTeamMatches.Add(new StageTeamMatch
|
||||
{
|
||||
StageId = stage.Id,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Application.DTOs.Divisions.Request;
|
||||
using Application.DTOs.Player.Request;
|
||||
using Application.DTOs.Tournament.Request;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization/contract test proving every "resource not found" lookup across the 9
|
||||
/// converted controllers returns 404 with a ProblemDetails-consistent body (matching the
|
||||
/// shape GlobalExceptionHandler already emits for unhandled
|
||||
/// exceptions), instead of the legacy bare-string 400. Also proves the create-time FK
|
||||
/// validation (Player POST referencing a missing TeamId) deliberately stays 400 — it is
|
||||
/// invalid input, not a not-found lookup.
|
||||
/// </summary>
|
||||
public class NotFoundContractTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public NotFoundContractTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One GET-by-id route per controller, for the controllers whose full dependency graph
|
||||
/// can boot through CustomWebApplicationFactory anonymously (6 of the 9 —
|
||||
/// see SupabaseDependentControllerNotFoundTests for Team/Venue/BlogPost, which are
|
||||
/// covered via direct-controller unit tests instead; see that class' doc comment for why).
|
||||
/// </summary>
|
||||
public static readonly TheoryData<string> NotFoundGetByIdRoutes =
|
||||
[
|
||||
"api/matches/{0}",
|
||||
"api/divisions/{0}/detail",
|
||||
"api/players/{0}",
|
||||
"api/player-sanctions/{0}",
|
||||
"api/player-statistics/{0}",
|
||||
"api/tournaments/{0}",
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotFoundGetByIdRoutes))]
|
||||
public async Task GetById_MissingEntity_Returns404ProblemDetails(string routeTemplate)
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
Guid missingId = Guid.NewGuid();
|
||||
string url = string.Format(routeTemplate, missingId);
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync(url);
|
||||
string debugBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"url={url} status={response.StatusCode} body={debugBody}");
|
||||
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(debugBody);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("title").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("detail").GetString()));
|
||||
Assert.Equal(404, root.GetProperty("status").GetInt32());
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("traceId").GetString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Covers the spec's second scenario for this requirement ("PUT/DELETE or nested action
|
||||
/// against nonexistent parent returns 404") for the PUT-by-id case: a full HTTP-round-trip
|
||||
/// PUT against a nonexistent division id must return 404 + ProblemDetails, not 400. Prior
|
||||
/// to this test, every not-found case in this suite was GET-shaped only, even though the
|
||||
/// PUT/DELETE/nested-action sites were converted alongside the GET sites in Phase 2.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateDivisionById_MissingEntity_Returns404ProblemDetails()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
Guid missingId = Guid.NewGuid();
|
||||
UpdateDivisionRequest request = new() { Name = "Nonexistent Division", IsFinished = false };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync($"api/divisions/{missingId}", request);
|
||||
|
||||
await AssertNotFoundProblemDetailsAsync(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Covers the spec's "nested action referencing a nonexistent parent id" case (the spec's
|
||||
/// own example is "adding a sanction to a nonexistent player"): enrolling a team into a
|
||||
/// nonexistent tournament must return 404 + ProblemDetails via a real HTTP round trip.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnrollTeam_MissingTournament_Returns404ProblemDetails()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
Guid missingTournamentId = Guid.NewGuid();
|
||||
EnrollTeamRequest request = new() { NewTeamName = "Nonexistent Tournament Team" };
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/tournaments/{missingTournamentId}/enroll-team", request);
|
||||
|
||||
await AssertNotFoundProblemDetailsAsync(response);
|
||||
}
|
||||
|
||||
private static async Task AssertNotFoundProblemDetailsAsync(HttpResponseMessage response)
|
||||
{
|
||||
string debugBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"status={response.StatusCode} body={debugBody}");
|
||||
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(debugBody);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("title").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("detail").GetString()));
|
||||
Assert.Equal(404, root.GetProperty("status").GetInt32());
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("traceId").GetString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard: the Player POST create-time FK check (missing TeamId) is
|
||||
/// invalid input, not a "resource not found" lookup, and must stay 400 — this change
|
||||
/// must NOT touch it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreatePlayer_MissingTeamId_StaysBadRequest()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
CreatePlayerRequest request = new()
|
||||
{
|
||||
FirstName = "Test",
|
||||
LastName = "Player",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
PhoneNumber = "1234567890",
|
||||
SocialSecurity = "OSDE",
|
||||
TeamId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/players/", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using API.Utils;
|
||||
|
||||
using Infrastructure.Identity;
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The self-hosted-Postgres change sets a bounded <c>CommandTimeout</c> on both
|
||||
/// DbContexts and deliberately does NOT enable retry-on-failure: a retrying
|
||||
/// execution strategy rejects the raw transactions that
|
||||
/// <c>DataMaintenanceService</c> opens, and the local DB has no transient
|
||||
/// network faults for retry to absorb. These tests pin both facts.
|
||||
/// </summary>
|
||||
public class NpgsqlOptionsTests
|
||||
{
|
||||
private const string PostgresConnectionString =
|
||||
"Host=db;Port=5432;Database=postgres;Username=postgres;Password=x;SSL Mode=Disable";
|
||||
|
||||
private static IConfiguration Configuration() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:DbConnection"] = PostgresConnectionString,
|
||||
})
|
||||
.Build();
|
||||
|
||||
private static ServiceProvider BuildProvider(Action<IServiceCollection> register)
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddSingleton(Configuration());
|
||||
register(services);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static int? CommandTimeoutOf<TContext>(IServiceProvider provider)
|
||||
where TContext : DbContext
|
||||
{
|
||||
DbContextOptions<TContext> options = provider.GetRequiredService<DbContextOptions<TContext>>();
|
||||
return options.Extensions.OfType<RelationalOptionsExtension>().Single().CommandTimeout;
|
||||
}
|
||||
|
||||
private static bool RetriesOnFailureOf<TContext>(IServiceProvider provider)
|
||||
where TContext : DbContext
|
||||
{
|
||||
using IServiceScope scope = provider.CreateScope();
|
||||
TContext context = scope.ServiceProvider.GetRequiredService<TContext>();
|
||||
return context.Database.CreateExecutionStrategy().RetriesOnFailure;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDbContextConfig_SetsCommandTimeout_AndDoesNotRetry()
|
||||
{
|
||||
using ServiceProvider provider = BuildProvider(s => s.AddDbContextConfig(Configuration()));
|
||||
|
||||
Assert.Equal(30, CommandTimeoutOf<ApplicationDBContext>(provider));
|
||||
Assert.False(RetriesOnFailureOf<ApplicationDBContext>(provider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddIdentityConfig_SetsCommandTimeout_AndDoesNotRetry()
|
||||
{
|
||||
using ServiceProvider provider = BuildProvider(s => s.AddIdentityConfig(Configuration()));
|
||||
|
||||
Assert.Equal(30, CommandTimeoutOf<IdentityAppDbContext>(provider));
|
||||
Assert.False(RetriesOnFailureOf<IdentityAppDbContext>(provider));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A player's DocumentNumber must be digits only, and BirthDate must put them
|
||||
/// at least 15 years old — both enforced as DataAnnotations on
|
||||
/// CreatePlayerRequest/UpdatePlayerRequest, so [ApiController] rejects a bad
|
||||
/// payload with 400 before any service code runs.
|
||||
/// </summary>
|
||||
public class PlayerRequestValidationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerRequestValidationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedTeamAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Player validation test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Status = TournamentStatus.OpenForRegistration,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team.Id;
|
||||
}
|
||||
|
||||
private static object ValidPayload(Guid teamId, object overrides) =>
|
||||
new
|
||||
{
|
||||
firstName = "Juan",
|
||||
lastName = "Pérez",
|
||||
documentNumber = $"30{Random.Shared.Next(100000, 999999)}",
|
||||
birthDate = DateTime.UtcNow.Date.AddYears(-20),
|
||||
phoneNumber = "3435551234",
|
||||
socialSecurity = "OSDE",
|
||||
teamId,
|
||||
}.Merge(overrides);
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayer_NonNumericDocumentNumber_ReturnsBadRequest()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Guid teamId = await SeedTeamAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
"api/players",
|
||||
ValidPayload(teamId, new { documentNumber = "d23" }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayer_YoungerThan15_ReturnsBadRequest()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Guid teamId = await SeedTeamAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
"api/players",
|
||||
ValidPayload(teamId, new { birthDate = DateTime.UtcNow.Date.AddYears(-10) }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayer_ExactlyMinimumAge_IsAccepted()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Guid teamId = await SeedTeamAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
"api/players",
|
||||
ValidPayload(teamId, new { birthDate = DateTime.UtcNow.Date.AddYears(-15) }));
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Created,
|
||||
$"Expected 201, got {response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayer_ValidPayload_IsAccepted()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Guid teamId = await SeedTeamAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
"api/players",
|
||||
ValidPayload(teamId, new { }));
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Created,
|
||||
$"Expected 201, got {response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Found live while E2E-testing the tournament wizard: submitting a
|
||||
/// DocumentNumber that already belongs to another player hit the DB's
|
||||
/// unique index (IX_Players_DocumentNumber) with no pre-check in
|
||||
/// PlayerService.CreatePlayerAsync, so it bubbled up as an unhandled
|
||||
/// DbUpdateException — a raw 500 instead of a friendly conflict, and the
|
||||
/// frontend had no error-shaped response to show a toast for. Must be a
|
||||
/// clean 409 with a Spanish message, exactly like every other duplicate
|
||||
/// guard in this codebase (team code, division slug, etc).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreatePlayer_DuplicateDocumentNumber_ReturnsConflictNotServerError()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Guid teamId = await SeedTeamAsync(db);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
string documentNumber = $"30{Random.Shared.Next(100000, 999999)}";
|
||||
|
||||
HttpResponseMessage first = await client.PostAsJsonAsync(
|
||||
"api/players",
|
||||
ValidPayload(teamId, new { documentNumber }));
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
HttpResponseMessage second = await client.PostAsJsonAsync(
|
||||
"api/players",
|
||||
ValidPayload(teamId, new { documentNumber }));
|
||||
|
||||
string body = await second.Content.ReadAsStringAsync();
|
||||
Assert.True(
|
||||
second.StatusCode == HttpStatusCode.Conflict,
|
||||
$"Expected 409, got {second.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
file static class ObjectMergeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges an anonymous-object payload with overrides by re-projecting to
|
||||
/// a plain dictionary, keeping the test payload builders one-liners.
|
||||
/// </summary>
|
||||
public static object Merge(this object baseObject, object overrides)
|
||||
{
|
||||
Dictionary<string, object?> merged = baseObject.GetType().GetProperties()
|
||||
.ToDictionary(p => p.Name, p => p.GetValue(baseObject));
|
||||
|
||||
foreach (var prop in overrides.GetType().GetProperties())
|
||||
{
|
||||
merged[prop.Name] = prop.GetValue(overrides);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.DTOs.PlayerSanction.Response;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for the sanction appeal state machine implemented in
|
||||
/// PlayerSanctionController.AppealPlayerSanction and
|
||||
/// PlayerSanctionController.ResolvePlayerSanctionAppeal — an
|
||||
/// accepted architecture smell (business logic living in the controller rather than a
|
||||
/// dedicated service). Locks in the current guard clauses and persisted status transitions via
|
||||
/// a real HTTP round trip through CustomWebApplicationFactory; introduces no
|
||||
/// behavior change.
|
||||
/// </summary>
|
||||
public class PlayerSanctionAppealTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
// Mirrors the API's JSON settings (enums serialized as strings) so the
|
||||
// typed response can be read back.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public PlayerSanctionAppealTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppealPlayerSanction_AlreadyPending_Returns400AndStatusUnchanged()
|
||||
{
|
||||
Guid sanctionId = await SeedSanctionIdAsync(SanctionAppealStatus.Pending);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
AppealPlayerSanctionRequest request = new() { Reason = "Second appeal while pending" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{sanctionId}/appeal", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(SanctionAppealStatus.Pending, await ReadAppealStatusAsync(sanctionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppealPlayerSanction_FromNone_Succeeds_AndPersistsPending()
|
||||
{
|
||||
Guid sanctionId = await SeedSanctionIdAsync(SanctionAppealStatus.None);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
AppealPlayerSanctionRequest request = new() { Reason = "I was not at fault" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{sanctionId}/appeal", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(SanctionAppealStatus.Pending, await ReadAppealStatusAsync(sanctionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppealPlayerSanction_MissingSanction_Returns404()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
AppealPlayerSanctionRequest request = new() { Reason = "Does not matter" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{Guid.NewGuid()}/appeal", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
public static readonly TheoryData<SanctionAppealStatus> NotPendingStatuses =
|
||||
[
|
||||
SanctionAppealStatus.None,
|
||||
SanctionAppealStatus.Accepted,
|
||||
SanctionAppealStatus.Rejected,
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotPendingStatuses))]
|
||||
public async Task ResolvePlayerSanctionAppeal_NotPending_Returns400AndStatusUnchanged(
|
||||
SanctionAppealStatus initialStatus)
|
||||
{
|
||||
Guid sanctionId = await SeedSanctionIdAsync(initialStatus);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
ResolveAppealRequest request = new() { Accepted = true, Resolution = "Reviewed" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{sanctionId}/appeal/resolve", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(initialStatus, await ReadAppealStatusAsync(sanctionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePlayerSanctionAppeal_Accept_PersistsAccepted()
|
||||
{
|
||||
Guid sanctionId = await SeedSanctionIdAsync(SanctionAppealStatus.Pending);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
ResolveAppealRequest request = new() { Accepted = true, Resolution = "Appeal accepted" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{sanctionId}/appeal/resolve", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(SanctionAppealStatus.Accepted, await ReadAppealStatusAsync(sanctionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePlayerSanctionAppeal_Accept_LiftsTheSanctionImmediately()
|
||||
{
|
||||
// Regression: accepting an appeal used to only record AppealStatus —
|
||||
// FechasRemaining/IsActive were derived purely from Duration and rounds
|
||||
// served, with no reference to AppealStatus at all, so an accepted
|
||||
// appeal had zero practical effect: the subject stayed sanctioned for
|
||||
// the full original duration regardless of the decision.
|
||||
Guid sanctionId = await SeedSanctionIdAsync(SanctionAppealStatus.Pending);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
ResolveAppealRequest request = new() { Accepted = true, Resolution = "Appeal accepted" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{sanctionId}/appeal/resolve", request);
|
||||
|
||||
PlayerSanctionResponse? body = await response.Content.ReadFromJsonAsync<PlayerSanctionResponse>(JsonOptions);
|
||||
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(0, body.FechasRemaining);
|
||||
Assert.False(body.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePlayerSanctionAppeal_Reject_PersistsRejected()
|
||||
{
|
||||
Guid sanctionId = await SeedSanctionIdAsync(SanctionAppealStatus.Pending);
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
ResolveAppealRequest request = new() { Accepted = false, Resolution = "Appeal rejected" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{sanctionId}/appeal/resolve", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(SanctionAppealStatus.Rejected, await ReadAppealStatusAsync(sanctionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePlayerSanctionAppeal_MissingSanction_Returns404()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
ResolveAppealRequest request = new() { Accepted = true, Resolution = "Does not matter" };
|
||||
|
||||
HttpResponseMessage response = await client.PutAsJsonAsync(
|
||||
$"api/player-sanctions/{Guid.NewGuid()}/appeal/resolve", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<Guid> SeedSanctionIdAsync(SanctionAppealStatus status)
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
PlayerSanction sanction = await SeedSanctionAsync(db, status);
|
||||
return sanction.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses a fresh scope/DbContext because the request pipeline persists through
|
||||
/// its own scoped DbContext; re-querying with a new one avoids reading a
|
||||
/// stale change-tracker snapshot instead of the actual persisted row.
|
||||
/// </summary>
|
||||
private async Task<SanctionAppealStatus> ReadAppealStatusAsync(Guid sanctionId)
|
||||
{
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerSanction sanction = await db.PlayerSanctions.AsNoTracking()
|
||||
.SingleAsync(s => s.Id == sanctionId);
|
||||
|
||||
return sanction.AppealStatus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the full object graph a PlayerSanction requires under SQLite's
|
||||
/// enforced FKs: Team→Player and Tournament→Division→Stage→Match, mirroring the object-
|
||||
/// graph depth of AutomatedMatchGenerationTests.SeedStageAsync.
|
||||
/// </summary>
|
||||
private static async Task<PlayerSanction> SeedSanctionAsync(
|
||||
ApplicationDBContext db, SanctionAppealStatus status)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date;
|
||||
DateTime endDate = startDate.AddDays(14);
|
||||
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Sanction appeal characterization tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Id = stageId,
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SAT",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Red",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Sanctioned",
|
||||
LastName = "Player",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = true,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = teamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = startDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
PlayerSanction sanction = new()
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = startDate,
|
||||
Description = "Sanction appeal characterization test",
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
Player = player,
|
||||
Match = match,
|
||||
AppealStatus = status,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
if (status != SanctionAppealStatus.None)
|
||||
{
|
||||
sanction.AppealReason = "Prior appeal reason";
|
||||
sanction.AppealDate = startDate;
|
||||
}
|
||||
|
||||
if (status is SanctionAppealStatus.Accepted or SanctionAppealStatus.Rejected)
|
||||
{
|
||||
sanction.AppealResolution = "Prior resolution notes";
|
||||
sanction.AppealResolvedDate = startDate;
|
||||
}
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Players.Add(player);
|
||||
db.Matches.Add(match);
|
||||
db.PlayerSanctions.Add(sanction);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return sanction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the "GET /api/player-sanctions/find returns 500" bug.
|
||||
///
|
||||
/// Root cause: the migration 20260828040000_AddSubjectToPlayerSanction shipped
|
||||
/// WITHOUT its .Designer.cs partial, so the [Migration] attribute (and the
|
||||
/// migration's target-model snapshot) were missing. EF Core only discovers a
|
||||
/// migration through that attribute, so the migration was silently skipped when
|
||||
/// the production Npgsql database was migrated: the PlayerSanctions table never
|
||||
/// got its SubjectType / TeamId / StaffName columns, while the current model
|
||||
/// expected them, and every read of the entity failed with "column does not
|
||||
/// exist" (HTTP 500) — even against an empty table, which is why it surfaced on
|
||||
/// a fresh DB / after a data wipe and, because a global active-sanctions check
|
||||
/// fires this endpoint on every page, on all routes.
|
||||
///
|
||||
/// The SQLite integration harness builds its schema from the current model via
|
||||
/// EnsureCreated(), so it can never reproduce a "migration is not registered"
|
||||
/// bug. The first test below is therefore provider-independent: it asserts every
|
||||
/// compiled Migration in the Infrastructure assembly carries a [Migration]
|
||||
/// attribute, which is exactly what a missing Designer partial removes.
|
||||
/// </summary>
|
||||
public class PlayerSanctionEmptyFilterRepro : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionEmptyFilterRepro(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryApplicationMigration_IsRegistered_WithMigrationAttribute()
|
||||
{
|
||||
Assembly migrationsAssembly = typeof(ApplicationDBContext).Assembly;
|
||||
|
||||
string[] orphanMigrations = migrationsAssembly
|
||||
.GetTypes()
|
||||
.Where(t => typeof(Migration).IsAssignableFrom(t) && !t.IsAbstract)
|
||||
.Where(t => t.GetCustomAttribute<MigrationAttribute>() is null)
|
||||
.Select(t => t.FullName!)
|
||||
.OrderBy(name => name)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
orphanMigrations.Length == 0,
|
||||
"These Migration classes have no [Migration] attribute (a missing .Designer.cs partial), "
|
||||
+ "so EF Core will never apply them: " + string.Join(", ", orphanMigrations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectToPlayerSanction_Migration_IsKnownToEfCore()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Assert.Contains(
|
||||
"20260828040000_AddSubjectToPlayerSanction",
|
||||
db.Database.GetMigrations());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerSanctionsAsync_NoFilters_DoesNotThrow()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { PageNumber = 1, PageSize = 10 });
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.TotalCount >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindEndpoint_NoFilters_Returns200()
|
||||
{
|
||||
System.Net.Http.HttpClient client = _factory.CreateClient();
|
||||
|
||||
System.Net.Http.HttpResponseMessage response =
|
||||
await client.GetAsync("/api/player-sanctions/find?pageNumber=1&pageSize=10");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using Application.DTOs.Abstract.Request;
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the paginated sanctions list served by
|
||||
/// <c>GET /api/player-sanctions/find</c> defaults to newest-first ordering by
|
||||
/// <see cref="PlayerSanction.IssuedDate"/>. The admin (<c>/panel/sanciones</c>)
|
||||
/// and public (<c>/sanciones</c>) pages send no sort parameter and do not wire
|
||||
/// server-side column sort, so the backend default is the effective order —
|
||||
/// it must surface the most recently issued sanctions on the first page, not
|
||||
/// the oldest.
|
||||
/// </summary>
|
||||
public class PlayerSanctionOrderingTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionOrderingTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRequest_DefaultOrdering_IsIssuedDateDescending()
|
||||
{
|
||||
GetPlayerSanctionsFilteredRequest request = new();
|
||||
|
||||
Assert.Equal(nameof(PlayerSanction.IssuedDate), request.OrderBy);
|
||||
Assert.Equal(SortOrder.Descending, request.Order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerSanctionsAsync_NoExplicitSort_ReturnsNewestIssuedFirst()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
DateTime oldest = new(2021, 6, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime newest = new(2023, 3, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime middle = new(2022, 9, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Inserted in a deliberately non-chronological order so an
|
||||
// insertion-order (DateCreated) default would produce a different
|
||||
// sequence than the issue-date one under test.
|
||||
await SeedSanctionAsync(db, token, oldest);
|
||||
await SeedSanctionAsync(db, token, newest);
|
||||
await SeedSanctionAsync(db, token, middle);
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { Description = token, PageSize = 50 });
|
||||
|
||||
List<DateTime> issuedDates = [.. result.Items.Select(s => s.IssuedDate)];
|
||||
|
||||
Assert.Equal([newest, middle, oldest], issuedDates);
|
||||
for (int i = 1; i < issuedDates.Count; i++)
|
||||
{
|
||||
Assert.True(issuedDates[i] <= issuedDates[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerSanctionsAsync_ExplicitAscendingSort_OverridesDefault()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
DateTime oldest = new(2021, 6, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime newest = new(2023, 3, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime middle = new(2022, 9, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await SeedSanctionAsync(db, token, newest);
|
||||
await SeedSanctionAsync(db, token, oldest);
|
||||
await SeedSanctionAsync(db, token, middle);
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest
|
||||
{
|
||||
Description = token,
|
||||
PageSize = 50,
|
||||
OrderBy = nameof(PlayerSanction.IssuedDate),
|
||||
Order = SortOrder.Ascending,
|
||||
});
|
||||
|
||||
List<DateTime> issuedDates = [.. result.Items.Select(s => s.IssuedDate)];
|
||||
|
||||
Assert.Equal([oldest, middle, newest], issuedDates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerSanctionsAsync_DescriptionSearch_StillMatches_Regression()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
PlayerSanction sanction = await SeedSanctionAsync(
|
||||
db, token, new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { Description = token });
|
||||
|
||||
Assert.Contains(result.Items, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the full object graph a <see cref="PlayerSanction"/> needs under
|
||||
/// SQLite's enforced FKs (Tournament→Division→Stage→Match, Team→Player),
|
||||
/// mirroring <c>PlayerSanctionSearchTests.SeedSanctionAsync</c> but with a
|
||||
/// caller-supplied issue date and a shared token embedded in the reason so
|
||||
/// a test can isolate its own rows.
|
||||
/// </summary>
|
||||
private static async Task<PlayerSanction> SeedSanctionAsync(
|
||||
ApplicationDBContext db, string token, DateTime issuedDate)
|
||||
{
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Sanction ordering test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = issuedDate.AddDays(-1),
|
||||
StartDate = issuedDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Id = stageId,
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = issuedDate,
|
||||
EndDate = issuedDate.AddDays(14),
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SEA",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Lionel",
|
||||
LastName = $"Messi-{Guid.NewGuid():N}",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = true,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = teamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = issuedDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
PlayerSanction sanction = new()
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = issuedDate,
|
||||
Description = $"Conducta antideportiva {token}",
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
Player = player,
|
||||
Match = match,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Players.Add(player);
|
||||
db.Matches.Add(match);
|
||||
db.PlayerSanctions.Add(sanction);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return sanction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies PlayerSanctionService.GetPlayerSanctionsAsync's free-text
|
||||
/// search (the Description filter) matches by the sanctioned PLAYER's name
|
||||
/// (First/Second/LastName), not only by the sanction reason — HU-23 — using a
|
||||
/// case-insensitive partial (Contains) match rather than an exact/prefix one —
|
||||
/// HU-24. Each test embeds a unique token in exactly one field so the shared
|
||||
/// IClassFixture database cannot leak matches between tests.
|
||||
/// </summary>
|
||||
public class PlayerSanctionSearchTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionSearchTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_ByPlayerLastName_CaseInsensitivePartial_Matches()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
// Token lives ONLY in the player's last name, stored upper-cased so a
|
||||
// lower-cased search term proves case-insensitivity; the reason has no
|
||||
// token, proving the match came from the name, not the description.
|
||||
PlayerSanction sanction = await SeedSanctionAsync(
|
||||
db,
|
||||
firstName: "Lionel",
|
||||
lastName: $"Messi{token.ToUpperInvariant()}",
|
||||
description: "Conducta antideportiva");
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { Description = token.ToLowerInvariant() });
|
||||
|
||||
Assert.Contains(result.Items, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_ByPlayerFirstName_MidStringSubstring_Matches()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
// Token sits in the MIDDLE of the first name — a startsWith/exact match
|
||||
// would miss it; only a Contains match succeeds.
|
||||
PlayerSanction sanction = await SeedSanctionAsync(
|
||||
db,
|
||||
firstName: $"Lio{token}nel",
|
||||
lastName: "Maradona",
|
||||
description: "Doble falta tecnica");
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { Description = token });
|
||||
|
||||
Assert.Contains(result.Items, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_ByDescription_StillMatches_Regression()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
// Token lives ONLY in the reason — the original description search must
|
||||
// keep working after adding the player-name clauses.
|
||||
PlayerSanction sanction = await SeedSanctionAsync(
|
||||
db,
|
||||
firstName: "Diego",
|
||||
lastName: "Simeone",
|
||||
description: $"Expulsion-{token.ToUpperInvariant()}");
|
||||
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { Description = token.ToLowerInvariant() });
|
||||
|
||||
Assert.Contains(result.Items, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_NonMatchingTerm_DoesNotMatch()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
PlayerSanction sanction = await SeedSanctionAsync(
|
||||
db,
|
||||
firstName: "Angel",
|
||||
lastName: $"DiMaria{token}",
|
||||
description: "Protesta");
|
||||
|
||||
// A different, unrelated token must not surface the sanction — guards
|
||||
// against the name clause degenerating into "match everything".
|
||||
string otherToken = Guid.NewGuid().ToString("N")[..8];
|
||||
PaginatedResponse<PlayerSanction> result = await sanctionService.GetPlayerSanctionsAsync(
|
||||
new GetPlayerSanctionsFilteredRequest { Description = otherToken });
|
||||
|
||||
Assert.DoesNotContain(result.Items, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the full object graph a PlayerSanction requires under SQLite's
|
||||
/// enforced FKs: Tournament→Division→Stage→Match and Team→Player, mirroring
|
||||
/// PlayerSanctionServiceTests.SeedSanctionAsync, but with caller-supplied
|
||||
/// player names and reason so a test can target one searchable field.
|
||||
/// </summary>
|
||||
private static async Task<PlayerSanction> SeedSanctionAsync(
|
||||
ApplicationDBContext db, string firstName, string lastName, string description)
|
||||
{
|
||||
DateTime issuedDate = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Sanction search test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = issuedDate.AddDays(-1),
|
||||
StartDate = issuedDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Id = stageId,
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = issuedDate,
|
||||
EndDate = issuedDate.AddDays(14),
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SEA",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = true,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = teamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = issuedDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
PlayerSanction sanction = new()
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = issuedDate,
|
||||
Description = description,
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
Player = player,
|
||||
Match = match,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Players.Add(player);
|
||||
db.Matches.Add(match);
|
||||
db.PlayerSanctions.Add(sanction);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return sanction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for PlayerSanctionService.GetExpiredSanctionsAsync
|
||||
/// (PlayerSanctionService.cs:33-36), the date-math predicate
|
||||
/// `IssuedDate.AddDays(Duration) <= cutoffDate` used to determine which
|
||||
/// sanctions have expired as of a cutoff date. These tests pin the currently-
|
||||
/// correct behavior of the existing, unmodified service — pure test
|
||||
/// additions, no production code changes.
|
||||
///
|
||||
/// Known test-harness limitation: SQLite (test provider, via
|
||||
/// CustomWebApplicationFactory) may translate `DateTime.AddDays` comparisons
|
||||
/// differently than Npgsql (production). All seed dates below use UTC,
|
||||
/// whole-second `DateTime` values with whole-day `Duration`, so expiry always
|
||||
/// lands on an exact instant and SQLite's translation is unambiguous; EF Core's
|
||||
/// SQLite provider successfully translates this predicate (verified by the
|
||||
/// GREEN runs of every scenario in this file — no runtime translation failure
|
||||
/// was observed).
|
||||
///
|
||||
/// Each test uses a base date far apart from the others (1000+ day offsets
|
||||
/// from a fixed anchor) so that seeded sanctions never overlap another test's
|
||||
/// cutoffDate — GetExpiredSanctionsAsync has no tournament/player scoping, and
|
||||
/// the CustomWebApplicationFactory database is shared across all tests in this
|
||||
/// class via IClassFixture.
|
||||
/// </summary>
|
||||
public class PlayerSanctionServiceTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private static readonly DateTime Anchor = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionServiceTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredSanctionsAsync_BeforeExpiry_IsExcluded()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
DateTime issuedDate = Anchor;
|
||||
int duration = 10;
|
||||
DateTime expiry = issuedDate.AddDays(duration);
|
||||
DateTime cutoffDateBeforeExpiry = expiry.AddDays(-1);
|
||||
|
||||
PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration);
|
||||
|
||||
IEnumerable<PlayerSanction> result = await sanctionService.GetExpiredSanctionsAsync(cutoffDateBeforeExpiry);
|
||||
|
||||
Assert.DoesNotContain(result, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredSanctionsAsync_ExactlyAtBoundary_IsIncluded()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
DateTime issuedDate = Anchor.AddDays(1000);
|
||||
int duration = 10;
|
||||
DateTime expiry = issuedDate.AddDays(duration);
|
||||
DateTime cutoffDateAtExpiryBoundary = expiry;
|
||||
|
||||
PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration);
|
||||
|
||||
IEnumerable<PlayerSanction> result = await sanctionService.GetExpiredSanctionsAsync(cutoffDateAtExpiryBoundary);
|
||||
|
||||
Assert.Contains(result, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredSanctionsAsync_WellBeforeCutoff_IsIncluded()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
DateTime issuedDate = Anchor.AddDays(2000);
|
||||
int duration = 5;
|
||||
DateTime expiry = issuedDate.AddDays(duration);
|
||||
DateTime cutoffDateWellAfterExpiry = expiry.AddDays(20);
|
||||
|
||||
PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration);
|
||||
|
||||
IEnumerable<PlayerSanction> result = await sanctionService.GetExpiredSanctionsAsync(cutoffDateWellAfterExpiry);
|
||||
|
||||
Assert.Contains(result, s => s.Id == sanction.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// cutoffDate precedes Anchor, the earliest issuedDate used by any test in
|
||||
/// this class (all others use Anchor plus a non-negative offset), so it
|
||||
/// excludes both this test's own not-yet-expired sanction and every other
|
||||
/// test's already-expired sanction. GetExpiredSanctionsAsync has no
|
||||
/// tournament/player scoping, and the CustomWebApplicationFactory database
|
||||
/// is shared across all tests in this class via IClassFixture.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetExpiredSanctionsAsync_NoMatches_ReturnsEmpty()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
DateTime issuedDate = Anchor.AddDays(3000);
|
||||
int duration = 10;
|
||||
DateTime cutoffDate = Anchor.AddDays(-1);
|
||||
|
||||
await SeedSanctionAsync(db, issuedDate, duration);
|
||||
|
||||
IEnumerable<PlayerSanction> result = await sanctionService.GetExpiredSanctionsAsync(cutoffDate);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredSanctionsAsync_IncludesPlayerNavigation()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
DateTime issuedDate = Anchor.AddDays(4000);
|
||||
int duration = 10;
|
||||
DateTime expiry = issuedDate.AddDays(duration);
|
||||
DateTime cutoffDate = expiry;
|
||||
|
||||
PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration);
|
||||
|
||||
IEnumerable<PlayerSanction> result = await sanctionService.GetExpiredSanctionsAsync(cutoffDate);
|
||||
|
||||
PlayerSanction returned = Assert.Single(result, s => s.Id == sanction.Id);
|
||||
Assert.NotNull(sanction.Player);
|
||||
Assert.NotNull(returned.Player);
|
||||
Assert.Equal(sanction.Player.Id, returned.Player.Id);
|
||||
Assert.Equal(sanction.Player.DocumentNumber, returned.Player.DocumentNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the full object graph a PlayerSanction requires under SQLite's
|
||||
/// enforced FKs: Tournament→Division→Stage→Match and Team→Player, mirroring
|
||||
/// PlayerSanctionAppealTests.SeedSanctionAsync.
|
||||
/// </summary>
|
||||
private static async Task<PlayerSanction> SeedSanctionAsync(
|
||||
ApplicationDBContext db, DateTime issuedDate, int duration)
|
||||
{
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Sanction expiry characterization tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = issuedDate.AddDays(-1),
|
||||
StartDate = issuedDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Id = stageId,
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = issuedDate,
|
||||
EndDate = issuedDate.AddDays(14),
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SEX",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Expiry",
|
||||
LastName = "Tester",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = true,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = teamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = issuedDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
PlayerSanction sanction = new()
|
||||
{
|
||||
Duration = duration,
|
||||
IssuedDate = issuedDate,
|
||||
Description = "Sanction expiry characterization test",
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
Player = player,
|
||||
Match = match,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Players.Add(player);
|
||||
db.Matches.Add(match);
|
||||
db.PlayerSanctions.Add(sanction);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return sanction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies PlayerSanction's slug support: PlayerSanctionService.CreatePlayerSanctionAsync
|
||||
/// generates a unique slug from the sanctioned player's name and the sanction's issued date
|
||||
/// ("{playerFullName} {issuedDate:yyyy-MM-dd}"), and GET api/player-sanctions/{idOrSlug}
|
||||
/// resolves a sanction by either its GUID id or its slug. Unlike BlogPost/Team/Venue,
|
||||
/// PlayerSanctionController has no SupabaseHelper dependency, so this can run as a real
|
||||
/// HTTP round trip through CustomWebApplicationFactory.
|
||||
/// </summary>
|
||||
public class PlayerSanctionSlugTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionSlugTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayerSanctionAsync_GeneratesSlugFromPlayerNameAndIssuedDate()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
(Player player, Match match) = await SeedPlayerAndMatchAsync(db);
|
||||
DateTime issuedDate = new(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
PlayerSanction created = await sanctionService.CreatePlayerSanctionAsync(new PlayerSanction
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = issuedDate,
|
||||
Description = "Slug generation test",
|
||||
Slug = null!,
|
||||
Player = player,
|
||||
PlayerId = player.Id,
|
||||
Match = match,
|
||||
MatchId = match.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(created.Slug));
|
||||
Assert.Equal(created.Slug, created.Slug.ToLowerInvariant());
|
||||
Assert.EndsWith("2026-03-15", created.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayerSanctionAsync_SamePlayerSameDay_AppendsSuffixToSlug()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
(Player player, Match match) = await SeedPlayerAndMatchAsync(db);
|
||||
DateTime issuedDate = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
PlayerSanction first = await sanctionService.CreatePlayerSanctionAsync(new PlayerSanction
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = issuedDate,
|
||||
Description = "First sanction same day",
|
||||
Slug = null!,
|
||||
Player = player,
|
||||
PlayerId = player.Id,
|
||||
Match = match,
|
||||
MatchId = match.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
PlayerSanction second = await sanctionService.CreatePlayerSanctionAsync(new PlayerSanction
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = issuedDate,
|
||||
Description = "Second sanction same day",
|
||||
Slug = null!,
|
||||
Player = player,
|
||||
PlayerId = player.Id,
|
||||
Match = match,
|
||||
MatchId = match.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
Assert.NotEqual(first.Slug, second.Slug);
|
||||
Assert.Equal($"{first.Slug}-2", second.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerSanctionById_BySlug_Returns200WithMatchingSanction()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService sanctionService = seedScope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
(Player player, Match match) = await SeedPlayerAndMatchAsync(db);
|
||||
PlayerSanction created = await sanctionService.CreatePlayerSanctionAsync(new PlayerSanction
|
||||
{
|
||||
Duration = 2,
|
||||
IssuedDate = DateTime.UtcNow.Date,
|
||||
Description = "Retrieved by slug",
|
||||
Slug = null!,
|
||||
Player = player,
|
||||
PlayerId = player.Id,
|
||||
Match = match,
|
||||
MatchId = match.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage byId = await client.GetAsync($"api/player-sanctions/{created.Id}");
|
||||
HttpResponseMessage bySlug = await client.GetAsync($"api/player-sanctions/{created.Slug}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, byId.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, bySlug.StatusCode);
|
||||
|
||||
SanctionIdResponse? bySlugBody = await bySlug.Content.ReadFromJsonAsync<SanctionIdResponse>();
|
||||
Assert.NotNull(bySlugBody);
|
||||
Assert.Equal(created.Id, bySlugBody!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerSanctionById_UnknownSlug_Returns404()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/player-sanctions/unknown-slug-{Guid.NewGuid():N}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record SanctionIdResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the minimal object graph a PlayerSanction requires under SQLite's enforced FKs
|
||||
/// (Team→Player and Tournament→Division→Stage→Match), mirroring
|
||||
/// PlayerSanctionAppealTests.SeedSanctionAsync but stopping short of creating the
|
||||
/// PlayerSanction itself, since these tests exercise PlayerSanctionService.CreatePlayerSanctionAsync
|
||||
/// directly.
|
||||
/// </summary>
|
||||
private static async Task<(Player Player, Match Match)> SeedPlayerAndMatchAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date;
|
||||
DateTime endDate = startDate.AddDays(14);
|
||||
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Sanction slug characterization tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Id = stageId,
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SLG",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Perez",
|
||||
LastName = "Juan",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = true,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = teamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = startDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Players.Add(player);
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (player, match);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.DTOs.PlayerSanction.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the sanction refinements (HU-75 fechas-based duration, HU-76
|
||||
/// auto-expiry / eligibility, HU-77 team & staff subjects). Fechas-remaining
|
||||
/// and eligibility are exercised at the service layer for determinism; subject
|
||||
/// creation and the public response shape go through a real HTTP round trip.
|
||||
/// </summary>
|
||||
public class PlayerSanctionSubjectAndFechasTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private static readonly DateTime Anchor = new(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Mirrors the API's JSON settings (enums serialized as strings) so the
|
||||
// typed response can be read back.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionSubjectAndFechasTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HU-75
|
||||
|
||||
[Fact]
|
||||
public async Task GetFechasRemainingAsync_DecrementsAsTeamPlaysRounds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
// Duration 3 fechas, issued in round 1. No later rounds finished yet.
|
||||
SanctionSeed seed = await SeedPlayerSanctionAsync(db, durationFechas: 3, issuedRound: 1);
|
||||
|
||||
PlayerSanction sanction = await ReloadWithPlayerAsync(seed.SanctionId);
|
||||
Assert.Equal(3, await service.GetFechasRemainingAsync(sanction));
|
||||
|
||||
// Team plays and finishes round 2 -> one fecha served -> 2 remaining.
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 2);
|
||||
Assert.Equal(2, await service.GetFechasRemainingAsync(sanction));
|
||||
|
||||
// Team plays and finishes round 3 -> two served -> 1 remaining.
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 3);
|
||||
Assert.Equal(1, await service.GetFechasRemainingAsync(sanction));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFechasRemainingAsync_ClampsToZero_WhenAllFechasServed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
SanctionSeed seed = await SeedPlayerSanctionAsync(db, durationFechas: 2, issuedRound: 1);
|
||||
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 2);
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 3);
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 4);
|
||||
|
||||
PlayerSanction sanction = await ReloadWithPlayerAsync(seed.SanctionId);
|
||||
|
||||
// 3 rounds played but duration is 2 -> clamped to 0, never negative.
|
||||
Assert.Equal(0, await service.GetFechasRemainingAsync(sanction));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFechasRemainingAsync_IgnoresUnfinishedAndLowerRounds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
SanctionSeed seed = await SeedPlayerSanctionAsync(db, durationFechas: 3, issuedRound: 2);
|
||||
|
||||
// Round 1 (before the sanction) is finished but must NOT count.
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 1);
|
||||
// Round 3 scheduled but NOT finished -> must NOT count.
|
||||
await AddMatchAsync(db, seed.StageId, seed.TeamId, round: 3, isFinished: false);
|
||||
|
||||
PlayerSanction sanction = await ReloadWithPlayerAsync(seed.SanctionId);
|
||||
Assert.Equal(3, await service.GetFechasRemainingAsync(sanction));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HU-76
|
||||
|
||||
[Fact]
|
||||
public async Task HasActiveSanctionAsync_TrueWhileFechasRemain_FalseOnceServed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService<IPlayerSanctionService>();
|
||||
|
||||
SanctionSeed seed = await SeedPlayerSanctionAsync(db, durationFechas: 1, issuedRound: 1);
|
||||
|
||||
// A player with fechas remaining is ineligible (active sanction).
|
||||
Assert.True(await service.HasActiveSanctionAsync(seed.PlayerId));
|
||||
|
||||
// Team finishes round 2 -> the single fecha is served -> sanction
|
||||
// auto-expires and the player becomes eligible again.
|
||||
await AddFinishedMatchAsync(db, seed.StageId, seed.TeamId, round: 2);
|
||||
Assert.False(await service.HasActiveSanctionAsync(seed.PlayerId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HU-77
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTeamSanction_IsPersisted_AndReturnedWithTeamSubject()
|
||||
{
|
||||
SanctionSeed seed;
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
seed = await SeedPlayerSanctionAsync(db, durationFechas: 2, issuedRound: 1);
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
CreatePlayerSanctionRequest request = new()
|
||||
{
|
||||
Duration = 2,
|
||||
IssuedDate = Anchor,
|
||||
Description = "Institutional sanction against the team.",
|
||||
SubjectType = SanctionSubjectType.Team,
|
||||
TeamId = seed.TeamId,
|
||||
MatchId = seed.MatchId,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/player-sanctions", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
PlayerSanctionResponse? created = await response.Content.ReadFromJsonAsync<PlayerSanctionResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(SanctionSubjectType.Team, created!.SubjectType);
|
||||
Assert.Equal(seed.TeamId, created.TeamId);
|
||||
Assert.Null(created.PlayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStaffSanction_IsPersisted_AndReturnedWithStaffSubject()
|
||||
{
|
||||
SanctionSeed seed;
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
seed = await SeedPlayerSanctionAsync(db, durationFechas: 1, issuedRound: 1);
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
CreatePlayerSanctionRequest request = new()
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = Anchor,
|
||||
Description = "Sanction against a staff member.",
|
||||
SubjectType = SanctionSubjectType.Staff,
|
||||
StaffName = "Coach Pep Guardiola",
|
||||
MatchId = seed.MatchId,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/player-sanctions", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
PlayerSanctionResponse? created = await response.Content.ReadFromJsonAsync<PlayerSanctionResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(SanctionSubjectType.Staff, created!.SubjectType);
|
||||
Assert.Equal("Coach Pep Guardiola", created.StaffName);
|
||||
Assert.Null(created.PlayerId);
|
||||
Assert.Null(created.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTeamSanction_WithoutTeamId_IsRejected()
|
||||
{
|
||||
SanctionSeed seed;
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
seed = await SeedPlayerSanctionAsync(db, durationFechas: 1, issuedRound: 1);
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
CreatePlayerSanctionRequest request = new()
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = Anchor,
|
||||
Description = "Team sanction missing its team.",
|
||||
SubjectType = SanctionSubjectType.Team,
|
||||
MatchId = seed.MatchId,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/player-sanctions", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayerSanction_StillWorks_AndReturnsPlayerSubject()
|
||||
{
|
||||
SanctionSeed seed;
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
seed = await SeedPlayerSanctionAsync(db, durationFechas: 2, issuedRound: 1);
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
// Backward-compatible payload: no SubjectType, only a PlayerId.
|
||||
CreatePlayerSanctionRequest request = new()
|
||||
{
|
||||
Duration = 2,
|
||||
IssuedDate = Anchor,
|
||||
Description = "Ordinary player sanction.",
|
||||
PlayerId = seed.PlayerId,
|
||||
MatchId = seed.MatchId,
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("api/player-sanctions", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
PlayerSanctionResponse? created = await response.Content.ReadFromJsonAsync<PlayerSanctionResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(SanctionSubjectType.Player, created!.SubjectType);
|
||||
Assert.Equal(seed.PlayerId, created.PlayerId);
|
||||
Assert.NotNull(created.PlayerFullName);
|
||||
Assert.Null(created.TeamId);
|
||||
// Duration 2 with no later finished rounds -> 2 fechas remaining, active.
|
||||
Assert.Equal(2, created.FechasRemaining);
|
||||
Assert.True(created.IsActive);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- helpers
|
||||
|
||||
private sealed record SanctionSeed(
|
||||
Guid SanctionId, Guid PlayerId, Guid TeamId, Guid StageId, Guid MatchId);
|
||||
|
||||
private async Task<PlayerSanction> ReloadWithPlayerAsync(Guid sanctionId)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
return await db.PlayerSanctions
|
||||
.Include(s => s.Player)
|
||||
.AsNoTracking()
|
||||
.SingleAsync(s => s.Id == sanctionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds Tournament -> Division -> Stage -> Match plus Team -> Player and a
|
||||
/// player sanction issued in <paramref name="issuedRound"/>. Uses a base
|
||||
/// date far from other tests' data; fechas are scoped by the stage's own
|
||||
/// rounds so different tests never interfere.
|
||||
/// </summary>
|
||||
private static async Task<SanctionSeed> SeedPlayerSanctionAsync(
|
||||
ApplicationDBContext db, int durationFechas, int issuedRound)
|
||||
{
|
||||
DateTime issuedDate = Anchor.AddDays(Random.Shared.Next(0, 100_000));
|
||||
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Fechas/subject test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = issuedDate.AddDays(-1),
|
||||
StartDate = issuedDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Id = divisionId,
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Id = stageId,
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = issuedDate,
|
||||
EndDate = issuedDate.AddDays(60),
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "FCH",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Fechas",
|
||||
LastName = "Tester",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = true,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = teamId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match sanctionMatch = new()
|
||||
{
|
||||
MatchDate = issuedDate,
|
||||
Type = MatchType.Regular,
|
||||
Round = issuedRound,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
HomeTeamId = teamId,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
PlayerSanction sanction = new()
|
||||
{
|
||||
Duration = durationFechas,
|
||||
IssuedDate = issuedDate,
|
||||
Description = "Fechas-based sanction",
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
SubjectType = SanctionSubjectType.Player,
|
||||
Player = player,
|
||||
PlayerId = player.Id,
|
||||
Match = sanctionMatch,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Players.Add(player);
|
||||
db.Matches.Add(sanctionMatch);
|
||||
db.PlayerSanctions.Add(sanction);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new SanctionSeed(sanction.Id, player.Id, teamId, stageId, sanctionMatch.Id);
|
||||
}
|
||||
|
||||
private static Task AddFinishedMatchAsync(
|
||||
ApplicationDBContext db, Guid stageId, Guid teamId, int round)
|
||||
=> AddMatchAsync(db, stageId, teamId, round, isFinished: true);
|
||||
|
||||
private static async Task AddMatchAsync(
|
||||
ApplicationDBContext db, Guid stageId, Guid teamId, int round, bool isFinished)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = Anchor.AddDays(round),
|
||||
Type = MatchType.Regular,
|
||||
Round = round,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = isFinished,
|
||||
HomeTeamId = teamId,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.PlayerSanction.Request;
|
||||
using Application.DTOs.PlayerSanction.Response;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the sanction subject name for TEAM sanctions (QA wave 1, Bug 4): when
|
||||
/// a sanction targets a team (SubjectType = Team), the team NAME must appear in
|
||||
/// both the created response and the sanctions list, so the list shows WHO was
|
||||
/// sanctioned — not a blank subject. Regression: the list query does not load
|
||||
/// the Team navigation, so the mapper alone left TeamName null; the subject is
|
||||
/// now resolved explicitly regardless of which navigations are loaded.
|
||||
/// </summary>
|
||||
public class PlayerSanctionTeamNameTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private static readonly DateTime Anchor = new(2023, 6, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSanctionTeamNameTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TeamSanction_ExposesTeamName_InCreatedResponseAndList()
|
||||
{
|
||||
Guid teamId;
|
||||
Guid matchId;
|
||||
string teamName;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
(teamId, matchId, teamName) = await SeedTeamAndMatchAsync(db);
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
CreatePlayerSanctionRequest request = new()
|
||||
{
|
||||
Duration = 2,
|
||||
IssuedDate = Anchor,
|
||||
Description = "Institutional sanction against the team.",
|
||||
SubjectType = SanctionSubjectType.Team,
|
||||
TeamId = teamId,
|
||||
MatchId = matchId,
|
||||
};
|
||||
|
||||
HttpResponseMessage createResponse = await client.PostAsJsonAsync("api/player-sanctions", request);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
PlayerSanctionResponse? created =
|
||||
await createResponse.Content.ReadFromJsonAsync<PlayerSanctionResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(SanctionSubjectType.Team, created!.SubjectType);
|
||||
Assert.Equal(teamId, created.TeamId);
|
||||
Assert.Equal(teamName, created.TeamName);
|
||||
Assert.Null(created.PlayerFullName);
|
||||
|
||||
// The list (find) does not load the Team navigation; TeamName must still
|
||||
// be present for the subject to render.
|
||||
HttpResponseMessage listResponse = await client.GetAsync(
|
||||
$"api/player-sanctions/find?TeamId={teamId}");
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
PaginatedResponse<PlayerSanctionResponse>? list =
|
||||
await listResponse.Content.ReadFromJsonAsync<PaginatedResponse<PlayerSanctionResponse>>(JsonOptions);
|
||||
Assert.NotNull(list);
|
||||
PlayerSanctionResponse listed = Assert.Single(list!.Items);
|
||||
Assert.Equal(SanctionSubjectType.Team, listed.SubjectType);
|
||||
Assert.Equal(teamName, listed.TeamName);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TeamId, Guid MatchId, string TeamName)> SeedTeamAndMatchAsync(
|
||||
ApplicationDBContext db)
|
||||
{
|
||||
Guid divisionId = Guid.NewGuid();
|
||||
Guid stageId = Guid.NewGuid();
|
||||
Guid teamId = Guid.NewGuid();
|
||||
string teamName = $"Sanctioned Team {Guid.NewGuid():N}";
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Team-name sanction test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = Anchor.AddDays(-1),
|
||||
StartDate = Anchor,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Id = divisionId,
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Id = stageId,
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = Anchor,
|
||||
EndDate = Anchor.AddDays(60),
|
||||
DivisionId = divisionId,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = teamName,
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SAN",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Red",
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = Anchor,
|
||||
Type = MatchType.Regular,
|
||||
Round = 1,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
HomeTeamId = teamId,
|
||||
Stage = stage,
|
||||
StageId = stageId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Divisions.Add(division);
|
||||
db.Stages.Add(stage);
|
||||
db.Teams.Add(team);
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (teamId, match.Id, teamName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Locks the single canonical slug-source rule shared by create, seed and the
|
||||
/// re-backfill migration: <see cref="Player.BuildSlugSource"/> joins the raw-case
|
||||
/// names as <c>apellido nombre[ segundo]</c> with no document number, while
|
||||
/// <see cref="Player.FullName"/> keeps its historical display output
|
||||
/// (last name upper-cased) byte-for-byte.
|
||||
/// </summary>
|
||||
public class PlayerSlugSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSlugSource_WithoutSecondName_JoinsLastThenFirst()
|
||||
{
|
||||
string source = Player.BuildSlugSource("Lopez", "Carlos", null);
|
||||
|
||||
Assert.Equal("Lopez Carlos", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSlugSource_WithSecondName_AppendsSecondName()
|
||||
{
|
||||
string source = Player.BuildSlugSource("Lopez", "Carlos", "Maria");
|
||||
|
||||
Assert.Equal("Lopez Carlos Maria", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSlugSource_WhitespaceSecondName_TreatedAsAbsent()
|
||||
{
|
||||
string source = Player.BuildSlugSource("Lopez", "Carlos", " ");
|
||||
|
||||
Assert.Equal("Lopez Carlos", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSlugSource_PreservesRawCase_NoUpperCasing()
|
||||
{
|
||||
string source = Player.BuildSlugSource("lopez", "carlos", null);
|
||||
|
||||
Assert.Equal("lopez carlos", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlugSource_UsesRawCaseNamesWithoutDocumentNumber()
|
||||
{
|
||||
Player player = BuildPlayer("Lopez", "Carlos", secondName: null, documentNumber: "30000001");
|
||||
|
||||
Assert.Equal("Lopez Carlos", player.SlugSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlugSource_WithSecondName_IncludesIt()
|
||||
{
|
||||
Player player = BuildPlayer("Lopez", "Carlos", secondName: "Maria", documentNumber: "30000001");
|
||||
|
||||
Assert.Equal("Lopez Carlos Maria", player.SlugSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullName_WithoutSecondName_UpperCasesLastNameAndMatchesLegacyFormat()
|
||||
{
|
||||
Player player = BuildPlayer("Lopez", "Carlos", secondName: null, documentNumber: "30000001");
|
||||
|
||||
string expected = string.Concat("Lopez".ToUpper(), " Carlos");
|
||||
Assert.Equal(expected, player.FullName);
|
||||
Assert.Equal("LOPEZ Carlos", player.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullName_WithSecondName_MatchesLegacyFormat()
|
||||
{
|
||||
Player player = BuildPlayer("Lopez", "Carlos", secondName: "Maria", documentNumber: "30000001");
|
||||
|
||||
string expected = string.Concat("Lopez".ToUpper(), " Carlos Maria");
|
||||
Assert.Equal(expected, player.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullName_WhitespaceSecondName_TreatedAsAbsentLikeLegacy()
|
||||
{
|
||||
Player player = BuildPlayer("Lopez", "Carlos", secondName: " ", documentNumber: "30000001");
|
||||
|
||||
Assert.Equal(string.Concat("Lopez".ToUpper(), " Carlos"), player.FullName);
|
||||
}
|
||||
|
||||
private static Player BuildPlayer(string lastName, string firstName, string? secondName, string documentNumber)
|
||||
{
|
||||
return new Player
|
||||
{
|
||||
FirstName = firstName,
|
||||
SecondName = secondName,
|
||||
LastName = lastName,
|
||||
Slug = "placeholder",
|
||||
DocumentNumber = documentNumber,
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = null!,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Player's slug support: PlayerService.CreatePlayerAsync generates a
|
||||
/// unique slug from the player's full name, duplicate names are disambiguated
|
||||
/// with a numeric suffix, GetPlayerByIdOrSlugAsync resolves a player by either
|
||||
/// its GUID id or its slug, and GET api/players/{idOrSlug} resolves by either
|
||||
/// form. PlayerController has no SupabaseHelper dependency, so this can run as a
|
||||
/// real HTTP round trip through CustomWebApplicationFactory.
|
||||
/// </summary>
|
||||
public class PlayerSlugTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerSlugTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayerAsync_GeneratesSlugFromFullName()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Guid tournamentId) = await SeedTeamAsync(db);
|
||||
|
||||
Player created = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, "Ñandú", "Pérez"), tournamentId);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(created.Slug));
|
||||
Assert.DoesNotContain(' ', created.Slug);
|
||||
Assert.Equal(created.Slug, created.Slug.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayerAsync_DuplicateFullName_AppendsSuffixToSlug()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Guid tournamentId) = await SeedTeamAsync(db);
|
||||
|
||||
string firstName = "Juan";
|
||||
string lastName = "Duplicado" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
Player first = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, firstName, lastName), tournamentId);
|
||||
Player second = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, firstName, lastName), tournamentId);
|
||||
|
||||
Assert.NotEqual(first.Slug, second.Slug);
|
||||
Assert.Equal($"{first.Slug}-2", second.Slug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerById_BySlug_Returns200WithMatchingPlayer()
|
||||
{
|
||||
Guid createdId;
|
||||
string createdSlug;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Guid tournamentId) = await SeedTeamAsync(db);
|
||||
Player created = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, "Carlos", "Slug" + Guid.NewGuid().ToString("N")[..8]), tournamentId);
|
||||
|
||||
createdId = created.Id;
|
||||
createdSlug = created.Slug;
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage byId = await client.GetAsync($"api/players/{createdId}");
|
||||
HttpResponseMessage bySlug = await client.GetAsync($"api/players/{createdSlug}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, byId.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, bySlug.StatusCode);
|
||||
|
||||
PlayerIdResponse? bySlugBody = await bySlug.Content.ReadFromJsonAsync<PlayerIdResponse>();
|
||||
Assert.NotNull(bySlugBody);
|
||||
Assert.Equal(createdId, bySlugBody!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerById_UnknownSlug_Returns404()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/players/unknown-slug-{Guid.NewGuid():N}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET api/players/admin/{slug} must resolve the player by its exact slug and
|
||||
/// return 200 with the AdminPlayerResponse body. Asserting documentNumber is
|
||||
/// present proves the request bound to the admin action and was NOT shadowed
|
||||
/// by the public {idOrSlug} route (documentNumber is an AdminPlayerResponse-
|
||||
/// only field).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerByIdCompleteData_BySlug_Returns200WithDocumentNumber()
|
||||
{
|
||||
Guid createdId;
|
||||
string createdSlug;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Guid tournamentId) = await SeedTeamAsync(db);
|
||||
Player created = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, "Carlos", "AdminSlug" + Guid.NewGuid().ToString("N")[..8]), tournamentId);
|
||||
|
||||
createdId = created.Id;
|
||||
createdSlug = created.Slug;
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage bySlug = await client.GetAsync($"api/players/admin/{createdSlug}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, bySlug.StatusCode);
|
||||
|
||||
AdminPlayerBody? body = await bySlug.Content.ReadFromJsonAsync<AdminPlayerBody>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(createdId, body!.Id);
|
||||
Assert.False(string.IsNullOrWhiteSpace(body.DocumentNumber));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for matchPage.tsx:513 — the admin route must keep resolving a
|
||||
/// player by its GUID id after the parameter is widened from Guid to string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerByIdCompleteData_ByGuid_Returns200()
|
||||
{
|
||||
Guid createdId;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Guid tournamentId) = await SeedTeamAsync(db);
|
||||
Player created = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, "Carlos", "AdminGuid" + Guid.NewGuid().ToString("N")[..8]), tournamentId);
|
||||
|
||||
createdId = created.Id;
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage byId = await client.GetAsync($"api/players/admin/{createdId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, byId.StatusCode);
|
||||
|
||||
AdminPlayerBody? body = await byId.Content.ReadFromJsonAsync<AdminPlayerBody>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(createdId, body!.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown id-or-slug on the admin route must return a 404 whose body is a
|
||||
/// ProblemDetails document (application/problem+json), not a routing-level 404
|
||||
/// with an empty body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerByIdCompleteData_UnknownSlug_Returns404ProblemJson()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
string missingSlug = $"no-such-player-{Guid.NewGuid():N}";
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/players/admin/{missingSlug}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
// The detail naming the looked-up identifier proves the request reached
|
||||
// the action's NotFoundProblem path, not a routing-level 404.
|
||||
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Contains(missingSlug, doc.RootElement.GetProperty("detail").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slug lookup on the admin route is exact/ordinal — a wrong-case slug is not
|
||||
/// normalized and must miss, returning a 404 ProblemDetails (the request still
|
||||
/// reaches the handler, so the body is problem+json rather than a routing 404).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetPlayerByIdCompleteData_WrongCaseSlug_Returns404()
|
||||
{
|
||||
string createdSlug;
|
||||
|
||||
using (IServiceScope scope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Guid tournamentId) = await SeedTeamAsync(db);
|
||||
Player created = await playerService.CreatePlayerAsync(
|
||||
BuildPlayer(team, "Carlos", "AdminCase" + Guid.NewGuid().ToString("N")[..8]), tournamentId);
|
||||
|
||||
createdSlug = created.Slug;
|
||||
}
|
||||
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
string wrongCaseSlug = createdSlug.ToUpperInvariant();
|
||||
|
||||
HttpResponseMessage response = await client.GetAsync($"api/players/admin/{wrongCaseSlug}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Contains(wrongCaseSlug, doc.RootElement.GetProperty("detail").GetString());
|
||||
}
|
||||
|
||||
private sealed record PlayerIdResponse(Guid Id, string Slug);
|
||||
|
||||
private sealed record AdminPlayerBody(Guid Id, string Slug, string DocumentNumber);
|
||||
|
||||
private static Player BuildPlayer(Team team, string firstName, string lastName)
|
||||
{
|
||||
return new Player
|
||||
{
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
Slug = null!,
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<(Team Team, Guid TournamentId)> SeedTeamAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date;
|
||||
Guid teamId = Guid.NewGuid();
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Player slug characterization tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Id = teamId,
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = "SLG",
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (team, tournament.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using Application.DTOs.PlayerStatistic.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers GetPlayerStatisticsAsync's TeamId filter (joining through the
|
||||
/// statistic's player) and that the response carries the match date
|
||||
/// without a separate lookup.
|
||||
/// </summary>
|
||||
public class PlayerStatisticFilterTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerStatisticFilterTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayerStatisticsAsync_FilterByTeamId_ReturnsOnlyThatTeamsPlayerStatistics()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerStatisticService playerStatisticService = scope.ServiceProvider.GetRequiredService<IPlayerStatisticService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Team teamA = await SeedTeamAsync(db, tournament);
|
||||
Team teamB = await SeedTeamAsync(db, tournament);
|
||||
Player playerA = await SeedPlayerAsync(db, teamA);
|
||||
Player playerB = await SeedPlayerAsync(db, teamB);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
Stage stage = await SeedStageAsync(db, division, tournament);
|
||||
Match match = await SeedMatchAsync(db, stage, teamA, teamB);
|
||||
|
||||
db.PlayersStatistics.Add(new PlayerStatistic { Value = 20, Type = StatisticType.Points, MatchId = match.Id, PlayerId = playerA.Id, CreatedBy = "test" });
|
||||
db.PlayersStatistics.Add(new PlayerStatistic { Value = 15, Type = StatisticType.Points, MatchId = match.Id, PlayerId = playerB.Id, CreatedBy = "test" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Application.DTOs.Abstract.Response.PaginatedResponse<PlayerStatistic> result =
|
||||
await playerStatisticService.GetPlayerStatisticsAsync(new GetPlayerStatisticsFilteredRequest { TeamId = teamA.Id });
|
||||
|
||||
PlayerStatistic onlyResult = Assert.Single(result.Items);
|
||||
Assert.Equal(playerA.Id, onlyResult.PlayerId);
|
||||
Assert.Equal(match.MatchDate, onlyResult.Match!.MatchDate);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "PlayerStatistic filter test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedPlayerAsync(ApplicationDBContext db, Team team)
|
||||
{
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Test",
|
||||
LastName = $"Player-{Guid.NewGuid()}",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..8],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "N/A",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division, Tournament tournament)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedMatchAsync(ApplicationDBContext db, Stage stage, Team home, Team visitor)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return match;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
using Application.Interfaces.Repositories;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Services;
|
||||
using Application.Utils.Options;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies season-scoped roster membership: PlayerTeamRegistration is the
|
||||
/// source of truth for "was this player on this team during this season",
|
||||
/// decoupled from Player.TeamId (a denormalized "current team" pointer) the
|
||||
/// same way StageTeamMatch decouples "which teams are in which stage" from
|
||||
/// any fixed property on Team. Exercises the real IPlayerService/ITeamService
|
||||
/// capabilities through a DI scope against the CustomWebApplicationFactory's
|
||||
/// SQLite-backed ApplicationDBContext, matching this project's established
|
||||
/// integration-test style (see DivisionTournamentReassignmentTests,
|
||||
/// ScorerRepositoryTests).
|
||||
/// </summary>
|
||||
public class PlayerTeamRegistrationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerTeamRegistrationTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTeamByIdAsync_TeamMovedToNewTournament_OldSeasonRosterNoLongerShowsForCurrentTournament()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura");
|
||||
Tournament clausura = await SeedTournamentAsync(seedDb, "Clausura");
|
||||
Team team = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Player player = await SeedPlayerAsync(seedDb, team);
|
||||
await SeedRegistrationAsync(seedDb, player, team, apertura);
|
||||
|
||||
using (IServiceScope readScope = _factory.Services.CreateScope())
|
||||
{
|
||||
ITeamService teamService = readScope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
|
||||
Team? currentSeasonTeam = await teamService.GetTeamByIdAsync(team.Id);
|
||||
Assert.NotNull(currentSeasonTeam);
|
||||
Assert.Contains(currentSeasonTeam!.Players, p => p.Id == player.Id);
|
||||
}
|
||||
|
||||
// Reassign the team to a new season, reusing the same Team row —
|
||||
// mirrors TeamService.RegisterTeamsToTournamentAsync, the mechanism
|
||||
// that actually split the real "Apertura"/"Femenino" tournaments.
|
||||
using (IServiceScope moveScope = _factory.Services.CreateScope())
|
||||
{
|
||||
ApplicationDBContext moveDb = moveScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
Team trackedTeam = await moveDb.Teams.SingleAsync(t => t.Id == team.Id);
|
||||
trackedTeam.TournamentId = clausura.Id;
|
||||
await moveDb.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (IServiceScope verifyScope = _factory.Services.CreateScope())
|
||||
{
|
||||
ITeamService teamService = verifyScope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
|
||||
Team? nowOnClausura = await teamService.GetTeamByIdAsync(team.Id);
|
||||
Assert.NotNull(nowOnClausura);
|
||||
Assert.DoesNotContain(nowOnClausura!.Players, p => p.Id == player.Id);
|
||||
|
||||
Team? pastApertura = await teamService.GetTeamByIdAsync(team.Id, apertura.Id);
|
||||
Assert.NotNull(pastApertura);
|
||||
Assert.Contains(pastApertura!.Players, p => p.Id == player.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlayerAsync_RegistersPlayerToTeamsCurrentTournament()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura");
|
||||
Team team = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
|
||||
// Team is intentionally left unset (null!) rather than pointing at
|
||||
// the detached `team` instance from the seed scope's own
|
||||
// DbContext: that's exactly what AutoMapper produces in production
|
||||
// (CreatePlayerRequest has no Team property, only TeamId, so
|
||||
// CreateMap<CreatePlayerRequest, Player> never touches the Team
|
||||
// nav) and avoids EF treating an unrelated detached object graph
|
||||
// (Team -> Tournament) as new rows to insert.
|
||||
Player newPlayer = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Nuevo",
|
||||
LastName = "Jugador",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2001, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = null!,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
using (IServiceScope actScope = _factory.Services.CreateScope())
|
||||
{
|
||||
IPlayerService playerService = actScope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
await playerService.CreatePlayerAsync(newPlayer, apertura.Id);
|
||||
}
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration? registration = await verifyDb.PlayerTeamRegistrations
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(r => r.PlayerId == newPlayer.Id);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Equal(team.Id, registration!.TeamId);
|
||||
Assert.Equal(apertura.Id, registration.TournamentId);
|
||||
|
||||
ITeamService teamService = verifyScope.ServiceProvider.GetRequiredService<ITeamService>();
|
||||
Team? teamWithRoster = await teamService.GetTeamByIdAsync(team.Id);
|
||||
Assert.Contains(teamWithRoster!.Players, p => p.Id == newPlayer.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePlayerAsync_TeamChangedWithinSameSeason_MovesRegistrationInstead_OfDuplicating()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura");
|
||||
Team originalTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Team newTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Player player = await SeedPlayerAsync(seedDb, originalTeam);
|
||||
await SeedRegistrationAsync(seedDb, player, originalTeam, apertura);
|
||||
|
||||
using (IServiceScope actScope = _factory.Services.CreateScope())
|
||||
{
|
||||
IPlayerService playerService = actScope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Player tracked = await actDb.Players.SingleAsync(p => p.Id == player.Id);
|
||||
tracked.TeamId = newTeam.Id;
|
||||
|
||||
await playerService.UpdatePlayerAsync(tracked, apertura.Id);
|
||||
}
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
List<PlayerTeamRegistration> registrations = await verifyDb.PlayerTeamRegistrations
|
||||
.AsNoTracking()
|
||||
.Where(r => r.PlayerId == player.Id)
|
||||
.ToListAsync();
|
||||
|
||||
PlayerTeamRegistration onlyRegistration = Assert.Single(registrations);
|
||||
Assert.Equal(newTeam.Id, onlyRegistration.TeamId);
|
||||
Assert.Equal(apertura.Id, onlyRegistration.TournamentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePlayerAsync_TeamChangedWithinSameSeason_ClearsStaleJerseyNumber()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura");
|
||||
Team originalTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Team newTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
|
||||
// Another player already wears #7 on the destination team.
|
||||
Player incumbent = await SeedPlayerAsync(seedDb, newTeam);
|
||||
await SeedRegistrationAsync(seedDb, incumbent, newTeam, apertura, jerseyNumber: 7);
|
||||
|
||||
// The moving player wears the SAME #7, but on the original team.
|
||||
Player mover = await SeedPlayerAsync(seedDb, originalTeam);
|
||||
await SeedRegistrationAsync(seedDb, mover, originalTeam, apertura, jerseyNumber: 7);
|
||||
|
||||
using (IServiceScope actScope = _factory.Services.CreateScope())
|
||||
{
|
||||
IPlayerService playerService = actScope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Player tracked = await actDb.Players.SingleAsync(p => p.Id == mover.Id);
|
||||
tracked.TeamId = newTeam.Id;
|
||||
|
||||
// Must not throw the unique (TeamId, TournamentId, JerseyNumber)
|
||||
// constraint violation despite both players sharing dorsal #7.
|
||||
await playerService.UpdatePlayerAsync(tracked, apertura.Id);
|
||||
}
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
PlayerTeamRegistration moverRegistration = await verifyDb.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.PlayerId == mover.Id);
|
||||
Assert.Equal(newTeam.Id, moverRegistration.TeamId);
|
||||
Assert.Null(moverRegistration.JerseyNumber);
|
||||
|
||||
PlayerTeamRegistration incumbentRegistration = await verifyDb.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.PlayerId == incumbent.Id);
|
||||
Assert.Equal(7, incumbentRegistration.JerseyNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePlayerAsync_TeamChangedToFullTeam_ThrowsAndKeepsPlayerOnOriginalTeam()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura");
|
||||
Team originalTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Team fullTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
|
||||
Player p1 = await SeedPlayerAsync(seedDb, fullTeam);
|
||||
await SeedRegistrationAsync(seedDb, p1, fullTeam, apertura);
|
||||
Player p2 = await SeedPlayerAsync(seedDb, fullTeam);
|
||||
await SeedRegistrationAsync(seedDb, p2, fullTeam, apertura);
|
||||
|
||||
Player mover = await SeedPlayerAsync(seedDb, originalTeam);
|
||||
await SeedRegistrationAsync(seedDb, mover, originalTeam, apertura);
|
||||
|
||||
using (IServiceScope actScope = _factory.Services.CreateScope())
|
||||
{
|
||||
IUnitOfWork unitOfWork = actScope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
// Deliberately tiny cap so the destination team ("fullTeam", with
|
||||
// 2 players already) is full without seeding dozens of players.
|
||||
PlayerService playerService = new(
|
||||
unitOfWork,
|
||||
actScope.ServiceProvider.GetRequiredService<IScorerRepository>(),
|
||||
Options.Create(new RosterOptions { MaxPlayersPerTeam = 2 }));
|
||||
|
||||
Player tracked = await actDb.Players.SingleAsync(p => p.Id == mover.Id);
|
||||
tracked.TeamId = fullTeam.Id;
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => playerService.UpdatePlayerAsync(tracked, apertura.Id));
|
||||
Assert.Contains("máximo", ex.Message);
|
||||
}
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
// Rejected move must leave BOTH the Player pointer and the season
|
||||
// registration on the original team — no partial write.
|
||||
Player reloadedPlayer = await verifyDb.Players.AsNoTracking().SingleAsync(p => p.Id == mover.Id);
|
||||
Assert.Equal(originalTeam.Id, reloadedPlayer.TeamId);
|
||||
|
||||
PlayerTeamRegistration registration = await verifyDb.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.PlayerId == mover.Id);
|
||||
Assert.Equal(originalTeam.Id, registration.TeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePlayerAsync_TeamChangedForPlayerWithScorerHistory_ThrowsAndKeepsOriginalTeam()
|
||||
{
|
||||
// Regression: a player who already scored points for their current
|
||||
// team could still be moved to a different team mid-season — since
|
||||
// MatchProfile.ScorersForTeam (and every other team-attributed
|
||||
// read path) resolves a scorer's team from Player.TeamId, the
|
||||
// CURRENT pointer rather than a point-in-time snapshot, the move
|
||||
// would silently re-attribute those past points to the new team.
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura");
|
||||
Team originalTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Team newTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Player mover = await SeedPlayerAsync(seedDb, originalTeam);
|
||||
await SeedRegistrationAsync(seedDb, mover, originalTeam, apertura);
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = apertura,
|
||||
TournamentId = apertura.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
seedDb.Divisions.Add(division);
|
||||
await seedDb.SaveChangesAsync();
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Group-{Guid.NewGuid()}",
|
||||
StageType = Domain.Enums.StageType.Group,
|
||||
IsActive = true,
|
||||
StartDate = apertura.StartDate,
|
||||
EndDate = apertura.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
seedDb.Stages.Add(stage);
|
||||
await seedDb.SaveChangesAsync();
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
Type = Domain.Enums.MatchType.Regular,
|
||||
IsFinished = true,
|
||||
HomeTeamId = originalTeam.Id,
|
||||
StageId = stage.Id,
|
||||
MatchDate = apertura.StartDate,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
seedDb.Matches.Add(match);
|
||||
await seedDb.SaveChangesAsync();
|
||||
|
||||
seedDb.Set<Scorer>().Add(new Scorer
|
||||
{
|
||||
PlayerId = mover.Id,
|
||||
MatchId = match.Id,
|
||||
Points = 15,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await seedDb.SaveChangesAsync();
|
||||
|
||||
using (IServiceScope actScope = _factory.Services.CreateScope())
|
||||
{
|
||||
IPlayerService playerService = actScope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Player tracked = await actDb.Players.SingleAsync(p => p.Id == mover.Id);
|
||||
tracked.TeamId = newTeam.Id;
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => playerService.UpdatePlayerAsync(tracked, apertura.Id));
|
||||
Assert.Contains("no se puede cambiar de equipo", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
using IServiceScope verifyScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Player reloadedPlayer = await verifyDb.Players.AsNoTracking().SingleAsync(p => p.Id == mover.Id);
|
||||
Assert.Equal(originalTeam.Id, reloadedPlayer.TeamId);
|
||||
|
||||
PlayerTeamRegistration registration = await verifyDb.PlayerTeamRegistrations
|
||||
.AsNoTracking().SingleAsync(r => r.PlayerId == mover.Id);
|
||||
Assert.Equal(originalTeam.Id, registration.TeamId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replicates, as an EF/LINQ query, the exact backfill join performed by
|
||||
/// the 20260817082125_AddPlayerTeamRegistrationTable migration's raw SQL
|
||||
/// (Players JOIN Teams ON Players.TeamId, attributing each player to
|
||||
/// their team's CURRENT TournamentId, skipping teams with none). The raw
|
||||
/// SQL itself was verified end-to-end against a disposable local
|
||||
/// Postgres container seeded with data shaped exactly like the real
|
||||
/// split (one team on an "Apertura"-named tournament, one on a
|
||||
/// "Femenino"-named tournament) — this test pins the same logic inside
|
||||
/// the automated suite using the SQLite-backed harness.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Backfill_JoinLogic_AttributesEachPlayerToTheirTeamsCurrentTournament()
|
||||
{
|
||||
using IServiceScope seedScope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
Tournament apertura = await SeedTournamentAsync(seedDb, "Apertura Club 12 2026");
|
||||
Tournament femenino = await SeedTournamentAsync(seedDb, "Femenino Club12 La Vuelta");
|
||||
Team aperturaTeam = await SeedTeamAsync(seedDb, apertura.Id);
|
||||
Team femeninoTeam = await SeedTeamAsync(seedDb, femenino.Id);
|
||||
Player aperturaPlayer = await SeedPlayerAsync(seedDb, aperturaTeam);
|
||||
Player femeninoPlayer = await SeedPlayerAsync(seedDb, femeninoTeam);
|
||||
|
||||
// The exact join the migration's Up() runs in raw SQL, expressed in LINQ:
|
||||
List<(Guid PlayerId, Guid TeamId, Guid TournamentId)> backfillRows = await seedDb.Players
|
||||
.Join(seedDb.Teams, p => p.TeamId, t => t.Id, (p, t) => new { Player = p, Team = t })
|
||||
.Where(pt => pt.Team.TournamentId != null)
|
||||
.Select(pt => new ValueTuple<Guid, Guid, Guid>(pt.Player.Id, pt.Team.Id, pt.Team.TournamentId!.Value))
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Contains(backfillRows, r => r.PlayerId == aperturaPlayer.Id && r.TournamentId == apertura.Id);
|
||||
Assert.Contains(backfillRows, r => r.PlayerId == femeninoPlayer.Id && r.TournamentId == femenino.Id);
|
||||
Assert.DoesNotContain(backfillRows, r => r.PlayerId == aperturaPlayer.Id && r.TournamentId == femenino.Id);
|
||||
Assert.DoesNotContain(backfillRows, r => r.PlayerId == femeninoPlayer.Id && r.TournamentId == apertura.Id);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db, string name)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = $"{name} description",
|
||||
Name = $"{name}-{Guid.NewGuid()}",
|
||||
Slug = $"{name.ToLowerInvariant().Replace(' ', '-')}-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Team> SeedTeamAsync(ApplicationDBContext db, Guid? tournamentId)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Green",
|
||||
TournamentId = tournamentId,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedPlayerAsync(ApplicationDBContext db, Team team)
|
||||
{
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Test",
|
||||
LastName = "Player",
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
private static async Task<PlayerTeamRegistration> SeedRegistrationAsync(
|
||||
ApplicationDBContext db, Player player, Team team, Tournament tournament, int? jerseyNumber = null)
|
||||
{
|
||||
PlayerTeamRegistration registration = new()
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournament.Id,
|
||||
JerseyNumber = jerseyNumber,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.PlayerTeamRegistrations.Add(registration);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integrity tests for player and venue deletion: a player with match
|
||||
/// statistics, scorer records or sanctions cannot be deleted (it would orphan
|
||||
/// tournament history), and a venue referenced by matches cannot be deleted.
|
||||
/// Players/venues with no such references are removed cleanly (a player's
|
||||
/// season registrations are cleaned up too). Exercised through the real
|
||||
/// services/repositories against the SQLite-backed integration host.
|
||||
/// </summary>
|
||||
public class PlayerVenueDeleteIntegrityTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayerVenueDeleteIntegrityTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
// ---------- Player ----------
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePlayer_WithStatistics_IsBlocked()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Stage stage, Guid tournamentId) = await SeedTeamAndStageAsync(db);
|
||||
Player player = await SeedPlayerAsync(db, team, tournamentId);
|
||||
Match match = await SeedMatchAsync(db, stage, venueId: null);
|
||||
|
||||
db.Set<PlayerStatistic>().Add(new PlayerStatistic
|
||||
{
|
||||
Value = 12,
|
||||
Type = StatisticType.Points,
|
||||
MatchId = match.Id,
|
||||
PlayerId = player.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => playerService.DeletePlayerAsync(player.Id));
|
||||
|
||||
Assert.Contains("No se puede eliminar", ex.Message);
|
||||
Assert.True(await db.Players.AnyAsync(p => p.Id == player.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePlayer_WithSanction_IsBlocked()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, Stage stage, Guid tournamentId) = await SeedTeamAndStageAsync(db);
|
||||
Player player = await SeedPlayerAsync(db, team, tournamentId);
|
||||
Match match = await SeedMatchAsync(db, stage, venueId: null);
|
||||
|
||||
db.Set<PlayerSanction>().Add(new PlayerSanction
|
||||
{
|
||||
Duration = 1,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
Description = "Test sanction",
|
||||
Slug = $"sanction-{Guid.NewGuid()}",
|
||||
SubjectType = SanctionSubjectType.Player,
|
||||
PlayerId = player.Id,
|
||||
Match = match,
|
||||
MatchId = match.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => playerService.DeletePlayerAsync(player.Id));
|
||||
|
||||
Assert.Contains("No se puede eliminar", ex.Message);
|
||||
Assert.True(await db.Players.AnyAsync(p => p.Id == player.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePlayer_WithoutHistory_RemovesPlayerAndRegistrations()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IPlayerService playerService = scope.ServiceProvider.GetRequiredService<IPlayerService>();
|
||||
|
||||
(Team team, _, Guid tournamentId) = await SeedTeamAndStageAsync(db);
|
||||
Player player = await SeedPlayerAsync(db, team, tournamentId);
|
||||
|
||||
Assert.True(await db.PlayerTeamRegistrations.AnyAsync(r => r.PlayerId == player.Id));
|
||||
|
||||
await playerService.DeletePlayerAsync(player.Id);
|
||||
|
||||
Assert.False(await db.Players.AnyAsync(p => p.Id == player.Id));
|
||||
Assert.False(await db.PlayerTeamRegistrations.AnyAsync(r => r.PlayerId == player.Id));
|
||||
}
|
||||
|
||||
// ---------- Venue ----------
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVenue_ReferencedByMatch_IsBlocked()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IVenueService venueService = scope.ServiceProvider.GetRequiredService<IVenueService>();
|
||||
|
||||
(_, Stage stage, _) = await SeedTeamAndStageAsync(db);
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
await SeedMatchAsync(db, stage, venueId: venue.Id);
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => venueService.DeleteVenueAsync(venue.Id));
|
||||
|
||||
Assert.Contains("No se puede eliminar", ex.Message);
|
||||
Assert.True(await db.Venues.AnyAsync(v => v.Id == venue.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVenue_NotReferenced_RemovesVenue()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IVenueService venueService = scope.ServiceProvider.GetRequiredService<IVenueService>();
|
||||
|
||||
Venue venue = await SeedVenueAsync(db);
|
||||
|
||||
await venueService.DeleteVenueAsync(venue.Id);
|
||||
|
||||
Assert.False(await db.Venues.AnyAsync(v => v.Id == venue.Id));
|
||||
}
|
||||
|
||||
// ---------- seeding ----------
|
||||
|
||||
private static async Task<(Team Team, Stage Stage, Guid TournamentId)> SeedTeamAndStageAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Delete-integrity test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "https://example.test/logo.png",
|
||||
ShirtColor = "Blue",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
db.Teams.Add(team);
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Group-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
IsElimination = false,
|
||||
StartDate = startDate,
|
||||
EndDate = startDate.AddDays(7),
|
||||
Division = division,
|
||||
DivisionId = division.Id,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (team, stage, tournament.Id);
|
||||
}
|
||||
|
||||
private static async Task<Player> SeedPlayerAsync(ApplicationDBContext db, Team team, Guid tournamentId)
|
||||
{
|
||||
Player player = new()
|
||||
{
|
||||
Slug = $"player-{Guid.NewGuid()}",
|
||||
FirstName = "Test",
|
||||
LastName = $"Player-{Guid.NewGuid():N}",
|
||||
SecondName = null,
|
||||
DocumentNumber = Guid.NewGuid().ToString("N")[..10],
|
||||
IsSanctioned = false,
|
||||
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
SocialSecurity = "OSDE",
|
||||
Team = team,
|
||||
TeamId = team.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration
|
||||
{
|
||||
Player = player,
|
||||
PlayerId = player.Id,
|
||||
TeamId = team.Id,
|
||||
TournamentId = tournamentId,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedMatchAsync(ApplicationDBContext db, Stage stage, Guid? venueId)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = stage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
IsFinished = true,
|
||||
Status = MatchStatus.Played,
|
||||
Stage = stage,
|
||||
StageId = stage.Id,
|
||||
VenueId = venueId,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
private static async Task<Venue> SeedVenueAsync(ApplicationDBContext db)
|
||||
{
|
||||
Venue venue = new()
|
||||
{
|
||||
Name = $"Venue-{Guid.NewGuid()}",
|
||||
Slug = $"venue-{Guid.NewGuid()}",
|
||||
Address = "123 Test St",
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Venues.Add(venue);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return venue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using Application.DTOs.PlayerStatistic.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers StageService.TryAutoSeedPlayoffPhaseAsync: once the LAST match of a
|
||||
/// division's group phase finishes, its position-range playoff cups
|
||||
/// (HU-45/HU-81) should seed themselves automatically — the admin no longer
|
||||
/// has to click "Sembrar bracket" by hand.
|
||||
/// </summary>
|
||||
public class PlayoffAutoSeedTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayoffAutoSeedTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinishingTheLastGroupMatch_AutoSeedsThePlayoffCup()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null);
|
||||
|
||||
// Single round-robin among 4 teams: 6 matches. The lower index always
|
||||
// wins, so the final standings are teams[0] > teams[1] > teams[2] > teams[3].
|
||||
Match? lastMatch = null;
|
||||
for (int i = 0; i < teams.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < teams.Count; j++)
|
||||
{
|
||||
// Leave exactly one pairing (the last one) unfinished — the
|
||||
// trigger under test.
|
||||
if (i == teams.Count - 2 && j == teams.Count - 1)
|
||||
{
|
||||
lastMatch = await SeedScheduledMatchAsync(db, groupStage, teams[i], teams[j]);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SeedFinishedMatchAsync(db, groupStage, teams[i], teams[j], 90, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage cupStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única");
|
||||
await SeedEmptyMatchAsync(db, cupStage);
|
||||
await SeedEmptyMatchAsync(db, cupStage);
|
||||
|
||||
Assert.NotNull(lastMatch);
|
||||
Match? updated = await matchService.LoadMatchResultAsync(lastMatch!.Id, 90, 80);
|
||||
Assert.NotNull(updated);
|
||||
Assert.True(updated!.IsFinished);
|
||||
|
||||
List<Match> cupMatches = await db.Matches
|
||||
.Where(m => m.StageId == cupStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.All(cupMatches, m => Assert.True(m.HomeTeamId.HasValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinishingAMidGroupMatch_DoesNotAutoSeed()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null);
|
||||
|
||||
Match scheduled = await SeedScheduledMatchAsync(db, groupStage, teams[0], teams[1]);
|
||||
// The rest of the group phase is still scheduled/unplayed.
|
||||
await SeedScheduledMatchAsync(db, groupStage, teams[2], teams[3]);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage cupStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única");
|
||||
await SeedEmptyMatchAsync(db, cupStage);
|
||||
await SeedEmptyMatchAsync(db, cupStage);
|
||||
|
||||
await matchService.LoadMatchResultAsync(scheduled.Id, 90, 80);
|
||||
|
||||
List<Match> cupMatches = await db.Matches
|
||||
.Where(m => m.StageId == cupStage.Id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.All(cupMatches, m => Assert.False(m.HomeTeamId.HasValue));
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Auto-seed test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task SeedMappingAsync(ApplicationDBContext db, Division division, int from, int to, string destination)
|
||||
{
|
||||
db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping
|
||||
{
|
||||
DivisionId = division.Id,
|
||||
FromPosition = from,
|
||||
ToPosition = to,
|
||||
Destination = destination,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, string? bracketName)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
BracketName = bracketName,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedFinishedMatchAsync(ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
HomeScore = homeScore,
|
||||
VisitorScore = visitorScore,
|
||||
IsFinished = true,
|
||||
WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<Match> SeedScheduledMatchAsync(ApplicationDBContext db, Stage stage, Team home, Team visitor)
|
||||
{
|
||||
Match match = new()
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate,
|
||||
HomeTeamId = home.Id,
|
||||
VisitorTeamId = visitor.Id,
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
return match;
|
||||
}
|
||||
|
||||
private static async Task SeedEmptyMatchAsync(ApplicationDBContext db, Stage stage)
|
||||
{
|
||||
int existingCount = await db.Matches.CountAsync(m => m.StageId == stage.Id);
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate.AddMinutes(existingCount),
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
using Application.DTOs.Stage.Response;
|
||||
using Application.Interfaces.Repositories;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Services;
|
||||
using Application.Utils.Helper.Playoff;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers StageService.PreviewDrawAsync/CommitDrawAsync: the playoffs-only
|
||||
/// random and manual seeding flow for a groupless bracket, the server-side
|
||||
/// preview-token round trip, bye handling reused unchanged from
|
||||
/// PlayoffSeeder, DrawnAt stamping, and the PlayoffDraw audit entry.
|
||||
/// </summary>
|
||||
public class PlayoffDrawTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayoffDrawTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewDrawAsync_GrouplessDivision_ReturnsPairsAndToken_PersistsNothing()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random);
|
||||
|
||||
Assert.Equal(2, preview.Pairs.Count);
|
||||
List<Guid> pairedTeamIds = [.. preview.Pairs
|
||||
.SelectMany(p => new[] { (Guid?) p.HomeTeamId, p.VisitorTeamId })
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)];
|
||||
Assert.Equal(4, pairedTeamIds.Count);
|
||||
Assert.All(teams, t => Assert.Contains(t.Id, pairedTeamIds));
|
||||
Assert.False(string.IsNullOrWhiteSpace(preview.DrawToken));
|
||||
|
||||
List<Match> matches = await db.Matches.Where(m => m.StageId == semiFinalStage.Id).ToListAsync();
|
||||
Assert.All(matches, m => Assert.False(m.HomeTeamId.HasValue));
|
||||
Assert.All(matches, m => Assert.False(m.VisitorTeamId.HasValue));
|
||||
|
||||
Stage? reloaded = await db.Stages.AsNoTracking().FirstOrDefaultAsync(s => s.Id == semiFinalStage.Id);
|
||||
Assert.Null(reloaded!.DrawnAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewDrawAsync_DivisionHasGroupPhase_Rejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewDrawAsync_NonPowerOfTwoRoster_SeedPairsProducesByes()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
await SeedTeamsAndRegisterAsync(db, tournament, division, 6);
|
||||
Stage quarterFinalStage = await SeedStageAsync(db, division, tournament, StageType.QuarterFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
await SeedEmptyMatchAsync(db, quarterFinalStage);
|
||||
}
|
||||
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(quarterFinalStage.Id, DrawMode.Random);
|
||||
|
||||
Assert.Equal(4, preview.Pairs.Count);
|
||||
Assert.Equal(2, preview.Pairs.Count(p => p.VisitorTeamId is null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_ValidToken_BracketMatchesPreview()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random);
|
||||
List<Match> committed = await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Random, drawToken: preview.DrawToken);
|
||||
|
||||
List<Match> ordered = [.. committed.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
Assert.Equal(preview.Pairs[i].HomeTeamId, ordered[i].HomeTeamId);
|
||||
Assert.Equal(preview.Pairs[i].VisitorTeamId, ordered[i].VisitorTeamId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_BestOfGreaterThanOne_CreatesSeriesAndSeedsFirstGame()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random);
|
||||
List<Match> committed = await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Random, drawToken: preview.DrawToken);
|
||||
|
||||
Assert.Equal(2, committed.Count);
|
||||
Assert.All(committed, m => Assert.NotNull(m.SeriesId));
|
||||
Assert.All(committed, m => Assert.Equal(1, m.GameNumber));
|
||||
|
||||
List<MatchSeries> series = await db.MatchSeries.Where(s => s.StageId == semiFinalStage.Id).ToListAsync();
|
||||
Assert.Equal(2, series.Count);
|
||||
Assert.All(series, s => Assert.Equal(3, s.BestOf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_StageHasNoPreGeneratedMatches_CreatesThemInsteadOfCrashing()
|
||||
{
|
||||
// GenerateFixtureAsync (TournamentService) only creates a stage's empty
|
||||
// Match placeholders when the tournament transitions to Ongoing. A
|
||||
// playoffs-only division's bracket has no group phase to generate a
|
||||
// fixture from first, and the draw UI is reachable while the
|
||||
// tournament is still OpenForRegistration — so a real first-ever draw
|
||||
// hits a stage with ZERO existing matches, unlike every other test in
|
||||
// this file, which pre-seeds them via SeedEmptyMatchAsync.
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
// Deliberately no SeedEmptyMatchAsync calls here.
|
||||
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random);
|
||||
List<Match> committed = await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Random, drawToken: preview.DrawToken);
|
||||
|
||||
Assert.Equal(2, committed.Count);
|
||||
Assert.All(committed, m => Assert.True(m.HomeTeamId.HasValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_InvalidOrMismatchedToken_Rejected()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa de Oro", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Random, drawToken: "not-a-real-token"));
|
||||
|
||||
Stage otherStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa de Plata", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, otherStage);
|
||||
await SeedEmptyMatchAsync(db, otherStage);
|
||||
|
||||
DrawPreviewResult otherPreview = await stageService.PreviewDrawAsync(otherStage.Id, DrawMode.Random);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Random, drawToken: otherPreview.DrawToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_ManualOrder_SeedsExactOrder_NoShuffle()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
List<Guid> manualOrder = [teams[3].Id, teams[2].Id, teams[1].Id, teams[0].Id];
|
||||
|
||||
List<Match> committed = await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Manual, manualOrder: manualOrder);
|
||||
List<Match> ordered = [.. committed.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> expectedPairs = PlayoffSeeder.SeedPairs(manualOrder);
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
Assert.Equal(expectedPairs[i].HomeTeamId, ordered[i].HomeTeamId);
|
||||
Assert.Equal(expectedPairs[i].VisitorTeamId, ordered[i].VisitorTeamId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_ByesAdvanceAutomatically_ViaTryAdvanceStageWinnerAsync()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 3);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
|
||||
List<Guid> manualOrder = [.. teams.Select(t => t.Id)];
|
||||
await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Manual, manualOrder: manualOrder);
|
||||
|
||||
Match finalMatch = await db.Matches.SingleAsync(m => m.StageId == finalStage.Id);
|
||||
|
||||
Assert.True(finalMatch.HomeTeamId == teams[0].Id || finalMatch.VisitorTeamId == teams[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_StampsDrawnAtOnFirstRoundStageOnly()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
|
||||
await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]);
|
||||
|
||||
Stage? reloadedSemi = await db.Stages.AsNoTracking().FirstOrDefaultAsync(s => s.Id == semiFinalStage.Id);
|
||||
Stage? reloadedFinal = await db.Stages.AsNoTracking().FirstOrDefaultAsync(s => s.Id == finalStage.Id);
|
||||
|
||||
Assert.NotNull(reloadedSemi!.DrawnAt);
|
||||
Assert.Null(reloadedFinal!.DrawnAt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DrawMode.Random, "aleatorio")]
|
||||
[InlineData(DrawMode.Manual, "manual")]
|
||||
public async Task CommitDrawAsync_WritesPlayoffDrawAuditEntry_DetailDescribesDrawMode(DrawMode mode, string expectedWord)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
string? drawToken = null;
|
||||
List<Guid>? manualOrder = null;
|
||||
|
||||
if (mode == DrawMode.Random)
|
||||
{
|
||||
DrawPreviewResult preview = await stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random);
|
||||
drawToken = preview.DrawToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
manualOrder = [.. teams.Select(t => t.Id)];
|
||||
}
|
||||
|
||||
await stageService.CommitDrawAsync(semiFinalStage.Id, mode, drawToken: drawToken, manualOrder: manualOrder);
|
||||
|
||||
AuditLog entry = await db.AuditLogs.SingleAsync(
|
||||
a => a.Action == AuditAction.PlayoffDraw && a.TargetId == semiFinalStage.Id.ToString());
|
||||
|
||||
Assert.Equal("Stage", entry.TargetType);
|
||||
Assert.Equal(semiFinalStage.Id.ToString(), entry.TargetId);
|
||||
Assert.Contains(expectedWord, entry.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("4", entry.Detail!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitDrawAsync_AuditServiceThrows_DrawStillSucceeds()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IUnitOfWork unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
ILogger<StageService> logger = scope.ServiceProvider.GetRequiredService<ILogger<StageService>>();
|
||||
IConfiguration configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
IStageService stageService = new StageService(unitOfWork, logger, configuration, new ThrowingAuditService());
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAndRegisterAsync(db, tournament, division, 4);
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
List<Match> committed = await stageService.CommitDrawAsync(
|
||||
semiFinalStage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]);
|
||||
|
||||
Assert.Equal(2, committed.Count);
|
||||
Assert.All(committed, m => Assert.True(m.HomeTeamId.HasValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A throwing IAuditService double used to prove a logging failure never blocks a draw commit.
|
||||
/// </summary>
|
||||
private sealed class ThrowingAuditService : IAuditService
|
||||
{
|
||||
public Task LogAsync(
|
||||
AuditAction action,
|
||||
string? targetType = null,
|
||||
string? targetId = null,
|
||||
string? targetName = null,
|
||||
string? detail = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated audit failure.");
|
||||
}
|
||||
|
||||
public Task<Application.DTOs.Abstract.Response.PaginatedResponse<AuditLog>> GetAuditLogsAsync(
|
||||
Application.DTOs.AuditLogs.Request.AuditLogFilteredRequest filter)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated audit failure.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Playoff draw test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAndRegisterAsync(
|
||||
ApplicationDBContext db, Tournament tournament, Division division, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration
|
||||
{
|
||||
TeamId = team.Id,
|
||||
DivisionId = division.Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, string? bracketName, int bestOf)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
BracketName = bracketName,
|
||||
BestOf = bestOf,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
||||
private static async Task SeedEmptyMatchAsync(ApplicationDBContext db, Stage stage)
|
||||
{
|
||||
int existingCount = await db.Matches.CountAsync(m => m.StageId == stage.Id);
|
||||
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate.AddMinutes(existingCount),
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Application.Utils.Helper.Playoff;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the HU-45 position-range mapping validation and the HU-81
|
||||
/// qualification resolver that turns final standings + ranges into the
|
||||
/// ordered team list of each playoff cup.
|
||||
/// </summary>
|
||||
public class PlayoffMappingTests
|
||||
{
|
||||
private static DivisionPlayoffMapping Range(int from, int to, string destination)
|
||||
{
|
||||
return new DivisionPlayoffMapping
|
||||
{
|
||||
FromPosition = from,
|
||||
ToPosition = to,
|
||||
Destination = destination,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
}
|
||||
|
||||
private static Position Pos(Guid teamId)
|
||||
{
|
||||
return new Position
|
||||
{
|
||||
TeamId = teamId,
|
||||
TeamName = "T",
|
||||
LogoUrl = "x",
|
||||
MatchesPlayed = 0,
|
||||
Wins = 0,
|
||||
Losses = 0,
|
||||
PointsFor = 0,
|
||||
PointsAgainst = 0,
|
||||
PointsDifference = 0,
|
||||
Points = 0,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NonOverlappingRanges_DoesNotThrow()
|
||||
{
|
||||
PlayoffMappingValidator.Validate(
|
||||
[
|
||||
Range(1, 4, "Copa Oro"),
|
||||
Range(5, 8, "Copa Plata"),
|
||||
]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OverlappingRanges_Throws()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PlayoffMappingValidator.Validate(
|
||||
[
|
||||
Range(1, 4, "Copa Oro"),
|
||||
Range(4, 8, "Copa Plata"),
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRange_ToBeforeFrom_Throws()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PlayoffMappingValidator.Validate(
|
||||
[
|
||||
Range(5, 2, "Copa Oro"),
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyDestination_Throws()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PlayoffMappingValidator.Validate(
|
||||
[
|
||||
Range(1, 4, " "),
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptySet_DoesNotThrow()
|
||||
{
|
||||
PlayoffMappingValidator.Validate([]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SingleDivision_PlacesEachRangeTeamsInItsCup()
|
||||
{
|
||||
List<Guid> teams = [.. Enumerable.Range(0, 8).Select(_ => Guid.NewGuid())];
|
||||
List<Position> standings = [.. teams.Select(Pos)];
|
||||
|
||||
Dictionary<string, List<Guid>> result = PlayoffQualificationResolver.Resolve(
|
||||
[
|
||||
new PlayoffQualificationResolver.DivisionStandings
|
||||
{
|
||||
Standings = standings,
|
||||
Mappings =
|
||||
[
|
||||
Range(1, 4, "Copa Oro"),
|
||||
Range(5, 8, "Copa Plata"),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Assert.Equal([teams[0], teams[1], teams[2], teams[3]], result["Copa Oro"]);
|
||||
Assert.Equal([teams[4], teams[5], teams[6], teams[7]], result["Copa Plata"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnmappedPositions_DoNotQualify()
|
||||
{
|
||||
List<Guid> teams = [.. Enumerable.Range(0, 10).Select(_ => Guid.NewGuid())];
|
||||
List<Position> standings = [.. teams.Select(Pos)];
|
||||
|
||||
Dictionary<string, List<Guid>> result = PlayoffQualificationResolver.Resolve(
|
||||
[
|
||||
new PlayoffQualificationResolver.DivisionStandings
|
||||
{
|
||||
Standings = standings,
|
||||
Mappings =
|
||||
[
|
||||
Range(1, 4, "Copa Oro"),
|
||||
Range(5, 8, "Copa Plata"),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Positions 9-10 are unmapped: they appear in no cup.
|
||||
List<Guid> everyone = [.. result.Values.SelectMany(v => v)];
|
||||
Assert.DoesNotContain(teams[8], everyone);
|
||||
Assert.DoesNotContain(teams[9], everyone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MultipleDivisions_MergeIntoCupSeededByPositionThenDivision()
|
||||
{
|
||||
List<Guid> divA = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
List<Guid> divB = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
Dictionary<string, List<Guid>> result = PlayoffQualificationResolver.Resolve(
|
||||
[
|
||||
new PlayoffQualificationResolver.DivisionStandings
|
||||
{
|
||||
Standings = [.. divA.Select(Pos)],
|
||||
Mappings = [Range(1, 2, "Copa")],
|
||||
DivisionOrder = 0,
|
||||
},
|
||||
new PlayoffQualificationResolver.DivisionStandings
|
||||
{
|
||||
Standings = [.. divB.Select(Pos)],
|
||||
Mappings = [Range(1, 2, "Copa")],
|
||||
DivisionOrder = 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Ordered by finishing position first, then division order:
|
||||
// A1, B1, A2, B2.
|
||||
Assert.Equal([divA[0], divB[0], divA[1], divB[1]], result["Copa"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Application.Utils.Helper.Playoff;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies PlayoffSeeder produces the classic bracket seed order (not
|
||||
/// naive adjacent pairing) — the property that keeps seed 1 and seed 2
|
||||
/// apart until the final — and that non-power-of-two counts are padded
|
||||
/// with byes awarded to the best seeds.
|
||||
/// </summary>
|
||||
public class PlayoffSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public void SeedPairs_EightTeams_ProducesClassicBracketOrder()
|
||||
{
|
||||
List<Guid> seeds = [.. new[] { 1, 2, 3, 4, 5, 6, 7, 8 }.Select(_ => Guid.NewGuid())];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(seeds);
|
||||
|
||||
Assert.Equal((seeds[0], (Guid?) seeds[7]), pairs[0]);
|
||||
Assert.Equal((seeds[3], (Guid?) seeds[4]), pairs[1]);
|
||||
Assert.Equal((seeds[1], (Guid?) seeds[6]), pairs[2]);
|
||||
Assert.Equal((seeds[2], (Guid?) seeds[5]), pairs[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedPairs_FourTeams_ProducesOneAndFourThenTwoAndThree()
|
||||
{
|
||||
List<Guid> seeds = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(seeds);
|
||||
|
||||
Assert.Equal((seeds[0], (Guid?) seeds[3]), pairs[0]);
|
||||
Assert.Equal((seeds[1], (Guid?) seeds[2]), pairs[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedPairs_TwoTeams_ProducesSinglePair()
|
||||
{
|
||||
List<Guid> seeds = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(seeds);
|
||||
|
||||
Assert.Equal([(seeds[0], (Guid?) seeds[1])], pairs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedPairs_FewerThanTwoTeams_Throws()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PlayoffSeeder.SeedPairs([]));
|
||||
Assert.Throws<InvalidOperationException>(() => PlayoffSeeder.SeedPairs([Guid.NewGuid()]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedPairs_ThreeTeams_BestSeedGetsAByeAndOthersPlay()
|
||||
{
|
||||
List<Guid> seeds = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(seeds);
|
||||
|
||||
Assert.Equal(2, pairs.Count);
|
||||
Assert.Equal((seeds[0], null), pairs[0]);
|
||||
Assert.Equal((seeds[1], (Guid?) seeds[2]), pairs[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedPairs_FiveTeams_TopThreeSeedsGetByes()
|
||||
{
|
||||
List<Guid> seeds = [.. Enumerable.Range(0, 5).Select(_ => Guid.NewGuid())];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(seeds);
|
||||
|
||||
Assert.Equal(4, pairs.Count);
|
||||
int byeCount = pairs.Count(pair => pair.VisitorTeamId is null);
|
||||
Assert.Equal(3, byeCount);
|
||||
|
||||
List<Guid> teamsWithByes = [.. pairs.Where(pair => pair.VisitorTeamId is null).Select(pair => pair.HomeTeamId)];
|
||||
Assert.Contains(seeds[0], teamsWithByes);
|
||||
Assert.Contains(seeds[1], teamsWithByes);
|
||||
Assert.Contains(seeds[2], teamsWithByes);
|
||||
|
||||
(Guid HomeTeamId, Guid? VisitorTeamId) realMatch = pairs.Single(pair => pair.VisitorTeamId is not null);
|
||||
Assert.Equal(seeds[3], realMatch.HomeTeamId);
|
||||
Assert.Equal(seeds[4], realMatch.VisitorTeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedPairs_SixTeams_TopTwoSeedsGetByes()
|
||||
{
|
||||
List<Guid> seeds = [.. Enumerable.Range(0, 6).Select(_ => Guid.NewGuid())];
|
||||
|
||||
List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(seeds);
|
||||
|
||||
Assert.Equal(4, pairs.Count);
|
||||
int byeCount = pairs.Count(pair => pair.VisitorTeamId is null);
|
||||
Assert.Equal(2, byeCount);
|
||||
|
||||
List<Guid> teamsWithByes = [.. pairs.Where(pair => pair.VisitorTeamId is null).Select(pair => pair.HomeTeamId)];
|
||||
Assert.Contains(seeds[0], teamsWithByes);
|
||||
Assert.Contains(seeds[1], teamsWithByes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the gap found auditing historias-de-usuario.md against the code
|
||||
/// (2026-09-02): a real tournament's auto-seed (StageService.SeedPlayoffCupsAsync)
|
||||
/// used to always drop a single plain Match into a BestOf>1 slot — it never
|
||||
/// created a MatchSeries, so a configured "mejor de 3" never actually produced
|
||||
/// 3 games. Only the demo seed builder did this correctly. These tests cover
|
||||
/// the fix: real MatchSeries creation at seed time, and automatic advancement
|
||||
/// of a decided slot's winner into the next round (StageService.
|
||||
/// TryAdvanceStageWinnerAsync), which did not exist in any form before.
|
||||
/// </summary>
|
||||
public class PlayoffSeriesAdvancementTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PlayoffSeriesAdvancementTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedPlayoffCupsAsync_BestOfThreeSemiFinal_FourTeams_CreatesRealSeriesForBothPairings()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(2, semiFinalMatches.Count);
|
||||
Assert.All(semiFinalMatches, m =>
|
||||
{
|
||||
Assert.True(m.SeriesId.HasValue);
|
||||
Assert.Equal(1, m.GameNumber);
|
||||
Assert.False(m.IsFinished);
|
||||
});
|
||||
|
||||
List<MatchSeries> series = await db.MatchSeries
|
||||
.Where(s => s.StageId == semiFinalStage.Id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(2, series.Count);
|
||||
Assert.All(series, s =>
|
||||
{
|
||||
Assert.Equal(3, s.BestOf);
|
||||
Assert.NotEqual(s.HomeTeamId, s.VisitorTeamId);
|
||||
Assert.False(s.WinningTeamId.HasValue);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedPlayoffCupsAsync_BestOfThreeStage_ByePairing_StaysAPlainFinishedMatch_NoSeries()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
// 3 qualifiers -> next power of two is 4 -> one bye for the best seed.
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 3);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 3, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
Match bye = Assert.Single(semiFinalMatches, m => !m.VisitorTeamId.HasValue);
|
||||
Assert.True(bye.IsFinished);
|
||||
Assert.Equal(bye.HomeTeamId, bye.WinningTeamId);
|
||||
Assert.False(bye.SeriesId.HasValue);
|
||||
|
||||
Match realPairing = Assert.Single(semiFinalMatches, m => m.VisitorTeamId.HasValue);
|
||||
Assert.True(realPairing.SeriesId.HasValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecidingBothSemiFinalSeries_AdvancesBothWinners_AndStartsARealFinalSeries()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService<IMatchSeriesService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalGame1s = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
// Decide EACH series 2-0 (best of 3, majority = 2): load game 1's
|
||||
// result (already exists from seeding), then add and load game 2 —
|
||||
// exactly what the missing "add next game" admin action now lets an
|
||||
// admin do (MatchSeriesController.AddGameToSeries).
|
||||
foreach (Match game1 in semiFinalGame1s)
|
||||
{
|
||||
Guid seriesId = game1.SeriesId!.Value;
|
||||
Guid homeTeamId = game1.HomeTeamId!.Value;
|
||||
|
||||
await DecideSeriesInFavorOfHomeAsync(
|
||||
db, matchService, matchSeriesService, stageService, seriesId, homeTeamId, semiFinalStage.Id);
|
||||
}
|
||||
|
||||
Match finalMatch = await db.Matches.SingleAsync(m => m.StageId == finalStage.Id);
|
||||
|
||||
Assert.True(finalMatch.HomeTeamId.HasValue);
|
||||
Assert.True(finalMatch.VisitorTeamId.HasValue);
|
||||
Assert.NotEqual(finalMatch.HomeTeamId, finalMatch.VisitorTeamId);
|
||||
Assert.True(finalMatch.SeriesId.HasValue, "the final's slot should have become game 1 of a real series once both semifinal winners arrived");
|
||||
Assert.Equal(1, finalMatch.GameNumber);
|
||||
|
||||
MatchSeries finalSeries = await db.MatchSeries.SingleAsync(s => s.Id == finalMatch.SeriesId!.Value);
|
||||
Assert.Equal(3, finalSeries.BestOf);
|
||||
Assert.Equal(finalMatch.HomeTeamId, finalSeries.HomeTeamId);
|
||||
Assert.Equal(finalMatch.VisitorTeamId, finalSeries.VisitorTeamId);
|
||||
|
||||
// Bracket adjacency (PlayoffSeeder's seed order [1,4,2,3]): slot 0
|
||||
// (seed 1 vs seed 4) feeds the final's Home; slot 1 (seed 2 vs seed 3)
|
||||
// feeds the final's Visitor.
|
||||
Match slot0 = semiFinalGame1s[0];
|
||||
Match slot1 = semiFinalGame1s[1];
|
||||
Assert.Equal(slot0.HomeTeamId, finalMatch.HomeTeamId);
|
||||
Assert.Equal(slot1.HomeTeamId, finalMatch.VisitorTeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecidingBothSemiFinals_PushesBothLosersIntoTheThirdPlaceMatch()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
Stage thirdPlaceStage = await SeedStageAsync(db, division, tournament, StageType.ThirdPlace, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, thirdPlaceStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
// Home always wins here, so slot 0's visitor and slot 1's visitor are
|
||||
// the two losers expected to land in the third-place match.
|
||||
Guid expectedThirdPlaceHome = semiFinalMatches[0].VisitorTeamId!.Value;
|
||||
Guid expectedThirdPlaceVisitor = semiFinalMatches[1].VisitorTeamId!.Value;
|
||||
|
||||
foreach (Match match in semiFinalMatches)
|
||||
{
|
||||
await matchService.LoadMatchResultAsync(match.Id, 90, 80);
|
||||
await stageService.TryAdvanceStageWinnerAsync(semiFinalStage.Id);
|
||||
}
|
||||
|
||||
Match thirdPlaceMatch = await db.Matches.SingleAsync(m => m.StageId == thirdPlaceStage.Id);
|
||||
Assert.Equal(expectedThirdPlaceHome, thirdPlaceMatch.HomeTeamId);
|
||||
Assert.Equal(expectedThirdPlaceVisitor, thirdPlaceMatch.VisitorTeamId);
|
||||
Assert.False(thirdPlaceMatch.SeriesId.HasValue, "a BestOf=1 third place decider stays a plain match");
|
||||
|
||||
// The winners still went to the final as usual — third place is an
|
||||
// additive side effect, not a replacement for the main advancement.
|
||||
Match finalMatch = await db.Matches.SingleAsync(m => m.StageId == finalStage.Id);
|
||||
Assert.True(finalMatch.HomeTeamId.HasValue);
|
||||
Assert.True(finalMatch.VisitorTeamId.HasValue);
|
||||
Assert.False(finalMatch.HomeTeamId == expectedThirdPlaceHome || finalMatch.HomeTeamId == expectedThirdPlaceVisitor);
|
||||
Assert.False(finalMatch.VisitorTeamId == expectedThirdPlaceHome || finalMatch.VisitorTeamId == expectedThirdPlaceVisitor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThirdPlaceStage_WithBestOfGreaterThanOne_BecomesARealSeriesOnceBothLosersArrive()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
Stage thirdPlaceStage = await SeedStageAsync(db, division, tournament, StageType.ThirdPlace, bracketName: "Copa Única", bestOf: 3);
|
||||
await SeedEmptyMatchAsync(db, thirdPlaceStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (Match match in semiFinalMatches)
|
||||
{
|
||||
await matchService.LoadMatchResultAsync(match.Id, 90, 80);
|
||||
await stageService.TryAdvanceStageWinnerAsync(semiFinalStage.Id);
|
||||
}
|
||||
|
||||
Match thirdPlaceMatch = await db.Matches.SingleAsync(m => m.StageId == thirdPlaceStage.Id);
|
||||
Assert.True(thirdPlaceMatch.SeriesId.HasValue, "a BestOf>1 third place decider should become game 1 of a real series");
|
||||
Assert.Equal(1, thirdPlaceMatch.GameNumber);
|
||||
|
||||
MatchSeries thirdPlaceSeries = await db.MatchSeries.SingleAsync(s => s.Id == thirdPlaceMatch.SeriesId!.Value);
|
||||
Assert.Equal(3, thirdPlaceSeries.BestOf);
|
||||
Assert.Equal(thirdPlaceMatch.HomeTeamId, thirdPlaceSeries.HomeTeamId);
|
||||
Assert.Equal(thirdPlaceMatch.VisitorTeamId, thirdPlaceSeries.VisitorTeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoThirdPlaceStageConfigured_AdvancingSemiFinalWinners_IsANoOp_NeverThrows()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
// No ThirdPlace stage seeded for this cup — the admin opted out.
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (Match match in semiFinalMatches)
|
||||
{
|
||||
await matchService.LoadMatchResultAsync(match.Id, 90, 80);
|
||||
await stageService.TryAdvanceStageWinnerAsync(semiFinalStage.Id);
|
||||
}
|
||||
|
||||
Match finalMatch = await db.Matches.SingleAsync(m => m.StageId == finalStage.Id);
|
||||
Assert.True(finalMatch.HomeTeamId.HasValue);
|
||||
Assert.True(finalMatch.VisitorTeamId.HasValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proof that the REAL seeding path (SeedPlayoffCupsAsync's
|
||||
/// PlayoffSeeder.SeedPairs, plus TryAdvanceStageWinnerAsync's slotIndex/2
|
||||
/// round-to-round pairing) reproduces the classic bracket property: with
|
||||
/// every favorite winning, seed 1 and seed 2 — never adjacent, kept apart
|
||||
/// by the seed order itself — meet in the final. This is the guarantee a
|
||||
/// live tournament actually gets when a group phase ends; any bracket
|
||||
/// that looks "unreseeded" round to round is not this code path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SeedPlayoffCupsAsync_EightTeams_AllFavoritesWin_Seed1MeetsSeed2InTheFinal()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
// teams[0] beats every other team in the round-robin below, so
|
||||
// standings order is teams[0] (seed 1) .. teams[7] (seed 8).
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 8);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 8, "Copa Única");
|
||||
Stage quarterFinalStage = await SeedStageAsync(db, division, tournament, StageType.QuarterFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
await SeedEmptyMatchAsync(db, quarterFinalStage);
|
||||
}
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> quarterFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == quarterFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
// The classic seed order for 8: 1v8, 4v5, 2v7, 3v6 — PlayoffSeeder
|
||||
// always makes the better seed HomeTeamId for a real pairing.
|
||||
Assert.Equal(
|
||||
[teams[0].Id, teams[3].Id, teams[1].Id, teams[2].Id],
|
||||
quarterFinalMatches.Select(m => m.HomeTeamId));
|
||||
Assert.Equal(
|
||||
[teams[7].Id, teams[4].Id, teams[6].Id, teams[5].Id],
|
||||
quarterFinalMatches.Select(m => m.VisitorTeamId));
|
||||
|
||||
foreach (Match match in quarterFinalMatches)
|
||||
{
|
||||
// The favorite (home, the better seed) always wins.
|
||||
await matchService.LoadMatchResultAsync(match.Id, 90, 80);
|
||||
await stageService.TryAdvanceStageWinnerAsync(quarterFinalStage.Id);
|
||||
}
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
// QF winners (all favorites): teams[0], teams[3], teams[1], teams[2]
|
||||
// — slot0 = winners[0] vs winners[1] = seed1 vs seed4; slot1 =
|
||||
// winners[2] vs winners[3] = seed2 vs seed3.
|
||||
Assert.Equal([teams[0].Id, teams[1].Id], semiFinalMatches.Select(m => m.HomeTeamId));
|
||||
Assert.Equal([teams[3].Id, teams[2].Id], semiFinalMatches.Select(m => m.VisitorTeamId));
|
||||
|
||||
foreach (Match match in semiFinalMatches)
|
||||
{
|
||||
await matchService.LoadMatchResultAsync(match.Id, 90, 80);
|
||||
await stageService.TryAdvanceStageWinnerAsync(semiFinalStage.Id);
|
||||
}
|
||||
|
||||
Match finalMatch = await db.Matches.SingleAsync(m => m.StageId == finalStage.Id);
|
||||
|
||||
Assert.Equal(teams[0].Id, finalMatch.HomeTeamId);
|
||||
Assert.Equal(teams[1].Id, finalMatch.VisitorTeamId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BestOfOneElimination_StillAdvancesTheWinnerDirectly_NoSeriesInvolved()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
IStageService stageService = scope.ServiceProvider.GetRequiredService<IStageService>();
|
||||
IMatchService matchService = scope.ServiceProvider.GetRequiredService<IMatchService>();
|
||||
|
||||
Tournament tournament = await SeedTournamentAsync(db);
|
||||
Division division = await SeedDivisionAsync(db, tournament);
|
||||
List<Team> teams = await SeedTeamsAsync(db, tournament, 4);
|
||||
Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group, bracketName: null, bestOf: 1);
|
||||
await SeedRoundRobinResultsAsync(db, groupStage, teams);
|
||||
|
||||
await SeedMappingAsync(db, division, 1, 4, "Copa Única");
|
||||
Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
await SeedEmptyMatchAsync(db, semiFinalStage);
|
||||
Stage finalStage = await SeedStageAsync(db, division, tournament, StageType.Final, bracketName: "Copa Única", bestOf: 1);
|
||||
await SeedEmptyMatchAsync(db, finalStage);
|
||||
|
||||
await stageService.SeedPlayoffCupsAsync(division.Id);
|
||||
|
||||
List<Match> semiFinalMatches = await db.Matches
|
||||
.Where(m => m.StageId == semiFinalStage.Id)
|
||||
.OrderBy(m => m.MatchDate)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.All(semiFinalMatches, m => Assert.False(m.SeriesId.HasValue));
|
||||
|
||||
foreach (Match match in semiFinalMatches)
|
||||
{
|
||||
Match? updated = await matchService.LoadMatchResultAsync(match.Id, 90, 80);
|
||||
Assert.NotNull(updated);
|
||||
await stageService.TryAdvanceStageWinnerAsync(semiFinalStage.Id);
|
||||
}
|
||||
|
||||
Match finalMatch = await db.Matches.SingleAsync(m => m.StageId == finalStage.Id);
|
||||
Assert.True(finalMatch.HomeTeamId.HasValue);
|
||||
Assert.True(finalMatch.VisitorTeamId.HasValue);
|
||||
Assert.False(finalMatch.SeriesId.HasValue, "a BestOf=1 next round stays a plain match, never a series");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors exactly what MatchController's result-loading actions do after
|
||||
/// this session's fix: load a game's result, recalculate the series
|
||||
/// winner if the game belongs to one, then try to advance the stage's
|
||||
/// winner into the next round. Plays game 1 (already 90-80 for home from
|
||||
/// seeding — re-asserted here) then adds and plays game 2, also 90-80 for
|
||||
/// home, which reaches the best-of-3 majority (2 games) and decides it.
|
||||
/// </summary>
|
||||
private static async Task DecideSeriesInFavorOfHomeAsync(
|
||||
ApplicationDBContext db,
|
||||
IMatchService matchService,
|
||||
IMatchSeriesService matchSeriesService,
|
||||
IStageService stageService,
|
||||
Guid seriesId,
|
||||
Guid homeTeamId,
|
||||
Guid decidedStageId)
|
||||
{
|
||||
Match game1 = await db.Matches.SingleAsync(m => m.SeriesId == seriesId && m.GameNumber == 1);
|
||||
await matchService.LoadMatchResultAsync(game1.Id, 90, 80);
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(seriesId);
|
||||
await stageService.TryAdvanceStageWinnerAsync(decidedStageId);
|
||||
|
||||
Match game2 = await matchSeriesService.AddGameToSeriesAsync(seriesId, DateTime.UtcNow.AddDays(1), venueId: null);
|
||||
await matchService.LoadMatchResultAsync(game2.Id, 90, 80);
|
||||
await matchSeriesService.RecalculateSeriesWinnerAsync(seriesId);
|
||||
await stageService.TryAdvanceStageWinnerAsync(decidedStageId);
|
||||
|
||||
MatchSeries decided = await db.MatchSeries.SingleAsync(s => s.Id == seriesId);
|
||||
Assert.Equal(homeTeamId, decided.WinningTeamId);
|
||||
}
|
||||
|
||||
private static async Task<Tournament> SeedTournamentAsync(ApplicationDBContext db)
|
||||
{
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Series-advancement test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
private static async Task<Division> SeedDivisionAsync(ApplicationDBContext db, Tournament tournament)
|
||||
{
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
IsCrossDivisionCup = false,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
return division;
|
||||
}
|
||||
|
||||
private static async Task SeedMappingAsync(ApplicationDBContext db, Division division, int from, int to, string destination)
|
||||
{
|
||||
db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping
|
||||
{
|
||||
DivisionId = division.Id,
|
||||
FromPosition = from,
|
||||
ToPosition = to,
|
||||
Destination = destination,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<List<Team>> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count)
|
||||
{
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return teams;
|
||||
}
|
||||
|
||||
private static async Task<Stage> SeedStageAsync(
|
||||
ApplicationDBContext db, Division division, Tournament tournament, StageType stageType, string? bracketName, int bestOf)
|
||||
{
|
||||
Stage stage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Stage-{Guid.NewGuid()}",
|
||||
StageType = stageType,
|
||||
IsActive = true,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
BracketName = bracketName,
|
||||
BestOf = bestOf,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Stages.Add(stage);
|
||||
await db.SaveChangesAsync();
|
||||
return stage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays a single round-robin among every pair, the lower index always
|
||||
/// winning, so final standings are teams[0] > teams[1] > … — enough
|
||||
/// for PositionCalculator/PlayoffQualificationResolver to produce a
|
||||
/// deterministic seed order for SeedPlayoffCupsAsync.
|
||||
/// </summary>
|
||||
private static async Task SeedRoundRobinResultsAsync(ApplicationDBContext db, Stage groupStage, List<Team> teams)
|
||||
{
|
||||
for (int i = 0; i < teams.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < teams.Count; j++)
|
||||
{
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = groupStage.Id,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = groupStage.StartDate,
|
||||
HomeTeamId = teams[i].Id,
|
||||
VisitorTeamId = teams[j].Id,
|
||||
HomeScore = 90,
|
||||
VisitorScore = 80,
|
||||
IsFinished = true,
|
||||
WinningTeamId = teams[i].Id,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedEmptyMatchAsync(ApplicationDBContext db, Stage stage)
|
||||
{
|
||||
int existingCount = await db.Matches.CountAsync(m => m.StageId == stage.Id);
|
||||
db.Matches.Add(new Match
|
||||
{
|
||||
StageId = stage.Id,
|
||||
Type = MatchType.Playoff,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
MatchDate = stage.StartDate.AddMinutes(existingCount),
|
||||
IsFinished = false,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using Application.DTOs.PointDeductions.Request;
|
||||
using Application.DTOs.PointDeductions.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using MatchType = Domain.Enums.MatchType;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration coverage for the point-deduction (deducción de puntos)
|
||||
/// endpoints: create/list/delete through real HTTP round trips, the
|
||||
/// AdminOrOwner gating, and the effect on the computed standings.
|
||||
/// </summary>
|
||||
public class PointDeductionEndpointTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public PointDeductionEndpointTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateThenList_ReturnsDeduction_AndSubtractsFromStandings()
|
||||
{
|
||||
(Division division, List<Team> teams) = await SeedDivisionWithFinishedMatchAsync();
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage create = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{division.Id}/point-deductions",
|
||||
new CreatePointDeductionRequest { TeamId = teams[0].Id, Points = 1, Reason = "Alineación indebida" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
PointDeductionResponse? created = await create.Content.ReadFromJsonAsync<PointDeductionResponse>();
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(teams[0].Id, created!.TeamId);
|
||||
Assert.Equal(1, created.Points);
|
||||
Assert.Equal("Alineación indebida", created.Reason);
|
||||
Assert.Equal(teams[0].Name, created.TeamName);
|
||||
|
||||
List<PointDeductionResponse>? listed = await client.GetFromJsonAsync<List<PointDeductionResponse>>(
|
||||
$"api/divisions/{division.Id}/point-deductions");
|
||||
Assert.NotNull(listed);
|
||||
Assert.Single(listed!);
|
||||
Assert.Equal(created.Id, listed![0].Id);
|
||||
|
||||
// The winner earned 2 points; the 1-point deduction drops it to 1 and
|
||||
// the position row exposes the applied deduction.
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
List<Position> positions = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
|
||||
|
||||
Position winner = Assert.Single(positions, p => p.TeamId == teams[0].Id);
|
||||
Assert.Equal(1, winner.Points);
|
||||
Assert.NotNull(winner.PointDeduction);
|
||||
Assert.Equal(1, winner.PointDeduction!.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesDeduction_AndRestoresStandings()
|
||||
{
|
||||
(Division division, List<Team> teams) = await SeedDivisionWithFinishedMatchAsync();
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner);
|
||||
|
||||
HttpResponseMessage create = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{division.Id}/point-deductions",
|
||||
new CreatePointDeductionRequest { TeamId = teams[0].Id, Points = 2, Reason = "Sanción" });
|
||||
PointDeductionResponse created = (await create.Content.ReadFromJsonAsync<PointDeductionResponse>())!;
|
||||
|
||||
HttpResponseMessage delete = await client.DeleteAsync($"api/point-deductions/{created.Id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
|
||||
|
||||
List<PointDeductionResponse>? listed = await client.GetFromJsonAsync<List<PointDeductionResponse>>(
|
||||
$"api/divisions/{division.Id}/point-deductions");
|
||||
Assert.Empty(listed!);
|
||||
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
IDivisionService divisionService = scope.ServiceProvider.GetRequiredService<IDivisionService>();
|
||||
List<Position> positions = await divisionService.GetPositionsByDivisionIdAsync(division.Id);
|
||||
Position winner = Assert.Single(positions, p => p.TeamId == teams[0].Id);
|
||||
Assert.Equal(2, winner.Points);
|
||||
Assert.Null(winner.PointDeduction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{Guid.NewGuid()}/point-deductions",
|
||||
new CreatePointDeductionRequest { TeamId = Guid.NewGuid(), Points = 1, Reason = "x" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WrongRole_ReturnsForbidden()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{Guid.NewGuid()}/point-deductions",
|
||||
new CreatePointDeductionRequest { TeamId = Guid.NewGuid(), Points = 1, Reason = "x" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UnknownDivision_ReturnsNotFound()
|
||||
{
|
||||
HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin);
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync(
|
||||
$"api/divisions/{Guid.NewGuid()}/point-deductions",
|
||||
new CreatePointDeductionRequest { TeamId = Guid.NewGuid(), Points = 1, Reason = "x" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<(Division, List<Team>)> SeedDivisionWithFinishedMatchAsync()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
|
||||
DateTime startDate = DateTime.UtcNow.Date.AddDays(30);
|
||||
Tournament tournament = new()
|
||||
{
|
||||
Description = "Deduction test tournament",
|
||||
Name = $"Tournament-{Guid.NewGuid()}",
|
||||
Slug = $"tournament-{Guid.NewGuid()}",
|
||||
TeamRegistrationDeadline = startDate.AddDays(-1),
|
||||
StartDate = startDate,
|
||||
Divisions = [],
|
||||
Teams = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Tournaments.Add(tournament);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
List<Team> teams = [];
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
Team team = new()
|
||||
{
|
||||
Name = $"Team-{Guid.NewGuid()}",
|
||||
Slug = $"team-{Guid.NewGuid()}",
|
||||
ThreeLetterCode = Guid.NewGuid().ToString("N")[..3].ToUpperInvariant(),
|
||||
LogoUrl = "http://example.com/logo.png",
|
||||
ShirtColor = "Red",
|
||||
TournamentId = tournament.Id,
|
||||
Players = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Teams.Add(team);
|
||||
teams.Add(team);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Division division = new()
|
||||
{
|
||||
Slug = $"division-{Guid.NewGuid()}",
|
||||
Name = $"Division-{Guid.NewGuid()}",
|
||||
Tournament = tournament,
|
||||
TournamentId = tournament.Id,
|
||||
Stages = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Stage groupStage = new()
|
||||
{
|
||||
Slug = $"stage-{Guid.NewGuid()}",
|
||||
Name = $"Group-{Guid.NewGuid()}",
|
||||
StageType = StageType.Group,
|
||||
IsActive = true,
|
||||
IsElimination = false,
|
||||
StartDate = tournament.StartDate,
|
||||
EndDate = tournament.StartDate.AddDays(7),
|
||||
DivisionId = division.Id,
|
||||
Division = division,
|
||||
Matches = [],
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Stages.Add(groupStage);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Match match = new()
|
||||
{
|
||||
MatchDate = groupStage.StartDate,
|
||||
Type = MatchType.Regular,
|
||||
Slug = $"match-{Guid.NewGuid()}",
|
||||
HomeTeam = teams[0],
|
||||
HomeTeamId = teams[0].Id,
|
||||
VisitorTeam = teams[1],
|
||||
VisitorTeamId = teams[1].Id,
|
||||
HomeScore = 90,
|
||||
VisitorScore = 80,
|
||||
IsFinished = true,
|
||||
WinningTeam = teams[0],
|
||||
WinningTeamId = teams[0].Id,
|
||||
Stage = groupStage,
|
||||
StageId = groupStage.Id,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
db.Matches.Add(match);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (division, teams);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user