Sube carpetas API, API.Tests, Application y Domain del Backend

This commit is contained in:
FrancoRu
2026-09-18 13:35:59 -03:00
parent bbb09554bb
commit 2eb4eeef03
572 changed files with 56405 additions and 0 deletions
+35
View File
@@ -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>
+121
View File
@@ -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&lt;ApplicationUser&gt; 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 &lt;tmp&gt;" 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", &lt;dumpFilePath&gt; — 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();
}
}
+534
View File
@@ -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 &gt; b0 &gt; 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 ` &lt;name&gt;:` 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&lt;ApplicationDBContext&gt;). 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&lt;Player, PublicPlayerResponse&gt;()</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&lt;Match, DetailedMatchResponse&gt;()</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] &gt; teams[1] &gt; ... .
/// </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) &lt;= 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 &amp; 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",
};
}
}
+297
View File
@@ -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&gt;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] &gt; teams[1] &gt; … — 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