diff --git a/Club12-Backend/API.Tests/API.Tests.csproj b/Club12-Backend/API.Tests/API.Tests.csproj new file mode 100644 index 0000000..5200176 --- /dev/null +++ b/Club12-Backend/API.Tests/API.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/Club12-Backend/API.Tests/AuditTrailTests.cs b/Club12-Backend/API.Tests/AuditTrailTests.cs new file mode 100644 index 0000000..60e29b7 --- /dev/null +++ b/Club12-Backend/API.Tests/AuditTrailTests.cs @@ -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; + +/// +/// 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. +/// +public class AuditTrailTests : IClassFixture +{ + 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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); + } +} diff --git a/Club12-Backend/API.Tests/AuthControllerLogoutTests.cs b/Club12-Backend/API.Tests/AuthControllerLogoutTests.cs new file mode 100644 index 0000000..5f670a1 --- /dev/null +++ b/Club12-Backend/API.Tests/AuthControllerLogoutTests.cs @@ -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; + +/// +/// Regression test locking AuthController.Logout's observable contract — 204 No +/// Content and refresh-token/expiry clearing for an existing user, no-op for a missing +/// user — before the boundary fix that routes the side effect through the new +/// IAuthenticationService.LogoutAsync instead of a direct +/// UserManager<ApplicationUser> injection in the controller. Written first +/// (RED) against the pre-refactor controller so it proves behavior is unchanged (GREEN) +/// after the refactor lands. +/// +public class AuthControllerLogoutTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public AuthControllerLogoutTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// 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. + /// + [Fact] + public async Task Logout_ExistingUserWithRefreshToken_Returns204AndClearsToken() + { + ApplicationUser user; + using (IServiceScope seedScope = _factory.Services.CreateScope()) + { + UserManager seedUserManager = + seedScope.ServiceProvider.GetRequiredService>(); + 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 verifyUserManager = + verifyScope.ServiceProvider.GetRequiredService>(); + + 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 SeedUserWithRefreshTokenAsync( + UserManager 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 CreateAccessTokenAsync(Guid userId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IAuthService authService = scope.ServiceProvider.GetRequiredService(); + + Claim[] claims = + [ + new(ClaimTypes.NameIdentifier, userId.ToString()), + new(ClaimTypes.Role, "ADMIN"), + ]; + + TokenResponse token = await authService.GenerateJwtTokenAsync(claims); + return token.AccessToken; + } +} diff --git a/Club12-Backend/API.Tests/AuthServiceJwtTests.cs b/Club12-Backend/API.Tests/AuthServiceJwtTests.cs new file mode 100644 index 0000000..8a634fd --- /dev/null +++ b/Club12-Backend/API.Tests/AuthServiceJwtTests.cs @@ -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; + +/// +/// 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. +/// +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(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 settings = new() + { + ["JWT:Key"] = SigningKey, + ["JWT:Issuer"] = Issuer, + ["JWT:Audience"] = Audience, + }; + + return new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + } +} diff --git a/Club12-Backend/API.Tests/AuthorizationGatingTests.cs b/Club12-Backend/API.Tests/AuthorizationGatingTests.cs new file mode 100644 index 0000000..e0b9afa --- /dev/null +++ b/Club12-Backend/API.Tests/AuthorizationGatingTests.cs @@ -0,0 +1,167 @@ +using Application.DTOs.Tournament.Request; + +using Domain.Enums; + +using System.Net; +using System.Net.Http.Json; + +namespace API.Tests; + +/// +/// 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. +/// +public class AuthorizationGatingTests : IClassFixture +{ + 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); + } + + /// + /// Guest is not one of the staff roles that may see full player details. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// Guest is not a staff role, so the slug form of the admin player-detail + /// route is forbidden — never a routing 404, never 200. + /// + [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); + } + + /// + /// 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. + /// + [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), + }; + } +} diff --git a/Club12-Backend/API.Tests/AutoMapperProfilesTests.cs b/Club12-Backend/API.Tests/AutoMapperProfilesTests.cs new file mode 100644 index 0000000..d0bb028 --- /dev/null +++ b/Club12-Backend/API.Tests/AutoMapperProfilesTests.cs @@ -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; + +/// +/// 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. +/// +public class AutoMapperProfilesTests +{ + private static IMapper CreateMapper() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), 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(match); + + Assert.Equal("River Plate", response.HomeTeamName); + Assert.Equal("Boca Juniors", response.VisitorTeamName); + } + + /// + /// Confirms that a match with no assigned home or visitor team maps to null team names + /// instead of throwing. + /// + [Fact] + public void Map_ToMinimalMatchResponse_WithUnassignedTeams_DegradesToNullInsteadOfThrowing() + { + Match match = CreateMatch(homeTeam: null, visitorTeam: null); + IMapper mapper = CreateMapper(); + + MinimalMatchResponse response = mapper.Map(match); + + Assert.Null(response.HomeTeamName); + Assert.Null(response.VisitorTeamName); + } + + /// + /// 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. + /// + [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); + } + + /// + /// The round flows through to the response DTOs so the frontend can group + /// the fixture by matchday (HU-63). + /// + [Fact] + public void Map_ToDetailedMatchResponse_ExposesRound() + { + Match match = CreateMatch(homeTeam: null, visitorTeam: null); + match.Round = 7; + IMapper mapper = CreateMapper(); + + DetailedMatchResponse response = mapper.Map(match); + + Assert.Equal(7, response.Round); + } + + /// + /// 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. + /// + [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(match); + + Assert.Equal(tournamentId, response.TournamentId); + } + + /// + /// The team's ClubId flows through to the response DTO so the frontend can + /// link a team back to its club. + /// + [Fact] + public void Map_ToTeamResponse_ExposesClubId() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), NullLoggerFactory.Instance); + IMapper mapper = configuration.CreateMapper(); + + Guid clubId = Guid.NewGuid(); + Team team = CreateTeam("River Plate"); + team.ClubId = clubId; + + TeamResponse response = mapper.Map(team); + + Assert.Equal(clubId, response.ClubId); + } + + /// + /// 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. + /// + [Fact] + public void Map_DivisionQualifiersPerGroup_RoundTripsThroughRequestAndResponse() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), NullLoggerFactory.Instance); + IMapper mapper = configuration.CreateMapper(); + + CreateDivisionRequest request = new() + { + Name = "Copa Club12", + TournamentId = Guid.NewGuid(), + IsCrossDivisionCup = true, + QualifiersPerGroup = 2, + }; + + Division division = mapper.Map(request); + Assert.Equal(2, division.QualifiersPerGroup); + + DivisionResponse response = mapper.Map(division); + Assert.Equal(2, response.QualifiersPerGroup); + } + + /// + /// An omitted QualifiersPerGroup defaults to 1 on the request DTO and + /// survives the mapping unchanged, leaving every existing division's + /// seeding behavior intact. + /// + [Fact] + public void Map_DivisionQualifiersPerGroup_DefaultsToOne() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), NullLoggerFactory.Instance); + IMapper mapper = configuration.CreateMapper(); + + CreateDivisionRequest request = new() + { + Name = "Primera", + TournamentId = Guid.NewGuid(), + }; + + Division division = mapper.Map(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, + }; + } + + /// + /// 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. + /// + [Fact] + public void Map_DivisionWithPlayoffMappings_ExposesOrderedQualificationRanges() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), 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(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); + }); + } + + /// + /// A division WITHOUT playoff mappings exposes an empty qualification-range + /// list, so the public standings table simply renders no highlight/legend. + /// + [Fact] + public void Map_DivisionWithoutPlayoffMappings_ExposesEmptyQualificationRanges() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), NullLoggerFactory.Instance); + IMapper mapper = configuration.CreateMapper(); + + Division division = CreateDivision(); + + DivisionResponse response = mapper.Map(division); + + Assert.NotNull(response.QualificationRanges); + Assert.Empty(response.QualificationRanges!); + } + + /// + /// 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. + /// + [Fact] + public void Map_ToTeamStaffResponse_ExposesRoleAsStringAndTeamName() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), 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(staff); + + Assert.Equal("Coach", response.Role); + Assert.Equal("River Plate", response.TeamName); + } + + /// + /// When the Team navigation was not loaded, TeamName degrades to null + /// instead of throwing. + /// + [Fact] + public void Map_ToTeamStaffResponse_WithNoTeamLoaded_TeamNameIsNull() + { + MapperConfiguration configuration = new(cfg => cfg.AddProfile(), 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(staff); + + Assert.Equal("AssistantCoach", response.Role); + Assert.Null(response.TeamName); + } +} diff --git a/Club12-Backend/API.Tests/AutomatedMatchGenerationTests.cs b/Club12-Backend/API.Tests/AutomatedMatchGenerationTests.cs new file mode 100644 index 0000000..542ac2b --- /dev/null +++ b/Club12-Backend/API.Tests/AutomatedMatchGenerationTests.cs @@ -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; + +/// +/// 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. +/// +public class AutomatedMatchGenerationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public AutomatedMatchGenerationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + public static readonly TheoryData 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Stage stage = await SeedStageAsync(db, stageType); + + List 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 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; + } +} diff --git a/Club12-Backend/API.Tests/Backup/AddBackupConfigStorageSelectionTests.cs b/Club12-Backend/API.Tests/Backup/AddBackupConfigStorageSelectionTests.cs new file mode 100644 index 0000000..8bb4e07 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/AddBackupConfigStorageSelectionTests.cs @@ -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; + +/// +/// 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). +/// +public sealed class AddBackupConfigStorageSelectionTests +{ + private static IServiceCollection BuildServices(string storageTarget) + { + Dictionary 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); + } + + /// + /// 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. + /// + [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); + } +} diff --git a/Club12-Backend/API.Tests/Backup/BackupControllerTests.cs b/Club12-Backend/API.Tests/Backup/BackupControllerTests.cs new file mode 100644 index 0000000..e123867 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/BackupControllerTests.cs @@ -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; + +/// +/// 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. +/// +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> result = await sut.GetAll(CancellationToken.None); + + OkObjectResult ok = Assert.IsType(result.Result); + IReadOnlyList body = Assert.IsAssignableFrom>(ok.Value); + Assert.Single(body); + Assert.Equal("a.sql", body[0].StoragePath); + } + + [Fact] + public async Task GetAll_EmptyCatalog_ReturnsOkWithEmptyList() + { + BackupController sut = CreateSut(); + + ActionResult> result = await sut.GetAll(CancellationToken.None); + + OkObjectResult ok = Assert.IsType(result.Result); + IReadOnlyList body = Assert.IsAssignableFrom>(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(result); + BackupRecordResponse body = Assert.IsType(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(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(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(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(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(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(result); + Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode); + } + + /// + /// 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). + /// + [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(result); + BackupRecordResponse body = Assert.IsType(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(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(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(result); + Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode); + } +} diff --git a/Club12-Backend/API.Tests/Backup/BackupOperationLockTests.cs b/Club12-Backend/API.Tests/Backup/BackupOperationLockTests.cs new file mode 100644 index 0000000..447ca73 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/BackupOperationLockTests.cs @@ -0,0 +1,41 @@ +using Application.Backup; + +namespace API.Tests.Backup; + +/// +/// 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. +/// +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(); + } +} diff --git a/Club12-Backend/API.Tests/Backup/BackupOperationsServiceTests.cs b/Club12-Backend/API.Tests/Backup/BackupOperationsServiceTests.cs new file mode 100644 index 0000000..120622f --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/BackupOperationsServiceTests.cs @@ -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; + +/// +/// 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). +/// +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? 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.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(TaskCreationOptions.RunContinuationsAsynchronously), + }; + BackupOperationsService sut = CreateSut(catalog: catalog, backupService: backupService); + + Task 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 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); + } + + /// + /// 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. + /// + [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 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 remaining = await catalog.ListNewestFirstAsync(); + Assert.Equal(2, remaining.Count); + Assert.Empty(storage.DeletedNames); + } + + /// + /// 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. + /// + [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 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); + } + + /// + /// 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). + /// + [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); + } + + /// + /// 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). + /// + [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)); + } + + /// + /// 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. + /// + [Fact] + public async Task RestoreBackupAsync_ConcurrentCalls_SecondReturnsBusy_OnlyOneRestoreRuns() + { + FakeBackupCatalog catalog = new(); + FakeBackupStorage storage = new(); + FakeDatabaseBackupService backupService = new() + { + Gate = new TaskCompletionSource(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 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); + } +} diff --git a/Club12-Backend/API.Tests/Backup/DatabaseBackupHostedServiceTests.cs b/Club12-Backend/API.Tests/Backup/DatabaseBackupHostedServiceTests.cs new file mode 100644 index 0000000..e5a8fc9 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/DatabaseBackupHostedServiceTests.cs @@ -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; + +/// +/// 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. +/// +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(); + } + + [Fact] + public async Task ExecuteAsync_IntervalElapses_TriggersOneBackupAttempt() + { + FakeBackupOperationsService operations = new(); + DatabaseBackupHostedService service = new( + ScopeFactoryFor(operations), EnabledOptions(), NullLogger.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."); + } + + /// + /// The 100ms delay is well short of the 5s interval used here. + /// + [Fact] + public async Task ExecuteAsync_IntervalNotYetElapsed_NoBackupAttemptTriggered() + { + FakeBackupOperationsService operations = new(); + DatabaseBackupHostedService service = new( + ScopeFactoryFor(operations), EnabledOptions(), NullLogger.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.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); + } + + /// + /// 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. + /// + [Fact] + public async Task ExecuteAsync_OperationReturnsBusy_LogsAndContinuesTicking_NoThrow() + { + FakeBackupOperationsService operations = new() + { + NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"), + }; + CapturingLogger 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 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)); + } +} diff --git a/Club12-Backend/API.Tests/Backup/EfBackupCatalogTests.cs b/Club12-Backend/API.Tests/Backup/EfBackupCatalogTests.cs new file mode 100644 index 0000000..fecd3f3 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/EfBackupCatalogTests.cs @@ -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; + +/// +/// 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. +/// +public sealed class EfBackupCatalogTests : IClassFixture +{ + 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(); + 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(); + 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(); + 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(); + 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 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 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(); + 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(); + 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); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/CapturingLogEntry.cs b/Club12-Backend/API.Tests/Backup/Fakes/CapturingLogEntry.cs new file mode 100644 index 0000000..92f7fe7 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/CapturingLogEntry.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Logging; + +namespace API.Tests.Backup.Fakes; + +/// +/// A single log call captured by CapturingLogger{T}. +/// +public sealed record CapturingLogEntry(LogLevel Level, string Message, Exception? Exception); diff --git a/Club12-Backend/API.Tests/Backup/Fakes/CapturingLogger.cs b/Club12-Backend/API.Tests/Backup/Fakes/CapturingLogger.cs new file mode 100644 index 0000000..2e9f984 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/CapturingLogger.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; + +namespace API.Tests.Backup.Fakes; + +/// +/// 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. +/// +public sealed class CapturingLogger : ILogger +{ + private readonly List _entries = []; + + public IReadOnlyList Entries => _entries; + + public IDisposable BeginScope(TState state) where TState : notnull + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + lock (_entries) + { + _entries.Add(new CapturingLogEntry(logLevel, formatter(state, exception), exception)); + } + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeAuditService.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeAuditService.cs new file mode 100644 index 0000000..6657bc5 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeAuditService.cs @@ -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; + +/// +/// Test double for IAuditService. Records every call so tests can assert on +/// what got logged without a real repository/database. +/// +public sealed class FakeAuditService : IAuditService +{ + public List 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> GetAuditLogsAsync(AuditLogFilteredRequest filter) + { + return Task.FromResult(new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = 0, + Items = [], + }); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupCatalog.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupCatalog.cs new file mode 100644 index 0000000..11f4e4b --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupCatalog.cs @@ -0,0 +1,50 @@ +using Application.Interfaces.Backup; + +using Domain.Entities.Models; + +namespace API.Tests.Backup.Fakes; + +/// +/// In-memory test double for IBackupCatalog. Lets +/// BackupOperationsServiceTests and BackupControllerTests exercise +/// the shared create/delete/retention logic without a real database. +/// +public sealed class FakeBackupCatalog : IBackupCatalog +{ + private readonly List _records = []; + + public int AddCallCount { get; private set; } + public int RemoveCallCount { get; private set; } + + public Task 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 GetByIdAsync(Guid id, CancellationToken ct = default) + { + return Task.FromResult(_records.Find(r => r.Id == id)); + } + + public Task> ListNewestFirstAsync(CancellationToken ct = default) + { + IReadOnlyList 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; + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupOperationsService.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupOperationsService.cs new file mode 100644 index 0000000..17931cb --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupOperationsService.cs @@ -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; + +/// +/// 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). +/// +public sealed class FakeBackupOperationsService : IBackupOperationsService +{ + private int _createCallCount; + + public int CreateCallCount => _createCallCount; + + /// + /// When set, CreateBackupAsync awaits this before returning — + /// simulates a still-running attempt for concurrency-related tests. + /// + public TaskCompletionSource? Gate { get; set; } + + /// + /// The first N calls to CreateBackupAsync return + /// Failed instead of NextCreateResult. + /// + 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 NextListResult { get; set; } = []; + + public Task> ListNewestFirstAsync(CancellationToken ct = default) + { + return Task.FromResult(NextListResult); + } + + public async Task 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 DeleteBackupAsync(Guid id, CancellationToken ct = default) + { + return Task.FromResult(NextDeleteResult); + } + + public Task RestoreBackupAsync(Guid id, CancellationToken ct = default) + { + LastRestoreId = id; + return Task.FromResult(NextRestoreResult); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupStorage.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupStorage.cs new file mode 100644 index 0000000..8e7a8da --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeBackupStorage.cs @@ -0,0 +1,74 @@ +using Application.Interfaces.Backup; + +namespace API.Tests.Backup.Fakes; + +/// +/// 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. +/// +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 FilesToList { get; set; } = Array.Empty(); + public List DeletedNames { get; } = []; + + /// + /// When set, DeleteAsync throws this instead of succeeding — + /// simulates the stored file already being missing out-of-band. + /// + public Exception? DeleteException { get; set; } + + public Task StoreAsync(string name, Stream content, CancellationToken ct = default) + { + Interlocked.Increment(ref _storeCallCount); + return Task.CompletedTask; + } + + public Task> 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; + } + + /// + /// When set, OpenReadAsync throws this instead of succeeding — + /// simulates a missing/unreadable stored file. + /// + public Exception? OpenReadException { get; set; } + + public Stream ContentToOpen { get; set; } = new MemoryStream(); + + public Task OpenReadAsync(string name, CancellationToken ct = default) + { + if (OpenReadException is not null) + { + throw OpenReadException; + } + + return Task.FromResult(ContentToOpen); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeDatabaseBackupService.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeDatabaseBackupService.cs new file mode 100644 index 0000000..d1db1de --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeDatabaseBackupService.cs @@ -0,0 +1,44 @@ +using Application.Interfaces.Backup; + +using System.Text; + +namespace API.Tests.Backup.Fakes; + +/// +/// 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. +/// +public sealed class FakeDatabaseBackupService : IDatabaseBackupService +{ + private int _callCount; + + public int CallCount => _callCount; + + /// + /// When set, CreateDumpAsync awaits this before returning/throwing. + /// + public TaskCompletionSource? Gate { get; set; } + + /// + /// The first N calls throw BackupExecutionException; subsequent calls succeed. + /// + public int FailFirstCalls { get; set; } + + public string DumpContent { get; set; } = "-- fake dump --"; + + public async Task 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)); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeDatabaseRestoreService.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeDatabaseRestoreService.cs new file mode 100644 index 0000000..122ec42 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeDatabaseRestoreService.cs @@ -0,0 +1,37 @@ +using Application.Interfaces.Backup; + +namespace API.Tests.Backup.Fakes; + +/// +/// 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). +/// +public sealed class FakeDatabaseRestoreService : IDatabaseRestoreService +{ + private int _callCount; + + public int CallCount => _callCount; + + public string? CapturedDumpFilePath { get; private set; } + + /// + /// When set, RestoreAsync throws this instead of succeeding. + /// + 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; + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeProcessRunner.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeProcessRunner.cs new file mode 100644 index 0000000..c4e2935 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeProcessRunner.cs @@ -0,0 +1,33 @@ +using Application.Interfaces.Backup; + +namespace API.Tests.Backup.Fakes; + +/// +/// 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. +/// +public sealed class FakeProcessRunner : IProcessRunner +{ + public string? CapturedFileName { get; private set; } + public IReadOnlyList? CapturedArgs { get; private set; } + public IReadOnlyDictionary? CapturedEnvironmentVariables { get; private set; } + public int CallCount { get; private set; } + + public ProcessResult ResultToReturn { get; set; } = new(0, string.Empty, string.Empty); + + public Task RunAsync( + string fileName, + IReadOnlyList args, + IReadOnlyDictionary? environmentVariables = null, + CancellationToken ct = default) + { + CallCount++; + CapturedFileName = fileName; + CapturedArgs = args; + CapturedEnvironmentVariables = environmentVariables; + + return Task.FromResult(ResultToReturn); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/FakeSupabaseRawStorage.cs b/Club12-Backend/API.Tests/Backup/Fakes/FakeSupabaseRawStorage.cs new file mode 100644 index 0000000..1ec77f2 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/FakeSupabaseRawStorage.cs @@ -0,0 +1,89 @@ +using Application.Utils.Helper.SupabaseHelper; + +namespace API.Tests.Backup.Fakes; + +/// +/// 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. +/// +public sealed class FakeSupabaseRawStorage : ISupabaseRawStorage +{ + public List UploadedPaths { get; } = []; + + /// + /// The bucket argument passed to each + /// call, in order — when the caller did not pass a + /// bucket (i.e. relied on the configured default). + /// + public List UploadedBuckets { get; } = []; + + public List RemovedPaths { get; } = []; + public string? LastListedPrefix { get; private set; } + public string? LastDownloadedPath { get; private set; } + + /// The bucket argument passed to the last call. + public string? DownloadedBucket { get; private set; } + + public IReadOnlyList EntriesToList { get; set; } = Array.Empty(); + + public byte[] BytesToDownload { get; set; } = Array.Empty(); + + /// + /// When set, every raw call throws this exception instead of succeeding — + /// simulates a network error, auth error, or any other Supabase client + /// failure. + /// + 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> 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 DownloadRawAsync(string objectPath, string? bucket = null) + { + if (ExceptionToThrow is not null) + { + throw ExceptionToThrow; + } + + LastDownloadedPath = objectPath; + DownloadedBucket = bucket; + return Task.FromResult(BytesToDownload); + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/NullScope.cs b/Club12-Backend/API.Tests/Backup/Fakes/NullScope.cs new file mode 100644 index 0000000..e7dfeec --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/NullScope.cs @@ -0,0 +1,14 @@ +namespace API.Tests.Backup.Fakes; + +/// +/// No-op IDisposable returned from ILogger.BeginScope, shared by every +/// CapturingLogger{T} instance regardless of T. +/// +public sealed class NullScope : IDisposable +{ + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } +} diff --git a/Club12-Backend/API.Tests/Backup/Fakes/TestTiming.cs b/Club12-Backend/API.Tests/Backup/Fakes/TestTiming.cs new file mode 100644 index 0000000..32e040a --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/Fakes/TestTiming.cs @@ -0,0 +1,24 @@ +namespace API.Tests.Backup.Fakes; + +/// +/// 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. +/// +internal static class TestTiming +{ + public static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + + await Task.Delay(10); + } + return condition(); + } +} diff --git a/Club12-Backend/API.Tests/Backup/KeepLastNRetentionPolicyTests.cs b/Club12-Backend/API.Tests/Backup/KeepLastNRetentionPolicyTests.cs new file mode 100644 index 0000000..c6caf7b --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/KeepLastNRetentionPolicyTests.cs @@ -0,0 +1,126 @@ +using Application.Backup; +using Application.Interfaces.Backup; + +namespace API.Tests.Backup; + +/// +/// 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. +/// +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 existing = + [ + File("backup-1", minutesAgo: 30), + File("backup-2", minutesAgo: 20), + File("backup-3", minutesAgo: 10), + ]; + + IReadOnlyList result = Policy.SelectForDeletion(existing, retainCount: 5); + + Assert.Empty(result); + } + + [Fact] + public void SelectForDeletion_CountEqualsLimit_SelectsNone() + { + List existing = + [ + File("backup-1", minutesAgo: 30), + File("backup-2", minutesAgo: 20), + ]; + + IReadOnlyList result = Policy.SelectForDeletion(existing, retainCount: 2); + + Assert.Empty(result); + } + + /// + /// Entries are oldest-to-newest by minutesAgo: backup-5 (50m) ... backup-1 (10m). + /// + [Fact] + public void SelectForDeletion_CountExceedsLimit_SelectsOldestExcess_RetainsNewestN() + { + List 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 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"); + } + + /// + /// Per the documented tie-break rule (design.md Open Questions), among + /// entries with identical timestamps the lexically-smallest name + /// (ordinal) is retained. + /// + [Fact] + public void SelectForDeletion_IdenticalTimestampsAtBoundary_IsDeterministicAndOrderIndependent() + { + DateTimeOffset tiedTimestamp = DateTimeOffset.UtcNow.AddMinutes(-10); + + List existing = + [ + new BackupFile("backup-b", tiedTimestamp), + new BackupFile("backup-a", tiedTimestamp), + new BackupFile("backup-c", tiedTimestamp), + ]; + + IReadOnlyList firstRun = Policy.SelectForDeletion(existing, retainCount: 1); + + List reordered = [existing[2], existing[0], existing[1]]; + IReadOnlyList 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 result = Policy.SelectForDeletion([], retainCount: 7); + + Assert.Empty(result); + } + + [Fact] + public void SelectForDeletion_RetainCountZero_SelectsAll() + { + List existing = + [ + File("backup-1", minutesAgo: 10), + File("backup-2", minutesAgo: 20), + ]; + + IReadOnlyList result = Policy.SelectForDeletion(existing, retainCount: 0); + + Assert.Equal(2, result.Count); + } +} diff --git a/Club12-Backend/API.Tests/Backup/LocalDirectoryBackupStorageTests.cs b/Club12-Backend/API.Tests/Backup/LocalDirectoryBackupStorageTests.cs new file mode 100644 index 0000000..2b68693 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/LocalDirectoryBackupStorageTests.cs @@ -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; + +/// +/// 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. +/// +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.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 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 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); + } + + /// + /// 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). + /// + [Fact] + public async Task OpenReadAsync_TraversalName_ThrowsArgumentException_NoFileOpened() + { + await Assert.ThrowsAsync( + () => _storage.OpenReadAsync("../../etc/passwd")); + } + + [Fact] + public async Task OpenReadAsync_RootedPathName_ThrowsArgumentException() + { + string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/passwd"; + + await Assert.ThrowsAsync(() => _storage.OpenReadAsync(rooted)); + } + + [Fact] + public async Task StoreAsync_NameEscapingConfiguredDirectory_ThrowsArgumentException() + { + await Assert.ThrowsAsync( + () => _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( + () => _storage.StoreAsync(rooted, ContentStream("evil"))); + } + + [Fact] + public async Task DeleteAsync_NameEscapingConfiguredDirectory_ThrowsArgumentException() + { + await Assert.ThrowsAsync(() => _storage.DeleteAsync("../../escape.sql")); + } + + /// + /// 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). + /// + [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 existing = await _storage.ListAsync(); + Assert.Equal(4, existing.Count); + + KeepLastNRetentionPolicy retention = new(); + IReadOnlyList 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 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); + } + } +} diff --git a/Club12-Backend/API.Tests/Backup/MaintenanceModeMiddlewareTests.cs b/Club12-Backend/API.Tests/Backup/MaintenanceModeMiddlewareTests.cs new file mode 100644 index 0000000..f69ec21 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/MaintenanceModeMiddlewareTests.cs @@ -0,0 +1,92 @@ +using API.Utils.Middlewares; + +using Application.Backup; +using Application.Interfaces.Backup; + +using Microsoft.AspNetCore.Http; + +namespace API.Tests.Backup; + +/// +/// 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. +/// +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); + } + + /// + /// 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. + /// + [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); + } +} diff --git a/Club12-Backend/API.Tests/Backup/MaintenanceModeStateTests.cs b/Club12-Backend/API.Tests/Backup/MaintenanceModeStateTests.cs new file mode 100644 index 0000000..a95d662 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/MaintenanceModeStateTests.cs @@ -0,0 +1,79 @@ +using Application.Backup; +using Application.Interfaces.Backup; + +namespace API.Tests.Backup; + +/// +/// 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. +/// +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); + } + + /// + /// 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(). + /// + [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); + } +} diff --git a/Club12-Backend/API.Tests/Backup/PgDumpBackupServiceTests.cs b/Club12-Backend/API.Tests/Backup/PgDumpBackupServiceTests.cs new file mode 100644 index 0000000..04774e8 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/PgDumpBackupServiceTests.cs @@ -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; + +/// +/// 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). +/// +public class PgDumpBackupServiceTests +{ + private static IConfiguration BuildConfiguration(string connectionString, string? pgDumpPath = null) + { + Dictionary 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.Instance); + + await using Stream dump = await service.CreateDumpAsync(); + + using StreamReader reader = new(dump); + string content = await reader.ReadToEndAsync(); + Assert.Equal("-- pg_dump output --", content); + } + + /// + /// 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. + /// + [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.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"]); + } + + /// + /// 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 &. + /// + [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.Instance); + + await service.CreateDumpAsync(); + + Assert.Contains(maliciousDbName, runner.CapturedArgs!); + Assert.All(runner.CapturedArgs!, a => Assert.DoesNotContain("&&", a)); + } + + /// + /// 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). + /// + [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.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!); + } + + /// + /// 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". + /// + [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.Instance); + + await service.CreateDumpAsync(); + + Assert.NotNull(runner.CapturedArgs); + IReadOnlyList args = runner.CapturedArgs!; + Assert.Contains("public", args); + Assert.Contains("\"Club12\"", args); + Assert.Equal(2, args.Count(a => a == "-n")); + } + + /// + /// 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). + /// + [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.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.Instance); + + BackupExecutionException ex = await Assert.ThrowsAsync( + () => service.CreateDumpAsync()); + + Assert.Contains("exit code 1", ex.Message); + Assert.Contains("connection failed", ex.Message); + } + + /// + /// Simulates what ProcessRunner returns when Process.Start fails for a + /// missing executable: sentinel exit code -1, detail in StdErr. + /// + [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.Instance); + + BackupExecutionException ex = await Assert.ThrowsAsync( + () => 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.Instance); + + await Assert.ThrowsAsync(() => service.CreateDumpAsync()); + + Assert.Equal(0, runner.CallCount); + } +} diff --git a/Club12-Backend/API.Tests/Backup/ProcessRunnerTests.cs b/Club12-Backend/API.Tests/Backup/ProcessRunnerTests.cs new file mode 100644 index 0000000..b1141e5 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/ProcessRunnerTests.cs @@ -0,0 +1,43 @@ +using Application.Interfaces.Backup; + +using Infrastructure.Backup; + +namespace API.Tests.Backup; + +/// +/// 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. +/// +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)); + } + + /// + /// "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. + /// + [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)); + } +} diff --git a/Club12-Backend/API.Tests/Backup/PsqlDatabaseRestoreServiceTests.cs b/Club12-Backend/API.Tests/Backup/PsqlDatabaseRestoreServiceTests.cs new file mode 100644 index 0000000..d53d616 --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/PsqlDatabaseRestoreServiceTests.cs @@ -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; + +/// +/// Unit tests for PsqlDatabaseRestoreService using a +/// FakeProcessRunner — no real psql binary involved. +/// Covers: correct argument-vector construction (design.md's +/// "psql -v ON_ERROR_STOP=1 -f <tmp>" decision — plain-SQL restore, not +/// pg_restore), subprocess argument-injection resistance for a +/// dump-file path crafted to look like shell/psql-flag injection, and +/// failure handling (non-zero exit / missing binary → logged, handled +/// exception, not a crash). +/// +public class PsqlDatabaseRestoreServiceTests +{ + private static IConfiguration BuildConfiguration(string connectionString, string? psqlPath = null) + { + Dictionary 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.Instance); + + await service.RestoreAsync("/tmp/backup-restore-test.sql"); + + Assert.Equal(1, runner.CallCount); + } + + /// + /// 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. + /// + [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.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"]); + } + + /// + /// Asserts the exact arg vector for design.md's chosen restore invocation: + /// "-v", "ON_ERROR_STOP=1", "-f", <dumpFilePath> — ON_ERROR_STOP=1 is what + /// turns the first SQL error inside the dump into a non-zero psql exit + /// code instead of silently continuing past it. + /// + [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.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!); + } + + /// + /// 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. + /// + [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.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.Instance); + + BackupExecutionException ex = await Assert.ThrowsAsync( + () => service.RestoreAsync("/tmp/backup-restore-test.sql")); + + Assert.Contains("exit code 1", ex.Message); + Assert.Contains("syntax error", ex.Message); + } + + /// + /// Simulates what ProcessRunner returns when Process.Start fails for a + /// missing executable: sentinel exit code -1, detail in StdErr. + /// + [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.Instance); + + BackupExecutionException ex = await Assert.ThrowsAsync( + () => 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.Instance); + + await Assert.ThrowsAsync( + () => service.RestoreAsync("/tmp/backup-restore-test.sql")); + + Assert.Equal(0, runner.CallCount); + } +} diff --git a/Club12-Backend/API.Tests/Backup/SupabaseBackupStorageTests.cs b/Club12-Backend/API.Tests/Backup/SupabaseBackupStorageTests.cs new file mode 100644 index 0000000..a9f45cf --- /dev/null +++ b/Club12-Backend/API.Tests/Backup/SupabaseBackupStorageTests.cs @@ -0,0 +1,188 @@ +using API.Tests.Backup.Fakes; + +using Application.Interfaces.Backup; + +using Infrastructure.Backup; + +using System.Text; + +namespace API.Tests.Backup; + +/// +/// 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. +/// +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(() => 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(() => 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); + } + + /// + /// 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). + /// + [Fact] + public async Task OpenReadAsync_TraversalName_ThrowsArgumentException_NoDownloadAttempted() + { + FakeSupabaseRawStorage raw = new(); + SupabaseBackupStorage storage = new(raw); + + await Assert.ThrowsAsync(() => 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( + () => 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(() => 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 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( + () => storage.StoreAsync("backup-1.sql", ContentStream("dump"))); + + Assert.Contains("backup-1.sql", ex.Message); + Assert.IsType(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(() => 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(() => storage.DeleteAsync("backup-1.sql")); + } +} diff --git a/Club12-Backend/API.Tests/BackupAuthorizationTests.cs b/Club12-Backend/API.Tests/BackupAuthorizationTests.cs new file mode 100644 index 0000000..af3e4e1 --- /dev/null +++ b/Club12-Backend/API.Tests/BackupAuthorizationTests.cs @@ -0,0 +1,120 @@ +using Domain.Enums; + +using System.Net; + +namespace API.Tests; + +/// +/// 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. +/// +public class BackupAuthorizationTests : IClassFixture +{ + 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); + } +} diff --git a/Club12-Backend/API.Tests/BlogPostDraftVisibilityTests.cs b/Club12-Backend/API.Tests/BlogPostDraftVisibilityTests.cs new file mode 100644 index 0000000..7be2350 --- /dev/null +++ b/Club12-Backend/API.Tests/BlogPostDraftVisibilityTests.cs @@ -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; + +/// +/// 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. +/// +public class BlogPostDraftVisibilityTests : IClassFixture +{ + 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + // 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 publicView = await service.GetAllBlogPostsAsync(filter); + Assert.Single(publicView.Items); + Assert.All(publicView.Items, post => Assert.True(post.IsPublished)); + + PaginatedResponse 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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, + }; + } +} diff --git a/Club12-Backend/API.Tests/BlogPostOrderingTests.cs b/Club12-Backend/API.Tests/BlogPostOrderingTests.cs new file mode 100644 index 0000000..cd5ee92 --- /dev/null +++ b/Club12-Backend/API.Tests/BlogPostOrderingTests.cs @@ -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; + +/// +/// Verifies the paginated blog-post list served by GET /api/blog-posts +/// defaults to newest-first ordering by . +/// The home "Últimas noticias" section, the public /blog 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. +/// +public class BlogPostOrderingTests : IClassFixture +{ + 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(); + IBlogPostService service = scope.ServiceProvider.GetRequiredService(); + + 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 result = await service.GetAllBlogPostsAsync( + new GetBlogPostsFilteredRequest { Author = author, PageSize = 50 }); + + List 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(); + IBlogPostService service = scope.ServiceProvider.GetRequiredService(); + + 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 result = await service.GetAllBlogPostsAsync( + new GetBlogPostsFilteredRequest + { + Author = author, + PageSize = 50, + OrderBy = nameof(EntityBase.DateCreated), + Order = SortOrder.Ascending, + }); + + List 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(); + IBlogPostService service = scope.ServiceProvider.GetRequiredService(); + + 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 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.", + }; + } +} diff --git a/Club12-Backend/API.Tests/BlogPostServiceSlugTests.cs b/Club12-Backend/API.Tests/BlogPostServiceSlugTests.cs new file mode 100644 index 0000000..2b25424 --- /dev/null +++ b/Club12-Backend/API.Tests/BlogPostServiceSlugTests.cs @@ -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; + +/// +/// 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. +/// +public class BlogPostServiceSlugTests : IClassFixture +{ + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + BlogPost? found = await blogPostService.GetBlogPostByIdOrSlugAsync($"unknown-slug-{Guid.NewGuid():N}"); + + Assert.Null(found); + } +} diff --git a/Club12-Backend/API.Tests/BlogPostViewCounterTests.cs b/Club12-Backend/API.Tests/BlogPostViewCounterTests.cs new file mode 100644 index 0000000..9d76f6e --- /dev/null +++ b/Club12-Backend/API.Tests/BlogPostViewCounterTests.cs @@ -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; + +/// +/// 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. +/// +public class BlogPostViewCounterTests +{ + private static readonly IMapper Mapper = new MapperConfiguration( + cfg => cfg.AddProfile(), 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 result = await controller.GetBlogPostById("some-post"); + + OkObjectResult ok = Assert.IsType(result.Result); + BlogPostResponse response = Assert.IsType(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 result = await controller.GetBlogPostById("some-post"); + + OkObjectResult ok = Assert.IsType(result.Result); + BlogPostResponse response = Assert.IsType(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 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 GetBlogPostByIdOrSlugAsync(string idOrSlug, bool includeUnpublished = false) + => Task.FromResult(post); + + public Task UpdateBlogPostAsync(BlogPost blogPostEntity) + { + UpdateCount++; + LastPersistedViews = blogPostEntity.Views; + return Task.CompletedTask; + } + + public Task CreateBlogPostAsync(BlogPost blogPostEntity) + => throw new System.NotImplementedException(); + + public Task GetBlogPostByIdAsync(System.Guid blogPostId) + => throw new System.NotImplementedException(); + + public Task DeleteBlogPostAsync(System.Guid id) => throw new System.NotImplementedException(); + + public Task> GetAllBlogPostsAsync( + GetBlogPostsFilteredRequest filter, bool includeUnpublished = false) + => throw new System.NotImplementedException(); + } +} diff --git a/Club12-Backend/API.Tests/BracketRedrawGuardTests.cs b/Club12-Backend/API.Tests/BracketRedrawGuardTests.cs new file mode 100644 index 0000000..cfcf799 --- /dev/null +++ b/Club12-Backend/API.Tests/BracketRedrawGuardTests.cs @@ -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; + +/// +/// 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. +/// +public class BracketRedrawGuardTests : IClassFixture +{ + 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 committed = await stageService.CommitDrawAsync( + stage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)]); + + Assert.Equal(2, committed.Count); + } + + public static readonly TheoryData 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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( + () => 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 firstOrder = [.. teams.Select(t => t.Id)]; + await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: firstOrder); + + List reversedOrder = [.. firstOrder.AsEnumerable().Reverse()]; + List 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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( + () => stageService.CommitDrawAsync(goldStage.Id, DrawMode.Manual, manualOrder: [.. teams.Select(t => t.Id)])); + + List 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 firstOrder = [teams[0].Id, teams[1].Id, teams[2].Id, teams[3].Id]; + await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: firstOrder); + + List firstSeriesIds = await db.MatchSeries.Where(s => s.StageId == stage.Id).Select(s => s.Id).ToListAsync(); + Assert.Equal(2, firstSeriesIds.Count); + + List secondOrder = [teams[3].Id, teams[2].Id, teams[1].Id, teams[0].Id]; + List reCommitted = await stageService.CommitDrawAsync(stage.Id, DrawMode.Manual, manualOrder: secondOrder); + + List 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 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 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 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> SeedTeamsAndRegisterAsync( + ApplicationDBContext db, Tournament tournament, Division division, int count) + { + List 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/ChampionServiceTests.cs b/Club12-Backend/API.Tests/ChampionServiceTests.cs new file mode 100644 index 0000000..e43be9f --- /dev/null +++ b/Club12-Backend/API.Tests/ChampionServiceTests.cs @@ -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; + +/// +/// 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. +/// +public class ChampionServiceTests : IClassFixture +{ + 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + // A finished tournament with a decided playoff division. + Tournament finished = await SeedTournamentAsync(db, TournamentStatus.Finished); + Division finishedDivision = await SeedDivisionAsync(db, finished); + List 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 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 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Finished); + Division division = await SeedDivisionAsync(db, tournament); + List 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 history = await championService.GetChampionsHistoryAsync(seasonId: null); + + List 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(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + Season season = await SeedSeasonAsync(db, year: 2026); + Tournament inSeason = await SeedTournamentAsync(db, TournamentStatus.Finished, seasonId: season.Id); + Division seasonDivision = await SeedDivisionAsync(db, inSeason); + List 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 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 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 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 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/ClubTests.cs b/Club12-Backend/API.Tests/ClubTests.cs new file mode 100644 index 0000000..b63cb7b --- /dev/null +++ b/Club12-Backend/API.Tests/ClubTests.cs @@ -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; + +/// +/// Verifies the stable cross-season club identity (HU-99): the idempotent +/// backfill collapses same-named season teams onto a single +/// , and club history resolves every per-season team through +/// the existing join. Exercises the +/// real through DI scopes against the +/// CustomWebApplicationFactory's SQLite-backed ApplicationDBContext, matching +/// this project's established integration-test style. +/// +public class ClubTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public ClubTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// 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. + /// + [Fact] + public async Task Backfill_LinksSameNamedTeamsToOneClub_AndSeparatesDistinctNames() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// The backfill is idempotent: a second run creates no new clubs and links + /// no additional teams (every team already carries a ClubId). + /// + [Fact] + public async Task Backfill_IsIdempotent_SecondRunIsNoOp() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + 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()); + } + + /// + /// Club history returns the per-season teams that belong to the club, each + /// with the tournaments (seasons) it was registered in. + /// + [Fact] + public async Task GetClubHistory_ReturnsPerSeasonTeamsWithTheirSeasons() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// 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. + /// + [Fact] + public async Task GetClubHistory_OrdersSeasonsByStartDateDescending() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// 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. + /// + [Fact] + public async Task CreateTeam_IsLinkedToAClub_Immediately() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Team created = await teamService.CreateTeamAsync(NewUnsavedTeam($"Newell's {Guid.NewGuid()}")); + + Team persisted = await ReadTeamAsync(created.Id); + Assert.NotNull(persisted.ClubId); + } + + /// + /// 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). + /// + [Fact] + public async Task CreateTeam_WithNameOfExistingClub_JoinsTheSameClub() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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(() => LinkParentAsync(club.Id, club.Id)); + } + + /// + /// 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. + /// + [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(() => LinkParentAsync(child.Id, parent.Id)); + } + + /// + /// The reverse of the previous rule: a club that already has squads of its + /// own can't become a squad of another club. + /// + [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( + () => 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); + } + + /// + /// 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. + /// + [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(() => 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())); + } + + /// + /// Deleting a club with per-season teams would orphan their history, so it is rejected. + /// + [Fact] + public async Task DeleteClub_RejectsWhenItHasTeams() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Team team = await SeedTeamAsync(db, name: $"Has Team {Guid.NewGuid()}"); + await BackfillAsync(); + Team linked = await ReadTeamAsync(team.Id); + + await Assert.ThrowsAsync( + () => DeleteClubAsync(linked.ClubId!.Value)); + } + + /// + /// Deleting a club with linked squad clubs would orphan them, so it is rejected. + /// + [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(() => DeleteClubAsync(parent.Id)); + } + + [Fact] + public async Task GetAllClubs_ReturnsEveryClubOrderedByName() + { + string suffix = Guid.NewGuid().ToString(); + await SeedClubAsync($"Zeta {suffix}"); + await SeedClubAsync($"Alfa {suffix}"); + + IReadOnlyList clubs = await GetAllClubsAsync(); + + List 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 SeedClubAsync(string name) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + Club club = new() + { + Name = name, + Slug = $"club-{Guid.NewGuid()}", + CreatedBy = "test", + }; + + db.Clubs.Add(club); + await db.SaveChangesAsync(); + + return club; + } + + private async Task LinkParentAsync(Guid childClubId, Guid parentClubId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + return await clubService.LinkClubToParentAsync(childClubId, parentClubId); + } + + private async Task UnlinkParentAsync(Guid childClubId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + return await clubService.UnlinkClubParentAsync(childClubId); + } + + private async Task RenameClubAsync(Guid clubId, string name) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + return await clubService.RenameClubAsync(clubId, name); + } + + private async Task DeleteClubAsync(Guid clubId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + await clubService.DeleteClubAsync(clubId); + } + + private async Task> GetAllClubsAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + 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 BackfillAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + return await clubService.BackfillClubsAsync(); + } + + private async Task GetHistoryAsync(string idOrSlug) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + return await clubService.GetClubHistoryAsync(idOrSlug); + } + + private async Task ReadTeamAsync(Guid teamId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + return await db.Teams.AsNoTracking().SingleAsync(t => t.Id == teamId); + } + + private async Task CountClubsAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + return await db.Clubs.AsNoTracking().CountAsync(); + } + + private static async Task 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/ContactFieldValidationTests.cs b/Club12-Backend/API.Tests/ContactFieldValidationTests.cs new file mode 100644 index 0000000..fa0e4a0 --- /dev/null +++ b/Club12-Backend/API.Tests/ContactFieldValidationTests.cs @@ -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; + +/// +/// 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). +/// +public class ContactFieldValidationTests +{ + private static IReadOnlyList Validate(object model) + { + List 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(), + }; +} diff --git a/Club12-Backend/API.Tests/CrossCupGroupSeederTests.cs b/Club12-Backend/API.Tests/CrossCupGroupSeederTests.cs new file mode 100644 index 0000000..5899368 --- /dev/null +++ b/Club12-Backend/API.Tests/CrossCupGroupSeederTests.cs @@ -0,0 +1,110 @@ +using Application.Utils.Helper.Playoff; + +using Domain.Entities.Models; + +namespace API.Tests; + +/// +/// 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. +/// +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 groupA = [Pos(a1, points: 6, wins: 3, diff: 30, pointsFor: 100), Pos(Guid.NewGuid(), 4, 2, 10, 80)]; + IReadOnlyList groupB = [Pos(b1, points: 6, wins: 3, diff: 20, pointsFor: 90), Pos(Guid.NewGuid(), 4, 2, 5, 70)]; + IReadOnlyList groupC = [Pos(c1, points: 4, wins: 2, diff: 40, pointsFor: 120), Pos(Guid.NewGuid(), 2, 1, 0, 60)]; + + List 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 groupA = [Pos(weak, points: 6, wins: 3, diff: 20, pointsFor: 80)]; + IReadOnlyList groupB = [Pos(strong, points: 6, wins: 3, diff: 20, pointsFor: 95)]; + + List 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 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 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 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 onlyGroup = [Pos(Guid.NewGuid(), points: 6, wins: 3, diff: 30, pointsFor: 100)]; + + Assert.Throws( + () => CrossCupGroupSeeder.ResolveSeedOrder([onlyGroup], qualifiersPerGroup: 1)); + } + + [Fact] + public void ResolveSeedOrder_QualifiersPerGroupLessThanOne_Throws() + { + IReadOnlyList groupA = [Pos(Guid.NewGuid(), 6, 3, 30, 100)]; + IReadOnlyList groupB = [Pos(Guid.NewGuid(), 6, 3, 30, 100)]; + + Assert.Throws( + () => 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, + }; + } +} diff --git a/Club12-Backend/API.Tests/CrossCupMultiGroupBracketGenerationTests.cs b/Club12-Backend/API.Tests/CrossCupMultiGroupBracketGenerationTests.cs new file mode 100644 index 0000000..47e3591 --- /dev/null +++ b/Club12-Backend/API.Tests/CrossCupMultiGroupBracketGenerationTests.cs @@ -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; + +/// +/// End-to-end backend proof for HU-110 (wave B): a cross-division cup with +/// SEVERAL internal 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. +/// +public class CrossCupMultiGroupBracketGenerationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public CrossCupMultiGroupBracketGenerationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// Groups of 4, 4 and 3 with K = 1. Pooled winners a0 (Diff +120), b0 + /// (Diff +60) and c0 (4 pts) order a0 > b0 > c0. Three seeds pad to a + /// 4-team bracket, so the whole pipeline must generate exactly 2 first-round + /// matches and seed a0 into the BYE, b0 vs c0 into the other. + /// + [Fact] + public async Task CrossCupMultiGroup_KOne_GeneratesTwoBracketMatchesAndSeedsPooledQualifiers() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedCrossCupDivisionAsync(db, tournament, qualifiersPerGroup: 1); + + List groupA = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 60); + List groupB = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 80); + List groupC = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 3, winnerScore: 90, loserScore: 85); + + Stage bracket = await CreateBracketStageAsync(stageService, division, tournament); + + List generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id); + + // Dynamic sizing: 3 pooled qualifiers -> next power of two (4) / 2 = 2. + Assert.Equal(2, generated.Count); + + List seeded = await stageService.SeedKnockoutStageAsync(bracket.Id); + List 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); + } + + /// + /// 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 + /// '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. + /// + [Fact] + public async Task CrossCupMultiGroup_KTwo_SizesBracketFromPooledQualifiers_NotStageType() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedCrossCupDivisionAsync(db, tournament, qualifiersPerGroup: 2); + + List groupA = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 60); + List groupB = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 4, winnerScore: 100, loserScore: 80); + List groupC = await BuildAndPlayGroupAsync(db, stageService, matchService, division, tournament, size: 3, winnerScore: 90, loserScore: 85); + + Stage bracket = await CreateBracketStageAsync(stageService, division, tournament); + + List 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 seeded = await stageService.SeedKnockoutStageAsync(bracket.Id); + List 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 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); + } + + /// + /// 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. + /// + [Fact] + public async Task RegularDivision_KeepsFixedSemiFinalBracketSize() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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 generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id); + + Assert.Equal(KnockoutMatchCount.SemiFinal, generated.Count); + } + + /// + /// 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. + /// + [Fact] + public async Task SingleGroupCrossCup_KeepsFixedSemiFinalBracketSize() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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 generated = await matchService.CreateAutomatedMatchesAsync(bracket.Id); + + Assert.Equal(KnockoutMatchCount.SemiFinal, generated.Count); + } + + private static async Task 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 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; + } + + /// + /// Creates a Group stage through the real service (exercising the relaxed + /// multi-group guard), assigns fresh teams, generates + /// its round-robin fixture, then finishes every match so the lower-ordered + /// team of each pair wins by -. Returns the teams best-first, so teams[0] wins + /// the group. + /// + private static async Task> BuildAndPlayGroupAsync( + ApplicationDBContext db, + IStageService stageService, + IMatchService matchService, + Division division, + Tournament tournament, + int size, + int winnerScore, + int loserScore) + { + List 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 rankByTeamId = teams + .Select((team, index) => (team.Id, index)) + .ToDictionary(pair => pair.Id, pair => pair.index); + + List 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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; + } +} diff --git a/Club12-Backend/API.Tests/CustomWebApplicationFactory.cs b/Club12-Backend/API.Tests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..6c90a6b --- /dev/null +++ b/Club12-Backend/API.Tests/CustomWebApplicationFactory.cs @@ -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; + +/// +/// 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. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly SqliteConnection _appConnection; + private readonly SqliteConnection _identityConnection; + + /// + /// 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. + /// + 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(services, _appConnection); + ReplaceDbContext(services, _identityConnection); + }); + } + + private static void ReplaceDbContext(IServiceCollection services, SqliteConnection connection) + where TContext : DbContext + { + ServiceDescriptor? descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor is not null) + { + services.Remove(descriptor); + } + + services.AddDbContext(options => options.UseSqlite(connection)); + + using ServiceProvider provider = services.BuildServiceProvider(); + using IServiceScope scope = provider.CreateScope(); + TContext db = scope.ServiceProvider.GetRequiredService(); + + db.Database.EnsureCreated(); + MarkAllMigrationsAsApplied(db); + } + + /// + /// 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. + /// + private static void MarkAllMigrationsAsApplied(DbContext db) + { + IHistoryRepository historyRepository = db.GetService(); + + 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); + } + } + + /// + /// 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. + /// + 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(); + } + } +} diff --git a/Club12-Backend/API.Tests/DataMaintenanceAuthorizationTests.cs b/Club12-Backend/API.Tests/DataMaintenanceAuthorizationTests.cs new file mode 100644 index 0000000..cdf21f1 --- /dev/null +++ b/Club12-Backend/API.Tests/DataMaintenanceAuthorizationTests.cs @@ -0,0 +1,115 @@ +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using System.Net; + +namespace API.Tests; + +/// +/// Proves both data-maintenance endpoints are staff-only (Admin or Owner), +/// mirroring the pattern in AuthorizationGatingTests.cs. +/// +public class DataMaintenanceAuthorizationTests : IClassFixture +{ + 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(); + + // 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()); + } +} diff --git a/Club12-Backend/API.Tests/DataMaintenanceServiceTests.cs b/Club12-Backend/API.Tests/DataMaintenanceServiceTests.cs new file mode 100644 index 0000000..9575caa --- /dev/null +++ b/Club12-Backend/API.Tests/DataMaintenanceServiceTests.cs @@ -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; + +/// +/// 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. +/// +public class DataMaintenanceServiceTests : IClassFixture +{ + 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + // 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(); + Infrastructure.Identity.IdentityAppDbContext identityDb = + scope.ServiceProvider.GetRequiredService(); + + int usersBefore = await identityDb.Users.CountAsync(); + + await service.WipeSampleDataAsync(); + + int usersAfter = await identityDb.Users.CountAsync(); + + Assert.Equal(usersBefore, usersAfter); + } + + /// + /// 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. + /// + private static async Task<(int Tournaments, int Divisions, int Teams, int Players, int Matches, int PlayerSanctions)> + BuildAndSaveSampleFixtureAsync(ApplicationDBContext db) + { + List 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + await service.WipeSampleDataAsync(); + await service.SeedSampleDataAsync(); + + List 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + await service.WipeSampleDataAsync(); + await service.SeedSampleDataAsync(); + + List 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> rounds = [.. primeraGroup.Matches.GroupBy(m => m.Round!.Value)]; + Assert.Equal(7, rounds.Count); + foreach (IGrouping round in rounds) + { + List 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + await service.WipeSampleDataAsync(); + await service.SeedSampleDataAsync(); + + List 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 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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 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 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 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 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 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().CountAsync(r => r.TournamentId == apertura.Id)); + Assert.Equal(128, await db.Set().CountAsync(r => r.TournamentId == apertura.Id)); + } + + [Fact] + public async Task SeedSampleDataAsync_OnEmptyDatabase_EveryGroupStageMatchIsFinished() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IDataMaintenanceService service = scope.ServiceProvider.GetRequiredService(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + await service.WipeSampleDataAsync(); + await service.SeedSampleDataAsync(); + + List 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 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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + await service.WipeSampleDataAsync(); + await service.SeedSampleDataAsync(); + + await Assert.ThrowsAsync(() => 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(); + + await service.WipeSampleDataAsync(); + await service.SeedSampleDataAsync(); + await service.WipeSampleDataAsync(); + + DataSeedResult result = await service.SeedSampleDataAsync(); + + Assert.Equal(2, result.Tournaments); + } +} diff --git a/Club12-Backend/API.Tests/DeploymentContractTests.cs b/Club12-Backend/API.Tests/DeploymentContractTests.cs new file mode 100644 index 0000000..e05c10f --- /dev/null +++ b/Club12-Backend/API.Tests/DeploymentContractTests.cs @@ -0,0 +1,124 @@ +using System.Text.RegularExpressions; + +namespace API.Tests; + +/// +/// 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. +/// +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")); + + /// + /// Returns the text of a single top-level compose service block (the lines + /// from ` <name>:` up to the next 2-space-indented key or a column-0 key). + /// + 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 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"); + } +} diff --git a/Club12-Backend/API.Tests/DivisionGroupStandingsTests.cs b/Club12-Backend/API.Tests/DivisionGroupStandingsTests.cs new file mode 100644 index 0000000..35e6a1e --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionGroupStandingsTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionGroupStandingsTests : IClassFixture +{ + 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + List group1Teams = await SeedTeamsAsync(db, tournament, 2); + List 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 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + List groups = await divisionService.GetGroupStandingsByDivisionIdAsync(division.Id); + + Assert.Empty(groups); + } + + private static async Task 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/DivisionListPositionsTests.cs b/Club12-Backend/API.Tests/DivisionListPositionsTests.cs new file mode 100644 index 0000000..792daa9 --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionListPositionsTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionListPositionsTests : IClassFixture +{ + 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(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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(); + 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? Positions); + private sealed record PaginatedDivisionsDto(List Items); + + private static async Task 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/DivisionRosterControllerTests.cs b/Club12-Backend/API.Tests/DivisionRosterControllerTests.cs new file mode 100644 index 0000000..e4d3998 --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionRosterControllerTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionRosterControllerTests : IClassFixture +{ + 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(); + 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(); + 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(); + Division division = await SeedDivisionAsync(db); + List 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? roster = await response.Content.ReadFromJsonAsync>(); + 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(); + 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(); + 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? stages = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(stages); + Assert.Single(stages!); + } + + [Fact] + public async Task AutoDistributeRoster_StaffRole_ReturnsNoContent() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + 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(); + 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 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 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 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> SeedTeamsAsync(ApplicationDBContext db, Guid tournamentId, int count) + { + List 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); +} diff --git a/Club12-Backend/API.Tests/DivisionRosterServiceTests.cs b/Club12-Backend/API.Tests/DivisionRosterServiceTests.cs new file mode 100644 index 0000000..35923f4 --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionRosterServiceTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionRosterServiceTests : IClassFixture +{ + 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedTeamsAsync(db, tournament, 2); + + List 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Team team = (await SeedTeamsAsync(db, tournament, 1))[0]; + + await Assert.ThrowsAsync( + () => 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// The roster edit lock mirrors StageService's structure lock so a locked + /// tournament can't be unenrolled from underneath its already-generated matches. + /// + [Fact] + public async Task UnenrollTeamsAsync_TournamentStructureLocked_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IDivisionRosterService rosterService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedStageAsync(db, division, StageType.Group, "Grupo A"); + List 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 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 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 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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(); + } +} diff --git a/Club12-Backend/API.Tests/DivisionSlugTests.cs b/Club12-Backend/API.Tests/DivisionSlugTests.cs new file mode 100644 index 0000000..aef55b6 --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionSlugTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionSlugTests : IClassFixture +{ + 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + 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(); + 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 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; + } +} diff --git a/Club12-Backend/API.Tests/DivisionStandingsTests.cs b/Club12-Backend/API.Tests/DivisionStandingsTests.cs new file mode 100644 index 0000000..75107df --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionStandingsTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionStandingsTests : IClassFixture +{ + 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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 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(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + List positions = await divisionService.GetPositionsByDivisionIdAsync(division.Id); + + Assert.Empty(positions); + } + + private static async Task 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 SeedGroupStageAsync(ApplicationDBContext db, Division division, Tournament tournament) + { + return await SeedEliminationStageAsync(db, division, tournament, StageType.Group, isElimination: false); + } + + private static async Task 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(); + } +} diff --git a/Club12-Backend/API.Tests/DivisionTeamRegistrationTests.cs b/Club12-Backend/API.Tests/DivisionTeamRegistrationTests.cs new file mode 100644 index 0000000..0e2dcd3 --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionTeamRegistrationTests.cs @@ -0,0 +1,266 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// 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. +/// +public class DivisionTeamRegistrationTests : IClassFixture +{ + 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(); + + 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 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(); + + 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 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(); + + 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 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(); + + 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 registrations = await ReadRegistrationsAsync(db, team.Id); + Assert.Single(registrations); + } + + /// + /// 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. + /// + 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(x.TeamId, x.DivisionId)) + .Distinct() + .ToListAsync(); + + HashSet<(Guid TeamId, Guid DivisionId)> existingPairs = [.. await db.DivisionTeamRegistrations + .Select(r => new ValueTuple(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> ReadRegistrationsAsync(ApplicationDBContext db, Guid teamId) + { + return await db.DivisionTeamRegistrations + .AsNoTracking() + .Where(r => r.TeamId == teamId) + .ToListAsync(); + } + + private static async Task 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 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/DivisionTournamentReassignmentTests.cs b/Club12-Backend/API.Tests/DivisionTournamentReassignmentTests.cs new file mode 100644 index 0000000..03c6e1f --- /dev/null +++ b/Club12-Backend/API.Tests/DivisionTournamentReassignmentTests.cs @@ -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; + +/// +/// 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. +/// +public class DivisionTournamentReassignmentTests : IClassFixture +{ + 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(); + + 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(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + 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(); + 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(); + + Tournament tournament = await SeedTournamentAsync(seedDb); + Division division = await SeedDivisionAsync(seedDb, tournament); + + using IServiceScope actScope = _factory.Services.CreateScope(); + IDivisionService divisionService = actScope.ServiceProvider.GetRequiredService(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + 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(); + + Tournament sourceTournament = await SeedTournamentAsync(seedDb); + Division division = await SeedDivisionAsync(seedDb, sourceTournament); + + using IServiceScope actScope = _factory.Services.CreateScope(); + IDivisionService divisionService = actScope.ServiceProvider.GetRequiredService(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/EntitySlugLookupTests.cs b/Club12-Backend/API.Tests/EntitySlugLookupTests.cs new file mode 100644 index 0000000..b424994 --- /dev/null +++ b/Club12-Backend/API.Tests/EntitySlugLookupTests.cs @@ -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; + +/// +/// 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). +/// +public class EntitySlugLookupTests : IClassFixture +{ + 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(); + ITeamService service = scope.ServiceProvider.GetRequiredService(); + + 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(); + ITournamentService service = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchService service = scope.ServiceProvider.GetRequiredService(); + + 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/FileExtensionsTests.cs b/Club12-Backend/API.Tests/FileExtensionsTests.cs new file mode 100644 index 0000000..2e7355d --- /dev/null +++ b/Club12-Backend/API.Tests/FileExtensionsTests.cs @@ -0,0 +1,84 @@ +using API.Utils; + +using Microsoft.AspNetCore.Http; + +using System.Text; + +namespace API.Tests; + +/// +/// Covers , 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. +/// +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, + }; + } +} diff --git a/Club12-Backend/API.Tests/FullTournamentCreationTests.cs b/Club12-Backend/API.Tests/FullTournamentCreationTests.cs new file mode 100644 index 0000000..2a25ce8 --- /dev/null +++ b/Club12-Backend/API.Tests/FullTournamentCreationTests.cs @@ -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; + +/// +/// 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. +/// +public class FullTournamentCreationTests : IClassFixture +{ + 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(); + + 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(); + + 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 divisionIds = tournament.Divisions.Select(d => d.Id).ToList(); + int mappingCount = await db.DivisionPlayoffMappings.CountAsync(m => divisionIds.Contains(m.DivisionId)); + Assert.Equal(1, mappingCount); + } + } + + /// + /// A division name past DivisionFieldLengths.NameMaxLength must fail model validation. + /// + [Fact] + public void CreateFullDivisionRequest_NameOverMaxLength_FailsValidation() + { + CreateFullDivisionRequest divisionRequest = new() + { + Name = new string('a', Application.Utils.Constants.Validation.DivisionFieldLengths.NameMaxLength + 1), + Stages = [], + }; + + List 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))); + } + + /// + /// Stage.Order is assigned from each stage's position in the request's own Stages list. + /// + [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(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + await tournamentService.CreateFullTournamentAsync(request); + + Division division = await db.Divisions.SingleAsync(d => d.Name == $"{name} - Zona"); + List 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(); + tournamentsBefore = await db.Tournaments.CountAsync(); + } + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ITournamentService tournamentService = + scope.ServiceProvider.GetRequiredService(); + + // 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( + () => tournamentService.CreateFullTournamentAsync(request)); + } + + // Nothing partial survives: no tournament, no divisions, no stages. + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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))); + } + } +} diff --git a/Club12-Backend/API.Tests/GlobalExceptionHandlerTests.cs b/Club12-Backend/API.Tests/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..1a8c4d9 --- /dev/null +++ b/Club12-Backend/API.Tests/GlobalExceptionHandlerTests.cs @@ -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 InvokeAsync(Exception exception) + { + GlobalExceptionHandler handler = new(NullLogger.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(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()); + } +} diff --git a/Club12-Backend/API.Tests/HealthEndpointsTests.cs b/Club12-Backend/API.Tests/HealthEndpointsTests.cs new file mode 100644 index 0000000..8d9b661 --- /dev/null +++ b/Club12-Backend/API.Tests/HealthEndpointsTests.cs @@ -0,0 +1,113 @@ +using System.Net; + +namespace API.Tests; + +/// +/// Proves the two container-probe endpoints defined by the +/// service-health-endpoint spec: `/health` (liveness — process is up, no +/// dependency checks) and `/health/ready` (readiness — DB connectivity via +/// AddDbContextCheck<ApplicationDBContext>). Both endpoints must be +/// anonymous. Real HTTP round trips through CustomWebApplicationFactory, +/// same pattern as SmokeTests/AuthorizationGatingTests. +/// +public class HealthEndpointsTests : IClassFixture +{ + 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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } +} diff --git a/Club12-Backend/API.Tests/HistoricalStatisticsTests.cs b/Club12-Backend/API.Tests/HistoricalStatisticsTests.cs new file mode 100644 index 0000000..46bbe45 --- /dev/null +++ b/Club12-Backend/API.Tests/HistoricalStatisticsTests.cs @@ -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; + +/// +/// 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. +/// +public class HistoricalStatisticsTests : IClassFixture +{ + 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(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 10, points2027: 7); + + (IEnumerable 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 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(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + SeededPlayer seed = await SeedPlayerAcrossTwoSeasonsAsync(db, points2026: 10, points2027: 7); + + (IEnumerable 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(); + IStatisticsService statisticsService = scope.ServiceProvider.GetRequiredService(); + + // 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 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(); + IStatisticsService statisticsService = scope.ServiceProvider.GetRequiredService(); + + 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 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); + + /// + /// 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. + /// + private static async Task 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 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 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 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 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/MagicLinkAccountFlowTests.cs b/Club12-Backend/API.Tests/MagicLinkAccountFlowTests.cs new file mode 100644 index 0000000..750c3a9 --- /dev/null +++ b/Club12-Backend/API.Tests/MagicLinkAccountFlowTests.cs @@ -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; + +/// +/// 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. +/// +public class MagicLinkAccountFlowTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public MagicLinkAccountFlowTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// Capturing IEmailService test double — records the last link of each kind. + 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 + { + ["Frontend:ActivationUrl"] = "http://localhost:3001/auth/activate", + ["Frontend:PasswordResetUrl"] = "http://localhost:3001/auth/password-reset", + }) + .Build(); + + private static (IdentityAuthenticationService service, UserManager userManager, CapturingEmailService email) + BuildService(IServiceScope scope) + { + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IAuthService authService = scope.ServiceProvider.GetRequiredService(); + 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 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 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 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(() => + 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); + } +} diff --git a/Club12-Backend/API.Tests/MaintenanceAuthorizationTests.cs b/Club12-Backend/API.Tests/MaintenanceAuthorizationTests.cs new file mode 100644 index 0000000..0e54c87 --- /dev/null +++ b/Club12-Backend/API.Tests/MaintenanceAuthorizationTests.cs @@ -0,0 +1,115 @@ +using Application.Interfaces.Backup; + +using Domain.Enums; + +using Microsoft.Extensions.DependencyInjection; + +using System.Net; + +namespace API.Tests; + +/// +/// 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. +/// +public class MaintenanceAuthorizationTests : IClassFixture +{ + 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(); + 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(); + 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(); + 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); + } +} diff --git a/Club12-Backend/API.Tests/MatchControllerVenueConflictTests.cs b/Club12-Backend/API.Tests/MatchControllerVenueConflictTests.cs new file mode 100644 index 0000000..21f106d --- /dev/null +++ b/Club12-Backend/API.Tests/MatchControllerVenueConflictTests.cs @@ -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; + +/// +/// Proves the venue schedule conflict guard (already shipped on +/// +/// and wired into UpdateMatchDate) is uniformly enforced on the two +/// previously-unguarded write paths: POST /api/matches and +/// PUT /api/matches/{id}/suspend. +/// +public class MatchControllerVenueConflictTests : IClassFixture +{ + 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(); + (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); + } + + /// + /// 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`. + /// + [Fact] + public async Task CreateMatch_CollidingVenueAndTime_ReturnsProblemDetailsShapeWithDetailField() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + (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(); + + 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(); + (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(); + (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(); + (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(); + (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(); + (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(); + (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); + } + + /// + /// 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. + /// + [Fact] + public async Task UpdateMatchDate_TeamsNotAssignedToStage_ReturnsProblemDetailsShapeWithDetailField() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + (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(); + + 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(); + (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(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(problem); + Assert.Equal(ErrorMessages.Match.CannotUpdateStartedOrFinished, problem!.Detail); + } + + private static async Task 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/MatchRescheduleMappingTests.cs b/Club12-Backend/API.Tests/MatchRescheduleMappingTests.cs new file mode 100644 index 0000000..422075d --- /dev/null +++ b/Club12-Backend/API.Tests/MatchRescheduleMappingTests.cs @@ -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; + +/// +/// 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. +/// +public class MatchRescheduleMappingTests : IClassFixture +{ + 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(); + + 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); + } +} diff --git a/Club12-Backend/API.Tests/MatchResultAndSheetTests.cs b/Club12-Backend/API.Tests/MatchResultAndSheetTests.cs new file mode 100644 index 0000000..d73e043 --- /dev/null +++ b/Club12-Backend/API.Tests/MatchResultAndSheetTests.cs @@ -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; + +/// +/// 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. +/// +public class MatchResultAndSheetTests : IClassFixture +{ + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Seeded seeded = await SeedMatchAsync(db, StageType.Group); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Seeded seeded = await SeedMatchAsync(db, StageType.Final); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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(); + + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Seeded seeded = await SeedMatchAsync(db, StageType.Group); + + await Assert.ThrowsAsync( + () => 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + 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 created = await statisticService.LoadTeamMatchSheetAsync(request); + Assert.Equal(2, created.Count); + + (IEnumerable items, _) = + await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id }); + + List 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + 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 items, _) = + await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id }); + List 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + // 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( + () => 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(); + + 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + 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 items, _) = + await scorerRepository.GetPlayerScoresAsync(new GetScorerFilteredRequest { MatchId = seeded.Match.Id }); + List 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 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 ReloadMatchAsync(ApplicationDBContext db, Guid matchId) + { + db.ChangeTracker.Clear(); + return await db.Matches.FindAsync(matchId) + ?? throw new InvalidOperationException("Seeded match disappeared."); + } + + private static async Task 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 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 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/MatchRosterEligibilityMappingTests.cs b/Club12-Backend/API.Tests/MatchRosterEligibilityMappingTests.cs new file mode 100644 index 0000000..a278af2 --- /dev/null +++ b/Club12-Backend/API.Tests/MatchRosterEligibilityMappingTests.cs @@ -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; + +/// +/// A match's roster () must +/// reflect each player's REAL season-scoped medical-record status. Found live: +/// CreateMap<Player, PublicPlayerResponse>() has no member mapping +/// for MedicalRecordStatus/IsHabilitado/JerseyNumber — those live on +/// , not — 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 +/// CreateMap<Match, DetailedMatchResponse>() that resolves each +/// roster player's matching registration for the match's tournament. +/// +public class MatchRosterEligibilityMappingTests : IClassFixture +{ + 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(); + + 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(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", + }; +} diff --git a/Club12-Backend/API.Tests/MatchScorersMappingTests.cs b/Club12-Backend/API.Tests/MatchScorersMappingTests.cs new file mode 100644 index 0000000..0b7cb9c --- /dev/null +++ b/Club12-Backend/API.Tests/MatchScorersMappingTests.cs @@ -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; + +/// +/// 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. +/// +public class MatchScorersMappingTests +{ + private static IMapper CreateMapper() + { + MapperConfiguration configuration = new( + cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }, + 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(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); + } + + /// + /// 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. + /// + [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(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, + }; + } +} diff --git a/Club12-Backend/API.Tests/MatchSeriesServiceTests.cs b/Club12-Backend/API.Tests/MatchSeriesServiceTests.cs new file mode 100644 index 0000000..95908cf --- /dev/null +++ b/Club12-Backend/API.Tests/MatchSeriesServiceTests.cs @@ -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; + +/// +/// 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. +/// +public class MatchSeriesServiceTests : IClassFixture +{ + 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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( + () => 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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( + () => 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List 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( + () => 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + MatchSeries series = await SeedDecidableSeriesAsync(db, matchSeriesService, bestOf: 1); + + await matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(1), null); + + await Assert.ThrowsAsync( + () => 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => matchSeriesService.AddGameToSeriesAsync(series.Id, DateTime.UtcNow.AddDays(2), null)); + } + + private static async Task SeedDecidableSeriesAsync(ApplicationDBContext db, IMatchSeriesService matchSeriesService, int bestOf) + { + Tournament tournament = await SeedTournamentAsync(db); + List 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/MatchServiceGenerationTests.cs b/Club12-Backend/API.Tests/MatchServiceGenerationTests.cs new file mode 100644 index 0000000..a8ec403 --- /dev/null +++ b/Club12-Backend/API.Tests/MatchServiceGenerationTests.cs @@ -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; + +/// +/// Characterization tests for group-stage match creation in MatchService. +/// +public class MatchServiceGenerationTests : IClassFixture +{ + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (_, Division division) = await SeedDivisionAsync(db); + List stages = await SeedGroupStagesAsync(db, division, groupCount: 1); + + await Assert.ThrowsAsync( + () => matchService.CreateAutomatedMatchesAsync(stages[0].Id)); + } + + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_TeamsNotDivisibleByGroupCount_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (Tournament tournament, Division division) = await SeedDivisionAsync(db); + await SeedTeamsAsync(db, 10, tournament.Id); + List stages = await SeedGroupStagesAsync(db, division, groupCount: 3); + + await Assert.ThrowsAsync( + () => matchService.CreateAutomatedMatchesAsync(stages[0].Id)); + } + + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_FewerThanTwoTeamsPerGroup_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (Tournament tournament, Division division) = await SeedDivisionAsync(db); + await SeedTeamsAsync(db, 2, tournament.Id); + List stages = await SeedGroupStagesAsync(db, division, groupCount: 2); + + await Assert.ThrowsAsync( + () => matchService.CreateAutomatedMatchesAsync(stages[0].Id)); + } + + /// + /// 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. + /// + [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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup, groupCount); + + List 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)); + } + + /// + /// 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. + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_SingleMatch_IsRoundOneOnFirstSunday() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + DateTime start = DateTime.UtcNow.Date; + DateTime end = start.AddDays(10); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 2, groupCount: 1, start, end); + + List matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id); + + Assert.Single(matches); + Assert.Equal(1, matches[0].Round); + Assert.Equal(FirstSundayOnOrAfter(start), matches[0].MatchDate); + } + + /// + /// 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. + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_OddTeams_OneMatchPerRoundOnConsecutiveSundays() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + DateTime start = DateTime.UtcNow.Date; + DateTime end = start.AddDays(30); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 3, groupCount: 1, start, end); + + List matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id); + List 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); + } + } + + /// + /// 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. + /// + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (_, _, List stages, List teams) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1); + + List 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); + } + } + + /// + /// 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. + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_ExplicitAssignmentDiffersFromTournamentTotal_UsesOnlyAssignedTeams() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (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 zoneATeams = await SeedTeamsAsync(db, 4, tournament.Id); + List zoneBTeams = await SeedTeamsAsync(db, 4, tournament.Id); + + List zoneAStages = await SeedGroupStagesAsync(db, zoneA, groupCount: 1); + List 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 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 2); + + List matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id); + + Assert.All(matches, m => Assert.Null(m.HomeTeamId)); + } + + /// + /// 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. + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_UnseededMatchesShareSameDate_AssignsUniqueSlugsViaSuffix() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + DateTime sameDate = DateTime.UtcNow.Date; + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 2, sameDate, sameDate); + + List 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()); + } + + /// + /// 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. + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_EndDateBeforeStartDate_StillSchedulesFromStartSunday() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + DateTime start = DateTime.UtcNow.Date; + DateTime end = start.AddDays(-1); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 2, groupCount: 1, start, end); + + List matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id); + + Assert.Single(matches); + Assert.Equal(1, matches[0].Round); + Assert.Equal(FirstSundayOnOrAfter(start), matches[0].MatchDate); + } + + /// + /// 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. + /// + [Fact] + public async Task SuspendMatchAsync_MarksSuspendedAndMovesDate_ButKeepsRoundAndRestOfFixture() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1); + + List 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 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 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); + } + } + + /// + /// 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. + /// + [Fact] + public async Task GetStageMatchesByRoundAsync_OrdersMatchesByRound() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1); + await matchService.CreateAutomatedMatchesAsync(stages[0].Id); + + List 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 rounds = [.. byRound.Select(m => m.Round!.Value)]; + Assert.Equal([1, 1, 2, 2, 3, 3], rounds); + } + + /// + /// HU-111: a group-stage fixture is laid out by jornada, weekly. Every match + /// sharing a 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). + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_GroupStage_SameRoundSharesDate_RoundsAreWeekly_NeutralTimeNoVenue() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + DateTime start = new(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc); // Fixed anchor keeps the assertions deterministic. + (_, _, List stages, _) = await SeedGroupStageWithTeamsAsync(db, teamsPerGroup: 4, groupCount: 1, start, start.AddDays(60)); + + List matches = await matchService.CreateAutomatedMatchesAsync(stages[0].Id); + + // 4 teams => 3 rounds of 2 matches each. + List> 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 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)); + } + + /// + /// 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. + /// + [Fact] + public async Task CreateAutomatedMatchesAsync_RegularZoneAndCrossCup_SameTournament_NeverShareADate() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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 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 cupTeams = await SeedTeamsAsync(db, 4, tournament.Id); + await AssignTeamsToStageAsync(db, cupStage, cupTeams); + + List zoneMatches = await matchService.CreateAutomatedMatchesAsync(zoneStage.Id); + List 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 zoneDates = [.. zoneMatches.Select(m => m.MatchDate)]; + HashSet cupDates = [.. cupMatches.Select(m => m.MatchDate)]; + Assert.Empty(zoneDates.Intersect(cupDates)); + } + + private static async Task 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 teams) + { + db.StageTeamMatches.AddRange(teams.Select(t => new StageTeamMatch + { + StageId = stage.Id, + TeamId = t.Id, + CreatedBy = "test", + })); + await db.SaveChangesAsync(); + } + + private static async Task> SeedTeamsAsync(ApplicationDBContext db, int count, Guid? tournamentId = null) + { + List 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> 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 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 stages, List teams)> + SeedGroupStageWithTeamsAsync( + ApplicationDBContext db, int teamsPerGroup, int groupCount, + DateTime? startDate = null, DateTime? endDate = null) + { + (Tournament tournament, Division division) = await SeedDivisionAsync(db); + List teams = await SeedTeamsAsync(db, teamsPerGroup * groupCount, tournament.Id); + List stages = await SeedGroupStagesAsync(db, division, groupCount, startDate, endDate); + + return (tournament, division, stages, teams); + } +} diff --git a/Club12-Backend/API.Tests/MatchServiceVenueScheduleConflictTests.cs b/Club12-Backend/API.Tests/MatchServiceVenueScheduleConflictTests.cs new file mode 100644 index 0000000..2077897 --- /dev/null +++ b/Club12-Backend/API.Tests/MatchServiceVenueScheduleConflictTests.cs @@ -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; + +/// +/// Characterization tests for the already-shipped +/// (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 CreateMatch and SuspendMatch. +/// +public class MatchServiceVenueScheduleConflictTests : IClassFixture +{ + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/MedicalRecordDownloadTests.cs b/Club12-Backend/API.Tests/MedicalRecordDownloadTests.cs new file mode 100644 index 0000000..f2e9625 --- /dev/null +++ b/Club12-Backend/API.Tests/MedicalRecordDownloadTests.cs @@ -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; + +/// +/// 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). +/// +public class MedicalRecordDownloadTests +{ + [Fact] + public async Task Storage_StoreThenDownload_RoundTripsBytes() + { + InMemoryRawStorage raw = new(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .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 { [fileUrl] = pdf }); + + MedicalRecordController controller = new(service, storage); + + IActionResult result = await controller.DownloadMedicalRecord(playerId, teamId, tournamentId); + + FileContentResult file = Assert.IsType(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(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 RecordUploadAsync( + Guid playerId, Guid teamId, Guid tournamentId, string fileReference, string fileName, string actor) + => Task.FromResult(response); + + public Task ReviewAsync( + Guid playerId, Guid teamId, Guid tournamentId, bool approve, string? reason, string actor) + => Task.FromResult(response); + + public Task GetAsync(Guid playerId, Guid teamId, Guid tournamentId) + => Task.FromResult(response); + } + + private sealed class FakeStorage(Dictionary objects) : IMedicalRecordStorage + { + public Task StoreAsync( + Guid teamId, Guid playerId, string fileName, Stream content, CancellationToken ct = default) + => Task.FromResult("unused"); + + public Task DownloadAsync(string objectPath, CancellationToken ct = default) + => Task.FromResult(objects[objectPath]); + } + + private sealed class InMemoryRawStorage : ISupabaseRawStorage + { + private readonly Dictionary _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 DownloadRawAsync(string objectPath, string? bucket = null) => Task.FromResult(_objects[objectPath]); + + public Task> ListRawAsync(string prefix, string? bucket = null) + => throw new System.NotImplementedException(); + + public Task RemoveRawAsync(string objectPath, string? bucket = null) => throw new System.NotImplementedException(); + } +} diff --git a/Club12-Backend/API.Tests/MedicalRecordEligibilityTests.cs b/Club12-Backend/API.Tests/MedicalRecordEligibilityTests.cs new file mode 100644 index 0000000..88c0564 --- /dev/null +++ b/Club12-Backend/API.Tests/MedicalRecordEligibilityTests.cs @@ -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; + +/// +/// 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. +/// +public class MedicalRecordEligibilityTests : IClassFixture +{ + 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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(); + + await Assert.ThrowsAsync(() => 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + Fixture fx = await SeedRegistrationAsync(db); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + Fixture fx = await SeedFinishedMatchAsync(db, homeScore: 20, medicalStatus: MedicalRecordStatus.Pending); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + + // 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( + () => 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(); + + 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(); + + 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(); + IPlayerStatisticService statisticService = scope.ServiceProvider.GetRequiredService(); + IMedicalRecordService medicalRecordService = scope.ServiceProvider.GetRequiredService(); + + // 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( + () => 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 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 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 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 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 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/MedicalRecordHabilitacionTests.cs b/Club12-Backend/API.Tests/MedicalRecordHabilitacionTests.cs new file mode 100644 index 0000000..b2af164 --- /dev/null +++ b/Club12-Backend/API.Tests/MedicalRecordHabilitacionTests.cs @@ -0,0 +1,179 @@ +using Application.DTOs.MedicalRecord.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +namespace API.Tests; + +/// +/// 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 Approved AND it carries +/// a real (non-legacy) stored file reference. Covers the single Domain +/// predicate (), the two +/// computed IsHabilitado properties that consume it +/// ( and the transient +/// carrier), and the DTO projection +/// (). No DB, no fixture — +/// these are plain object-graph assertions. +/// +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, + }; + } +} diff --git a/Club12-Backend/API.Tests/MedicalRecordSeedTests.cs b/Club12-Backend/API.Tests/MedicalRecordSeedTests.cs new file mode 100644 index 0000000..507f568 --- /dev/null +++ b/Club12-Backend/API.Tests/MedicalRecordSeedTests.cs @@ -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; + +/// +/// Covers Part 3 of medical-records-storage-eligibility: the idempotent, +/// failure-tolerant +/// step, shared by both seed paths (the startup DataSeeder and the +/// admin-triggered DataMaintenanceService). Constructed directly +/// (no DI, no SupabaseHelper at all) since this step never touches +/// team logos — only . +/// +public class MedicalRecordSeedTests : IClassFixture +{ + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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.Instance, storage); + return backfiller.BackfillMedicalRecordsAsync(medicalRecordPath); + } + + private static Task SeedApprovedRegistrationAsync( + ApplicationDBContext db, string? fileUrl) => + SeedRegistrationAsync(db, MedicalRecordStatus.Approved, createdBy: "test", fileUrl); + + private static async Task 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 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 DownloadAsync(string objectPath, CancellationToken ct = default) + => throw new NotImplementedException(); + } + + /// A small real file on disk so File.Exists/ReadAllBytesAsync succeed. + 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); + } + } + } +} diff --git a/Club12-Backend/API.Tests/MedicalRecordStorageTests.cs b/Club12-Backend/API.Tests/MedicalRecordStorageTests.cs new file mode 100644 index 0000000..1b7acee --- /dev/null +++ b/Club12-Backend/API.Tests/MedicalRecordStorageTests.cs @@ -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; + +/// +/// Covers Part 1 of the medical-records storage relocation: the object key +/// shape ({teamId}/{playerId}/{guid}{ext}, no medical-records/ +/// prefix, no tournamentId 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. +/// and image-upload regression (default bucket preserved) live in +/// SupabaseBackupStorageTests and are asserted here only for the +/// bucket-parameterized raw boundary itself. +/// +public class MedicalRecordStorageTests +{ + private static IConfiguration EmptyConfiguration() + { + return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + } + + private static IConfiguration ConfigurationWithBucket(string bucketName) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{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)); + } +} diff --git a/Club12-Backend/API.Tests/MultiGroupSeedingTests.cs b/Club12-Backend/API.Tests/MultiGroupSeedingTests.cs new file mode 100644 index 0000000..6a984a8 --- /dev/null +++ b/Club12-Backend/API.Tests/MultiGroupSeedingTests.cs @@ -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; + +/// +/// 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 . +/// +public class MultiGroupSeedingTests : IClassFixture +{ + 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); + } + + /// + /// 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. + /// + [Fact] + public async Task SeedKnockoutStageAsync_MultiGroupCrossCup_PoolsTopOnePerGroupOrderedByStrength() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 1); + + List groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 100, visitorScore: 70); + List groupB = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 90, visitorScore: 80); + List 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 seeded = await stageService.SeedKnockoutStageAsync(bracket.Id); + List 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); + } + + /// + /// 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). + /// + [Fact] + public async Task SeedKnockoutStageAsync_MultiGroupCrossCup_KTwo_PoolsTopTwoPerGroup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 2); + + List groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 4, homeScore: 100, visitorScore: 70); + List 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 seeded = await stageService.SeedKnockoutStageAsync(bracket.Id); + List 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); + } + + /// + /// 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. + /// + [Fact] + public async Task SeedKnockoutStageAsync_SingleGroupCrossCup_BehavesLikeToday() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true, qualifiersPerGroup: 1); + + List 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 seeded = await stageService.SeedKnockoutStageAsync(bracket.Id); + List 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); + } + + /// + /// Multi-group cross cup where only one group has finished results yields a + /// single pooled qualifier, which is below the two-team seeding floor. + /// + [Fact] + public async Task SeedKnockoutStageAsync_MultiGroupCrossCup_FewerThanTwoQualifiers_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => stageService.SeedKnockoutStageAsync(bracket.Id)); + } + + /// + /// 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. + /// + [Fact] + public async Task SeedKnockoutStageAsync_MultiGroupRegularDivision_PoolsTopOnePerGroup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false, qualifiersPerGroup: 1); + + List groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 2, homeScore: 100, visitorScore: 70); + List 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 seeded = await stageService.SeedKnockoutStageAsync(bracket.Id); + + Assert.Equal(groupA[0].Id, seeded[0].HomeTeamId); + Assert.Equal(groupB[0].Id, seeded[0].VisitorTeamId); + } + + /// + /// 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. + /// + [Fact] + public async Task SeedPlayoffCupsAsync_MultiGroupRegularDivision_PoolsTopOnePerGroup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false, qualifiersPerGroup: 1); + + List groupA = await SeedRoundRobinGroupAsync(db, division, tournament, size: 2, homeScore: 100, visitorScore: 70); + List 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> seededByCup = await stageService.SeedPlayoffCupsAsync(division.Id); + + List seeded = seededByCup["Final Interzonal"]; + Assert.Equal(groupA[0].Id, seeded[0].HomeTeamId); + Assert.Equal(groupB[0].Id, seeded[0].VisitorTeamId); + } + + private static async Task 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 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; + } + + /// + /// Creates a group stage with teams and a full + /// single round-robin where the lower-index team is always home and wins + /// -, producing + /// the deterministic standings teams[0] > teams[1] > ... . + /// + private static async Task> SeedRoundRobinGroupAsync( + ApplicationDBContext db, Division division, Tournament tournament, int size, int homeScore, int visitorScore) + { + List 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/NotFoundContractTests.cs b/Club12-Backend/API.Tests/NotFoundContractTests.cs new file mode 100644 index 0000000..ef08fe2 --- /dev/null +++ b/Club12-Backend/API.Tests/NotFoundContractTests.cs @@ -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; + +/// +/// 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. +/// +public class NotFoundContractTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public NotFoundContractTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// 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). + /// + public static readonly TheoryData 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())); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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())); + } + + /// + /// 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. + /// + [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); + } +} diff --git a/Club12-Backend/API.Tests/NpgsqlOptionsTests.cs b/Club12-Backend/API.Tests/NpgsqlOptionsTests.cs new file mode 100644 index 0000000..68c3b31 --- /dev/null +++ b/Club12-Backend/API.Tests/NpgsqlOptionsTests.cs @@ -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; + +/// +/// The self-hosted-Postgres change sets a bounded CommandTimeout on both +/// DbContexts and deliberately does NOT enable retry-on-failure: a retrying +/// execution strategy rejects the raw transactions that +/// DataMaintenanceService opens, and the local DB has no transient +/// network faults for retry to absorb. These tests pin both facts. +/// +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 + { + ["ConnectionStrings:DbConnection"] = PostgresConnectionString, + }) + .Build(); + + private static ServiceProvider BuildProvider(Action register) + { + ServiceCollection services = new(); + services.AddSingleton(Configuration()); + register(services); + return services.BuildServiceProvider(); + } + + private static int? CommandTimeoutOf(IServiceProvider provider) + where TContext : DbContext + { + DbContextOptions options = provider.GetRequiredService>(); + return options.Extensions.OfType().Single().CommandTimeout; + } + + private static bool RetriesOnFailureOf(IServiceProvider provider) + where TContext : DbContext + { + using IServiceScope scope = provider.CreateScope(); + TContext context = scope.ServiceProvider.GetRequiredService(); + return context.Database.CreateExecutionStrategy().RetriesOnFailure; + } + + [Fact] + public void AddDbContextConfig_SetsCommandTimeout_AndDoesNotRetry() + { + using ServiceProvider provider = BuildProvider(s => s.AddDbContextConfig(Configuration())); + + Assert.Equal(30, CommandTimeoutOf(provider)); + Assert.False(RetriesOnFailureOf(provider)); + } + + [Fact] + public void AddIdentityConfig_SetsCommandTimeout_AndDoesNotRetry() + { + using ServiceProvider provider = BuildProvider(s => s.AddIdentityConfig(Configuration())); + + Assert.Equal(30, CommandTimeoutOf(provider)); + Assert.False(RetriesOnFailureOf(provider)); + } +} diff --git a/Club12-Backend/API.Tests/PlayerRequestValidationTests.cs b/Club12-Backend/API.Tests/PlayerRequestValidationTests.cs new file mode 100644 index 0000000..557fa9c --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerRequestValidationTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerRequestValidationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public PlayerRequestValidationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task 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(); + 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(); + 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(); + 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(); + 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}"); + } + + /// + /// 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). + /// + [Fact] + public async Task CreatePlayer_DuplicateDocumentNumber_ReturnsConflictNotServerError() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + 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 +{ + /// + /// Merges an anonymous-object payload with overrides by re-projecting to + /// a plain dictionary, keeping the test payload builders one-liners. + /// + public static object Merge(this object baseObject, object overrides) + { + Dictionary 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionAppealTests.cs b/Club12-Backend/API.Tests/PlayerSanctionAppealTests.cs new file mode 100644 index 0000000..0a9748f --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionAppealTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerSanctionAppealTests : IClassFixture +{ + 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 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(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 SeedSanctionIdAsync(SanctionAppealStatus status) + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext db = seedScope.ServiceProvider.GetRequiredService(); + PlayerSanction sanction = await SeedSanctionAsync(db, status); + return sanction.Id; + } + + /// + /// 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. + /// + private async Task ReadAppealStatusAsync(Guid sanctionId) + { + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext db = verifyScope.ServiceProvider.GetRequiredService(); + + PlayerSanction sanction = await db.PlayerSanctions.AsNoTracking() + .SingleAsync(s => s.Id == sanctionId); + + return sanction.AppealStatus; + } + + /// + /// 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. + /// + private static async Task 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionEmptyFilterRepro.cs b/Club12-Backend/API.Tests/PlayerSanctionEmptyFilterRepro.cs new file mode 100644 index 0000000..3b0128c --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionEmptyFilterRepro.cs @@ -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; + +/// +/// 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. +/// +public class PlayerSanctionEmptyFilterRepro : IClassFixture +{ + 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() 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(); + + 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(); + + PaginatedResponse 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); + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionOrderingTests.cs b/Club12-Backend/API.Tests/PlayerSanctionOrderingTests.cs new file mode 100644 index 0000000..4f88940 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionOrderingTests.cs @@ -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; + +/// +/// Verifies the paginated sanctions list served by +/// GET /api/player-sanctions/find defaults to newest-first ordering by +/// . The admin (/panel/sanciones) +/// and public (/sanciones) 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. +/// +public class PlayerSanctionOrderingTests : IClassFixture +{ + 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 result = await sanctionService.GetPlayerSanctionsAsync( + new GetPlayerSanctionsFilteredRequest { Description = token, PageSize = 50 }); + + List 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 result = await sanctionService.GetPlayerSanctionsAsync( + new GetPlayerSanctionsFilteredRequest + { + Description = token, + PageSize = 50, + OrderBy = nameof(PlayerSanction.IssuedDate), + Order = SortOrder.Ascending, + }); + + List 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + string token = Guid.NewGuid().ToString("N")[..8]; + + PlayerSanction sanction = await SeedSanctionAsync( + db, token, new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + PaginatedResponse result = await sanctionService.GetPlayerSanctionsAsync( + new GetPlayerSanctionsFilteredRequest { Description = token }); + + Assert.Contains(result.Items, s => s.Id == sanction.Id); + } + + /// + /// Seeds the full object graph a needs under + /// SQLite's enforced FKs (Tournament→Division→Stage→Match, Team→Player), + /// mirroring PlayerSanctionSearchTests.SeedSanctionAsync but with a + /// caller-supplied issue date and a shared token embedded in the reason so + /// a test can isolate its own rows. + /// + private static async Task 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionSearchTests.cs b/Club12-Backend/API.Tests/PlayerSanctionSearchTests.cs new file mode 100644 index 0000000..7e7cbf3 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionSearchTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerSanctionSearchTests : IClassFixture +{ + 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 result = await sanctionService.GetPlayerSanctionsAsync( + new GetPlayerSanctionsFilteredRequest { Description = otherToken }); + + Assert.DoesNotContain(result.Items, s => s.Id == sanction.Id); + } + + /// + /// 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. + /// + private static async Task 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionServiceTests.cs b/Club12-Backend/API.Tests/PlayerSanctionServiceTests.cs new file mode 100644 index 0000000..09e616d --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionServiceTests.cs @@ -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; + +/// +/// Characterization tests for PlayerSanctionService.GetExpiredSanctionsAsync +/// (PlayerSanctionService.cs:33-36), the date-math predicate +/// `IssuedDate.AddDays(Duration) <= cutoffDate` used to determine which +/// sanctions have expired as of a cutoff date. These tests pin the currently- +/// correct behavior of the existing, unmodified service — pure test +/// additions, no production code changes. +/// +/// Known test-harness limitation: SQLite (test provider, via +/// CustomWebApplicationFactory) may translate `DateTime.AddDays` comparisons +/// differently than Npgsql (production). All seed dates below use UTC, +/// whole-second `DateTime` values with whole-day `Duration`, so expiry always +/// lands on an exact instant and SQLite's translation is unambiguous; EF Core's +/// SQLite provider successfully translates this predicate (verified by the +/// GREEN runs of every scenario in this file — no runtime translation failure +/// was observed). +/// +/// Each test uses a base date far apart from the others (1000+ day offsets +/// from a fixed anchor) so that seeded sanctions never overlap another test's +/// cutoffDate — GetExpiredSanctionsAsync has no tournament/player scoping, and +/// the CustomWebApplicationFactory database is shared across all tests in this +/// class via IClassFixture. +/// +public class PlayerSanctionServiceTests : IClassFixture +{ + 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + DateTime issuedDate = Anchor; + int duration = 10; + DateTime expiry = issuedDate.AddDays(duration); + DateTime cutoffDateBeforeExpiry = expiry.AddDays(-1); + + PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration); + + IEnumerable 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + DateTime issuedDate = Anchor.AddDays(1000); + int duration = 10; + DateTime expiry = issuedDate.AddDays(duration); + DateTime cutoffDateAtExpiryBoundary = expiry; + + PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration); + + IEnumerable 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + 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 result = await sanctionService.GetExpiredSanctionsAsync(cutoffDateWellAfterExpiry); + + Assert.Contains(result, s => s.Id == sanction.Id); + } + + /// + /// 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. + /// + [Fact] + public async Task GetExpiredSanctionsAsync_NoMatches_ReturnsEmpty() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + DateTime issuedDate = Anchor.AddDays(3000); + int duration = 10; + DateTime cutoffDate = Anchor.AddDays(-1); + + await SeedSanctionAsync(db, issuedDate, duration); + + IEnumerable 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + DateTime issuedDate = Anchor.AddDays(4000); + int duration = 10; + DateTime expiry = issuedDate.AddDays(duration); + DateTime cutoffDate = expiry; + + PlayerSanction sanction = await SeedSanctionAsync(db, issuedDate, duration); + + IEnumerable 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); + } + + /// + /// Seeds the full object graph a PlayerSanction requires under SQLite's + /// enforced FKs: Tournament→Division→Stage→Match and Team→Player, mirroring + /// PlayerSanctionAppealTests.SeedSanctionAsync. + /// + private static async Task 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionSlugTests.cs b/Club12-Backend/API.Tests/PlayerSanctionSlugTests.cs new file mode 100644 index 0000000..2638f94 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionSlugTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerSanctionSlugTests : IClassFixture +{ + 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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + (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(); + IPlayerSanctionService sanctionService = scope.ServiceProvider.GetRequiredService(); + + (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(); + IPlayerSanctionService sanctionService = seedScope.ServiceProvider.GetRequiredService(); + + (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(); + 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); + + /// + /// 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. + /// + 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); + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionSubjectAndFechasTests.cs b/Club12-Backend/API.Tests/PlayerSanctionSubjectAndFechasTests.cs new file mode 100644 index 0000000..d5a490a --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionSubjectAndFechasTests.cs @@ -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; + +/// +/// Tests for the sanction refinements (HU-75 fechas-based duration, HU-76 +/// auto-expiry / eligibility, HU-77 team & staff subjects). Fechas-remaining +/// and eligibility are exercised at the service layer for determinism; subject +/// creation and the public response shape go through a real HTTP round trip. +/// +public class PlayerSanctionSubjectAndFechasTests : IClassFixture +{ + 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(); + IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService(); + + // 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(); + IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService(); + + 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(); + IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService(); + + 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(); + IPlayerSanctionService service = scope.ServiceProvider.GetRequiredService(); + + 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(); + 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(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(); + 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(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(); + 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(); + 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(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 ReloadWithPlayerAsync(Guid sanctionId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + return await db.PlayerSanctions + .Include(s => s.Player) + .AsNoTracking() + .SingleAsync(s => s.Id == sanctionId); + } + + /// + /// Seeds Tournament -> Division -> Stage -> Match plus Team -> Player and a + /// player sanction issued in . Uses a base + /// date far from other tests' data; fechas are scoped by the stage's own + /// rounds so different tests never interfere. + /// + private static async Task 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(); + } +} diff --git a/Club12-Backend/API.Tests/PlayerSanctionTeamNameTests.cs b/Club12-Backend/API.Tests/PlayerSanctionTeamNameTests.cs new file mode 100644 index 0000000..9d1dc14 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSanctionTeamNameTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerSanctionTeamNameTests : IClassFixture +{ + 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(); + (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(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? list = + await listResponse.Content.ReadFromJsonAsync>(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); + } +} diff --git a/Club12-Backend/API.Tests/PlayerSlugSourceTests.cs b/Club12-Backend/API.Tests/PlayerSlugSourceTests.cs new file mode 100644 index 0000000..f8b6962 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSlugSourceTests.cs @@ -0,0 +1,105 @@ +using Domain.Entities.Models; + +namespace API.Tests; + +/// +/// Locks the single canonical slug-source rule shared by create, seed and the +/// re-backfill migration: joins the raw-case +/// names as apellido nombre[ segundo] with no document number, while +/// keeps its historical display output +/// (last name upper-cased) byte-for-byte. +/// +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", + }; + } +} diff --git a/Club12-Backend/API.Tests/PlayerSlugTests.cs b/Club12-Backend/API.Tests/PlayerSlugTests.cs new file mode 100644 index 0000000..40c5a41 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerSlugTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerSlugTests : IClassFixture +{ + 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(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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(); + 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); + } + + /// + /// 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). + /// + [Fact] + public async Task GetPlayerByIdCompleteData_BySlug_Returns200WithDocumentNumber() + { + Guid createdId; + string createdSlug; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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(); + Assert.NotNull(body); + Assert.Equal(createdId, body!.Id); + Assert.False(string.IsNullOrWhiteSpace(body.DocumentNumber)); + } + + /// + /// 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. + /// + [Fact] + public async Task GetPlayerByIdCompleteData_ByGuid_Returns200() + { + Guid createdId; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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(); + Assert.NotNull(body); + Assert.Equal(createdId, body!.Id); + } + + /// + /// 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. + /// + [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()); + } + + /// + /// 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). + /// + [Fact] + public async Task GetPlayerByIdCompleteData_WrongCaseSlug_Returns404() + { + string createdSlug; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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); + } +} diff --git a/Club12-Backend/API.Tests/PlayerStatisticFilterTests.cs b/Club12-Backend/API.Tests/PlayerStatisticFilterTests.cs new file mode 100644 index 0000000..11fdfd7 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerStatisticFilterTests.cs @@ -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; + +/// +/// Covers GetPlayerStatisticsAsync's TeamId filter (joining through the +/// statistic's player) and that the response carries the match date +/// without a separate lookup. +/// +public class PlayerStatisticFilterTests : IClassFixture +{ + 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(); + IPlayerStatisticService playerStatisticService = scope.ServiceProvider.GetRequiredService(); + + 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 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 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 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 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerTeamRegistrationTests.cs b/Club12-Backend/API.Tests/PlayerTeamRegistrationTests.cs new file mode 100644 index 0000000..d678a8a --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerTeamRegistrationTests.cs @@ -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; + +/// +/// 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). +/// +public class PlayerTeamRegistrationTests : IClassFixture +{ + 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(); + + 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(); + + 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(); + 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(); + + 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(); + + 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 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(); + await playerService.CreatePlayerAsync(newPlayer, apertura.Id); + } + + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + + 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(); + 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(); + + 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(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + + 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(); + + List 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(); + + 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(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + + 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(); + + 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(); + + 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(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + + // 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(), + 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( + () => playerService.UpdatePlayerAsync(tracked, apertura.Id)); + Assert.Contains("máximo", ex.Message); + } + + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + + // 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(); + + 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().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(); + ApplicationDBContext actDb = actScope.ServiceProvider.GetRequiredService(); + + Player tracked = await actDb.Players.SingleAsync(p => p.Id == mover.Id); + tracked.TeamId = newTeam.Id; + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + + 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); + } + + /// + /// 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. + /// + [Fact] + public async Task Backfill_JoinLogic_AttributesEachPlayerToTheirTeamsCurrentTournament() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + 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(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 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayerVenueDeleteIntegrityTests.cs b/Club12-Backend/API.Tests/PlayerVenueDeleteIntegrityTests.cs new file mode 100644 index 0000000..bd186fe --- /dev/null +++ b/Club12-Backend/API.Tests/PlayerVenueDeleteIntegrityTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayerVenueDeleteIntegrityTests : IClassFixture +{ + 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(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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().Add(new PlayerStatistic + { + Value = 12, + Type = StatisticType.Points, + MatchId = match.Id, + PlayerId = player.Id, + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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().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( + () => 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(); + IPlayerService playerService = scope.ServiceProvider.GetRequiredService(); + + (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(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + (_, Stage stage, _) = await SeedTeamAndStageAsync(db); + Venue venue = await SeedVenueAsync(db); + await SeedMatchAsync(db, stage, venueId: venue.Id); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => 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(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + 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 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/PlayoffAutoSeedTests.cs b/Club12-Backend/API.Tests/PlayoffAutoSeedTests.cs new file mode 100644 index 0000000..d108c38 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayoffAutoSeedTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayoffAutoSeedTests : IClassFixture +{ + 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 cupMatches = await db.Matches + .Where(m => m.StageId == cupStage.Id) + .ToListAsync(); + + Assert.All(cupMatches, m => Assert.False(m.HomeTeamId.HasValue)); + } + + private static async Task 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/PlayoffDrawTests.cs b/Club12-Backend/API.Tests/PlayoffDrawTests.cs new file mode 100644 index 0000000..a19c44c --- /dev/null +++ b/Club12-Backend/API.Tests/PlayoffDrawTests.cs @@ -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; + +/// +/// 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. +/// +public class PlayoffDrawTests : IClassFixture +{ + 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => stageService.PreviewDrawAsync(semiFinalStage.Id, DrawMode.Random)); + } + + [Fact] + public async Task PreviewDrawAsync_NonPowerOfTwoRoster_SeedPairsProducesByes() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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 committed = await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Random, drawToken: preview.DrawToken); + + List 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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( + () => 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( + () => 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 manualOrder = [teams[3].Id, teams[2].Id, teams[1].Id, teams[0].Id]; + + List committed = await stageService.CommitDrawAsync(semiFinalStage.Id, DrawMode.Manual, manualOrder: manualOrder); + List 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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? 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(); + IUnitOfWork unitOfWork = scope.ServiceProvider.GetRequiredService(); + ILogger logger = scope.ServiceProvider.GetRequiredService>(); + IConfiguration configuration = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = new StageService(unitOfWork, logger, configuration, new ThrowingAuditService()); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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)); + } + + /// + /// A throwing IAuditService double used to prove a logging failure never blocks a draw commit. + /// + 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> GetAuditLogsAsync( + Application.DTOs.AuditLogs.Request.AuditLogFilteredRequest filter) + { + throw new InvalidOperationException("Simulated audit failure."); + } + } + + private static async Task 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 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> SeedTeamsAndRegisterAsync( + ApplicationDBContext db, Tournament tournament, Division division, int count) + { + List 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/PlayoffMappingTests.cs b/Club12-Backend/API.Tests/PlayoffMappingTests.cs new file mode 100644 index 0000000..06f4d74 --- /dev/null +++ b/Club12-Backend/API.Tests/PlayoffMappingTests.cs @@ -0,0 +1,160 @@ +using Application.Utils.Helper.Playoff; + +using Domain.Entities.Models; + +namespace API.Tests; + +/// +/// 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. +/// +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(() => PlayoffMappingValidator.Validate( + [ + Range(1, 4, "Copa Oro"), + Range(4, 8, "Copa Plata"), + ])); + } + + [Fact] + public void Validate_InvalidRange_ToBeforeFrom_Throws() + { + Assert.Throws(() => PlayoffMappingValidator.Validate( + [ + Range(5, 2, "Copa Oro"), + ])); + } + + [Fact] + public void Validate_EmptyDestination_Throws() + { + Assert.Throws(() => PlayoffMappingValidator.Validate( + [ + Range(1, 4, " "), + ])); + } + + [Fact] + public void Validate_EmptySet_DoesNotThrow() + { + PlayoffMappingValidator.Validate([]); + } + + [Fact] + public void Resolve_SingleDivision_PlacesEachRangeTeamsInItsCup() + { + List teams = [.. Enumerable.Range(0, 8).Select(_ => Guid.NewGuid())]; + List standings = [.. teams.Select(Pos)]; + + Dictionary> 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 teams = [.. Enumerable.Range(0, 10).Select(_ => Guid.NewGuid())]; + List standings = [.. teams.Select(Pos)]; + + Dictionary> 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 everyone = [.. result.Values.SelectMany(v => v)]; + Assert.DoesNotContain(teams[8], everyone); + Assert.DoesNotContain(teams[9], everyone); + } + + [Fact] + public void Resolve_MultipleDivisions_MergeIntoCupSeededByPositionThenDivision() + { + List divA = [Guid.NewGuid(), Guid.NewGuid()]; + List divB = [Guid.NewGuid(), Guid.NewGuid()]; + + Dictionary> 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"]); + } +} diff --git a/Club12-Backend/API.Tests/PlayoffSeederTests.cs b/Club12-Backend/API.Tests/PlayoffSeederTests.cs new file mode 100644 index 0000000..85e97bb --- /dev/null +++ b/Club12-Backend/API.Tests/PlayoffSeederTests.cs @@ -0,0 +1,102 @@ +using Application.Utils.Helper.Playoff; + +namespace API.Tests; + +/// +/// 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. +/// +public class PlayoffSeederTests +{ + [Fact] + public void SeedPairs_EightTeams_ProducesClassicBracketOrder() + { + List 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 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 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(() => PlayoffSeeder.SeedPairs([])); + Assert.Throws(() => PlayoffSeeder.SeedPairs([Guid.NewGuid()])); + } + + [Fact] + public void SeedPairs_ThreeTeams_BestSeedGetsAByeAndOthersPlay() + { + List 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 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 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 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 teamsWithByes = [.. pairs.Where(pair => pair.VisitorTeamId is null).Select(pair => pair.HomeTeamId)]; + Assert.Contains(seeds[0], teamsWithByes); + Assert.Contains(seeds[1], teamsWithByes); + } +} diff --git a/Club12-Backend/API.Tests/PlayoffSeriesAdvancementTests.cs b/Club12-Backend/API.Tests/PlayoffSeriesAdvancementTests.cs new file mode 100644 index 0000000..17dcb3a --- /dev/null +++ b/Club12-Backend/API.Tests/PlayoffSeriesAdvancementTests.cs @@ -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; + +/// +/// Covers the gap found auditing historias-de-usuario.md against the code +/// (2026-09-02): a real tournament's auto-seed (StageService.SeedPlayoffCupsAsync) +/// used to always drop a single plain Match into a BestOf>1 slot — it never +/// created a MatchSeries, so a configured "mejor de 3" never actually produced +/// 3 games. Only the demo seed builder did this correctly. These tests cover +/// the fix: real MatchSeries creation at seed time, and automatic advancement +/// of a decided slot's winner into the next round (StageService. +/// TryAdvanceStageWinnerAsync), which did not exist in any form before. +/// +public class PlayoffSeriesAdvancementTests : IClassFixture +{ + 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + 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 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + IMatchSeriesService matchSeriesService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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); + } + + /// + /// 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. + /// + [Fact] + public async Task SeedPlayoffCupsAsync_EightTeams_AllFavoritesWin_Seed1MeetsSeed2InTheFinal() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + 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 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 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 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(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List 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 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"); + } + + /// + /// 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. + /// + 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 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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; + } + + /// + /// Plays a single round-robin among every pair, the lower index always + /// winning, so final standings are teams[0] > teams[1] > … — enough + /// for PositionCalculator/PlayoffQualificationResolver to produce a + /// deterministic seed order for SeedPlayoffCupsAsync. + /// + private static async Task SeedRoundRobinResultsAsync(ApplicationDBContext db, Stage groupStage, List 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(); + } +} diff --git a/Club12-Backend/API.Tests/PointDeductionEndpointTests.cs b/Club12-Backend/API.Tests/PointDeductionEndpointTests.cs new file mode 100644 index 0000000..717aa70 --- /dev/null +++ b/Club12-Backend/API.Tests/PointDeductionEndpointTests.cs @@ -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; + +/// +/// 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. +/// +public class PointDeductionEndpointTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public PointDeductionEndpointTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateThenList_ReturnsDeduction_AndSubtractsFromStandings() + { + (Division division, List 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(); + 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? listed = await client.GetFromJsonAsync>( + $"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(); + List 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 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())!; + + HttpResponseMessage delete = await client.DeleteAsync($"api/point-deductions/{created.Id}"); + Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); + + List? listed = await client.GetFromJsonAsync>( + $"api/divisions/{division.Id}/point-deductions"); + Assert.Empty(listed!); + + using IServiceScope scope = _factory.Services.CreateScope(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + List 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)> SeedDivisionWithFinishedMatchAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + 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 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); + } +} diff --git a/Club12-Backend/API.Tests/PositionCalculatorDeductionTests.cs b/Club12-Backend/API.Tests/PositionCalculatorDeductionTests.cs new file mode 100644 index 0000000..0b17733 --- /dev/null +++ b/Club12-Backend/API.Tests/PositionCalculatorDeductionTests.cs @@ -0,0 +1,162 @@ +using Application.Utils.Helper.Standings; + +using Domain.Entities.Models; + +using MatchType = Domain.Enums.MatchType; + +namespace API.Tests; + +/// +/// Covers the disciplinary point-deduction adjustment (deducción de puntos): +/// a team's accumulated deductions are subtracted from its standings total, +/// the table re-ranks accordingly, and the applied deduction is surfaced on +/// the position row. The raw subtraction is allowed to drop a team's points +/// below zero — a penalty can legitimately sink a team, mirroring real +/// disciplinary sanctions; the value is shown as-is and never clamped. +/// +public class PositionCalculatorDeductionTests +{ + private static Team MakeTeam(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 MakeFinishedMatch(Team home, Team visitor, int homeScore, int visitorScore) + { + return new() + { + Id = Guid.NewGuid(), + CreatedBy = "system", + MatchDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + 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, + }; + } + + private static TeamPointDeduction MakeDeduction(Guid teamId, int points, string reason) + { + return new() + { + Id = Guid.NewGuid(), + CreatedBy = "system", + DivisionId = Guid.NewGuid(), + TeamId = teamId, + Points = points, + Reason = reason, + }; + } + + [Fact] + public void CalculatePositions_WithDeduction_SubtractsFromTotalAndReportsIt() + { + Team winner = MakeTeam("Winner"); + Team loser = MakeTeam("Loser"); + List matches = [MakeFinishedMatch(winner, loser, 80, 70)]; + List deductions = [MakeDeduction(winner.Id, 1, "Alineación indebida")]; + + List positions = PositionCalculator.CalculatePositions( + matches, deductions: deductions); + + Position winnerPosition = Assert.Single(positions, p => p.TeamId == winner.Id); + // 2 for the win minus a 1-point deduction. + Assert.Equal(1, winnerPosition.Points); + Assert.NotNull(winnerPosition.PointDeduction); + Assert.Equal(1, winnerPosition.PointDeduction!.Points); + Assert.Equal("Alineación indebida", winnerPosition.PointDeduction.Reason); + + Position loserPosition = Assert.Single(positions, p => p.TeamId == loser.Id); + Assert.Null(loserPosition.PointDeduction); + } + + [Fact] + public void CalculatePositions_MultipleDeductionsForOneTeam_AggregatesPointsAndReasons() + { + Team team = MakeTeam("Penalised"); + Team other = MakeTeam("Other"); + List matches = [MakeFinishedMatch(team, other, 90, 80)]; + List deductions = + [ + MakeDeduction(team.Id, 1, "Motivo A"), + MakeDeduction(team.Id, 2, "Motivo B"), + ]; + + List positions = PositionCalculator.CalculatePositions( + matches, deductions: deductions); + + Position row = Assert.Single(positions, p => p.TeamId == team.Id); + // 2 for the win minus (1 + 2) deducted. + Assert.Equal(-1, row.Points); + Assert.NotNull(row.PointDeduction); + Assert.Equal(3, row.PointDeduction!.Points); + Assert.Contains("Motivo A", row.PointDeduction.Reason); + Assert.Contains("Motivo B", row.PointDeduction.Reason); + } + + [Fact] + public void CalculatePositions_DeductionDropsTeam_ReordersStandings() + { + // Two teams that both won one game: without a deduction the head-to-head + // winner leads. A heavy deduction on the leader must drop it below the + // other team in the final ordering. + Team leader = MakeTeam("Leader"); + Team chaser = MakeTeam("Chaser"); + Team filler1 = MakeTeam("Filler1"); + Team filler2 = MakeTeam("Filler2"); + + List matches = + [ + MakeFinishedMatch(leader, filler1, 100, 50), + MakeFinishedMatch(chaser, filler2, 60, 58), + ]; + + // No deduction: leader (DIF +50) ranks above chaser (DIF +2). + List raw = PositionCalculator.CalculatePositions(matches); + Assert.Equal(leader.Id, raw[0].TeamId); + + // Deduct the leader's 2 points: it falls to 0, below chaser's 2. + List deductions = [MakeDeduction(leader.Id, 2, "Sanción")]; + List adjusted = PositionCalculator.CalculatePositions( + matches, deductions: deductions); + + Position chaserRow = adjusted.First(p => p.TeamId == chaser.Id); + Position leaderRow = adjusted.First(p => p.TeamId == leader.Id); + Assert.True( + adjusted.IndexOf(chaserRow) < adjusted.IndexOf(leaderRow), + "The deducted leader must rank below the chaser after the penalty."); + Assert.Equal(0, leaderRow.Points); + } + + [Fact] + public void CalculatePositions_NullOrEmptyDeductions_LeavesTotalsUntouched() + { + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + List matches = [MakeFinishedMatch(a, b, 80, 70)]; + + List withNull = PositionCalculator.CalculatePositions(matches, deductions: null); + List withEmpty = PositionCalculator.CalculatePositions(matches, deductions: []); + + Assert.Equal(2, withNull.Single(p => p.TeamId == a.Id).Points); + Assert.Null(withNull.Single(p => p.TeamId == a.Id).PointDeduction); + Assert.Equal(2, withEmpty.Single(p => p.TeamId == a.Id).Points); + } +} diff --git a/Club12-Backend/API.Tests/PositionCalculatorTests.cs b/Club12-Backend/API.Tests/PositionCalculatorTests.cs new file mode 100644 index 0000000..17dae24 --- /dev/null +++ b/Club12-Backend/API.Tests/PositionCalculatorTests.cs @@ -0,0 +1,376 @@ +using Application.Utils.Helper.Standings; + +using Domain.Entities.Models; +using Domain.Enums; + +using MatchType = Domain.Enums.MatchType; + +namespace API.Tests; + +public class PositionCalculatorTests +{ + private static Team MakeTeam(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 MakeFinishedMatch(Team home, Team visitor, int homeScore, int visitorScore) + { + return new() + { + Id = Guid.NewGuid(), + CreatedBy = "system", + MatchDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + 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, + }; + } + + [Fact] + public void CalculatePositions_WithRoster_SeedsAllAssignedTeamsAtZeroBeforeAnyMatch() + { + Team a = MakeTeam("Alpha"); + Team b = MakeTeam("Bravo"); + Team c = MakeTeam("Charlie"); + + // No matches played yet, but three teams are assigned to the zone. + List positions = PositionCalculator.CalculatePositions( + [], + rosterTeams: [a, b, c]); + + Assert.Equal(3, positions.Count); + Assert.All(positions, p => + { + Assert.Equal(0, p.MatchesPlayed); + Assert.Equal(0, p.Wins); + Assert.Equal(0, p.Losses); + Assert.Equal(0, p.Points); + }); + } + + [Fact] + public void CalculatePositions_WithRoster_OverlaysFinishedResultsOnSeededTeams() + { + Team home = MakeTeam("Home"); + Team visitor = MakeTeam("Visitor"); + Team bench = MakeTeam("Bench"); + List matches = [MakeFinishedMatch(home, visitor, 80, 70)]; + + List positions = PositionCalculator.CalculatePositions( + matches, + rosterTeams: [home, visitor, bench]); + + // All three assigned teams appear; the bench team that has not played is + // still listed, at 0-0. + Assert.Equal(3, positions.Count); + Position benchPosition = Assert.Single(positions, p => p.TeamId == bench.Id); + Assert.Equal(0, benchPosition.MatchesPlayed); + Assert.Equal(0, benchPosition.Points); + + Position homePosition = Assert.Single(positions, p => p.TeamId == home.Id); + Assert.Equal(1, homePosition.MatchesPlayed); + Assert.Equal(2, homePosition.Points); + } + + [Fact] + public void CalculatePositions_TwoTeamsOneMatch_AwardsTwoPointsForWinOneForLoss() + { + Team home = MakeTeam("Home"); + Team visitor = MakeTeam("Visitor"); + List matches = [MakeFinishedMatch(home, visitor, 80, 70)]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position homePosition = Assert.Single(positions, p => p.TeamId == home.Id); + Assert.Equal(home.LogoUrl, homePosition.LogoUrl); + Assert.Equal(1, homePosition.MatchesPlayed); + Assert.Equal(1, homePosition.Wins); + Assert.Equal(0, homePosition.Losses); + Assert.Equal(80, homePosition.PointsFor); + Assert.Equal(70, homePosition.PointsAgainst); + Assert.Equal(10, homePosition.PointsDifference); + Assert.Equal(2, homePosition.Points); + + Position visitorPosition = Assert.Single(positions, p => p.TeamId == visitor.Id); + Assert.Equal(1, visitorPosition.MatchesPlayed); + Assert.Equal(0, visitorPosition.Wins); + Assert.Equal(1, visitorPosition.Losses); + Assert.Equal(70, visitorPosition.PointsFor); + Assert.Equal(80, visitorPosition.PointsAgainst); + Assert.Equal(-10, visitorPosition.PointsDifference); + Assert.Equal(1, visitorPosition.Points); + } + + [Fact] + public void CalculatePositions_AccumulatesAcrossMultipleMatchesForTheSameTeam() + { + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team c = MakeTeam("C"); + List matches = + [ + MakeFinishedMatch(a, b, 90, 80), + MakeFinishedMatch(c, a, 85, 60), + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position teamA = Assert.Single(positions, p => p.TeamId == a.Id); + Assert.Equal(2, teamA.MatchesPlayed); + Assert.Equal(1, teamA.Wins); + Assert.Equal(1, teamA.Losses); + Assert.Equal(150, teamA.PointsFor); + Assert.Equal(165, teamA.PointsAgainst); + Assert.Equal(3, teamA.Points); + } + + [Fact] + public void CalculatePositions_UnfinishedOrUnseededMatches_AreIgnored() + { + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Match unfinished = MakeFinishedMatch(a, b, 90, 80); + unfinished.IsFinished = false; + Match unseeded = new() + { + Id = Guid.NewGuid(), + CreatedBy = "system", + MatchDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Type = MatchType.Regular, + Slug = $"match-{Guid.NewGuid()}", + HomeTeam = null, + VisitorTeam = null, + IsFinished = true, + }; + + List positions = PositionCalculator.CalculatePositions([unfinished, unseeded]); + + Assert.Empty(positions); + } + + [Fact] + public void CalculatePositions_NoMatches_ReturnsEmptyList() + { + List positions = PositionCalculator.CalculatePositions([]); + + Assert.Empty(positions); + } + + [Fact] + public void CalculatePositions_RanksByPointsThenPointsDifferenceThenPointsFor() + { + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team c = MakeTeam("C"); + + List matches = + [ + MakeFinishedMatch(a, b, 90, 70), + MakeFinishedMatch(a, c, 85, 80), + MakeFinishedMatch(b, c, 100, 60), + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Assert.Equal([a.Id, b.Id, c.Id], positions.Select(p => p.TeamId)); + } + + // ---- HU-79: configurable points --------------------------------------- + + [Fact] + public void CalculatePositions_ConfiguredPoints_UsesGivenWinAndLossValues() + { + Team home = MakeTeam("Home"); + Team visitor = MakeTeam("Visitor"); + List matches = [MakeFinishedMatch(home, visitor, 80, 70)]; + + List positions = PositionCalculator.CalculatePositions(matches, pointsForWin: 3, pointsForLoss: 0); + + Assert.Equal(3, positions.Single(p => p.TeamId == home.Id).Points); + Assert.Equal(0, positions.Single(p => p.TeamId == visitor.Id).Points); + } + + // ---- HU-80: tiebreaker chain ------------------------------------------ + + [Fact] + public void CalculatePositions_TieOnPoints_BrokenByGamesWon() + { + // A goes 2-1 and B goes 1-3: both reach 5 points (2*W + 1*L), but A + // has more wins, so PG breaks the tie before DG or H2H are consulted. + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team[] fill = [.. Enumerable.Range(0, 7).Select(i => MakeTeam($"F{i}"))]; + + List matches = + [ + MakeFinishedMatch(a, fill[0], 90, 80), + MakeFinishedMatch(a, fill[1], 90, 80), + MakeFinishedMatch(fill[2], a, 90, 80), + MakeFinishedMatch(b, fill[3], 90, 80), + MakeFinishedMatch(fill[4], b, 90, 80), + MakeFinishedMatch(fill[5], b, 90, 80), + MakeFinishedMatch(fill[6], b, 90, 80), + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position pa = positions.Single(p => p.TeamId == a.Id); + Position pb = positions.Single(p => p.TeamId == b.Id); + Assert.Equal(pa.Points, pb.Points); + Assert.True(positions.IndexOf(pa) < positions.IndexOf(pb)); + Assert.Equal(TiebreakerCriterion.GamesWon, pb.ResolvedBy); + } + + [Fact] + public void CalculatePositions_TieOnPointsAndWins_BrokenByPointsDifference() + { + // A and B are both 1-1 (equal PTS and PG) but A has a better overall + // points difference, so DG breaks the tie. + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team c = MakeTeam("C"); + Team d = MakeTeam("D"); + Team e = MakeTeam("E"); + Team f = MakeTeam("F"); + + List matches = + [ + MakeFinishedMatch(a, c, 90, 70), // A +20 + MakeFinishedMatch(d, a, 85, 80), // A -5 -> DG +15 + MakeFinishedMatch(b, e, 85, 80), // B +5 + MakeFinishedMatch(f, b, 90, 70), // B -20 -> DG -15 + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position pa = positions.Single(p => p.TeamId == a.Id); + Position pb = positions.Single(p => p.TeamId == b.Id); + Assert.Equal(pa.Points, pb.Points); + Assert.Equal(pa.Wins, pb.Wins); + Assert.True(positions.IndexOf(pa) < positions.IndexOf(pb)); + Assert.Equal(TiebreakerCriterion.PointsDifference, pb.ResolvedBy); + } + + [Fact] + public void CalculatePositions_TieOnAllZoneCriteria_BrokenByHeadToHeadBetweenTwo() + { + // A and B end equal on PTS, PG and whole-zone DG; A beat B head to + // head (their only meeting), so H2H breaks the tie. + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team c = MakeTeam("C"); + Team d = MakeTeam("D"); + + List matches = + [ + MakeFinishedMatch(a, b, 90, 80), // A beats B, A +10 / B -10 + MakeFinishedMatch(c, a, 90, 80), // A loses, A -10 -> DG 0 + MakeFinishedMatch(b, d, 90, 80), // B beats D, B +10 -> DG 0 + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position pa = positions.Single(p => p.TeamId == a.Id); + Position pb = positions.Single(p => p.TeamId == b.Id); + Assert.Equal(pa.Points, pb.Points); + Assert.Equal(pa.Wins, pb.Wins); + Assert.Equal(pa.PointsDifference, pb.PointsDifference); + Assert.True(positions.IndexOf(pa) < positions.IndexOf(pb)); + Assert.Equal(TiebreakerCriterion.HeadToHead, pb.ResolvedBy); + } + + [Fact] + public void CalculatePositions_ThreeWayTie_BrokenByHeadToHeadMiniTable() + { + // A, B, C all finish 2-2 with DG 0. Their round-robin mini-table is + // linear (A beat B and C, B beat C), so H2H fully resolves A>B>C. + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team c = MakeTeam("C"); + Team[] ext = [.. Enumerable.Range(0, 6).Select(i => MakeTeam($"X{i}"))]; + + List matches = + [ + // Mini-table among the tied set. + MakeFinishedMatch(a, b, 90, 80), + MakeFinishedMatch(a, c, 90, 80), + MakeFinishedMatch(b, c, 90, 80), + // External games that equalize wins and DG across A, B, C. + MakeFinishedMatch(ext[0], a, 90, 80), // A loss + MakeFinishedMatch(ext[1], a, 90, 80), // A loss + MakeFinishedMatch(b, ext[2], 90, 80), // B win + MakeFinishedMatch(ext[3], b, 90, 80), // B loss + MakeFinishedMatch(c, ext[4], 90, 80), // C win + MakeFinishedMatch(c, ext[5], 90, 80), // C win + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position pa = positions.Single(p => p.TeamId == a.Id); + Position pb = positions.Single(p => p.TeamId == b.Id); + Position pc = positions.Single(p => p.TeamId == c.Id); + + Assert.Equal(pa.Points, pb.Points); + Assert.Equal(pb.Points, pc.Points); + Assert.Equal(pa.PointsDifference, pc.PointsDifference); + + // A, B, C occupy the top three slots, in that order. + Assert.Equal([a.Id, b.Id, c.Id], positions.Take(3).Select(p => p.TeamId)); + Assert.Equal(TiebreakerCriterion.HeadToHead, pb.ResolvedBy); + Assert.Equal(TiebreakerCriterion.HeadToHead, pc.ResolvedBy); + } + + [Fact] + public void CalculatePositions_TwoTeamsPlayedTwice_BrokenByHeadToHeadPointsDifference() + { + // A and B split their two head-to-head games (equal H2H result), so + // the tie falls through to the points difference within those games. + // A won its game by 20 and lost by 10, so its H2H difference is + // better. External games keep whole-zone PTS/PG/DG equal. + Team a = MakeTeam("A"); + Team b = MakeTeam("B"); + Team p = MakeTeam("P"); + Team q = MakeTeam("Q"); + Team r = MakeTeam("R"); + Team s = MakeTeam("S"); + + List matches = + [ + MakeFinishedMatch(a, b, 90, 70), // A wins by 20 + MakeFinishedMatch(b, a, 90, 80), // B wins by 10 + MakeFinishedMatch(a, p, 85, 80), // A win +5 + MakeFinishedMatch(q, a, 90, 75), // A loss -15 -> A DG 0 + MakeFinishedMatch(b, r, 90, 75), // B win +15 + MakeFinishedMatch(s, b, 85, 80), // B loss -5 -> B DG 0 + ]; + + List positions = PositionCalculator.CalculatePositions(matches); + + Position pa = positions.Single(p => p.TeamId == a.Id); + Position pb = positions.Single(p => p.TeamId == b.Id); + Assert.Equal(pa.Points, pb.Points); + Assert.Equal(pa.Wins, pb.Wins); + Assert.Equal(pa.PointsDifference, pb.PointsDifference); + Assert.True(positions.IndexOf(pa) < positions.IndexOf(pb)); + Assert.Equal(TiebreakerCriterion.HeadToHead, pb.ResolvedBy); + } +} diff --git a/Club12-Backend/API.Tests/QualificationRangeBuilderTests.cs b/Club12-Backend/API.Tests/QualificationRangeBuilderTests.cs new file mode 100644 index 0000000..d814e9b --- /dev/null +++ b/Club12-Backend/API.Tests/QualificationRangeBuilderTests.cs @@ -0,0 +1,73 @@ +using Application.DTOs.Divisions.Response; +using Application.Utils.Helper.Playoff; + +using Domain.Entities.Models; + +namespace API.Tests; + +/// +/// Covers the HU-45 qualification-range derivation that turns a division's +/// playoff mappings into the ordered, cup-ranked ranges the public standings +/// table paints. +/// +public class QualificationRangeBuilderTests +{ + private static DivisionPlayoffMapping Range(int from, int to, string destination) + { + return new DivisionPlayoffMapping + { + FromPosition = from, + ToPosition = to, + Destination = destination, + CreatedBy = "test", + }; + } + + [Fact] + public void Build_OrdersRangesTopDown_AndAssignsCupRank() + { + // Deliberately out of order to prove the builder sorts top-down. + List ranges = QualificationRangeBuilder.Build( + [ + Range(5, 8, "Copa Plata"), + Range(1, 4, "Copa Oro"), + Range(9, 12, "Copa Bronce"), + ]); + + Assert.Collection( + ranges, + first => + { + Assert.Equal(1, first.FromPosition); + Assert.Equal(4, first.ToPosition); + Assert.Equal("Copa Oro", first.CupName); + Assert.Equal(0, first.Order); + }, + second => + { + Assert.Equal(5, second.FromPosition); + Assert.Equal(8, second.ToPosition); + Assert.Equal("Copa Plata", second.CupName); + Assert.Equal(1, second.Order); + }, + third => + { + Assert.Equal(9, third.FromPosition); + Assert.Equal(12, third.ToPosition); + Assert.Equal("Copa Bronce", third.CupName); + Assert.Equal(2, third.Order); + }); + } + + [Fact] + public void Build_NoMappings_ReturnsEmpty() + { + Assert.Empty(QualificationRangeBuilder.Build([])); + } + + [Fact] + public void Build_Null_ReturnsEmpty() + { + Assert.Empty(QualificationRangeBuilder.Build(null)); + } +} diff --git a/Club12-Backend/API.Tests/RoleSeedingTests.cs b/Club12-Backend/API.Tests/RoleSeedingTests.cs new file mode 100644 index 0000000..9647b7a --- /dev/null +++ b/Club12-Backend/API.Tests/RoleSeedingTests.cs @@ -0,0 +1,85 @@ +using Application.DTOs.Tournament.Request; + +using Domain.Enums; + +using Infrastructure.Identity; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using System.Net; +using System.Net.Http.Json; + +namespace API.Tests; + +/// +/// HU-05: the role model is reduced to the two operator accounts (Owner, +/// Admin IT) plus the technical Guest role. These tests prove the removed +/// TournamentManager / TeamManager roles are gone from seeding and no longer +/// authorize a staff-only endpoint, while Owner and Admin still do. +/// +public class RoleSeedingTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public RoleSeedingTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task SeededRoles_AreExactlyOwnerAdminAndGuest() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IdentityAppDbContext db = scope.ServiceProvider.GetRequiredService(); + + List roleNames = await db.Roles + .Select(r => r.Name!) + .ToListAsync(); + + Assert.Contains(Roles.Admin, roleNames); + Assert.Contains(Roles.Owner, roleNames); + Assert.Contains(Roles.Guest, roleNames); + + Assert.DoesNotContain("TOURNAMENT_MANAGER", roleNames); + Assert.DoesNotContain("TEAM_MANAGER", roleNames); + Assert.Equal(3, roleNames.Count); + } + + [Theory] + [InlineData("TOURNAMENT_MANAGER")] + [InlineData("TEAM_MANAGER")] + public async Task RemovedRole_DoesNotAuthorizeStaffEndpoint(string removedRole) + { + HttpClient client = _factory.CreateAuthenticatedClient(removedRole); + + HttpResponseMessage response = await client.PostAsJsonAsync("api/tournaments", BuildTournamentRequest()); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData(Roles.Owner)] + [InlineData(Roles.Admin)] + public async Task OwnerAndAdmin_AreAuthorizedOnStaffEndpoint(string staffRole) + { + HttpClient client = _factory.CreateAuthenticatedClient(staffRole); + + HttpResponseMessage response = await client.PostAsJsonAsync("api/tournaments", BuildTournamentRequest()); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + private static CreateTournamentRequest BuildTournamentRequest() + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + return new CreateTournamentRequest + { + Name = $"Tournament-{Guid.NewGuid()}", + Description = "Role seeding test tournament", + StartDate = start, + TeamRegistrationDeadline = start.AddDays(-1), + }; + } +} diff --git a/Club12-Backend/API.Tests/RosterCopyTests.cs b/Club12-Backend/API.Tests/RosterCopyTests.cs new file mode 100644 index 0000000..b0523dc --- /dev/null +++ b/Club12-Backend/API.Tests/RosterCopyTests.cs @@ -0,0 +1,253 @@ +using Application.DTOs.Roster.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; + +/// +/// Verifies copying a roster from a previous season into a new season (HU-53): +/// every source player gets a fresh season registration on the target team +/// (reusing the same Player rows), medical records are NOT inherited — they +/// start Pending (HU-59) — sanctions are never carried over, and re-running the +/// copy is idempotent. Exercises the real +/// through DI scopes against the CustomWebApplicationFactory's SQLite-backed +/// ApplicationDBContext. +/// +public class RosterCopyTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public RosterCopyTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// Each player registered to the source team/season gets a new registration + /// on the target team for the target season, reusing the same Player row. + /// + [Fact] + public async Task CopyRoster_CreatesTargetRegistrationForEachSourcePlayer() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament sourceSeason = await SeedTournamentAsync(seedDb, "Prev season"); + Tournament targetSeason = await SeedTournamentAsync(seedDb, "New season"); + Team sourceTeam = await SeedTeamAsync(seedDb, sourceSeason.Id); + Team targetTeam = await SeedTeamAsync(seedDb, targetSeason.Id); + + Player playerA = await SeedPlayerAsync(seedDb, sourceTeam); + Player playerB = await SeedPlayerAsync(seedDb, sourceTeam); + await SeedRegistrationAsync(seedDb, playerA, sourceTeam, sourceSeason); + await SeedRegistrationAsync(seedDb, playerB, sourceTeam, sourceSeason); + + int playersBefore = await CountPlayersAsync(); + + RosterCopyResult result = await CopyAsync(sourceTeam.Id, sourceSeason.Id, targetTeam.Id, targetSeason.Id); + + Assert.Equal(2, result.CopiedCount); + Assert.Equal(0, result.SkippedCount); + + List targetRegistrations = await ReadRegistrationsAsync(targetTeam.Id, targetSeason.Id); + Assert.Equal(2, targetRegistrations.Count); + Assert.Contains(targetRegistrations, r => r.PlayerId == playerA.Id); + Assert.Contains(targetRegistrations, r => r.PlayerId == playerB.Id); + + // Same person reused — the copy creates no new Player rows. + Assert.Equal(playersBefore, await CountPlayersAsync()); + } + + /// + /// The medical record (ficha) is not inherited: even when the source + /// registration was Approved with an uploaded file and a dorsal, the target + /// registration starts Pending, with no file and no dorsal (HU-59). And the + /// copy creates no sanctions. + /// + [Fact] + public async Task CopyRoster_DoesNotCopyMedicalRecordOrSanctions() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament sourceSeason = await SeedTournamentAsync(seedDb, "Prev season"); + Tournament targetSeason = await SeedTournamentAsync(seedDb, "New season"); + Team sourceTeam = await SeedTeamAsync(seedDb, sourceSeason.Id); + Team targetTeam = await SeedTeamAsync(seedDb, targetSeason.Id); + + Player player = await SeedPlayerAsync(seedDb, sourceTeam); + PlayerTeamRegistration sourceRegistration = await SeedRegistrationAsync(seedDb, player, sourceTeam, sourceSeason); + + // Source has a fully-approved ficha with a file and a dorsal. + sourceRegistration.MedicalRecordStatus = MedicalRecordStatus.Approved; + sourceRegistration.MedicalRecordFileUrl = "medical-records/prev.pdf"; + sourceRegistration.MedicalRecordFileName = "prev.pdf"; + sourceRegistration.JerseyNumber = 10; + await seedDb.SaveChangesAsync(); + + int sanctionsBefore = await CountSanctionsAsync(); + + await CopyAsync(sourceTeam.Id, sourceSeason.Id, targetTeam.Id, targetSeason.Id); + + PlayerTeamRegistration target = Assert.Single(await ReadRegistrationsAsync(targetTeam.Id, targetSeason.Id)); + Assert.Equal(MedicalRecordStatus.Pending, target.MedicalRecordStatus); + Assert.Null(target.MedicalRecordFileUrl); + Assert.Null(target.MedicalRecordFileName); + Assert.Null(target.MedicalRecordReviewedAt); + Assert.Null(target.JerseyNumber); + + // The copy created no sanctions. + Assert.Equal(sanctionsBefore, await CountSanctionsAsync()); + } + + /// + /// Re-running the copy does not duplicate registrations: a source player + /// already registered to the target season is skipped. + /// + [Fact] + public async Task CopyRoster_IsIdempotent_DoesNotDuplicateExistingRegistrations() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament sourceSeason = await SeedTournamentAsync(seedDb, "Prev season"); + Tournament targetSeason = await SeedTournamentAsync(seedDb, "New season"); + Team sourceTeam = await SeedTeamAsync(seedDb, sourceSeason.Id); + Team targetTeam = await SeedTeamAsync(seedDb, targetSeason.Id); + + Player player = await SeedPlayerAsync(seedDb, sourceTeam); + await SeedRegistrationAsync(seedDb, player, sourceTeam, sourceSeason); + + RosterCopyResult first = await CopyAsync(sourceTeam.Id, sourceSeason.Id, targetTeam.Id, targetSeason.Id); + Assert.Equal(1, first.CopiedCount); + + RosterCopyResult second = await CopyAsync(sourceTeam.Id, sourceSeason.Id, targetTeam.Id, targetSeason.Id); + Assert.Equal(0, second.CopiedCount); + Assert.Equal(1, second.SkippedCount); + + // Exactly one registration for the player in the target season. + Assert.Single(await ReadRegistrationsAsync(targetTeam.Id, targetSeason.Id)); + } + + private async Task CopyAsync( + Guid sourceTeamId, Guid sourceTournamentId, Guid targetTeamId, Guid targetTournamentId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + IRosterCopyService rosterCopyService = scope.ServiceProvider.GetRequiredService(); + return await rosterCopyService.CopyRosterAsync(sourceTeamId, sourceTournamentId, targetTeamId, targetTournamentId); + } + + private async Task> ReadRegistrationsAsync(Guid teamId, Guid tournamentId) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + return await db.PlayerTeamRegistrations + .AsNoTracking() + .Where(r => r.TeamId == teamId && r.TournamentId == tournamentId) + .ToListAsync(); + } + + private async Task CountPlayersAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + return await db.Players.AsNoTracking().CountAsync(); + } + + private async Task CountSanctionsAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + return await db.PlayerSanctions.AsNoTracking().CountAsync(); + } + + private static async Task 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, + Status = TournamentStatus.OpenForRegistration, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task 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 SeedPlayerAsync(ApplicationDBContext db, Team team) + { + Player player = new() + { + FirstName = "First", + LastName = $"Last-{Guid.NewGuid()}", + Slug = $"player-{Guid.NewGuid()}", + DocumentNumber = Guid.NewGuid().ToString("N")[..8], + IsSanctioned = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + SocialSecurity = "OSDE", + Team = team, + TeamId = team.Id, + PlayerTeamRegistrations = [], + CreatedBy = "test", + }; + + db.Players.Add(player); + await db.SaveChangesAsync(); + + return player; + } + + private static async Task SeedRegistrationAsync( + ApplicationDBContext db, Player player, Team team, Tournament tournament) + { + PlayerTeamRegistration registration = new() + { + PlayerId = player.Id, + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }; + + db.PlayerTeamRegistrations.Add(registration); + await db.SaveChangesAsync(); + + return registration; + } +} diff --git a/Club12-Backend/API.Tests/RosterRegistrationTests.cs b/Club12-Backend/API.Tests/RosterRegistrationTests.cs new file mode 100644 index 0000000..5d9bf6c --- /dev/null +++ b/Club12-Backend/API.Tests/RosterRegistrationTests.cs @@ -0,0 +1,172 @@ +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; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Xunit; + +namespace API.Tests; + +/// +/// HU-54: roster limits and unique dorsals. The dorsal lives on the +/// season-scoped PlayerTeamRegistration, so all three invariants +/// (no two teams per tournament, roster-size cap, unique dorsal within a +/// team+tournament) are enforced by PlayerService.RegisterPlayerToTeamAsync. +/// +public class RosterRegistrationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public RosterRegistrationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task RegisterPlayer_DuplicateDorsalInSameTeamAndTournament_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IPlayerService service = scope.ServiceProvider.GetRequiredService(); + + (Guid tournamentId, Guid teamId, _, List players) = await SeedAsync(db, playerCount: 2); + + await service.RegisterPlayerToTeamAsync(players[0], teamId, tournamentId, jerseyNumber: 10); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => service.RegisterPlayerToTeamAsync(players[1], teamId, tournamentId, jerseyNumber: 10)); + + Assert.Contains("dorsal", ex.Message); + } + + [Fact] + public async Task RegisterPlayer_ExceedingRosterSizeCap_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IUnitOfWork unitOfWork = scope.ServiceProvider.GetRequiredService(); + + (Guid tournamentId, Guid teamId, _, List players) = await SeedAsync(db, playerCount: 3); + + // Build a PlayerService with a deliberately tiny cap so the test is + // deterministic without seeding dozens of players. + PlayerService service = new( + unitOfWork, + scope.ServiceProvider.GetRequiredService(), + Options.Create(new RosterOptions { MaxPlayersPerTeam = 2 })); + + await service.RegisterPlayerToTeamAsync(players[0], teamId, tournamentId, jerseyNumber: 1); + await service.RegisterPlayerToTeamAsync(players[1], teamId, tournamentId, jerseyNumber: 2); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => service.RegisterPlayerToTeamAsync(players[2], teamId, tournamentId, jerseyNumber: 3)); + + Assert.Contains("máximo", ex.Message); + } + + [Fact] + public async Task RegisterPlayer_ToTwoTeamsInSameTournament_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IPlayerService service = scope.ServiceProvider.GetRequiredService(); + + (Guid tournamentId, Guid teamAId, Guid teamBId, List players) = await SeedAsync(db, playerCount: 1); + + await service.RegisterPlayerToTeamAsync(players[0], teamAId, tournamentId, jerseyNumber: 7); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => service.RegisterPlayerToTeamAsync(players[0], teamBId, tournamentId, jerseyNumber: 8)); + + Assert.Contains("dos equipos del mismo torneo", ex.Message); + } + + /// + /// Seeds one tournament, two teams (A and B) and + /// players (all created against team A). Returns the tournament id, both + /// team ids and the player ids. + /// + private static async Task<(Guid TournamentId, Guid TeamAId, Guid TeamBId, List Players)> SeedAsync( + ApplicationDBContext db, int playerCount) + { + Guid tournamentId = Guid.NewGuid(); + + Team teamA = NewTeam(tournamentId, "Alpha"); + Team teamB = NewTeam(tournamentId, "Bravo"); + + Tournament tournament = new() + { + Id = tournamentId, + CreatedBy = "test", + Name = $"Roster Tournament {tournamentId}", + Description = "Roster fixture.", + Slug = $"roster-tournament-{tournamentId}", + TeamRegistrationDeadline = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + StartDate = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc), + Divisions = [], + Teams = [teamA, teamB], + }; + + db.Tournaments.Add(tournament); + + List players = []; + for (int i = 0; i < playerCount; i++) + { + Player player = NewPlayer(teamA); + db.Players.Add(player); + players.Add(player.Id); + } + + await db.SaveChangesAsync(); + + return (tournamentId, teamA.Id, teamB.Id, players); + } + + private static Team NewTeam(Guid tournamentId, string name) + { + Guid id = Guid.NewGuid(); + return new Team + { + Id = id, + CreatedBy = "test", + Name = $"{name} {id}", + Slug = $"{name.ToLowerInvariant()}-{id}", + ThreeLetterCode = name[..3].ToUpperInvariant(), + LogoUrl = "https://example.test/logo.png", + ShirtColor = "#123456", + TournamentId = tournamentId, + Players = [], + }; + } + + private static Player NewPlayer(Team team) + { + Guid id = Guid.NewGuid(); + return new Player + { + Id = id, + CreatedBy = "test", + FirstName = "Test", + LastName = $"Player {id}", + Slug = $"player-{id}", + DocumentNumber = id.ToString("N")[..10], + IsSanctioned = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + SocialSecurity = "OSDE", + Team = team, + TeamId = team.Id, + }; + } +} diff --git a/Club12-Backend/API.Tests/RoundRobinSchedulerTests.cs b/Club12-Backend/API.Tests/RoundRobinSchedulerTests.cs new file mode 100644 index 0000000..302ea7a --- /dev/null +++ b/Club12-Backend/API.Tests/RoundRobinSchedulerTests.cs @@ -0,0 +1,176 @@ +using Application.Utils.Helper.RoundRobin; + +namespace API.Tests; + +public class RoundRobinSchedulerTests +{ + [Fact] + public void GenerateFixture_FourTeams_SingleLeg_EveryPairPlaysExactlyOnce() + { + List teams = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + List<(Guid HomeTeamId, Guid VisitorTeamId)> fixture = RoundRobinScheduler.GenerateFixture(teams, legs: 1, new Random(1)); + + Assert.Equal(6, fixture.Count); + + HashSet<(Guid, Guid)> unorderedPairs = [.. fixture.Select(m => + m.HomeTeamId.CompareTo(m.VisitorTeamId) < 0 ? (m.HomeTeamId, m.VisitorTeamId) : (m.VisitorTeamId, m.HomeTeamId))]; + Assert.Equal(6, unorderedPairs.Count); + + foreach (Guid team in teams) + { + int appearances = fixture.Count(m => m.HomeTeamId == team || m.VisitorTeamId == team); + Assert.Equal(3, appearances); + } + } + + [Fact] + public void GenerateFixture_OddTeamCount_HandlesByeWithoutIncludingIt() + { + List teams = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + List<(Guid HomeTeamId, Guid VisitorTeamId)> fixture = RoundRobinScheduler.GenerateFixture(teams, legs: 1, new Random(1)); + + Assert.Equal(3, fixture.Count); + Assert.All(fixture, m => + { + Assert.Contains(m.HomeTeamId, teams); + Assert.Contains(m.VisitorTeamId, teams); + }); + } + + [Fact] + public void GenerateFixture_TwoLegs_DoublesPairCount() + { + List teams = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + List<(Guid HomeTeamId, Guid VisitorTeamId)> singleLeg = RoundRobinScheduler.GenerateFixture(teams, legs: 1, new Random(1)); + List<(Guid HomeTeamId, Guid VisitorTeamId)> doubleLeg = RoundRobinScheduler.GenerateFixture(teams, legs: 2, new Random(1)); + + Assert.Equal(singleLeg.Count * 2, doubleLeg.Count); + } + + [Fact] + public void GenerateFixture_FewerThanTwoTeams_ReturnsEmpty() + { + List<(Guid HomeTeamId, Guid VisitorTeamId)> fixture = RoundRobinScheduler.GenerateFixture([Guid.NewGuid()], legs: 1); + + Assert.Empty(fixture); + } + + // ---- Round (jornada) assignment: HU-63 / HU-65 ---- + + /// + /// Even team count: a single round-robin has (teams - 1) rounds, and within + /// every round each team appears exactly once (no team plays twice, none is + /// idle since the count is even). + /// + [Theory] + [InlineData(4)] + [InlineData(6)] + [InlineData(8)] + public void GenerateRounds_EvenTeams_SingleLeg_EachTeamPlaysOncePerRoundAndRoundCountIsTeamsMinusOne(int teamCount) + { + List teams = [.. Enumerable.Range(0, teamCount).Select(_ => Guid.NewGuid())]; + + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = + RoundRobinScheduler.GenerateRounds(teams, legs: 1, new Random(1)); + + int expectedRounds = teamCount - 1; + List rounds = [.. fixture.Select(m => m.Round).Distinct().OrderBy(r => r)]; + Assert.Equal(expectedRounds, rounds.Count); + Assert.Equal(Enumerable.Range(1, expectedRounds), rounds); + + // teams/2 games per round, and every team plays exactly once in it. + foreach (int round in rounds) + { + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> games = [.. fixture.Where(m => m.Round == round)]; + Assert.Equal(teamCount / 2, games.Count); + + List participants = [.. games.SelectMany(g => new[] { g.HomeTeamId, g.VisitorTeamId })]; + Assert.Equal(teamCount, participants.Count); + Assert.Equal(teamCount, participants.Distinct().Count()); + Assert.All(teams, t => Assert.Contains(t, participants)); + } + } + + /// + /// Odd team count: the schedule has exactly `teams` rounds, and in every + /// round exactly one team is idle ("libre") — i.e. (teams - 1) teams play, + /// one does not, and no team plays twice. + /// + [Theory] + [InlineData(3)] + [InlineData(5)] + [InlineData(7)] + public void GenerateRounds_OddTeams_SingleLeg_ExactlyOneByePerRound(int teamCount) + { + List teams = [.. Enumerable.Range(0, teamCount).Select(_ => Guid.NewGuid())]; + + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = + RoundRobinScheduler.GenerateRounds(teams, legs: 1, new Random(1)); + + List rounds = [.. fixture.Select(m => m.Round).Distinct().OrderBy(r => r)]; + Assert.Equal(teamCount, rounds.Count); + Assert.Equal(Enumerable.Range(1, teamCount), rounds); + + foreach (int round in rounds) + { + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> games = [.. fixture.Where(m => m.Round == round)]; + Assert.Equal((teamCount - 1) / 2, games.Count); + + List participants = [.. games.SelectMany(g => new[] { g.HomeTeamId, g.VisitorTeamId })]; + Assert.Equal(participants.Count, participants.Distinct().Count()); + + // Exactly one team sits out this round. + int idle = teams.Count(t => !participants.Contains(t)); + Assert.Equal(1, idle); + } + + // Bye (Guid.Empty) never leaks into the returned fixture. + Assert.DoesNotContain(fixture, m => m.HomeTeamId == Guid.Empty || m.VisitorTeamId == Guid.Empty); + + // Over the whole single round-robin every pair still meets once. + foreach (Guid team in teams) + { + int appearances = fixture.Count(m => m.HomeTeamId == team || m.VisitorTeamId == team); + Assert.Equal(teamCount - 1, appearances); + } + } + + /// + /// Multi-leg numbering: rounds are numbered consecutively across legs, so a + /// double round-robin of N teams runs from round 1 to 2*(N-1) with no gaps, + /// and every pair meets exactly twice. + /// + [Fact] + public void GenerateRounds_FourTeams_TwoLegs_NumbersRoundsConsecutivelyAcrossLegs() + { + List teams = [.. Enumerable.Range(0, 4).Select(_ => Guid.NewGuid())]; + + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = + RoundRobinScheduler.GenerateRounds(teams, legs: 2, new Random(1)); + + List rounds = [.. fixture.Select(m => m.Round).Distinct().OrderBy(r => r)]; + Assert.Equal(Enumerable.Range(1, 6), rounds); // 2 * (4 - 1) = 6 rounds + + // 12 matches total, each team plays once per round. + Assert.Equal(12, fixture.Count); + foreach (int round in rounds) + { + List participants = [.. fixture.Where(m => m.Round == round) + .SelectMany(g => new[] { g.HomeTeamId, g.VisitorTeamId })]; + Assert.Equal(4, participants.Distinct().Count()); + } + + // Every unordered pair meets exactly twice across the two legs. + Dictionary<(Guid, Guid), int> pairCounts = []; + foreach ((Guid home, Guid visitor, _) in fixture) + { + (Guid, Guid) key = home.CompareTo(visitor) < 0 ? (home, visitor) : (visitor, home); + pairCounts[key] = pairCounts.GetValueOrDefault(key) + 1; + } + Assert.Equal(6, pairCounts.Count); + Assert.All(pairCounts.Values, count => Assert.Equal(2, count)); + } +} diff --git a/Club12-Backend/API.Tests/SampleTournamentBuilderCategoryTests.cs b/Club12-Backend/API.Tests/SampleTournamentBuilderCategoryTests.cs new file mode 100644 index 0000000..942e163 --- /dev/null +++ b/Club12-Backend/API.Tests/SampleTournamentBuilderCategoryTests.cs @@ -0,0 +1,197 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +namespace API.Tests; + +/// +/// Covers the seed builder's category support (HU-48): a +/// must flow +/// onto the built and onto EVERY division — +/// the regular divisions AND the cross-division cup — so the seeded graph never +/// mixes feminine and masculine divisions. Also guards that the cross-division +/// cup division is actually flagged and that seeded teams always carry a +/// (placeholder) logo before any real upload runs. +/// +public class SampleTournamentBuilderCategoryTests +{ + private static List BuildVenues() => + [ + new() { Slug = "venue-uno", CreatedBy = "test", Name = "Cancha Uno", Address = "Calle 1" }, + new() { Slug = "venue-dos", CreatedBy = "test", Name = "Cancha Dos", Address = "Calle 2" }, + ]; + + private static SampleTournamentBuilder.TournamentDefinition MakeDefinition( + string name, TournamentCategory category) => new( + Name: name, + Description: "Torneo de prueba con copa cruzada.", + 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( + "Damas A", + ["Equipo A", "Equipo B", "Equipo C", "Equipo D"], + ["EQA", "EQB", "EQC", "EQD"], + ["#111111", "#222222", "#333333", "#444444"]), + ], + CrossCup: new("Copa Club 12", GroupCount: 2, QualifiersPerGroup: 1), + Category: category); + + [Fact] + public void Build_DefaultCategory_IsMasculineOnTournamentAndDivisions() + { + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition( + "Apertura Masculino", TournamentCategory.Masculine); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Assert.Equal(TournamentCategory.Masculine, result.Tournament.Category); + Assert.All(result.Tournament.Divisions, + d => Assert.Equal(TournamentCategory.Masculine, d.Category)); + } + + [Fact] + public void Build_FeminineCategory_FlowsToTournamentAndEveryDivisionIncludingCup() + { + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition( + "Apertura Femenino", TournamentCategory.Feminine); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Assert.Equal(TournamentCategory.Feminine, result.Tournament.Category); + + // Every division — regular zones AND the cross-division cup — shares the + // tournament's category. + Assert.All(result.Tournament.Divisions, + d => Assert.Equal(TournamentCategory.Feminine, d.Category)); + } + + [Fact] + public void Build_WithCrossCup_ProducesFlaggedCupDivision() + { + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition( + "Apertura Femenino", TournamentCategory.Feminine); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Division cup = Assert.Single(result.Tournament.Divisions, d => d.IsCrossDivisionCup); + Assert.Equal("Copa Club 12", cup.Name); + Assert.Equal(TournamentCategory.Feminine, cup.Category); + } + + /// + /// medical-records-storage-eligibility (Part 3): the seeded fake ficha + /// reference is removed — an Approved registration must come out of + /// Build() with a null file reference, so before + /// DataSeeder.SeedMedicalRecordsAsync runs it correctly reads as + /// NOT habilitado under the Part 2 file-backed rule. + /// + [Fact] + public void Build_ApprovedRegistrations_HaveNullMedicalRecordFileUrl() + { + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition( + "Apertura Masculino", TournamentCategory.Masculine); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + List approved = [.. result.Tournament.Teams + .SelectMany(t => t.PlayerTeamRegistrations) + .Where(r => r.MedicalRecordStatus == MedicalRecordStatus.Approved)]; + + Assert.NotEmpty(approved); + Assert.All(approved, r => Assert.Null(r.MedicalRecordFileUrl)); + } + + [Fact] + public void Build_EverySeededTeam_HasANonEmptyLogoUrl() + { + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition( + "Apertura Masculino", TournamentCategory.Masculine); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Assert.NotEmpty(result.Tournament.Teams); + Assert.All(result.Tournament.Teams, t => Assert.False(string.IsNullOrWhiteSpace(t.LogoUrl))); + } + + /// + /// An in-progress (ONGOING) tournament seeded as a double round-robin + /// (RoundRobinLegs = 2) with a played-rounds cutoff finishes exactly the + /// leading jornadas and leaves the rest as upcoming (unplayed) games — the + /// shape the Clausura uses so its standings AND "Próximos" fixture both have + /// data. Finished games carry a decisive score and winner; upcoming games + /// carry none. + /// + [Fact] + public void Build_OngoingDoubleRoundRobin_FinishesLeadingJornadasAndLeavesRestUpcoming() + { + SampleTournamentBuilder.TournamentDefinition definition = new( + Name: "Clausura En Curso", + Description: "Torneo en curso a dos ruedas.", + TeamRegistrationDeadline: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + StartDate: new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc), + StageStartDate: new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc), + StageEndDate: new DateTime(2026, 12, 1, 0, 0, 0, DateTimeKind.Utc), + Divisions: + [ + new( + "Primera División", + ["Equipo A", "Equipo B", "Equipo C", "Equipo D"], + ["EQA", "EQB", "EQC", "EQD"], + ["#111111", "#222222", "#333333", "#444444"]), + ], + Status: TournamentStatus.Ongoing, + RoundRobinLegs: 2, + PlayedRoundsPerZone: 3); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: false); + + // Group-only: no playoffs while the tournament is in progress. + Stage groupStage = Assert.Single(result.Tournament.Divisions.Single().Stages); + Assert.Equal(StageType.Group, groupStage.StageType); + Assert.Equal(2, groupStage.RoundRobinLegs); + + // 4 teams double round-robin = 6 jornadas (1..6); the first 3 are played. + List finishedRounds = [.. groupStage.Matches.Where(m => m.IsFinished).Select(m => m.Round!.Value).Distinct().OrderBy(r => r)]; + List upcomingRounds = [.. groupStage.Matches.Where(m => !m.IsFinished).Select(m => m.Round!.Value).Distinct().OrderBy(r => r)]; + + Assert.Equal([1, 2, 3], finishedRounds); + Assert.Equal([4, 5, 6], upcomingRounds); + + // Finished games have a decisive result and a winner; upcoming games do not. + Assert.All(groupStage.Matches.Where(m => m.IsFinished), m => + { + Assert.NotNull(m.HomeScore); + Assert.NotNull(m.VisitorScore); + Assert.NotNull(m.WinningTeamId); + }); + Assert.All(groupStage.Matches.Where(m => !m.IsFinished), m => + { + Assert.Null(m.HomeScore); + Assert.Null(m.VisitorScore); + Assert.Null(m.WinningTeamId); + Assert.Equal(MatchStatus.Scheduled, m.Status); + }); + + // Upcoming jornadas are scheduled on later dates than the played ones. + DateTime lastPlayed = groupStage.Matches.Where(m => m.IsFinished).Max(m => m.MatchDate); + DateTime firstUpcoming = groupStage.Matches.Where(m => !m.IsFinished).Min(m => m.MatchDate); + Assert.True(firstUpcoming > lastPlayed); + } +} diff --git a/Club12-Backend/API.Tests/SampleTournamentBuilderEliminationBracketTests.cs b/Club12-Backend/API.Tests/SampleTournamentBuilderEliminationBracketTests.cs new file mode 100644 index 0000000..4fddd97 --- /dev/null +++ b/Club12-Backend/API.Tests/SampleTournamentBuilderEliminationBracketTests.cs @@ -0,0 +1,237 @@ +using Application.Utils.Helper.Series; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using System.Linq; + +namespace API.Tests; + +/// +/// Covers SampleTournamentBuilder.SeedEliminationBracket: a playoff cup whose +/// position range has more than 4 real seeds (e.g. a 9-team Copa Plata, byes +/// padding the bracket to 16) must build a FULL multi-round bracket +/// (RoundOf16 -> QuarterFinal -> SemiFinal -> Final) where every round's +/// winners actually advance and play the next round — not just the fixed +/// SemiFinal + Final shape older/smaller (exactly 4-seed) cups use, which +/// used to silently drop every seed past the first two round-1 winners. +/// BestOf only applies to the SemiFinal and Final rounds; earlier rounds +/// always play Bo1. A BestOf > 1 round is a REAL MatchSeries with as many +/// finished games as it took to decide it — not one collapsed match. +/// +public class SampleTournamentBuilderEliminationBracketTests +{ + private static List BuildVenues() => + [ + new() { Slug = "venue-uno", CreatedBy = "test", Name = "Cancha Uno", Address = "Calle 1" }, + new() { Slug = "venue-dos", CreatedBy = "test", Name = "Cancha Dos", Address = "Calle 2" }, + ]; + + private static SampleTournamentBuilder.TournamentDefinition MakeDefinition( + int teamCount, SampleTournamentBuilder.PlayoffCupDefinition[] cups) + { + string[] names = [.. Enumerable.Range(1, teamCount).Select(i => $"Equipo {i}")]; + string[] codes = [.. Enumerable.Range(1, teamCount).Select(i => $"EQ{i:00}")]; + string[] colors = [.. Enumerable.Range(1, teamCount).Select(_ => "#111111")]; + + return new( + Name: "Torneo de prueba", + Description: "Torneo de prueba con un cupo de playoffs mayor a 4 equipos.", + 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("Zona Única", names, codes, colors, cups), + ]); + } + + [Fact] + public void Build_CupWithNineSeeds_BuildsFullRoundOf16ToFinalBracket() + { + SampleTournamentBuilder.PlayoffCupDefinition[] cups = + [new("Copa Plata", FromPosition: 1, ToPosition: 9, BestOf: 3)]; + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition(9, cups); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Division division = result.Tournament.Divisions.Single(); + List cupStages = [.. division.Stages.Where(s => s.BracketName == "Copa Plata")]; + + // 9 seeds -> next power of two (16): RoundOf16, QuarterFinal, SemiFinal, Final. + Assert.Equal(4, cupStages.Count); + Assert.Contains(cupStages, s => s.StageType == StageType.RoundOf16); + Assert.Contains(cupStages, s => s.StageType == StageType.QuarterFinal); + Assert.Contains(cupStages, s => s.StageType == StageType.SemiFinal); + Assert.Contains(cupStages, s => s.StageType == StageType.Final); + + Stage roundOf16 = cupStages.Single(s => s.StageType == StageType.RoundOf16); + Stage quarterFinal = cupStages.Single(s => s.StageType == StageType.QuarterFinal); + Stage semiFinal = cupStages.Single(s => s.StageType == StageType.SemiFinal); + Stage final = cupStages.Single(s => s.StageType == StageType.Final); + + // 9 real seeds padded to 16 = 7 byes + 1 real match in round 1. Every + // one of the 8 pairings gets a match row: a bye is a finished match with + // no visitor and no score whose home team is the winner — the same shape + // StageService.FillStageWithSeedsAsync writes — so the stage always has + // slots for the teams assigned to it. + Assert.Equal(9, roundOf16.StageTeamMatches.Count); + Assert.Equal(8, roundOf16.Matches.Count); + + List roundOf16Byes = [.. roundOf16.Matches.Where(m => m.VisitorTeamId is null)]; + Assert.Equal(7, roundOf16Byes.Count); + Assert.All(roundOf16Byes, m => + { + Assert.True(m.IsFinished); + Assert.Null(m.HomeScore); + Assert.Null(m.VisitorScore); + Assert.Equal(m.HomeTeamId, m.WinningTeamId); + }); + + // Every later round is bye-free: the 8 round-1 winners (7 byes plus + // the one real match's winner) actually play on, halving each round + // down to the champion. + Assert.Equal(8, quarterFinal.StageTeamMatches.Count); + Assert.Equal(4, quarterFinal.Matches.Count); + Assert.Equal(4, semiFinal.StageTeamMatches.Count); + Assert.Equal(2, final.StageTeamMatches.Count); + + // Series length applies only to the last two rounds. + // Earlier rounds are always single games. + Assert.Equal(1, roundOf16.BestOf); + Assert.Equal(1, quarterFinal.BestOf); + Assert.Equal(3, semiFinal.BestOf); + Assert.Equal(3, final.BestOf); + + // SemiFinal (2 pairings) and Final (1 pairing) are BestOf=3: each + // pairing is a REAL MatchSeries — SeriesDecisionCalculator decides + // it, not one collapsed match — with between 2 (a sweep) and 3 (the + // full distance) finished games, and every one of those games also + // belongs to the stage's Matches (so total match counts include + // every game actually played). + Assert.Equal(2, semiFinal.MatchSeries.Count); + Assert.All(semiFinal.MatchSeries, AssertDecidedSeries); + Assert.Equal(semiFinal.MatchSeries.Sum(s => s.Matches.Count), semiFinal.Matches.Count); + + MatchSeries finalSeries = Assert.Single(final.MatchSeries); + AssertDecidedSeries(finalSeries); + Assert.Equal(finalSeries.Matches.Count, final.Matches.Count); + } + + /// + /// A decided best-of-N series: SeriesDecisionCalculator.DetermineWinner + /// agrees with the recorded WinningTeamId (single source of truth), and + /// the number of finished games generated is between the minimum needed + /// to clinch it and the series' full BestOf — never more (no games are + /// generated past the decisive one) and never less (undecided). + /// + private static void AssertDecidedSeries(MatchSeries series) + { + Assert.NotNull(series.WinningTeamId); + Assert.Equal(series.WinningTeamId, 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.All(series.Matches, m => Assert.NotNull(m.GameNumber)); + Assert.Equal( + Enumerable.Range(1, series.Matches.Count), + series.Matches.OrderBy(m => m.GameNumber).Select(m => m.GameNumber!.Value)); + } + + [Fact] + public void Build_CupWithFourSeeds_KeepsTheClassicSemiFinalPlusFinalShape() + { + SampleTournamentBuilder.PlayoffCupDefinition[] cups = + [new("Copa Oro", FromPosition: 1, ToPosition: 4, BestOf: 3)]; + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition(4, cups); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Division division = result.Tournament.Divisions.Single(); + List cupStages = [.. division.Stages.Where(s => s.BracketName == "Copa Oro")]; + + Assert.Equal(2, cupStages.Count); + 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)); + + Stage final = cupStages.Single(s => s.StageType == StageType.Final); + MatchSeries finalSeries = Assert.Single(final.MatchSeries); + AssertDecidedSeries(finalSeries); + Assert.Equal(finalSeries.Matches.Count, final.Matches.Count); + } + + /// + /// BestOf=1 stays exactly how every pre-existing elimination stage in + /// this builder already works (see Stage.BestOf's doc comment: "1 means + /// a single match decides the round") — one plain, finished, decided + /// Match per pairing, with NO MatchSeries created. Only BestOf>1 rounds + /// generate a real series. + /// + [Fact] + public void Build_CupWithBestOfOne_UsesAPlainSingleMatchNotASeries() + { + SampleTournamentBuilder.PlayoffCupDefinition[] cups = + [new("Copa Bronce", FromPosition: 1, ToPosition: 4, BestOf: 1)]; + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition(4, cups); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Division division = result.Tournament.Divisions.Single(); + Stage final = division.Stages.Single(s => s.BracketName == "Copa Bronce" && s.StageType == StageType.Final); + + Assert.Equal(1, final.BestOf); + Assert.Empty(final.MatchSeries); + Match finalMatch = Assert.Single(final.Matches); + Assert.True(finalMatch.IsFinished); + Assert.NotNull(finalMatch.WinningTeamId); + Assert.Null(finalMatch.SeriesId); + Assert.Null(finalMatch.GameNumber); + } + + /// + /// The generated game count is not fixed per BestOf — a batch of several + /// Bo5 series (3 cups x SemiFinal+Final pairings, 9 series total) mixes + /// sweeps (3 games), 4-game series, and full-distance 5-game series, + /// instead of every series ending identically. Determinism (same input, + /// same output every run — no Random/DateTime) is implicit: this test + /// itself passing reliably (not flaking) across runs proves it. + /// + [Fact] + public void Build_SeveralBestOfFiveSeries_ProducesVariedGameCounts() + { + SampleTournamentBuilder.PlayoffCupDefinition[] cups = + [ + new("Copa A", FromPosition: 1, ToPosition: 4, BestOf: 5), + new("Copa B", FromPosition: 5, ToPosition: 8, BestOf: 5), + new("Copa C", FromPosition: 9, ToPosition: 12, BestOf: 5), + ]; + SampleTournamentBuilder.TournamentDefinition definition = MakeDefinition(12, cups); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true); + + Division division = result.Tournament.Divisions.Single(); + List allSeries = [.. division.Stages.SelectMany(s => s.MatchSeries)]; + + // 3 cups x (2 SemiFinal pairings + 1 Final pairing) = 9 series. + Assert.Equal(9, allSeries.Count); + Assert.All(allSeries, AssertDecidedSeries); + + List gameCounts = [.. allSeries.Select(s => s.Matches.Count)]; + Assert.True( + gameCounts.Distinct().Count() > 1, + $"Expected varied game counts across the batch, got: [{string.Join(", ", gameCounts)}]"); + } +} diff --git a/Club12-Backend/API.Tests/SampleTournamentBuilderSlugTests.cs b/Club12-Backend/API.Tests/SampleTournamentBuilderSlugTests.cs new file mode 100644 index 0000000..4a8a622 --- /dev/null +++ b/Club12-Backend/API.Tests/SampleTournamentBuilderSlugTests.cs @@ -0,0 +1,213 @@ +using Domain.Entities.Models; + +using Infrastructure.Persistance; + +using System.Text.RegularExpressions; + +namespace API.Tests; + +/// +/// Regression guard for the "uuids everywhere" bug: SampleTournamentBuilder +/// used to force division/stage slug uniqueness by appending a raw +/// to the slug source, so seeded public slugs looked +/// like "primera-division-34ce6485-e5be-4f85-80d9-e5afbb359547". Slugs must now +/// be clean kebab-case derived from the display name, with a numeric suffix +/// (-2, -3, ...) as the only disambiguator, and must stay unique across the +/// whole seeded batch so the DB unique indexes are never violated. +/// +public class SampleTournamentBuilderSlugTests +{ + // 8-4-4-4-12 lowercase-hex groups: exactly how a raw GUID survives slugging. + private static readonly Regex GuidPattern = + new("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", RegexOptions.Compiled); + + private static readonly Regex KebabPattern = + new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled); + + // Any run of 8+ consecutive digits — how a DNI/document number would survive + // slugging if it were still part of the player slug source. + private static readonly Regex EightDigitRunPattern = + new("[0-9]{8}", RegexOptions.Compiled); + + [Fact] + public void Build_DivisionAndStageSlugs_AreCleanKebabWithoutGuid() + { + List venues = + [ + new() { Slug = "venue-uno", CreatedBy = "test", Name = "Cancha Uno", Address = "Calle 1" }, + new() { Slug = "venue-dos", CreatedBy = "test", Name = "Cancha Dos", Address = "Calle 2" }, + ]; + + SampleTournamentBuilder.TournamentDefinition definition = new( + Name: "Torneo Apertura 2026", + Description: "Torneo de prueba con playoffs y copa cruzada.", + 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 División", + ["Equipo A", "Equipo B", "Equipo C", "Equipo D"], + ["EQA", "EQB", "EQC", "EQD"], + ["#111111", "#222222", "#333333", "#444444"]), + new( + "Segunda División", + ["Equipo E", "Equipo F", "Equipo G", "Equipo H"], + ["EQE", "EQF", "EQG", "EQH"], + ["#555555", "#666666", "#777777", "#888888"]), + ], + CrossCup: new("Copa Club 12", GroupCount: 2, QualifiersPerGroup: 1)); + + int playerCounter = 0; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, venues, ref playerCounter, includePlayoffs: true); + + List divisions = [.. result.Tournament.Divisions]; + List stages = [.. divisions.SelectMany(d => d.Stages)]; + + Assert.NotEmpty(divisions); + Assert.NotEmpty(stages); + + foreach (Division division in divisions) + { + AssertCleanSlug(division.Slug); + } + + foreach (Stage stage in stages) + { + AssertCleanSlug(stage.Slug); + } + + // The clean cross-cup division slug is exactly the kebab of its name. + Assert.Contains(divisions, d => d.Slug == "primera-division"); + Assert.Contains(divisions, d => d.Slug == "copa-club-12"); + + // Slugs stay unique within each table so the DB unique indexes hold. + List divisionSlugs = [.. divisions.Select(d => d.Slug)]; + List stageSlugs = [.. stages.Select(s => s.Slug)]; + Assert.Equal(divisionSlugs.Count, divisionSlugs.Distinct().Count()); + Assert.Equal(stageSlugs.Count, stageSlugs.Distinct().Count()); + } + + [Fact] + public void Build_SharedRegistryAcrossTournaments_KeepsStageSlugsUniqueWithNumericSuffix() + { + List venues = + [ + new() { Slug = "venue-uno", CreatedBy = "test", Name = "Cancha Uno", Address = "Calle 1" }, + ]; + + SampleTournamentBuilder.TournamentDefinition Make(string name) => new( + Name: name, + Description: "Torneo de prueba.", + 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.SlugRegistry slugRegistry = new(); + SampleTournamentBuilder.BuildResult first = + SampleTournamentBuilder.Build(Make("Torneo Apertura 2026"), venues, ref playerCounter, includePlayoffs: false, slugRegistry); + SampleTournamentBuilder.BuildResult second = + SampleTournamentBuilder.Build(Make("Torneo Clausura 2026"), venues, ref playerCounter, includePlayoffs: false, slugRegistry); + + List allStageSlugs = + [ + .. first.Tournament.Divisions.SelectMany(d => d.Stages).Select(s => s.Slug), + .. second.Tournament.Divisions.SelectMany(d => d.Stages).Select(s => s.Slug), + ]; + + foreach (string slug in allStageSlugs) + { + AssertCleanSlug(slug); + } + + // Both tournaments have a "Fase de Grupos" group stage for the same + // division name, so the shared registry disambiguates the second with a + // numeric suffix instead of a GUID. + Assert.Contains("fase-de-grupos-primera", allStageSlugs); + Assert.Contains("fase-de-grupos-primera-2", allStageSlugs); + Assert.Equal(allStageSlugs.Count, allStageSlugs.Distinct().Count()); + } + + [Fact] + public void Build_PlayerSlugs_AreCleanKebabDniFreeAndDistinctAcrossTheBatch() + { + List venues = + [ + new() { Slug = "venue-uno", CreatedBy = "test", Name = "Cancha Uno", Address = "Calle 1" }, + ]; + + SampleTournamentBuilder.TournamentDefinition Make(string name) => new( + Name: name, + Description: "Torneo de prueba.", + 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( + "Segunda", + ["Equipo E", "Equipo F", "Equipo G", "Equipo H"], + ["EQE", "EQF", "EQG", "EQH"], + ["#555555", "#666666", "#777777", "#888888"]), + ]); + + int playerCounter = 0; + SampleTournamentBuilder.SlugRegistry slugRegistry = new(); + + List allPlayerSlugs = []; + string[] tournamentNames = + [ + "Torneo Apertura 2022", "Torneo Clausura 2022", "Torneo Apertura 2023", "Torneo Clausura 2023", + "Torneo Apertura 2024", "Torneo Clausura 2024", "Torneo Apertura 2025", "Torneo Clausura 2025", + "Torneo Apertura 2026", "Torneo Clausura 2026", + ]; + foreach (string name in tournamentNames) + { + SampleTournamentBuilder.BuildResult result = SampleTournamentBuilder.Build( + Make(name), venues, ref playerCounter, includePlayoffs: false, slugRegistry); + + allPlayerSlugs.AddRange(result.Tournament.Teams.SelectMany(t => t.Players).Select(p => p.Slug)); + } + + // The batch must actually contain players and repeated names (so the + // registry's numeric-suffix path is exercised, not vacuously satisfied). + Assert.True(allPlayerSlugs.Count >= 128); + Assert.Contains(allPlayerSlugs, s => s.EndsWith("-2")); + + foreach (string slug in allPlayerSlugs) + { + AssertCleanSlug(slug); + Assert.DoesNotMatch(EightDigitRunPattern, slug); + } + + // Distinct across the whole multi-tournament batch, so IX_Players_Slug + // is never violated when seed names repeat. + Assert.Equal(allPlayerSlugs.Count, allPlayerSlugs.Distinct().Count()); + } + + private static void AssertCleanSlug(string slug) + { + Assert.False(string.IsNullOrWhiteSpace(slug)); + Assert.DoesNotMatch(GuidPattern, slug); + Assert.Matches(KebabPattern, slug); + } +} diff --git a/Club12-Backend/API.Tests/ScorerRepositoryTests.cs b/Club12-Backend/API.Tests/ScorerRepositoryTests.cs new file mode 100644 index 0000000..d2ba153 --- /dev/null +++ b/Club12-Backend/API.Tests/ScorerRepositoryTests.cs @@ -0,0 +1,514 @@ +using Application.DTOs.Scorer.Request; +using Application.DTOs.Scorer.Response; +using Application.Interfaces.Repositories; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.Extensions.DependencyInjection; + +using MatchType = Domain.Enums.MatchType; + +namespace API.Tests; + +/// +/// Characterization tests for ScorerRepository.GetPlayerScoresAsync +/// (ScorerRepository.cs:17-66), the EF aggregation query that produces the +/// top-scorer ranking (per-player point totals, name formatting, ordering, +/// pagination, and filters). These tests pin the currently-correct behavior +/// of the existing, unmodified repository — pure test additions, no +/// production code changes. +/// +/// Known test-harness limitation: the query uses `ToUpper()`, null-coalescing +/// string concatenation, and a correlated `Sum` subquery, all EF-translated to +/// SQL. SQLite (test provider) may translate these differently than Npgsql +/// (production). All seed data below is ASCII-only so SQLite's `upper()` +/// matches Npgsql `UPPER`, and every scenario in this file executed and +/// asserted successfully against the SQLite-backed harness — no runtime +/// translation failure was observed for `ToUpper`, the null-coalescing +/// concatenation, or the correlated `Sum` subquery. +/// +/// Each test scopes its query with `TournamentId` or `PlayerId` so that the +/// class-shared CustomWebApplicationFactory database (via IClassFixture) never +/// leaks another test's seeded rows into this test's assertions. +/// +public class ScorerRepositoryTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public ScorerRepositoryTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetPlayerScoresAsync_MultipleScores_SumsPoints() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + (Player player, Match match, _) = await SeedPlayerWithMatchAsync(db, "Sums", "Player", secondName: null); + await AddScorerAsync(db, player.Id, match.Id, points: 2); + await AddScorerAsync(db, player.Id, match.Id, points: 3); + + GetScorerFilteredRequest filter = new() { PlayerId = player.Id }; + (IEnumerable items, int totalCount) = await scorerRepository.GetPlayerScoresAsync(filter); + + ScorerByPlayerResponse result = Assert.Single(items); + Assert.Equal(5, result.Points); + Assert.Equal(1, totalCount); + } + + [Fact] + public async Task GetPlayerScoresAsync_NoScores_DefaultsToZero() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + (Player player, _, _) = await SeedPlayerWithMatchAsync(db, "Scoreless", "Player", secondName: null); + + GetScorerFilteredRequest filter = new() { PlayerId = player.Id }; + (IEnumerable items, _) = await scorerRepository.GetPlayerScoresAsync(filter); + + ScorerByPlayerResponse result = Assert.Single(items); + Assert.Equal(0, result.Points); + } + + [Fact] + public async Task GetPlayerScoresAsync_NoSecondName_FormatsWithoutTrailingSeparator() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + (Player player, _, _) = await SeedPlayerWithMatchAsync(db, "Smith", "John", secondName: null); + + GetScorerFilteredRequest filter = new() { PlayerId = player.Id }; + (IEnumerable items, _) = await scorerRepository.GetPlayerScoresAsync(filter); + + ScorerByPlayerResponse result = Assert.Single(items); + Assert.Equal("SMITH John", result.FullName); + } + + [Fact] + public async Task GetPlayerScoresAsync_WithSecondName_AppendsSecondName() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + (Player player, _, _) = await SeedPlayerWithMatchAsync(db, "Doe", "Jane", secondName: "Marie"); + + GetScorerFilteredRequest filter = new() { PlayerId = player.Id }; + (IEnumerable items, _) = await scorerRepository.GetPlayerScoresAsync(filter); + + ScorerByPlayerResponse result = Assert.Single(items); + Assert.Equal("DOE Jane Marie", result.FullName); + } + + [Fact] + public async Task GetPlayerScoresAsync_DistinctTotals_OrdersDescending() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + (Player low, Match match, _) = await SeedPlayerInTournamentAsync(db, tournament, "Low", "Scorer"); + (Player mid, _, _) = await SeedPlayerInTournamentAsync(db, tournament, "Mid", "Scorer", match); + (Player high, _, _) = await SeedPlayerInTournamentAsync(db, tournament, "High", "Scorer", match); + + await AddScorerAsync(db, low.Id, match.Id, points: 1); + await AddScorerAsync(db, mid.Id, match.Id, points: 5); + await AddScorerAsync(db, high.Id, match.Id, points: 9); + + GetScorerFilteredRequest filter = new() { TournamentId = tournament.Id }; + (IEnumerable items, int totalCount) = await scorerRepository.GetPlayerScoresAsync(filter); + + List ordered = [.. items]; + Assert.Equal(3, totalCount); + Assert.Equal(3, ordered.Count); + Assert.Equal(high.Id, ordered[0].PlayerId); + Assert.Equal(mid.Id, ordered[1].PlayerId); + Assert.Equal(low.Id, ordered[2].PlayerId); + Assert.Equal(9, ordered[0].Points); + Assert.Equal(5, ordered[1].Points); + Assert.Equal(1, ordered[2].Points); + } + + [Fact] + public async Task GetPlayerScoresAsync_PageTwo_ReturnsNextSliceAndFullTotalCount() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + + int[] pointsByRank = [50, 40, 30, 20, 10]; + List players = []; + Match? sharedMatch = null; + + for (int i = 0; i < pointsByRank.Length; i++) + { + (Player player, Match match, _) = await SeedPlayerInTournamentAsync(db, tournament, $"Rank{i}", "Scorer", sharedMatch); + sharedMatch ??= match; + players.Add(player); + await AddScorerAsync(db, player.Id, match.Id, points: pointsByRank[i]); + } + + GetScorerFilteredRequest filter = new() { TournamentId = tournament.Id, PageNumber = 2, PageSize = 2 }; + (IEnumerable items, int totalCount) = await scorerRepository.GetPlayerScoresAsync(filter); + + List page = [.. items]; + Assert.Equal(5, totalCount); + Assert.Equal(2, page.Count); + ScorerByPlayerResponse thirdPlace = page[0]; + ScorerByPlayerResponse fourthPlace = page[1]; + Assert.Equal(30, thirdPlace.Points); + Assert.Equal(20, fourthPlace.Points); + } + + [Fact] + public async Task GetPlayerScoresAsync_TournamentFilter_RestrictsPlayersAndScorers() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + Tournament tournamentA = await SeedTournamentAsync(db); + Tournament tournamentB = await SeedTournamentAsync(db); + + (Player playerA, Match matchA, _) = await SeedPlayerInTournamentAsync(db, tournamentA, "Alpha", "Player"); + await AddScorerAsync(db, playerA.Id, matchA.Id, points: 7); + + (Player playerB, Match matchB, _) = await SeedPlayerInTournamentAsync(db, tournamentB, "Bravo", "Player"); + await AddScorerAsync(db, playerB.Id, matchB.Id, points: 4); + + GetScorerFilteredRequest filter = new() { TournamentId = tournamentA.Id }; + (IEnumerable items, int totalCount) = await scorerRepository.GetPlayerScoresAsync(filter); + + List result = [.. items]; + Assert.Equal(1, totalCount); + ScorerByPlayerResponse single = Assert.Single(result); + Assert.Equal(playerA.Id, single.PlayerId); + Assert.Equal(7, single.Points); + } + + [Fact] + public async Task GetPlayerScoresAsync_MatchTeamOrPlayerFilter_NarrowsResultSet() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + (_, Stage stage) = await SeedDivisionAndStageAsync(db, tournament); + + Team homeTeam = await SeedTeamAsync(db, tournament.Id); + Team visitorTeam = await SeedTeamAsync(db, tournament.Id); + Team unrelatedTeam = await SeedTeamAsync(db, tournament.Id); + + Player homePlayer = await SeedPlayerAsync(db, homeTeam, "Home", "Player", null); + Player visitorPlayer = await SeedPlayerAsync(db, visitorTeam, "Visitor", "Player", null); + Player unrelatedPlayer = await SeedPlayerAsync(db, unrelatedTeam, "Unrelated", "Player", null); + + Match sharedMatch = await SeedMatchAsync(db, stage, homeTeamId: homeTeam.Id, visitorTeamId: visitorTeam.Id); + Match otherMatch = await SeedMatchAsync(db, stage, homeTeamId: unrelatedTeam.Id, visitorTeamId: null); + + await AddScorerAsync(db, homePlayer.Id, sharedMatch.Id, points: 3); + await AddScorerAsync(db, visitorPlayer.Id, sharedMatch.Id, points: 6); + await AddScorerAsync(db, unrelatedPlayer.Id, otherMatch.Id, points: 9); + + GetScorerFilteredRequest matchFilter = new() { MatchId = sharedMatch.Id }; + (IEnumerable matchItems, int matchTotal) = await scorerRepository.GetPlayerScoresAsync(matchFilter); + List matchPlayerIds = [.. matchItems.Select(i => i.PlayerId)]; + Assert.Equal(2, matchTotal); + Assert.Contains(homePlayer.Id, matchPlayerIds); + Assert.Contains(visitorPlayer.Id, matchPlayerIds); + Assert.DoesNotContain(unrelatedPlayer.Id, matchPlayerIds); + + GetScorerFilteredRequest teamFilter = new() { TeamId = homeTeam.Id }; + (IEnumerable teamItems, int teamTotal) = await scorerRepository.GetPlayerScoresAsync(teamFilter); + ScorerByPlayerResponse teamResult = Assert.Single(teamItems); + Assert.Equal(1, teamTotal); + Assert.Equal(homePlayer.Id, teamResult.PlayerId); + + GetScorerFilteredRequest playerFilter = new() { PlayerId = visitorPlayer.Id }; + (IEnumerable playerItems, int playerTotal) = await scorerRepository.GetPlayerScoresAsync(playerFilter); + ScorerByPlayerResponse playerResult = Assert.Single(playerItems); + Assert.Equal(1, playerTotal); + Assert.Equal(visitorPlayer.Id, playerResult.PlayerId); + Assert.Equal(6, playerResult.Points); + } + + [Fact] + public async Task GetPlayerScoresAsync_IncludesTeamAndTournamentScopedJerseyNumber() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + (Player player, Match match, Team team) = + await SeedPlayerInTournamentAsync(db, tournament, "Goleador", "Estrella"); + await AddScorerAsync(db, player.Id, match.Id, points: 12); + + db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration + { + PlayerId = player.Id, + TeamId = team.Id, + TournamentId = tournament.Id, + JerseyNumber = 23, + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + GetScorerFilteredRequest filter = new() { TournamentId = tournament.Id }; + (IEnumerable items, _) = await scorerRepository.GetPlayerScoresAsync(filter); + + ScorerByPlayerResponse result = Assert.Single(items); + Assert.Equal(team.Id, result.TeamId); + Assert.Equal(team.Name, result.TeamName); + Assert.Equal(23, result.JerseyNumber); + Assert.Equal(team.LogoUrl, result.TeamLogoUrl); + Assert.Equal(team.ShirtColor, result.TeamShirtColor); + } + + [Fact] + public async Task GetPlayerScoresAsync_DivisionFilter_RestrictsToThatDivisionsScorers() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IScorerRepository scorerRepository = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + (Division divisionA, Stage stageA) = await SeedDivisionAndStageAsync(db, tournament); + (_, Stage stageB) = await SeedDivisionAndStageAsync(db, tournament); + + Team teamA = await SeedTeamAsync(db, tournament.Id); + Team teamB = await SeedTeamAsync(db, tournament.Id); + await AssignTeamToStageAsync(db, stageA, teamA); + await AssignTeamToStageAsync(db, stageB, teamB); + + Player playerA = await SeedPlayerAsync(db, teamA, "Alpha", "Player", null); + Player playerB = await SeedPlayerAsync(db, teamB, "Bravo", "Player", null); + + Match matchA = await SeedMatchAsync(db, stageA, homeTeamId: teamA.Id, visitorTeamId: null); + Match matchB = await SeedMatchAsync(db, stageB, homeTeamId: teamB.Id, visitorTeamId: null); + await AddScorerAsync(db, playerA.Id, matchA.Id, points: 8); + await AddScorerAsync(db, playerB.Id, matchB.Id, points: 3); + + GetScorerFilteredRequest divisionFilter = new() { DivisionId = divisionA.Id }; + (IEnumerable divisionItems, int divisionTotal) = await scorerRepository.GetPlayerScoresAsync(divisionFilter); + ScorerByPlayerResponse divisionResult = Assert.Single(divisionItems); + Assert.Equal(1, divisionTotal); + Assert.Equal(playerA.Id, divisionResult.PlayerId); + Assert.Equal(8, divisionResult.Points); + + GetScorerFilteredRequest stageFilter = new() { StageId = stageB.Id }; + (IEnumerable stageItems, int stageTotal) = await scorerRepository.GetPlayerScoresAsync(stageFilter); + ScorerByPlayerResponse stageResult = Assert.Single(stageItems); + Assert.Equal(1, stageTotal); + Assert.Equal(playerB.Id, stageResult.PlayerId); + Assert.Equal(3, stageResult.Points); + } + + 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(); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Scorer ranking characterization 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 division, Stage stage)> SeedDivisionAndStageAsync(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 (division, stage); + } + + private static async Task 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 = "Yellow", + TournamentId = tournamentId, + Players = [], + CreatedBy = "test", + }; + + db.Teams.Add(team); + await db.SaveChangesAsync(); + + return team; + } + + private static async Task SeedPlayerAsync(ApplicationDBContext db, Team team, string lastName, string firstName, string? secondName) + { + Player player = new() + { + Slug = $"player-{Guid.NewGuid()}", + FirstName = firstName, + LastName = lastName, + SecondName = secondName, + 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 SeedMatchAsync(ApplicationDBContext db, Stage stage, Guid? homeTeamId, Guid? visitorTeamId) + { + Match match = new() + { + MatchDate = stage.StartDate, + Type = MatchType.Regular, + Slug = $"match-{Guid.NewGuid()}", + IsFinished = true, + Stage = stage, + StageId = stage.Id, + HomeTeamId = homeTeamId, + VisitorTeamId = visitorTeamId, + CreatedBy = "test", + }; + + db.Matches.Add(match); + await db.SaveChangesAsync(); + + return match; + } + + /// + /// Seeds a full standalone graph (own tournament/division/stage/team/match) for a single + /// player, used by tests that scope the query via PlayerId and don't care about a shared + /// tournament. + /// + private static async Task<(Player player, Match match, Team team)> SeedPlayerWithMatchAsync( + ApplicationDBContext db, string lastName, string firstName, string? secondName) + { + Tournament tournament = await SeedTournamentAsync(db); + return await SeedPlayerInTournamentAsync(db, tournament, lastName, firstName, sharedMatch: null, secondName); + } + + /// + /// Seeds a player (with its own team) inside an existing tournament, reusing a shared + /// match when provided (so multiple players can be scored against the same match/stage), + /// or creating a new division/stage/match otherwise. + /// + private static async Task<(Player player, Match match, Team team)> SeedPlayerInTournamentAsync( + ApplicationDBContext db, Tournament tournament, string lastName, string firstName, + Match? sharedMatch = null, string? secondName = null) + { + Team team = await SeedTeamAsync(db, tournament.Id); + Player player = await SeedPlayerAsync(db, team, lastName, firstName, secondName); + + Match match; + if (sharedMatch is not null) + { + match = sharedMatch; + } + else + { + (_, Stage stage) = await SeedDivisionAndStageAsync(db, tournament); + match = await SeedMatchAsync(db, stage, homeTeamId: team.Id, visitorTeamId: null); + } + + return (player, match, team); + } + + /// + /// HU-72: the goleadores ranking now aggregates from PlayerStatistic + /// (Type == Points) — the same table the per-match loading path writes — + /// instead of the retired Scorer read-model. These characterization tests + /// therefore seed the player's points as a Points PlayerStatistic. + /// + private static async Task AddScorerAsync(ApplicationDBContext db, Guid playerId, Guid matchId, int points) + { + PlayerStatistic statistic = new() + { + PlayerId = playerId, + MatchId = matchId, + Value = points, + Type = StatisticType.Points, + CreatedBy = "test", + }; + + db.PlayersStatistics.Add(statistic); + await db.SaveChangesAsync(); + + return statistic; + } +} diff --git a/Club12-Backend/API.Tests/SeasonControllerAuthorizationTests.cs b/Club12-Backend/API.Tests/SeasonControllerAuthorizationTests.cs new file mode 100644 index 0000000..4a9a4ed --- /dev/null +++ b/Club12-Backend/API.Tests/SeasonControllerAuthorizationTests.cs @@ -0,0 +1,108 @@ +using Application.DTOs.Season.Request; + +using Domain.Enums; + +using System.Net; +using System.Net.Http.Json; + +namespace API.Tests; + +/// +/// Proves SeasonController's authorization contract: reads are public +/// (AllowAnonymous) while writes require a staff role (Owner or Admin). These +/// are real HTTP round trips through CustomWebApplicationFactory, since +/// [Authorize] only takes effect in the MVC pipeline — mirroring +/// AuthorizationGatingTests. +/// +public class SeasonControllerAuthorizationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SeasonControllerAuthorizationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static CreateSeasonRequest BuildSeasonRequest() => new() + { + Name = $"Temporada {Guid.NewGuid():N}", + Year = 2026, + }; + + [Fact] + public async Task GetAllSeasons_Anonymous_ReturnsOk() + { + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = await client.GetAsync("api/seasons"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task CreateSeason_Anonymous_ReturnsUnauthorized() + { + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = await client.PostAsJsonAsync("api/seasons", BuildSeasonRequest()); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CreateSeason_GuestRole_ReturnsForbidden() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest); + + HttpResponseMessage response = await client.PostAsJsonAsync("api/seasons", BuildSeasonRequest()); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData(Roles.Admin)] + [InlineData(Roles.Owner)] + public async Task CreateSeason_StaffRole_Succeeds(string role) + { + HttpClient client = _factory.CreateAuthenticatedClient(role); + + HttpResponseMessage response = await client.PostAsJsonAsync("api/seasons", BuildSeasonRequest()); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task CreateThenGetSeasonBySlug_IsPublicAndRoundTrips() + { + HttpClient staffClient = _factory.CreateAuthenticatedClient(Roles.Admin); + CreateSeasonRequest request = BuildSeasonRequest(); + + HttpResponseMessage created = await staffClient.PostAsJsonAsync("api/seasons", request); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + SeasonBody? body = await created.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(string.IsNullOrWhiteSpace(body!.Slug)); + + // Public read by slug works without any authentication. + HttpClient anonymous = _factory.CreateClient(); + HttpResponseMessage bySlug = await anonymous.GetAsync($"api/seasons/{body.Slug}"); + + Assert.Equal(HttpStatusCode.OK, bySlug.StatusCode); + SeasonBody? bySlugBody = await bySlug.Content.ReadFromJsonAsync(); + Assert.NotNull(bySlugBody); + Assert.Equal(body.Id, bySlugBody!.Id); + } + + [Fact] + public async Task DeleteSeason_GuestRole_ReturnsForbidden() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest); + + HttpResponseMessage response = await client.DeleteAsync($"api/seasons/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + private sealed record SeasonBody(Guid Id, string Slug, string Name); +} diff --git a/Club12-Backend/API.Tests/SeasonDeleteCascadeTests.cs b/Club12-Backend/API.Tests/SeasonDeleteCascadeTests.cs new file mode 100644 index 0000000..99fb4ca --- /dev/null +++ b/Club12-Backend/API.Tests/SeasonDeleteCascadeTests.cs @@ -0,0 +1,125 @@ +using Application.Interfaces.Services; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Season→Tournament is DB-level SetNull, not Cascade (a season is a purely +/// additive grouping and must never silently wipe a tournament's history — +/// see SeasonEntityConfiguration). A bare bulk delete of the Season +/// row would therefore only detach its tournaments, leaving them alive but +/// orphaned: no season, yet still enrolling teams, still blocking those +/// teams from being deleted, and invisible from any season-scoped screen. +/// must route through +/// for each of the +/// season's tournaments instead. +/// +public class SeasonDeleteCascadeTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SeasonDeleteCascadeTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task<(Guid SeasonId, Guid TournamentId)> CreateSeasonWithTournamentAsync( + IServiceProvider services, string label) + { + ISeasonService seasonService = services.GetRequiredService(); + ITournamentService tournamentService = services.GetRequiredService(); + + Season season = await seasonService.CreateSeasonAsync(new Season + { + Name = $"Temp-{label}-{Guid.NewGuid():N}", + Slug = null!, + Year = 2026, + CreatedBy = AuditConstants.SystemUser, + }); + + Tournament tournament = new() + { + Name = $"Torneo-{label}-{Guid.NewGuid():N}", + Slug = null!, + Description = "cascade-delete test tournament", + StartDate = DateTime.UtcNow.Date.AddDays(30), + TeamRegistrationDeadline = DateTime.UtcNow.Date.AddDays(29), + Category = TournamentCategory.Masculine, + SeasonId = season.Id, + CreatedBy = AuditConstants.SystemUser, + Divisions = [], + Teams = [], + }; + await tournamentService.CreateTournamentAsync(tournament); + + return (season.Id, tournament.Id); + } + + [Fact] + public async Task DeleteSeason_DeletesItsTournament_InsteadOfOrphaningIt() + { + Guid seasonId, tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + (seasonId, tournamentId) = + await CreateSeasonWithTournamentAsync(scope.ServiceProvider, "Cascade"); + } + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + await seasonService.DeleteSeasonAsync(seasonId); + } + + using IServiceScope assertScope = _factory.Services.CreateScope(); + ApplicationDBContext db = assertScope.ServiceProvider.GetRequiredService(); + + Assert.Null(await db.Seasons.SingleOrDefaultAsync(s => s.Id == seasonId)); + // The tournament must be gone entirely, not merely orphaned with a + // null SeasonId — an orphan would still block its enrolled teams + // from being deleted and would be unreachable from the UI. + Assert.Null(await db.Tournaments.SingleOrDefaultAsync(t => t.Id == tournamentId)); + } + + [Fact] + public async Task DeleteSeason_WithAnOngoingTournament_ThrowsAndKeepsBoth() + { + Guid seasonId, tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + (seasonId, tournamentId) = + await CreateSeasonWithTournamentAsync(scope.ServiceProvider, "Blocked"); + + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await db.Tournaments.SingleAsync(t => t.Id == tournamentId); + tournament.Status = TournamentStatus.Ongoing; + await db.SaveChangesAsync(); + } + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAsync( + () => seasonService.DeleteSeasonAsync(seasonId)); + } + + using IServiceScope assertScope = _factory.Services.CreateScope(); + ApplicationDBContext db2 = assertScope.ServiceProvider.GetRequiredService(); + + // The failed delete must not leave the season gone with the + // tournament orphaned — both must still exist, linked, so the admin + // sees exactly why the season can't be deleted. + Assert.NotNull(await db2.Seasons.SingleOrDefaultAsync(s => s.Id == seasonId)); + Tournament survivingTournament = + await db2.Tournaments.SingleAsync(t => t.Id == tournamentId); + Assert.Equal(seasonId, survivingTournament.SeasonId); + } +} diff --git a/Club12-Backend/API.Tests/SeasonListOrderingTests.cs b/Club12-Backend/API.Tests/SeasonListOrderingTests.cs new file mode 100644 index 0000000..7948314 --- /dev/null +++ b/Club12-Backend/API.Tests/SeasonListOrderingTests.cs @@ -0,0 +1,91 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies SeasonService.GetAllSeasonsAsync returns seasons ordered by +/// descending (newest first), null years last, with +/// as the deterministic tiebreaker. The admin +/// (/panel/temporadas) and public (/temporadas) pages render the +/// array in the order the endpoint returns it, so this ordering is the +/// effective one. Each test tags its seasons with a unique name token so the +/// shared fixture database cannot leak rows between tests. +/// +public class SeasonListOrderingTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SeasonListOrderingTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetAllSeasonsAsync_OrdersByYearDescending() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + string token = Guid.NewGuid().ToString("N")[..8]; + // Created out of chronological order — a physical/insertion-order + // result would come back 2024, 2026, 2025. + await CreateSeasonAsync(seasonService, $"Temporada {token} 2024", 2024); + await CreateSeasonAsync(seasonService, $"Temporada {token} 2026", 2026); + await CreateSeasonAsync(seasonService, $"Temporada {token} 2025", 2025); + + List years = [.. (await seasonService.GetAllSeasonsAsync()) + .Where(season => season.Name.Contains(token)) + .Select(season => season.Year)]; + + Assert.Equal([2026, 2025, 2024], years); + } + + [Fact] + public async Task GetAllSeasonsAsync_NullYearSeasons_SortLast() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + string token = Guid.NewGuid().ToString("N")[..8]; + await CreateSeasonAsync(seasonService, $"Temporada {token} sin anio", year: null); + await CreateSeasonAsync(seasonService, $"Temporada {token} 2025", 2025); + + List years = [.. (await seasonService.GetAllSeasonsAsync()) + .Where(season => season.Name.Contains(token)) + .Select(season => season.Year)]; + + Assert.Equal([2025, null], years); + } + + [Fact] + public async Task GetAllSeasonsAsync_SameYear_BrokenByName() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + string token = Guid.NewGuid().ToString("N")[..8]; + await CreateSeasonAsync(seasonService, $"Temporada {token} B", 2026); + await CreateSeasonAsync(seasonService, $"Temporada {token} A", 2026); + + List names = [.. (await seasonService.GetAllSeasonsAsync()) + .Where(season => season.Name.Contains(token)) + .Select(season => season.Name)]; + + Assert.Equal([$"Temporada {token} A", $"Temporada {token} B"], names); + } + + private static async Task CreateSeasonAsync(ISeasonService service, string name, int? year) + { + await service.CreateSeasonAsync(new Season + { + Name = name, + Slug = null!, + Year = year, + CreatedBy = "test", + }); + } +} diff --git a/Club12-Backend/API.Tests/SeasonServiceSlugTests.cs b/Club12-Backend/API.Tests/SeasonServiceSlugTests.cs new file mode 100644 index 0000000..0a8e485 --- /dev/null +++ b/Club12-Backend/API.Tests/SeasonServiceSlugTests.cs @@ -0,0 +1,104 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies Season's slug support: SeasonService.CreateSeasonAsync generates a +/// clean, unique kebab-case slug from the season's Name, and +/// GetSeasonByIdOrSlugAsync resolves a season by either its GUID id or its +/// slug. Exercised at the service layer (resolved directly from DI), mirroring +/// VenueServiceSlugTests / DivisionSlugTests. +/// +public class SeasonServiceSlugTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SeasonServiceSlugTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateSeasonAsync_GeneratesCleanKebabSlugFromName() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + Season created = await seasonService.CreateSeasonAsync(new Season + { + Name = $"Temporada Ñandú {Guid.NewGuid():N}", + Slug = null!, + Year = 2026, + CreatedBy = "test", + }); + + Assert.False(string.IsNullOrWhiteSpace(created.Slug)); + Assert.DoesNotContain(' ', created.Slug); + Assert.Equal(created.Slug, created.Slug.ToLowerInvariant()); + // Clean kebab: only lowercase letters, digits and single hyphens, no GUID braces/underscores. + Assert.Matches("^[a-z0-9]+(-[a-z0-9]+)*$", created.Slug); + } + + [Fact] + public async Task CreateSeasonAsync_DuplicateName_AppendsSuffixToSlug() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + string sharedName = $"Temporada Compartida {Guid.NewGuid():N}"; + + Season first = await seasonService.CreateSeasonAsync(new Season + { + Name = sharedName, + Slug = null!, + CreatedBy = "test", + }); + + Season second = await seasonService.CreateSeasonAsync(new Season + { + Name = sharedName, + Slug = null!, + CreatedBy = "test", + }); + + Assert.NotEqual(first.Slug, second.Slug); + Assert.Equal($"{first.Slug}-2", second.Slug); + } + + [Fact] + public async Task GetSeasonByIdOrSlugAsync_ResolvesByGuidIdAndBySlug() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + Season created = await seasonService.CreateSeasonAsync(new Season + { + Name = $"Temporada Resolucion {Guid.NewGuid():N}", + Slug = null!, + CreatedBy = "test", + }); + + Season? byId = await seasonService.GetSeasonByIdOrSlugAsync(created.Id.ToString()); + Season? bySlug = await seasonService.GetSeasonByIdOrSlugAsync(created.Slug); + + Assert.NotNull(byId); + Assert.Equal(created.Id, byId!.Id); + Assert.NotNull(bySlug); + Assert.Equal(created.Id, bySlug!.Id); + } + + [Fact] + public async Task GetSeasonByIdOrSlugAsync_UnknownSlug_ReturnsNull() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ISeasonService seasonService = scope.ServiceProvider.GetRequiredService(); + + Season? found = await seasonService.GetSeasonByIdOrSlugAsync($"unknown-slug-{Guid.NewGuid():N}"); + + Assert.Null(found); + } +} diff --git a/Club12-Backend/API.Tests/SeasonTournamentLinkTests.cs b/Club12-Backend/API.Tests/SeasonTournamentLinkTests.cs new file mode 100644 index 0000000..3bc5855 --- /dev/null +++ b/Club12-Backend/API.Tests/SeasonTournamentLinkTests.cs @@ -0,0 +1,126 @@ +using Application.DTOs.Tournament.Request; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// A tournament created under a season must actually persist its +/// , so the season's Tournaments collection +/// (what the admin "Temporadas" grid counts) reflects it. Covers BOTH create +/// paths: the incremental base create the wizard uses (POST /api/tournaments, +/// mapper + CreateTournamentAsync) and the compound create +/// (POST /api/tournaments/full, CreateFullTournamentAsync). +/// +public class SeasonTournamentLinkTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SeasonTournamentLinkTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task CreateSeasonAsync(IServiceProvider services, string name) + { + ISeasonService seasonService = services.GetRequiredService(); + Season season = await seasonService.CreateSeasonAsync(new Season + { + Name = name, + Slug = null!, + Year = 2026, + CreatedBy = AuditConstants.SystemUser, + }); + return season.Id; + } + + [Fact] + public async Task BaseCreate_LinksTournamentToItsSeason() + { + string seasonName = $"Temp-Base-{Guid.NewGuid():N}"; + string tournamentName = $"Torneo-Base-{Guid.NewGuid():N}"; + Guid seasonId; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + seasonId = await CreateSeasonAsync(scope.ServiceProvider, seasonName); + + IMapper mapper = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = + scope.ServiceProvider.GetRequiredService(); + + // Exactly what TournamentController.CreateTournament does: bind the + // wizard's payload (which carries seasonId) and map it to the entity. + CreateTournamentRequest request = new() + { + Name = tournamentName, + Description = "wizard base create", + StartDate = DateTime.UtcNow.Date.AddDays(30), + TeamRegistrationDeadline = DateTime.UtcNow.Date.AddDays(29), + Category = TournamentCategory.Masculine, + SeasonId = seasonId, + }; + + Tournament tournament = mapper.Map(request); + await tournamentService.CreateTournamentAsync(tournament); + } + + await AssertSeasonHasOneTournamentAsync(seasonId, tournamentName); + } + + [Fact] + public async Task FullCreate_LinksTournamentToItsSeason() + { + string seasonName = $"Temp-Full-{Guid.NewGuid():N}"; + string tournamentName = $"Torneo-Full-{Guid.NewGuid():N}"; + Guid seasonId; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + seasonId = await CreateSeasonAsync(scope.ServiceProvider, seasonName); + + ITournamentService tournamentService = + scope.ServiceProvider.GetRequiredService(); + + CreateFullTournamentRequest request = new() + { + Name = tournamentName, + Description = "wizard full create", + StartDate = DateTime.UtcNow.Date.AddDays(30), + TeamRegistrationDeadline = DateTime.UtcNow.Date.AddDays(29), + Category = TournamentCategory.Masculine, + SeasonId = seasonId, + Divisions = [], + }; + + await tournamentService.CreateFullTournamentAsync(request); + } + + await AssertSeasonHasOneTournamentAsync(seasonId, tournamentName); + } + + private async Task AssertSeasonHasOneTournamentAsync(Guid seasonId, string tournamentName) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await db.Tournaments.SingleAsync(t => t.Name == tournamentName); + Assert.Equal(seasonId, tournament.SeasonId); + + // This is exactly what the admin "Temporadas" grid counts. + Season season = await db.Seasons + .Include(s => s.Tournaments) + .SingleAsync(s => s.Id == seasonId); + Assert.Contains(season.Tournaments, t => t.Name == tournamentName); + } +} diff --git a/Club12-Backend/API.Tests/SeedChampionsResolutionTests.cs b/Club12-Backend/API.Tests/SeedChampionsResolutionTests.cs new file mode 100644 index 0000000..9125548 --- /dev/null +++ b/Club12-Backend/API.Tests/SeedChampionsResolutionTests.cs @@ -0,0 +1,162 @@ +using Application.DTOs.Champions.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; + +/// +/// End-to-end proof that the seed builder's tournaments feed the Campeones page +/// correctly: a tournament built the way the startup DataSeeder builds the +/// Apertura (playoffs + cross cup, ) +/// resolves real champions through , while a +/// Clausura-style ONGOING tournament never appears in the champions history and +/// keeps its upcoming (unplayed) fixture. Persisted to the shared SQLite harness +/// and queried through the real service, so it exercises the exact path +/// GET api/champions uses. +/// +public class SeedChampionsResolutionTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + // Both facts persist their players into the SAME shared class DB. Player + // slugs no longer carry the document number, so a shared registry (not the + // distinct counter bases) is what keeps every seeded Player.Slug unique + // across the two builds — exactly how DataSeeder/DataMaintenanceService + // share one registry across the tournaments they save together. + private static readonly SampleTournamentBuilder.SlugRegistry SharedSlugRegistry = new(); + + public SeedChampionsResolutionTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static List BuildVenues() => + [ + new() { Slug = $"venue-{Guid.NewGuid()}", CreatedBy = "test", Name = "Cancha Uno", Address = "Calle 1" }, + new() { Slug = $"venue-{Guid.NewGuid()}", CreatedBy = "test", Name = "Cancha Dos", Address = "Calle 2" }, + ]; + + private static string[] UniqueNames(int count) => + [.. Enumerable.Range(0, count).Select(_ => $"Club {Guid.NewGuid()}")]; + + private static string[] Codes(int count) => + [.. Enumerable.Range(0, count).Select(i => $"C{i:00}")]; + + private static string[] Colors(int count) => + [.. Enumerable.Range(0, count).Select(_ => "#1E3A8A")]; + + [Fact] + public async Task FinishedTournament_LikeSeededApertura_ResolvesChampionsInHistory() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + // Division/cup names are Guid-suffixed so the shared SQLite harness (one + // DB per test class, reused across facts) never collides on the global + // Division.Slug unique index. + string zoneName = $"Primera División {Guid.NewGuid()}"; + string cupName = $"Copa Club 12 {Guid.NewGuid()}"; + // Position-range cups exactly like the seeded Apertura's regular + // divisions: they register DivisionPlayoffMappings AND make the champion + // resolve from the top cup (Copa Oro, positions 1-4) Final. + SampleTournamentBuilder.PlayoffCupDefinition[] cups = + [ + new("Copa Oro", FromPosition: 1, ToPosition: 4, BestOf: 1), + new("Copa Plata", FromPosition: 5, ToPosition: 8, BestOf: 1), + ]; + SampleTournamentBuilder.TournamentDefinition definition = new( + Name: $"Apertura Finished {Guid.NewGuid()}", + Description: "Torneo finalizado con playoffs y copa.", + TeamRegistrationDeadline: new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), + StartDate: new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + StageStartDate: new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + StageEndDate: new DateTime(2026, 6, 30, 0, 0, 0, DateTimeKind.Utc), + Divisions: + [ + new(zoneName, UniqueNames(8), Codes(8), Colors(8), cups), + ], + CrossCup: new(cupName, GroupCount: 2, QualifiersPerGroup: 1), + Status: TournamentStatus.Finished, + Category: TournamentCategory.Masculine); + + // Distinct counter base so player document numbers (and thus slugs) never + // collide with the other fact's players in the shared class DB. + int playerCounter = 100_000; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: true, SharedSlugRegistry); + + db.Tournaments.Add(result.Tournament); + db.PlayerSanctions.AddRange(result.Sanctions); + await db.SaveChangesAsync(); + + // The position-range cups registered the playoff mappings the standings + // colouring reads (Copa Oro 1-4, Copa Plata 5-8). + Division zoneDivision = await db.Divisions + .Include(d => d.PlayoffMappings) + .SingleAsync(d => d.Name == zoneName); + Assert.Contains(zoneDivision.PlayoffMappings, m => m.Destination == "Copa Oro" && m.FromPosition == 1 && m.ToPosition == 4); + Assert.Contains(zoneDivision.PlayoffMappings, m => m.Destination == "Copa Plata" && m.FromPosition == 5 && m.ToPosition == 8); + + List history = await championService.GetChampionsHistoryAsync(seasonId: null); + List entries = [.. history.Where(h => h.TournamentId == result.Tournament.Id)]; + + // Champions STILL resolve after switching to SeedCupPlayoffs: the zone + // division is crowned via its top cup's (Copa Oro) Final. + Assert.NotEmpty(entries); + Assert.All(entries, e => Assert.NotNull(e.ChampionTeam)); + Assert.Contains(entries, e => e.DivisionName == zoneName); + Assert.All(entries, e => Assert.Equal(TournamentCategory.Masculine.ToString(), e.Category)); + } + + [Fact] + public async Task OngoingTournament_LikeSeededClausura_HasNoChampionsButKeepsUpcomingFixture() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IChampionService championService = scope.ServiceProvider.GetRequiredService(); + + SampleTournamentBuilder.TournamentDefinition definition = new( + Name: $"Clausura Ongoing {Guid.NewGuid()}", + Description: "Torneo en curso a dos ruedas.", + TeamRegistrationDeadline: new DateTime(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc), + StartDate: new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc), + StageStartDate: new DateTime(2026, 7, 5, 0, 0, 0, DateTimeKind.Utc), + StageEndDate: new DateTime(2026, 12, 15, 0, 0, 0, DateTimeKind.Utc), + Divisions: + [ + new($"Primera División {Guid.NewGuid()}", UniqueNames(8), Codes(8), Colors(8)), + ], + Status: TournamentStatus.Ongoing, + Category: TournamentCategory.Masculine, + RoundRobinLegs: 2, + PlayedRoundsPerZone: 8); + + // Distinct counter base so player document numbers (and thus slugs) never + // collide with the other fact's players in the shared class DB. + int playerCounter = 500_000; + SampleTournamentBuilder.BuildResult result = + SampleTournamentBuilder.Build(definition, BuildVenues(), ref playerCounter, includePlayoffs: false, SharedSlugRegistry); + + db.Tournaments.Add(result.Tournament); + db.PlayerSanctions.AddRange(result.Sanctions); + await db.SaveChangesAsync(); + + List history = await championService.GetChampionsHistoryAsync(seasonId: null); + Assert.DoesNotContain(history, h => h.TournamentId == result.Tournament.Id); + + // The in-progress tournament persists both finished (played) and upcoming + // (unplayed) matches so its standings and "Próximos" fixture have data. + List stageIds = [.. result.Tournament.Divisions.SelectMany(d => d.Stages).Select(s => s.Id)]; + List matches = await db.Matches.Where(m => stageIds.Contains(m.StageId)).ToListAsync(); + Assert.Contains(matches, m => m.IsFinished); + Assert.Contains(matches, m => !m.IsFinished); + } +} diff --git a/Club12-Backend/API.Tests/SeriesDecisionCalculatorTests.cs b/Club12-Backend/API.Tests/SeriesDecisionCalculatorTests.cs new file mode 100644 index 0000000..e3cf72f --- /dev/null +++ b/Club12-Backend/API.Tests/SeriesDecisionCalculatorTests.cs @@ -0,0 +1,142 @@ +using Application.Utils.Helper.Series; + +using Domain.Entities.Models; + +using MatchType = Domain.Enums.MatchType; + +namespace API.Tests; + +public class SeriesDecisionCalculatorTests +{ + private static Match MakeGame(Guid homeTeamId, Guid visitorTeamId, int gameNumber, Guid? winningTeamId, bool isFinished = true) + { + return new() + { + Id = Guid.NewGuid(), + CreatedBy = "test", + MatchDate = new DateTime(2026, 1, gameNumber, 0, 0, 0, DateTimeKind.Utc), + Type = MatchType.Playoff, + Slug = $"match-{Guid.NewGuid()}", + HomeTeamId = homeTeamId, + VisitorTeamId = visitorTeamId, + IsFinished = isFinished, + WinningTeamId = winningTeamId, + GameNumber = gameNumber, + }; + } + + [Fact] + public void DetermineWinner_BestOfThree_TwoWinsForOneTeam_DecidesTheSeriesEarly() + { + Guid home = Guid.NewGuid(); + Guid visitor = Guid.NewGuid(); + MatchSeries series = new() + { + Id = Guid.NewGuid(), + CreatedBy = "test", + StageId = Guid.NewGuid(), + HomeTeamId = home, + VisitorTeamId = visitor, + BestOf = 3, + Matches = + [ + MakeGame(home, visitor, 1, home), + MakeGame(home, visitor, 2, home), + ], + }; + + Guid? winner = SeriesDecisionCalculator.DetermineWinner(series); + + Assert.Equal(home, winner); + } + + [Fact] + public void DetermineWinner_BestOfThree_SplitOneEach_NotYetDecided() + { + Guid home = Guid.NewGuid(); + Guid visitor = Guid.NewGuid(); + MatchSeries series = new() + { + Id = Guid.NewGuid(), + CreatedBy = "test", + StageId = Guid.NewGuid(), + HomeTeamId = home, + VisitorTeamId = visitor, + BestOf = 3, + Matches = + [ + MakeGame(home, visitor, 1, home), + MakeGame(home, visitor, 2, visitor), + ], + }; + + Guid? winner = SeriesDecisionCalculator.DetermineWinner(series); + + Assert.Null(winner); + } + + [Fact] + public void DetermineWinner_UnfinishedGamesAreIgnored() + { + Guid home = Guid.NewGuid(); + Guid visitor = Guid.NewGuid(); + MatchSeries series = new() + { + Id = Guid.NewGuid(), + CreatedBy = "test", + StageId = Guid.NewGuid(), + HomeTeamId = home, + VisitorTeamId = visitor, + BestOf = 3, + Matches = + [ + MakeGame(home, visitor, 1, home), + MakeGame(home, visitor, 2, null, isFinished: false), + ], + }; + + Guid? winner = SeriesDecisionCalculator.DetermineWinner(series); + + Assert.Null(winner); + } + + [Fact] + public void DetermineWinner_BestOfOne_SingleGameDecidesImmediately() + { + Guid home = Guid.NewGuid(); + Guid visitor = Guid.NewGuid(); + MatchSeries series = new() + { + Id = Guid.NewGuid(), + CreatedBy = "test", + StageId = Guid.NewGuid(), + HomeTeamId = home, + VisitorTeamId = visitor, + BestOf = 1, + Matches = [MakeGame(home, visitor, 1, visitor)], + }; + + Guid? winner = SeriesDecisionCalculator.DetermineWinner(series); + + Assert.Equal(visitor, winner); + } + + [Fact] + public void DetermineWinner_NoGamesYet_ReturnsNull() + { + MatchSeries series = new() + { + Id = Guid.NewGuid(), + CreatedBy = "test", + StageId = Guid.NewGuid(), + HomeTeamId = Guid.NewGuid(), + VisitorTeamId = Guid.NewGuid(), + BestOf = 5, + Matches = [], + }; + + Guid? winner = SeriesDecisionCalculator.DetermineWinner(series); + + Assert.Null(winner); + } +} diff --git a/Club12-Backend/API.Tests/SlugGeneratorTests.cs b/Club12-Backend/API.Tests/SlugGeneratorTests.cs new file mode 100644 index 0000000..738e4a2 --- /dev/null +++ b/Club12-Backend/API.Tests/SlugGeneratorTests.cs @@ -0,0 +1,57 @@ +using Application.Utils.Helper.Slug; + +namespace API.Tests; + +/// +/// Verifies slug generation produces lowercase, kebab-case, accent-free +/// identifiers and that unique-slug resolution appends numeric suffixes on +/// collision. +/// +public class SlugGeneratorTests +{ + [Theory] + [InlineData("Campeonato Principal - La Vuelta", "campeonato-principal-la-vuelta")] + [InlineData("River Plate", "river-plate")] + [InlineData("Águilas Doradas F.C.", "aguilas-doradas-f-c")] + [InlineData(" Leading and trailing spaces ", "leading-and-trailing-spaces")] + [InlineData("Multiple---Hyphens", "multiple-hyphens")] + [InlineData("Ñoño Ünïçode", "nono-unicode")] + public void GenerateSlug_ProducesExpectedKebabCaseSlug(string input, string expectedSlug) + { + string slug = SlugGenerator.GenerateSlug(input); + + Assert.Equal(expectedSlug, slug); + } + + [Fact] + public async Task GenerateUniqueSlugAsync_NoCollision_ReturnsBaseSlug() + { + string slug = await SlugGenerator.GenerateUniqueSlugAsync( + "River Plate", + _ => Task.FromResult(false)); + + Assert.Equal("river-plate", slug); + } + + [Fact] + public async Task GenerateUniqueSlugAsync_SingleCollision_AppendsSuffixTwo() + { + string slug = await SlugGenerator.GenerateUniqueSlugAsync( + "River Plate", + candidate => Task.FromResult(candidate == "river-plate")); + + Assert.Equal("river-plate-2", slug); + } + + [Fact] + public async Task GenerateUniqueSlugAsync_MultipleCollisions_AppendsNextAvailableSuffix() + { + HashSet taken = ["river-plate", "river-plate-2", "river-plate-3"]; + + string slug = await SlugGenerator.GenerateUniqueSlugAsync( + "River Plate", + candidate => Task.FromResult(taken.Contains(candidate))); + + Assert.Equal("river-plate-4", slug); + } +} diff --git a/Club12-Backend/API.Tests/SmokeTests.cs b/Club12-Backend/API.Tests/SmokeTests.cs new file mode 100644 index 0000000..58871f5 --- /dev/null +++ b/Club12-Backend/API.Tests/SmokeTests.cs @@ -0,0 +1,28 @@ +using System.Net; + +namespace API.Tests; + +/// +/// Proves the test harness itself works: boots the real API host via +/// CustomWebApplicationFactory and issues an anonymous GET. +/// This is a harness smoke test, not business-logic coverage. +/// +public class SmokeTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SmokeTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetDivisions_ReturnsOk() + { + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = await client.GetAsync("api/divisions/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/Club12-Backend/API.Tests/StageControllerDrawTests.cs b/Club12-Backend/API.Tests/StageControllerDrawTests.cs new file mode 100644 index 0000000..4eef5d9 --- /dev/null +++ b/Club12-Backend/API.Tests/StageControllerDrawTests.cs @@ -0,0 +1,281 @@ +using Application.DTOs.Stage.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; + +using MatchType = Domain.Enums.MatchType; + +namespace API.Tests; + +/// +/// Proves StageController's preview-draw/draw HTTP routes: authorization +/// gating (mirroring SeasonControllerAuthorizationTests) and a full +/// preview-token-to-commit round trip through the real pipeline. The +/// underlying seeding rules are already covered service-side by +/// PlayoffDrawTests. +/// +public class StageControllerDrawTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public StageControllerDrawTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task PreviewDraw_Anonymous_ReturnsUnauthorized() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Stage stage = await SeedSemiFinalStageAsync(db); + + HttpClient client = _factory.CreateClient(); + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/preview-draw", new DrawRequest { Mode = DrawMode.Random }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PreviewDraw_GuestRole_ReturnsForbidden() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Stage stage = await SeedSemiFinalStageAsync(db); + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Guest); + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/preview-draw", new DrawRequest { Mode = DrawMode.Random }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task PreviewDraw_UnknownStage_ReturnsNotFound() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/stages/{Guid.NewGuid()}/preview-draw", new DrawRequest { Mode = DrawMode.Random }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PreviewThenCommitDraw_StaffRole_SeedsMatchesFromPreviewToken() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Stage stage = await SeedSemiFinalStageAsync(db); + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage previewResponse = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/preview-draw", new DrawRequest { Mode = DrawMode.Random }); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + + DrawPreviewBody? preview = await previewResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(preview); + Assert.False(string.IsNullOrWhiteSpace(preview!.DrawToken)); + + HttpResponseMessage commitResponse = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/draw", + new DrawRequest { Mode = DrawMode.Random, DrawToken = preview.DrawToken }); + + Assert.Equal(HttpStatusCode.OK, commitResponse.StatusCode); + List? matches = await commitResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(matches); + Assert.Equal(2, matches!.Count); + + List persistedMatches = await db.Matches.AsNoTracking() + .Where(m => m.StageId == stage.Id).ToListAsync(); + Assert.All(persistedMatches, m => Assert.True(m.HomeTeamId.HasValue)); + + Stage? reloaded = await db.Stages.AsNoTracking().FirstOrDefaultAsync(s => s.Id == stage.Id); + Assert.NotNull(reloaded!.DrawnAt); + } + + [Fact] + public async Task PreviewThenCommitDraw_BestOfGreaterThanOne_SeedsMatchesThroughFullHttpPipeline() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Stage stage = await SeedSemiFinalStageAsync(db, bestOf: 3); + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage previewResponse = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/preview-draw", new DrawRequest { Mode = DrawMode.Random }); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + + DrawPreviewBody? preview = await previewResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(preview); + + HttpResponseMessage commitResponse = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/draw", + new DrawRequest { Mode = DrawMode.Random, DrawToken = preview!.DrawToken }); + + string body = await commitResponse.Content.ReadAsStringAsync(); + Assert.True( + commitResponse.StatusCode == HttpStatusCode.OK, + $"Expected 200 but got {commitResponse.StatusCode}: {body}"); + } + + [Fact] + public async Task PreviewThenCommitDraw_StageHasNoPreGeneratedMatches_SucceedsThroughFullHttpPipeline() + { + // Matches placeholders normally come from GenerateFixtureAsync when the tournament + // starts (TournamentService.ChangeStatusAsync), but a playoffs-only division's bracket + // has no group phase to trigger that, and the draw UI is reachable before then — so a + // real first-ever draw hits a stage with zero matches. This is the exact shape a live + // QA pass hit as a 500 on /api/stages/{id}/draw. + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Stage stage = await SeedSemiFinalStageAsync(db, seedMatches: false); + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage previewResponse = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/preview-draw", new DrawRequest { Mode = DrawMode.Random }); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + + DrawPreviewBody? preview = await previewResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(preview); + + HttpResponseMessage commitResponse = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/draw", + new DrawRequest { Mode = DrawMode.Random, DrawToken = preview!.DrawToken }); + + string body = await commitResponse.Content.ReadAsStringAsync(); + Assert.True( + commitResponse.StatusCode == HttpStatusCode.OK, + $"Expected 200 but got {commitResponse.StatusCode}: {body}"); + + List? matches = await commitResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(matches); + Assert.Equal(2, matches!.Count); + } + + [Fact] + public async Task CommitDraw_StaleTokenAfterAnotherDraw_ReturnsConflict() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Stage stage = await SeedSemiFinalStageAsync(db); + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/stages/{stage.Id}/draw", + new DrawRequest { Mode = DrawMode.Random, DrawToken = "not-a-real-token" }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + private static async Task SeedSemiFinalStageAsync(ApplicationDBContext db, int bestOf = 1, bool seedMatches = true) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Stage controller 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(); + + 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(); + + for (int i = 0; i < 4; 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); + await db.SaveChangesAsync(); + + db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration + { + TeamId = team.Id, + DivisionId = division.Id, + CreatedBy = "test", + }); + } + + await db.SaveChangesAsync(); + + 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, + BracketName = "Copa Única", + BestOf = bestOf, + Matches = [], + CreatedBy = "test", + }; + db.Stages.Add(stage); + await db.SaveChangesAsync(); + + if (seedMatches) + { + for (int i = 0; i < 2; i++) + { + db.Matches.Add(new Match + { + StageId = stage.Id, + Type = MatchType.Playoff, + Slug = $"match-{Guid.NewGuid()}", + MatchDate = stage.StartDate.AddMinutes(i), + IsFinished = false, + CreatedBy = "test", + }); + } + + await db.SaveChangesAsync(); + } + + return stage; + } + + private sealed record DrawPreviewBody(string DrawToken); + + private sealed record MatchBody(Guid Id); +} diff --git a/Club12-Backend/API.Tests/StagePlayoffCupSeedingTests.cs b/Club12-Backend/API.Tests/StagePlayoffCupSeedingTests.cs new file mode 100644 index 0000000..4802b25 --- /dev/null +++ b/Club12-Backend/API.Tests/StagePlayoffCupSeedingTests.cs @@ -0,0 +1,215 @@ +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; + +/// +/// Covers StageService.SeedPlayoffCupsAsync (HU-45/HU-81): seeding multiple +/// playoff cups from a division's final standings using its position-range +/// mapping, so the right positions land in the right cup's bracket. +/// +public class StagePlayoffCupSeedingTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public StagePlayoffCupSeedingTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task SeedPlayoffCupsAsync_TwoCups_PlacesTopRangeInGoldAndNextInSilver() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + // 8 teams. A ladder round-robin where the lower index always wins + // yields the exact standings teams[0] > teams[1] > ... > teams[7]. + List teams = await SeedTeamsAsync(db, tournament, 8); + 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); + } + } + + await SeedMappingAsync(db, division, 1, 4, "Copa Oro"); + await SeedMappingAsync(db, division, 5, 8, "Copa Plata"); + + Stage goldStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Oro"); + await SeedEmptyMatchAsync(db, goldStage); + await SeedEmptyMatchAsync(db, goldStage); + + Stage silverStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal, bracketName: "Copa Plata"); + await SeedEmptyMatchAsync(db, silverStage); + await SeedEmptyMatchAsync(db, silverStage); + + Dictionary> seeded = await stageService.SeedPlayoffCupsAsync(division.Id); + + List gold = [.. seeded["Copa Oro"].OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + Assert.Equal(teams[0].Id, gold[0].HomeTeamId); + Assert.Equal(teams[3].Id, gold[0].VisitorTeamId); + Assert.Equal(teams[1].Id, gold[1].HomeTeamId); + Assert.Equal(teams[2].Id, gold[1].VisitorTeamId); + + List silver = [.. seeded["Copa Plata"].OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + Assert.Equal(teams[4].Id, silver[0].HomeTeamId); + Assert.Equal(teams[7].Id, silver[0].VisitorTeamId); + Assert.Equal(teams[5].Id, silver[1].HomeTeamId); + Assert.Equal(teams[6].Id, silver[1].VisitorTeamId); + } + + [Fact] + public async Task SeedPlayoffCupsAsync_NoMappings_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + await Assert.ThrowsAsync( + () => stageService.SeedPlayoffCupsAsync(division.Id)); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + Tournament tournament = new() + { + Description = "Cup seeding 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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(); + } +} diff --git a/Club12-Backend/API.Tests/StageSeedingTests.cs b/Club12-Backend/API.Tests/StageSeedingTests.cs new file mode 100644 index 0000000..57dc267 --- /dev/null +++ b/Club12-Backend/API.Tests/StageSeedingTests.cs @@ -0,0 +1,302 @@ +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; + +/// +/// Covers StageService.SeedKnockoutStageAsync: pairing an elimination +/// stage's matches from group-stage standings in classic bracket seed +/// order, and the guardrails around it (stage must have matches, must not +/// already be seeded, every assigned team must have a position). +/// +public class StageSeedingTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public StageSeedingTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// Seeds a 6-match round robin among 4 teams that produces the standings + /// A(3-0) > B(2-1) > C(1-2) > D(0-3). + /// + [Fact] + public async Task SeedKnockoutStageAsync_FourTeams_PairsBestSeedAgainstWorst() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedTeamsAsync(db, tournament, 4); + + Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[2], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[3], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[1], teams[2], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[1], teams[3], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[2], teams[3], 90, 80); + + Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal); + await SeedEmptyMatchAsync(db, semiFinalStage); + await SeedEmptyMatchAsync(db, semiFinalStage); + foreach (Team team in teams) + { + await AssignTeamToStageAsync(db, semiFinalStage, team); + } + + List seeded = await stageService.SeedKnockoutStageAsync(semiFinalStage.Id); + + List ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + 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); + } + + /// + /// Seeds a 3-match round robin among 3 teams that produces the standings + /// A(2-0) > B(1-1) > C(0-2). + /// + [Fact] + public async Task SeedKnockoutStageAsync_ThreeTeams_BestSeedGetsAByeAndIsMarkedAsAWalkoverWin() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedTeamsAsync(db, tournament, 3); + + Stage groupStage = await SeedStageAsync(db, division, tournament, StageType.Group); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[2], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[1], teams[2], 90, 80); + + Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal); + await SeedEmptyMatchAsync(db, semiFinalStage); + await SeedEmptyMatchAsync(db, semiFinalStage); + foreach (Team team in teams) + { + await AssignTeamToStageAsync(db, semiFinalStage, team); + } + + List seeded = await stageService.SeedKnockoutStageAsync(semiFinalStage.Id); + List ordered = [.. seeded.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + + Match byeMatch = ordered[0]; + Assert.Equal(teams[0].Id, byeMatch.HomeTeamId); + Assert.Null(byeMatch.VisitorTeamId); + Assert.True(byeMatch.IsFinished); + Assert.Equal(teams[0].Id, byeMatch.WinningTeamId); + + Match realMatch = ordered[1]; + Assert.Equal(teams[1].Id, realMatch.HomeTeamId); + Assert.Equal(teams[2].Id, realMatch.VisitorTeamId); + Assert.False(realMatch.IsFinished); + } + + [Fact] + public async Task SeedKnockoutStageAsync_NoMatchesGeneratedYet_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal); + + await Assert.ThrowsAsync( + () => stageService.SeedKnockoutStageAsync(semiFinalStage.Id)); + } + + [Fact] + public async Task SeedKnockoutStageAsync_AlreadySeeded_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedTeamsAsync(db, tournament, 2); + Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal); + await SeedFinishedMatchAsync(db, semiFinalStage, teams[0], teams[1], 90, 80); + + await Assert.ThrowsAsync( + () => stageService.SeedKnockoutStageAsync(semiFinalStage.Id)); + } + + [Fact] + public async Task SeedKnockoutStageAsync_AssignedTeamMissingStandings_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedTeamsAsync(db, tournament, 2); + + Stage semiFinalStage = await SeedStageAsync(db, division, tournament, StageType.SemiFinal); + await SeedEmptyMatchAsync(db, semiFinalStage); + await AssignTeamToStageAsync(db, semiFinalStage, teams[0]); + await AssignTeamToStageAsync(db, semiFinalStage, teams[1]); + + await Assert.ThrowsAsync( + () => stageService.SeedKnockoutStageAsync(semiFinalStage.Id)); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Seeding 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 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 SeedStageAsync(ApplicationDBContext db, Division division, Tournament tournament, StageType stageType) + { + 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 = [], + 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() + { + 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", + }; + + db.Matches.Add(match); + await db.SaveChangesAsync(); + } + + private static async Task SeedEmptyMatchAsync(ApplicationDBContext db, Stage stage) + { + Match match = new() + { + 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", + }; + + db.Matches.Add(match); + 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(); + } +} diff --git a/Club12-Backend/API.Tests/StageServiceTests.cs b/Club12-Backend/API.Tests/StageServiceTests.cs new file mode 100644 index 0000000..7d10fde --- /dev/null +++ b/Club12-Backend/API.Tests/StageServiceTests.cs @@ -0,0 +1,899 @@ +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Constants.Stage; +using Application.Utils.Helper.StageHelper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Covers StageService's stage creation, structural edit guards, and +/// team-assignment logic (IStageService.AssignTeamsToStageAsync). Also holds +/// characterization tests for behavior the roster and D2 changes deliberately +/// preserved or relaxed. +/// +public class StageServiceTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public StageServiceTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// A regular division can hold multiple sub-group Group stages when it also has a cup. + /// + [Fact] + public async Task CreateStageAsync_RegularDivision_AllowsSecondGroupStageWithDistinctName() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, withStages: true); + + db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping + { + DivisionId = division.Id, + FromPosition = 1, + ToPosition = 2, + Destination = "Final", + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + Stage secondGroupStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Grupo B-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + Stage created = await stageService.CreateStageAsync(secondGroupStage); + + Assert.Equal(StageType.Group, created.StageType); + Assert.Equal(2, await db.Stages.CountAsync(s => s.DivisionId == division.Id && s.StageType == StageType.Group)); + } + + /// + /// A regular division split into 2+ groups with no cup has no way to crown a champion. + /// + [Fact] + public async Task CreateStageAsync_RegularDivision_RejectsSecondGroupStageWithoutCup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, withStages: true); + + Stage secondGroupStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Grupo B-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + await Assert.ThrowsAsync( + () => stageService.CreateStageAsync(secondGroupStage)); + + Assert.Equal(1, await db.Stages.CountAsync(s => s.DivisionId == division.Id && s.StageType == StageType.Group)); + } + + /// + /// A position-range cup on a sub-grouped regular division reads the + /// pooled top-QualifiersPerGroup-of-each-group order (the same mechanism + /// a cross-division cup's own pooled groups already use), not a single + /// combined standings table — so a second sub-group no longer needs to + /// be rejected just because a cup is already configured. + /// + [Fact] + public async Task CreateStageAsync_SecondGroupStage_AllowedWhenDivisionHasPositionRangeCup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, withStages: true); + + db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping + { + DivisionId = division.Id, + FromPosition = 1, + ToPosition = 2, + Destination = "Copa de Oro", + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + Stage secondGroupStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Grupo B-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + Stage created = await stageService.CreateStageAsync(secondGroupStage); + + Assert.Equal(StageType.Group, created.StageType); + Assert.Equal(2, await db.Stages.CountAsync(s => s.DivisionId == division.Id && s.StageType == StageType.Group)); + } + + /// + /// Non-Group stage types (elimination rounds) are unaffected by the + /// one-Group-stage-per-division rule: a division can have many. + /// + [Fact] + public async Task CreateStageAsync_DivisionAlreadyHasGroupStage_StillAllowsNonGroupStage() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, withStages: true); + + Stage semiFinalStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Semifinal-{Guid.NewGuid()}", + StageType = StageType.SemiFinal, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + Stage created = await stageService.CreateStageAsync(semiFinalStage); + + Assert.Equal(StageType.SemiFinal, created.StageType); + } + + /// + /// HU-110: a cross-division cup is seeded by pooling the top teams of + /// SEVERAL internal group stages, so — unlike a regular division — it may + /// hold more than one Group stage. + /// + [Fact] + public async Task CreateStageAsync_CrossDivisionCup_AllowsSecondGroupStage() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament, withStages: true, isCrossDivisionCup: true); + + Stage secondGroupStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Grupo B - {Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + Stage created = await stageService.CreateStageAsync(secondGroupStage); + + Assert.Equal(StageType.Group, created.StageType); + Assert.Equal(2, await db.Stages.CountAsync(s => s.DivisionId == division.Id && s.StageType == StageType.Group)); + } + + /// + /// A phase (stage) cannot be added to a division once its tournament has + /// started (fixture generated) or was canceled: Ongoing, Finished and + /// Canceled all lock the phase structure — Ongoing/Finished because the + /// matches already reference the existing stage set and a new stage would + /// corrupt the bracket, Canceled because a dead tournament's structure must + /// stay frozen regardless of how far it got. + /// + [Theory] + [InlineData(TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Finished)] + [InlineData(TournamentStatus.Canceled)] + public async Task CreateStageAsync_TournamentStartedOrCanceled_ThrowsAndCreatesNoStage(TournamentStatus status) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, status); + Division division = await SeedDivisionAsync(db, tournament); + + Stage newStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Semifinal-{Guid.NewGuid()}", + StageType = StageType.SemiFinal, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => stageService.CreateStageAsync(newStage)); + + Assert.Equal(ErrorMessages.Stage.StructureLockedTournamentStarted, exception.Message); + Assert.Equal(0, await db.Stages.CountAsync(s => s.DivisionId == division.Id)); + } + + /// + /// While the tournament structure is still editable (no fixture yet) a + /// phase can be added. RegistrationClosed is the last editable state before + /// the tournament starts. + /// + [Theory] + [InlineData(TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.RegistrationClosed)] + public async Task CreateStageAsync_TournamentStructureEditable_CreatesStage(TournamentStatus status) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, status); + Division division = await SeedDivisionAsync(db, tournament); + + Stage newStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Semifinal-{Guid.NewGuid()}", + StageType = StageType.SemiFinal, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + Stage created = await stageService.CreateStageAsync(newStage); + + Assert.Equal(StageType.SemiFinal, created.StageType); + Assert.Equal(1, await db.Stages.CountAsync(s => s.DivisionId == division.Id)); + } + + /// + /// Removing a phase once the tournament has started is likewise blocked, + /// and the stage is left untouched. + /// + [Fact] + public async Task DeleteStageAsync_TournamentStarted_ThrowsAndKeepsStage() + { + Guid stageId; + Guid divisionId; + + using (IServiceScope seedScope = _factory.Services.CreateScope()) + { + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(seedDb, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(seedDb, tournament, withStages: true); + divisionId = division.Id; + stageId = await seedDb.Stages.Where(s => s.DivisionId == division.Id).Select(s => s.Id).FirstAsync(); + } + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => stageService.DeleteStageAsync(stageId)); + + Assert.Equal(ErrorMessages.Stage.StructureLockedTournamentStarted, exception.Message); + Assert.Equal(1, await db.Stages.CountAsync(s => s.DivisionId == divisionId)); + } + + /// + /// Editing a stage's own fields (dates, type, ...) once the tournament has + /// started is likewise blocked — a prior gap let this through even though + /// creating/deleting a stage was already guarded. + /// + [Fact] + public async Task UpdateStageAsync_TournamentStarted_ThrowsAndKeepsOriginalDates() + { + Guid stageId; + DateTime originalEndDate; + + using (IServiceScope seedScope = _factory.Services.CreateScope()) + { + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(seedDb, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(seedDb, tournament, withStages: true); + Stage seededStage = await seedDb.Stages.FirstAsync(s => s.DivisionId == division.Id); + stageId = seededStage.Id; + originalEndDate = seededStage.EndDate; + } + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Stage toUpdate = await db.Stages.FirstAsync(s => s.Id == stageId); + toUpdate.EndDate = toUpdate.EndDate.AddDays(30); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => stageService.UpdateStageAsync(toUpdate)); + + Assert.Equal(ErrorMessages.Stage.StructureLockedTournamentStarted, exception.Message); + + Stage reloaded = await db.Stages.AsNoTracking().FirstAsync(s => s.Id == stageId); + Assert.Equal(originalEndDate, reloaded.EndDate); + } + + /// + /// Assigning a new team to a zone once the tournament has started is + /// blocked — it would sit in the zone's standings without ever getting a + /// fixture, since matches are only generated once, at tournament start. + /// + [Fact] + public async Task AssignTeamsToStageAsync_TournamentStarted_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + db.Stages.Add(stage); + await db.SaveChangesAsync(); + List teams = await SeedTeamsAsync(db, tournament, 1); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => stageService.AssignTeamsToStageAsync(stage, [teams[0].Id])); + + Assert.Equal(ErrorMessages.Stage.StructureLockedTournamentStarted, exception.Message); + Assert.Equal(0, await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id)); + } + + /// + /// Removing a team from a zone once the tournament has started is + /// blocked — its already-generated matches would keep referencing a team + /// no longer assigned to that zone's standings. + /// + [Fact] + public async Task UnassignTeamsFromStageAsync_TournamentStarted_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + db.Stages.Add(stage); + await db.SaveChangesAsync(); + List teams = await SeedTeamsAsync(db, tournament, 1); + db.StageTeamMatches.Add(new StageTeamMatch + { + StageId = stage.Id, + TeamId = teams[0].Id, + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => stageService.UnassignTeamsFromStageAsync(stage, [teams[0].Id])); + + Assert.Equal(ErrorMessages.Stage.StructureLockedTournamentStarted, exception.Message); + Assert.Equal(1, await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id)); + } + + [Fact] + public async Task AssignTeamsToStageAsync_ExactSlotMatch_AssignsAllTeams() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, _) = await SeedStageWithSlotsAsync(db, StageType.SemiFinal, existingAssignmentCount: 0); + List poolTeams = await SeedTeamsAsync(db, tournament, 4, stage.Division); + List teamIds = [.. poolTeams.Select(t => t.Id)]; + + await stageService.AssignTeamsToStageAsync(stage, teamIds, auto: false); + + List records = await db.StageTeamMatches.Where(stm => stm.StageId == stage.Id).ToListAsync(); + Assert.Equal(4, records.Count); + + List expectedIds = [.. teamIds.OrderBy(id => id)]; + List actualIds = [.. records.Select(r => r.TeamId).OrderBy(id => id)]; + Assert.Equal(expectedIds, actualIds); + } + + /// + /// Regression test for a NullReferenceException at + /// StageService.EnsureNoCrossDivisionConflictAsync (stage.Division.IsCrossDivisionCup) + /// that only reproduced when the stage came from GetStageByIdAsync's real EF Core + /// round-trip in a fresh scope — every other test seeds a Stage with Division set + /// directly in-memory, which masked the missing repository include. + /// + [Fact] + public async Task AssignTeamsToStageAsync_StageFetchedByIdInFreshScope_DoesNotThrow() + { + Guid stageId; + Guid tournamentId; + Guid divisionId; + using (IServiceScope seedScope = _factory.Services.CreateScope()) + { + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + (Stage stage, Tournament tournament, _) = await SeedStageWithSlotsAsync(seedDb, StageType.SemiFinal, existingAssignmentCount: 0); + stageId = stage.Id; + tournamentId = tournament.Id; + divisionId = stage.DivisionId; + } + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament2 = await db.Tournaments.FirstAsync(t => t.Id == tournamentId); + Division division2 = await db.Divisions.FirstAsync(d => d.Id == divisionId); + List poolTeams = await SeedTeamsAsync(db, tournament2, 4, division2); + List teamIds = [.. poolTeams.Select(t => t.Id)]; + + Stage? fetchedStage = await stageService.GetStageByIdAsync(stageId); + Assert.NotNull(fetchedStage); + + await stageService.AssignTeamsToStageAsync(fetchedStage, teamIds, auto: false); + + int recordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == stageId); + Assert.Equal(4, recordCount); + } + + /// + /// ThirdPlace capacity is 2; 1 existing assignment leaves exactly 1 available slot. + /// + [Fact] + public async Task AssignTeamsToStageAsync_TooManyTeamsForSlots_ThrowsAndCreatesNoRecords() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, _) = await SeedStageWithSlotsAsync(db, StageType.ThirdPlace, existingAssignmentCount: 1); + List poolTeams = await SeedTeamsAsync(db, tournament, 3, stage.Division); + List teamIds = [.. poolTeams.Select(t => t.Id)]; + + await Assert.ThrowsAsync( + () => stageService.AssignTeamsToStageAsync(stage, teamIds, auto: false)); + + int recordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id); + Assert.Equal(1, recordCount); + } + + [Fact] + public async Task AssignTeamsToStageAsync_FewerTeamsThanSlots_LeavesSlotsAvailable() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, _) = await SeedStageWithSlotsAsync(db, StageType.SemiFinal, existingAssignmentCount: 0); + List poolTeams = await SeedTeamsAsync(db, tournament, 2, stage.Division); + List teamIds = [.. poolTeams.Select(t => t.Id)]; + + await stageService.AssignTeamsToStageAsync(stage, teamIds, auto: false); + + int recordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id); + Assert.Equal(2, recordCount); + + int maxTeams = StageHelper.GetMaxTeamsForStage(stage.StageType); + Assert.Equal(2, maxTeams - recordCount); + } + + /// + /// ThirdPlace capacity is 2; 2 existing assignments fill the stage. + /// + [Fact] + public async Task AssignTeamsToStageAsync_StageAlreadyAtCapacity_ThrowsForManualAndAuto() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, _) = await SeedStageWithSlotsAsync(db, StageType.ThirdPlace, existingAssignmentCount: 2); + List poolTeams = await SeedTeamsAsync(db, tournament, 1, stage.Division); + List teamIds = [.. poolTeams.Select(t => t.Id)]; + + await Assert.ThrowsAsync( + () => stageService.AssignTeamsToStageAsync(stage, teamIds, auto: false)); + await Assert.ThrowsAsync( + () => stageService.AssignTeamsToStageAsync(stage, null, auto: true)); + + int recordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id); + Assert.Equal(2, recordCount); + } + + /// + /// SemiFinal capacity is 4; 1 existing assignment leaves 3 available slots. + /// + [Fact] + public async Task AssignTeamsToStageAsync_DuplicateAndAlreadyAssignedIds_AreFilteredOut() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, List existingIds) = await SeedStageWithSlotsAsync(db, StageType.SemiFinal, existingAssignmentCount: 1); + List poolTeams = await SeedTeamsAsync(db, tournament, 2, stage.Division); + + List requestIds = [poolTeams[0].Id, poolTeams[0].Id, poolTeams[1].Id, existingIds[0]]; + + await stageService.AssignTeamsToStageAsync(stage, requestIds, auto: false); + + List records = await db.StageTeamMatches.Where(stm => stm.StageId == stage.Id).ToListAsync(); + Assert.Equal(3, records.Count); + Assert.Contains(records, r => r.TeamId == poolTeams[0].Id); + Assert.Contains(records, r => r.TeamId == poolTeams[1].Id); + Assert.Single(records, r => r.TeamId == existingIds[0]); + } + + /// + /// SemiFinal capacity is 4; 1 existing assignment leaves 3 available slots. + /// The eligible pool of 5 seeded teams is kept larger than the available + /// slots so auto-assignment has more candidates than it can place. + /// + [Fact] + public async Task AssignTeamsToStageAsync_AutoMode_AssignsUpToAvailableSlots() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, List existingIds) = await SeedStageWithSlotsAsync(db, StageType.SemiFinal, existingAssignmentCount: 1); + await SeedTeamsAsync(db, tournament, 5, stage.Division); + + await stageService.AssignTeamsToStageAsync(stage, null, auto: true); + + List records = await db.StageTeamMatches.Where(stm => stm.StageId == stage.Id).ToListAsync(); + Assert.Equal(4, records.Count); + + List newlyAssigned = [.. records.Select(r => r.TeamId).Except(existingIds)]; + Assert.Equal(3, newlyAssigned.Count); + Assert.DoesNotContain(existingIds[0], newlyAssigned); + } + + [Fact] + public async Task AssignTeamsToStageAsync_AutoMode_OnlyAssignsTeamsFromStagesTournament() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournamentA = await SeedTournamentAsync(db); + Division divisionA = await SeedDivisionAsync(db, tournamentA); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Stage-{Guid.NewGuid()}", + StageType = StageType.SemiFinal, + IsActive = true, + StartDate = tournamentA.StartDate, + EndDate = tournamentA.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = divisionA.Id, + Division = divisionA, + Matches = [], + CreatedBy = "test", + }; + + db.Stages.Add(stage); + await db.SaveChangesAsync(); + + List teamsA = await SeedTeamsAsync(db, tournamentA, 2, divisionA); + + Tournament tournamentB = await SeedTournamentAsync(db); + List teamsB = await SeedTeamsAsync(db, tournamentB, 5); + + await stageService.AssignTeamsToStageAsync(stage, null, auto: true); + + List records = await db.StageTeamMatches.Where(stm => stm.StageId == stage.Id).ToListAsync(); + + Assert.Equal(2, records.Count); + + List assignedTeamIds = [.. records.Select(r => r.TeamId)]; + List teamAIds = [.. teamsA.Select(t => t.Id)]; + List teamBIds = [.. teamsB.Select(t => t.Id)]; + + Assert.All(assignedTeamIds, id => Assert.Contains(id, teamAIds)); + Assert.DoesNotContain(assignedTeamIds, teamBIds.Contains); + } + + /// + /// A team with no DivisionTeamRegistration for the stage's division can no longer + /// be placed directly into a StageTeamMatch, manually or automatically — placement + /// is a subset of division enrollment, never the reverse. + /// + [Fact] + public async Task AssignTeamsToStageAsync_TeamWithNoDivisionRegistration_RejectsThatTeam_CreatesNoStageTeamMatch() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + (Stage stage, Tournament tournament, _) = await SeedStageWithSlotsAsync(db, StageType.SemiFinal, existingAssignmentCount: 0); + List unregisteredTeams = await SeedTeamsAsync(db, tournament, 1); + + await Assert.ThrowsAsync( + () => stageService.AssignTeamsToStageAsync(stage, [unregisteredTeams[0].Id], auto: false)); + + int recordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id); + Assert.Equal(0, recordCount); + } + + /// + /// Auto-fill now pulls from the division roster, not every team registered to + /// the stage's tournament: a team registered only to a different division of + /// the same tournament must never be auto-assigned into this stage. + /// + [Fact] + public async Task AssignTeamsToStageAsync_AutoMode_OnlyDrawsFromDivisionRoster_NotAllTournamentTeams() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division divisionA = await SeedDivisionAsync(db, tournament); + Division divisionOther = await SeedDivisionAsync(db, tournament); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Stage-{Guid.NewGuid()}", + StageType = StageType.SemiFinal, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = divisionA.Id, + Division = divisionA, + Matches = [], + CreatedBy = "test", + }; + + db.Stages.Add(stage); + await db.SaveChangesAsync(); + + List rosterTeams = await SeedTeamsAsync(db, tournament, 2, divisionA); + List otherDivisionTeams = await SeedTeamsAsync(db, tournament, 2, divisionOther); + + await stageService.AssignTeamsToStageAsync(stage, null, auto: true); + + List records = await db.StageTeamMatches.Where(stm => stm.StageId == stage.Id).ToListAsync(); + + Assert.Equal(2, records.Count); + + List assignedTeamIds = [.. records.Select(r => r.TeamId)]; + List rosterTeamIds = [.. rosterTeams.Select(t => t.Id)]; + List otherDivisionTeamIds = [.. otherDivisionTeams.Select(t => t.Id)]; + + Assert.All(assignedTeamIds, id => Assert.Contains(id, rosterTeamIds)); + Assert.DoesNotContain(assignedTeamIds, otherDivisionTeamIds.Contains); + } + + private static async Task SeedTournamentAsync( + ApplicationDBContext db, TournamentStatus status = TournamentStatus.Scheduled) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Characterization 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; + } + + /// + /// Seeds teams, optionally also registering each one to registerToDivision's + /// roster so a subsequent AssignTeamsToStageAsync call finds them enrolled. + /// + private static async Task> SeedTeamsAsync( + ApplicationDBContext db, Tournament tournament, int count, Division? registerToDivision = null) + { + List 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(); + + if (registerToDivision is not null) + { + foreach (Team team in teams) + { + db.DivisionTeamRegistrations.Add(new DivisionTeamRegistration + { + TeamId = team.Id, + DivisionId = registerToDivision.Id, + CreatedBy = "test", + }); + } + + await db.SaveChangesAsync(); + } + + return teams; + } + + private static async Task SeedDivisionAsync( + ApplicationDBContext db, Tournament tournament, bool withStages = false, 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(); + + if (withStages) + { + Stage existingStage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Existing-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + db.Stages.Add(existingStage); + await db.SaveChangesAsync(); + } + + return division; + } + + private static async Task<(Stage stage, Tournament tournament, List existingTeamIds)> SeedStageWithSlotsAsync( + ApplicationDBContext db, StageType stageType, int existingAssignmentCount) + { + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Stage-{Guid.NewGuid()}", + StageType = stageType, + IsActive = true, + StartDate = tournament.StartDate, + EndDate = tournament.StartDate.AddDays(StageTemplate.DurationDays), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + db.Stages.Add(stage); + await db.SaveChangesAsync(); + + List existingTeamIds = []; + + if (existingAssignmentCount > 0) + { + List existingTeams = await SeedTeamsAsync(db, tournament, existingAssignmentCount, division); + + foreach (Team team in existingTeams) + { + db.StageTeamMatches.Add(new StageTeamMatch + { + StageId = stage.Id, + TeamId = team.Id, + CreatedBy = "test", + }); + existingTeamIds.Add(team.Id); + } + + await db.SaveChangesAsync(); + } + + return (stage, tournament, existingTeamIds); + } +} diff --git a/Club12-Backend/API.Tests/StageSlugTests.cs b/Club12-Backend/API.Tests/StageSlugTests.cs new file mode 100644 index 0000000..f45ac6a --- /dev/null +++ b/Club12-Backend/API.Tests/StageSlugTests.cs @@ -0,0 +1,165 @@ +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; + +/// +/// Verifies Stage's slug support: StageService.CreateStageAsync generates a +/// unique slug from the stage's Name, GetStageByIdOrSlugAsync resolves a stage +/// by either its GUID id or its slug, and GET api/stages/{idOrSlug} resolves by +/// either form. StageController has no SupabaseHelper dependency, so this can +/// run as a real HTTP round trip through CustomWebApplicationFactory. +/// +/// A stage name is unique per division (CreateStageAsync rejects a duplicate +/// name in the same division), so slug collision — and therefore the numeric +/// suffix disambiguation — is exercised with two stages that share a name +/// across two different divisions. +/// +public class StageSlugTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public StageSlugTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateStageAsync_GeneratesSlugFromName() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Division division = await SeedDivisionAsync(db); + + Stage created = await stageService.CreateStageAsync(BuildStage(division, "Fase de Grupos Ñandú")); + + Assert.False(string.IsNullOrWhiteSpace(created.Slug)); + Assert.DoesNotContain(' ', created.Slug); + Assert.Equal(created.Slug, created.Slug.ToLowerInvariant()); + } + + [Fact] + public async Task CreateStageAsync_DuplicateNameAcrossDivisions_AppendsSuffixToSlug() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Division divisionA = await SeedDivisionAsync(db); + Division divisionB = await SeedDivisionAsync(db); + string sharedName = "Playoff Oro " + Guid.NewGuid().ToString("N")[..12]; + + Stage first = await stageService.CreateStageAsync(BuildStage(divisionA, sharedName)); + Stage second = await stageService.CreateStageAsync(BuildStage(divisionB, sharedName)); + + Assert.NotEqual(first.Slug, second.Slug); + Assert.Equal($"{first.Slug}-2", second.Slug); + } + + [Fact] + public async Task GetStageById_BySlug_Returns200WithMatchingStage() + { + Guid createdId; + string createdSlug; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Division division = await SeedDivisionAsync(db); + Stage created = await stageService.CreateStageAsync( + BuildStage(division, "Fase " + Guid.NewGuid().ToString("N")[..12])); + + createdId = created.Id; + createdSlug = created.Slug; + } + + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage byId = await client.GetAsync($"api/stages/{createdId}"); + HttpResponseMessage bySlug = await client.GetAsync($"api/stages/{createdSlug}"); + + Assert.Equal(HttpStatusCode.OK, byId.StatusCode); + Assert.Equal(HttpStatusCode.OK, bySlug.StatusCode); + + StageIdResponse? bySlugBody = await bySlug.Content.ReadFromJsonAsync(); + Assert.NotNull(bySlugBody); + Assert.Equal(createdId, bySlugBody!.Id); + } + + [Fact] + public async Task GetStageById_UnknownSlug_Returns404() + { + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = await client.GetAsync($"api/stages/unknown-slug-{Guid.NewGuid():N}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private sealed record StageIdResponse(Guid Id, string Slug); + + private static Stage BuildStage(Division division, string name) + { + DateTime startDate = DateTime.UtcNow.Date; + + return new Stage + { + Name = name, + Slug = null!, + StageType = StageType.SemiFinal, + IsActive = true, + StartDate = startDate, + EndDate = startDate.AddDays(14), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + } + + private static async Task SeedDivisionAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date; + + Tournament tournament = new() + { + Description = "Stage slug characterization tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = startDate.AddDays(-1), + StartDate = startDate, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + Division division = new() + { + Id = Guid.NewGuid(), + Name = $"Division-{Guid.NewGuid()}", + Slug = $"division-{Guid.NewGuid()}", + Tournament = tournament, + Stages = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + db.Divisions.Add(division); + await db.SaveChangesAsync(); + + return division; + } +} diff --git a/Club12-Backend/API.Tests/StageTeamAssignmentConsistencyTests.cs b/Club12-Backend/API.Tests/StageTeamAssignmentConsistencyTests.cs new file mode 100644 index 0000000..aaae182 --- /dev/null +++ b/Club12-Backend/API.Tests/StageTeamAssignmentConsistencyTests.cs @@ -0,0 +1,203 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Guards against a team being assigned to two competitive divisions of +/// the same tournament at once — the exact inconsistent state flagged +/// during this session's business-logic audit. Cross-division-cup +/// divisions (e.g. "Copa Club12") are deliberately exempt: a team is +/// expected to belong to both its zone and the cup at the same time. +/// +public class StageTeamAssignmentConsistencyTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public StageTeamAssignmentConsistencyTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task AssignTeamsToStageAsync_TeamAlreadyInAnotherDivisionOfSameTournament_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 1); + Division zoneA = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false); + Division zoneB = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false); + Stage zoneAStage = await SeedStageAsync(db, zoneA, tournament); + Stage zoneBStage = await SeedStageAsync(db, zoneB, tournament); + await SeedRegistrationAsync(db, zoneA, teams[0]); + await SeedRegistrationAsync(db, zoneB, teams[0]); + + await stageService.AssignTeamsToStageAsync(zoneAStage, [teams[0].Id], auto: false); + + await Assert.ThrowsAsync( + () => stageService.AssignTeamsToStageAsync(zoneBStage, [teams[0].Id], auto: false)); + + int zoneBRecordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == zoneBStage.Id); + Assert.Equal(0, zoneBRecordCount); + } + + [Fact] + public async Task AssignTeamsToStageAsync_TeamAlreadyInHomeZone_CanAlsoJoinCrossDivisionCup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 1); + Division zoneA = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: false); + Division copaClub12 = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true); + Stage zoneAStage = await SeedStageAsync(db, zoneA, tournament); + Stage copaStage = await SeedStageAsync(db, copaClub12, tournament); + await SeedRegistrationAsync(db, zoneA, teams[0]); + await SeedRegistrationAsync(db, copaClub12, teams[0]); + + await stageService.AssignTeamsToStageAsync(zoneAStage, [teams[0].Id], auto: false); + await stageService.AssignTeamsToStageAsync(copaStage, [teams[0].Id], auto: false); + + int copaRecordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == copaStage.Id && stm.TeamId == teams[0].Id); + Assert.Equal(1, copaRecordCount); + } + + [Fact] + public async Task AssignTeamsToStageAsync_TeamFromDifferentTournament_DoesNotConflict() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournamentOne = await SeedTournamentAsync(db); + Tournament tournamentTwo = await SeedTournamentAsync(db); + List teamsInTournamentOne = await SeedTeamsAsync(db, tournamentOne, 1); + Division divisionInTournamentOne = await SeedDivisionAsync(db, tournamentOne, isCrossDivisionCup: false); + Division divisionInTournamentTwo = await SeedDivisionAsync(db, tournamentTwo, isCrossDivisionCup: false); + Stage stageInTournamentOne = await SeedStageAsync(db, divisionInTournamentOne, tournamentOne); + Stage stageInTournamentTwo = await SeedStageAsync(db, divisionInTournamentTwo, tournamentTwo); + await SeedRegistrationAsync(db, divisionInTournamentOne, teamsInTournamentOne[0]); + await SeedRegistrationAsync(db, divisionInTournamentTwo, teamsInTournamentOne[0]); + + await stageService.AssignTeamsToStageAsync(stageInTournamentOne, [teamsInTournamentOne[0].Id], auto: false); + + await stageService.AssignTeamsToStageAsync(stageInTournamentTwo, [teamsInTournamentOne[0].Id], auto: false); + + int recordCount = await db.StageTeamMatches.CountAsync(stm => stm.StageId == stageInTournamentTwo.Id); + Assert.Equal(1, recordCount); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Consistency 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 SeedDivisionAsync(ApplicationDBContext db, Tournament tournament, bool isCrossDivisionCup) + { + 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 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 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; + } +} diff --git a/Club12-Backend/API.Tests/SubGroupDistributionTests.cs b/Club12-Backend/API.Tests/SubGroupDistributionTests.cs new file mode 100644 index 0000000..a1c106b --- /dev/null +++ b/Club12-Backend/API.Tests/SubGroupDistributionTests.cs @@ -0,0 +1,69 @@ +using Application.Utils.Helper.SubGroupDistribution; + +namespace API.Tests; + +/// +/// Pure-logic tests for the balanced round-robin dealer behind sub-group +/// distribution (HU-121/122): every group stays within one team of every +/// other group, the minimum-size check matches the floor(T/G) >= 4 rule, +/// and the dealt result is always a valid permutation of the input roster. +/// +public class SubGroupDistributionTests +{ + private static List MakeRoster(int count) + { + return [.. Enumerable.Range(0, count).Select(_ => Guid.NewGuid())]; + } + + [Fact] + public void Distribute_16TeamsInto3Groups_Balances5_5_6() + { + List roster = MakeRoster(16); + + List> groups = SubGroupDistribution.Distribute(roster, 3); + + List sizes = [.. groups.Select(g => g.Count).OrderBy(size => size)]; + Assert.Equal([5, 5, 6], sizes); + Assert.True(sizes[^1] - sizes[0] < 2); + } + + [Fact] + public void Distribute_16TeamsInto4Groups_AllExactlyFour() + { + List roster = MakeRoster(16); + + List> groups = SubGroupDistribution.Distribute(roster, 4); + + Assert.All(groups, g => Assert.Equal(4, g.Count)); + } + + [Fact] + public void Distribute_ResultIsPermutationOfRoster_NoTeamDroppedOrDuplicated() + { + List roster = MakeRoster(13); + + List> groups = SubGroupDistribution.Distribute(roster, 3); + + List dealt = [.. groups.SelectMany(g => g)]; + Assert.Equal(roster.Count, dealt.Count); + Assert.Equal([.. roster.OrderBy(id => id)], [.. dealt.OrderBy(id => id)]); + } + + [Fact] + public void MeetsMinimumSize_TenTeamsThreeGroups_ReturnsFalse() + { + Assert.False(SubGroupDistribution.MeetsMinimumSize(10, 3)); + } + + [Fact] + public void MeetsMinimumSize_TwelveTeamsThreeGroups_ReturnsTrue() + { + Assert.True(SubGroupDistribution.MeetsMinimumSize(12, 3)); + } + + [Fact] + public void MeetsMinimumSize_EmptyRoster_SkipsCheckAndReturnsTrue() + { + Assert.True(SubGroupDistribution.MeetsMinimumSize(0, 5)); + } +} diff --git a/Club12-Backend/API.Tests/SubGroupRebuildTests.cs b/Club12-Backend/API.Tests/SubGroupRebuildTests.cs new file mode 100644 index 0000000..4b00299 --- /dev/null +++ b/Club12-Backend/API.Tests/SubGroupRebuildTests.cs @@ -0,0 +1,385 @@ +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; + +/// +/// Covers HU-121/122/123: balanced sub-group distribution, manual +/// reassignment bounded only by the minimum sub-group size, and rebuilding +/// the sub-group stage layer after a count change without ever touching the +/// underlying DivisionTeamRegistration roster. +/// +public class SubGroupRebuildTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public SubGroupRebuildTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task RebuildSubGroupsAsync_RosterUnchanged_AcrossCountChange() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedRosterAsync(db, tournament, division, 16); + + await stageService.RebuildSubGroupsAsync(division.Id, 3); + List registrationIdsBefore = [.. (await db.DivisionTeamRegistrations + .Where(r => r.DivisionId == division.Id) + .Select(r => r.Id) + .ToListAsync()) + .OrderBy(id => id)]; + + await stageService.RebuildSubGroupsAsync(division.Id, 4); + List registrationIdsAfter = [.. (await db.DivisionTeamRegistrations + .Where(r => r.DivisionId == division.Id) + .Select(r => r.Id) + .ToListAsync()) + .OrderBy(id => id)]; + + Assert.Equal(16, registrationIdsAfter.Count); + Assert.Equal(registrationIdsBefore, registrationIdsAfter); + + List placedTeamIds = [.. await db.StageTeamMatches + .Where(stm => stm.Stage!.DivisionId == division.Id) + .Select(stm => stm.TeamId) + .Distinct() + .ToListAsync()]; + Assert.Equal([.. teams.Select(t => t.Id).OrderBy(id => id)], [.. placedTeamIds.OrderBy(id => id)]); + } + + [Fact] + public async Task RebuildSubGroupsAsync_OldStageStructureFullyReplaced_NotMerged() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + await SeedRosterAsync(db, tournament, division, 16); + + List firstBuild = await stageService.RebuildSubGroupsAsync(division.Id, 3); + List firstStageIds = [.. firstBuild.Select(s => s.Id)]; + + List secondBuild = await stageService.RebuildSubGroupsAsync(division.Id, 2); + + Assert.Equal(2, secondBuild.Count); + Assert.Equal(2, await db.Stages.CountAsync(s => s.DivisionId == division.Id && s.StageType == StageType.Group)); + Assert.False(await db.Stages.AnyAsync(s => firstStageIds.Contains(s.Id))); + } + + [Fact] + public async Task RebuildSubGroupsAsync_TooFewTeamsPerGroup_RejectedNoChange() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + await SeedRosterAsync(db, tournament, division, 10); + + await Assert.ThrowsAsync( + () => stageService.RebuildSubGroupsAsync(division.Id, 3)); + + Assert.Equal(0, await db.Stages.CountAsync(s => s.DivisionId == division.Id)); + Assert.Equal(10, await db.DivisionTeamRegistrations.CountAsync(r => r.DivisionId == division.Id)); + } + + [Fact] + public async Task RebuildSubGroupsAsync_TournamentOngoing_Rejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + await SeedRosterAsync(db, tournament, division, 12); + + await Assert.ThrowsAsync( + () => stageService.RebuildSubGroupsAsync(division.Id, 3)); + } + + [Fact] + public async Task RebuildSubGroupsAsync_EmptyRoster_SkipsMinCheck_CreatesEmptyGroups() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + + List stages = await stageService.RebuildSubGroupsAsync(division.Id, 3); + + Assert.Equal(3, stages.Count); + Assert.Equal(0, await db.StageTeamMatches.CountAsync(stm => stm.Stage!.DivisionId == division.Id)); + } + + [Fact] + public async Task RebuildSubGroupsAsync_MultipleSubGroups_AllowedWhenDivisionHasPositionRangeCup() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + await SeedRosterAsync(db, tournament, division, 16); + + db.DivisionPlayoffMappings.Add(new DivisionPlayoffMapping + { + DivisionId = division.Id, + FromPosition = 1, + ToPosition = 2, + Destination = "Copa de Oro", + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + List stages = await stageService.RebuildSubGroupsAsync(division.Id, 2); + + Assert.Equal(2, stages.Count); + Assert.Equal(2, await db.Stages.CountAsync(s => s.DivisionId == division.Id && s.StageType == StageType.Group)); + } + + [Fact] + public async Task AutoDistributeRosterAsync_ClearsThenRedistributes_AlwaysBalanced() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedRosterAsync(db, tournament, division, 16); + + List stages = await stageService.RebuildSubGroupsAsync(division.Id, 4); + + // Manually collapse every team into the first group, an imbalance auto-distribute must undo. + List current = await db.StageTeamMatches + .Where(stm => stm.Stage!.DivisionId == division.Id) + .ToListAsync(); + foreach (StageTeamMatch stm in current) + { + stm.StageId = stages[0].Id; + } + await db.SaveChangesAsync(); + + await stageService.AutoDistributeRosterAsync(division.Id); + + List sizes = []; + foreach (Stage stage in stages) + { + sizes.Add(await db.StageTeamMatches.CountAsync(stm => stm.StageId == stage.Id)); + } + + Assert.Equal(16, sizes.Sum()); + Assert.All(sizes, size => Assert.Equal(4, size)); + + List placedTeamIds = [.. await db.StageTeamMatches + .Where(stm => stm.Stage!.DivisionId == division.Id) + .Select(stm => stm.TeamId) + .Distinct() + .ToListAsync()]; + Assert.Equal([.. teams.Select(t => t.Id).OrderBy(id => id)], [.. placedTeamIds.OrderBy(id => id)]); + } + + [Fact] + public async Task ReassignTeamToSubGroupAsync_MovesTeam_OtherPlacementsUnchanged() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + List teams = await SeedRosterAsync(db, tournament, division, 15); + + // 3 groups of 5 each, so moving one team away leaves the source at exactly the minimum of 4. + List stages = await stageService.RebuildSubGroupsAsync(division.Id, 3); + + StageTeamMatch moved = await db.StageTeamMatches.FirstAsync(stm => stm.Stage!.DivisionId == division.Id); + Guid fromStageId = moved.StageId; + Guid toStageId = stages.First(s => s.Id != fromStageId).Id; + Guid teamId = moved.TeamId; + + List otherTeamIdsBefore = [.. await db.StageTeamMatches + .Where(stm => stm.Stage!.DivisionId == division.Id && stm.TeamId != teamId) + .Select(stm => new { stm.TeamId, stm.StageId }) + .Select(x => x.TeamId) + .ToListAsync()]; + + await stageService.ReassignTeamToSubGroupAsync(teamId, fromStageId, toStageId); + + StageTeamMatch? afterMove = await db.StageTeamMatches + .FirstOrDefaultAsync(stm => stm.TeamId == teamId && stm.Stage!.DivisionId == division.Id); + + Assert.NotNull(afterMove); + Assert.Equal(toStageId, afterMove!.StageId); + + List otherTeamIdsAfter = [.. await db.StageTeamMatches + .Where(stm => stm.Stage!.DivisionId == division.Id && stm.TeamId != teamId) + .Select(stm => stm.TeamId) + .ToListAsync()]; + Assert.Equal([.. otherTeamIdsBefore.OrderBy(id => id)], [.. otherTeamIdsAfter.OrderBy(id => id)]); + Assert.Equal(15, teams.Count); + } + + [Fact] + public async Task ReassignTeamToSubGroupAsync_WouldDropSourceBelowMinimum_Rejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + await SeedRosterAsync(db, tournament, division, 8); + + // 2 groups of 4 each — the minimum — so any move away from either group drops it below 4. + List stages = await stageService.RebuildSubGroupsAsync(division.Id, 2); + + StageTeamMatch moved = await db.StageTeamMatches.FirstAsync(stm => stm.StageId == stages[0].Id); + + await Assert.ThrowsAsync( + () => stageService.ReassignTeamToSubGroupAsync(moved.TeamId, stages[0].Id, stages[1].Id)); + + Assert.Equal(4, await db.StageTeamMatches.CountAsync(stm => stm.StageId == stages[0].Id)); + } + + [Fact] + public async Task ReassignTeamToSubGroupAsync_ArbitraryMoveAboveMinimum_Allowed() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IStageService stageService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + Division division = await SeedDivisionAsync(db, tournament); + await SeedRosterAsync(db, tournament, division, 12); + + // 2 groups of 6 each, well above the minimum-4 floor. + List stages = await stageService.RebuildSubGroupsAsync(division.Id, 2); + + StageTeamMatch moved = await db.StageTeamMatches.FirstAsync(stm => stm.StageId == stages[0].Id); + + // Nothing but geography/rivalry motivates this move; the organizer needs it to succeed + // even though it makes the destination group larger than the source, since the only + // hard constraint is the minimum-4 floor on the source, not staying balanced. + await stageService.ReassignTeamToSubGroupAsync(moved.TeamId, stages[0].Id, stages[1].Id); + + Assert.Equal(5, await db.StageTeamMatches.CountAsync(stm => stm.StageId == stages[0].Id)); + Assert.Equal(7, await db.StageTeamMatches.CountAsync(stm => stm.StageId == stages[1].Id)); + } + + // ---- seeding helpers ---- + + private static async Task SeedTournamentAsync( + ApplicationDBContext db, TournamentStatus status = TournamentStatus.Scheduled) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Sub-group rebuild 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 SeedDivisionAsync(ApplicationDBContext db, Tournament tournament) + { + Division division = new() + { + Slug = $"division-{Guid.NewGuid()}", + Name = $"Division-{Guid.NewGuid()}", + Tournament = tournament, + TournamentId = tournament.Id, + Stages = [], + PlayoffMappings = + [ + new DivisionPlayoffMapping + { + FromPosition = 1, + ToPosition = 2, + Destination = "Final", + CreatedBy = "test", + }, + ], + CreatedBy = "test", + }; + + db.Divisions.Add(division); + await db.SaveChangesAsync(); + + return division; + } + + private static async Task> SeedRosterAsync( + ApplicationDBContext db, Tournament tournament, Division division, int count) + { + List 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; + } +} diff --git a/Club12-Backend/API.Tests/SupabaseDependentControllerNotFoundTests.cs b/Club12-Backend/API.Tests/SupabaseDependentControllerNotFoundTests.cs new file mode 100644 index 0000000..88dc7d6 --- /dev/null +++ b/Club12-Backend/API.Tests/SupabaseDependentControllerNotFoundTests.cs @@ -0,0 +1,366 @@ +using API.Controllers; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.BlogPosts.Request; +using Application.DTOs.BlogPosts.Response; +using Application.DTOs.Team.Request; +using Application.DTOs.Team.Response; +using Application.DTOs.Venue.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +using System.Linq.Expressions; + +namespace API.Tests; + +/// +/// Covers the 3 not-found sites (TeamController.GetTeamById, +/// VenueController.GetVenueById, BlogPostController.GetBlogPostById) that cannot be +/// exercised through CustomWebApplicationFactory's full HTTP host: all three controllers +/// take a constructor-injected SupabaseHelper, whose constructor eagerly opens a Supabase +/// Realtime websocket connection (AutoConnectRealtime = true) — this throws/hangs in +/// any sandboxed environment without live Supabase network access. That is a pre-existing +/// production-code testability gap, unrelated to and out of scope for this 400→404 fix, so +/// this test degrades to the next available layer per the TDD "Choosing Test Layer" rule: +/// direct-controller unit tests with hand-rolled service fakes, bypassing DI entirely. +/// SupabaseHelper is passed as null! because the not-found branch under test +/// never touches it. +/// +public class SupabaseDependentControllerNotFoundTests +{ + [Fact] + public async Task TeamController_GetTeamById_MissingEntity_Returns404ProblemDetails() + { + TeamController controller = new(new NotFoundTeamService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + ActionResult result = await controller.GetTeamById(Guid.NewGuid().ToString()); + + AssertNotFoundProblem(result.Result, "Team"); + } + + [Fact] + public async Task TeamController_GetTeamSummary_MissingEntity_Returns404ProblemDetails() + { + TeamController controller = new(new NotFoundTeamService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + ActionResult result = await controller.GetTeamSummary(Guid.NewGuid().ToString()); + + AssertNotFoundProblem(result.Result, "Team"); + } + + [Fact] + public async Task TeamController_GetTeamMatches_MissingEntity_Returns404ProblemDetails() + { + TeamController controller = new(new NotFoundTeamService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + ActionResult> result = await controller.GetTeamMatches(Guid.NewGuid().ToString()); + + AssertNotFoundProblem(result.Result, "Team"); + } + + [Fact] + public async Task TeamController_GetTeamParticipations_MissingEntity_Returns404ProblemDetails() + { + TeamController controller = new(new NotFoundTeamService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + ActionResult> result = await controller.GetTeamParticipations(Guid.NewGuid().ToString()); + + AssertNotFoundProblem(result.Result, "Team"); + } + + [Fact] + public async Task VenueController_GetVenueById_MissingEntity_Returns404ProblemDetails() + { + VenueController controller = new(new NotFoundVenueService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + ActionResult result = await controller.GetVenueById(Guid.NewGuid().ToString()); + + AssertNotFoundProblem(result.Result, "Venue"); + } + + /// + /// Covers the spec's DELETE case ("PUT/DELETE or nested action against nonexistent parent + /// returns 404") for the sites that hit the same Supabase testability gap as the GET cases + /// above: TeamController.DeleteTeamById checks for the entity via + /// ITeamService.GetTeamByIdAsync and returns 404 before ever touching + /// SupabaseHelper, so the same null!-SupabaseHelper direct-controller pattern + /// applies here. + /// + [Fact] + public async Task TeamController_DeleteTeamById_MissingEntity_Returns404ProblemDetails() + { + TeamController controller = new(new NotFoundTeamService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + IActionResult result = await controller.DeleteTeamById(Guid.NewGuid()); + + AssertNotFoundProblem(result as ActionResult, "Team"); + } + + [Fact] + public async Task BlogPostController_GetBlogPostById_MissingEntity_Returns404ProblemDetails() + { + BlogPostController controller = new(new NotFoundBlogPostService(), null!, new NotUsedMapper()); + ConfigureProblemDetailsFactory(controller); + + ActionResult result = await controller.GetBlogPostById(Guid.NewGuid().ToString()); + + AssertNotFoundProblem(result.Result, "BlogPost"); + } + + private static void AssertNotFoundProblem(ActionResult? actionResult, string expectedEntity) + { + ObjectResult objectResult = Assert.IsType(actionResult); + Assert.Equal(StatusCodes.Status404NotFound, objectResult.StatusCode); + + ProblemDetails problemDetails = Assert.IsAssignableFrom(objectResult.Value); + Assert.Equal(StatusCodes.Status404NotFound, problemDetails.Status); + Assert.False(string.IsNullOrWhiteSpace(problemDetails.Title)); + Assert.Contains(expectedEntity, problemDetails.Detail); + } + + /// + /// Gives the controller just enough of an HttpContext for + /// ControllerBase.Problem() to resolve a real ProblemDetailsFactory + /// (the same one the full host uses), without booting the whole application. + /// + 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 NotFoundTeamService : ITeamService + { + public Task CreateTeamAsync(Team teamEntity) + { + throw new NotImplementedException(); + } + + public Task GetTeamByIdAsync(Guid teamId, Guid? tournamentId = null) + { + return Task.FromResult(null); + } + + public Task GetTeamByIdOrSlugAsync(string idOrSlug, Guid? tournamentId = null) + { + return Task.FromResult(null); + } + + public Task UpdateTeamAsync(Team teamEntity) + { + throw new NotImplementedException(); + } + + public Task EnsureTeamIdentityEditableAsync(Team existingTeam, string? requestedName, string? requestedThreeLetterCode) + { + throw new NotImplementedException(); + } + + public Task UpdateTeamsAsync(IEnumerable teams) + { + throw new NotImplementedException(); + } + + public Task DeleteTeamAsync(Guid id) + { + throw new NotImplementedException(); + } + + public Task> GetAllTeamsAsync(GetTeamsFilteredRequest filter) + { + throw new NotImplementedException(); + } + + public Task RegisterTeamsToTournamentAsync(Tournament tournament, List teamIds) + { + throw new NotImplementedException(); + } + + public Task EnrollTeamAsync( + Tournament tournament, + Guid? existingTeamId, + string? newTeamName, + Guid? copyRosterFromTournamentId) + { + throw new NotImplementedException(); + } + + public Task UnenrollTeamAsync(Tournament tournament, Guid teamId) + { + throw new NotImplementedException(); + } + + public Task GetTeamSummaryAsync(Guid teamId, Guid? tournamentId) + { + throw new NotImplementedException(); + } + + public Task> GetTeamMatchesAsync(Guid teamId, Guid? tournamentId) + { + throw new NotImplementedException(); + } + + public Task> GetTeamParticipationsAsync(Guid teamId, Guid? currentTournamentId) + { + throw new NotImplementedException(); + } + } + + private sealed class NotFoundVenueService : IVenueService + { + public Task CreateVenueAsync(Venue venueEntity) + { + throw new NotImplementedException(); + } + + public Task GetVenueByIdAsync(Guid venueId) + { + return Task.FromResult(null); + } + + public Task GetVenueByIdOrSlugAsync(string idOrSlug) + { + return Task.FromResult(null); + } + + public Task UpdateVenueAsync(Venue venueEntity) + { + throw new NotImplementedException(); + } + + public Task DeleteVenueAsync(Guid id) + { + throw new NotImplementedException(); + } + + public Task> GetAllVenuesAsync() + { + throw new NotImplementedException(); + } + } + + private sealed class NotFoundBlogPostService : IBlogPostService + { + public Task CreateBlogPostAsync(BlogPost blogPostEntity) + { + throw new NotImplementedException(); + } + + public Task GetBlogPostByIdAsync(Guid blogPostId) + { + return Task.FromResult(null); + } + + public Task GetBlogPostByIdOrSlugAsync(string idOrSlug, bool includeUnpublished = false) + { + return Task.FromResult(null); + } + + public Task DeleteBlogPostAsync(Guid id) + { + throw new NotImplementedException(); + } + + public Task UpdateBlogPostAsync(BlogPost blogPostEntity) + { + throw new NotImplementedException(); + } + + public Task> GetAllBlogPostsAsync(GetBlogPostsFilteredRequest filter, bool includeUnpublished = false) + { + throw new NotImplementedException(); + } + } + + /// + /// Never invoked — the not-found branch under test returns before any mapping happens. + /// + private sealed class NotUsedMapper : IMapper + { + public IConfigurationProvider ConfigurationProvider => throw new NotImplementedException(); + public TDestination Map(object source) + { + throw new NotImplementedException(); + } + + public TDestination Map(object source, Action> opts) + { + throw new NotImplementedException(); + } + + public TDestination Map(TSource source) + { + throw new NotImplementedException(); + } + + public TDestination Map(TSource source, Action> opts) + { + throw new NotImplementedException(); + } + + public TDestination Map(TSource source, TDestination destination) + { + throw new NotImplementedException(); + } + + public TDestination Map(TSource source, TDestination destination, Action> opts) + { + throw new NotImplementedException(); + } + + public object Map(object source, Type sourceType, Type destinationType) + { + throw new NotImplementedException(); + } + + public object Map(object source, Type sourceType, Type destinationType, Action> opts) + { + throw new NotImplementedException(); + } + + public object Map(object source, object destination, Type sourceType, Type destinationType) + { + throw new NotImplementedException(); + } + + public object Map(object source, object destination, Type sourceType, Type destinationType, Action> opts) + { + throw new NotImplementedException(); + } + + public IQueryable ProjectTo(IQueryable source, object? parameters = null, params Expression>[] membersToExpand) + { + throw new NotImplementedException(); + } + + public IQueryable ProjectTo(IQueryable source, IDictionary parameters, params string[] membersToExpand) + { + throw new NotImplementedException(); + } + + public IQueryable ProjectTo(IQueryable source, Type destinationType, IDictionary? parameters = null, params string[] membersToExpand) + { + throw new NotImplementedException(); + } + } +} diff --git a/Club12-Backend/API.Tests/TeamIdentityFreezeTests.cs b/Club12-Backend/API.Tests/TeamIdentityFreezeTests.cs new file mode 100644 index 0000000..8db8d72 --- /dev/null +++ b/Club12-Backend/API.Tests/TeamIdentityFreezeTests.cs @@ -0,0 +1,186 @@ +using API.Controllers; + +using Application.DTOs.Team.Request; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// A team's identity ( and +/// ) is frozen while the team participates in +/// a tournament that is Ongoing (en curso). The PUT api/teams/{id} path rejects +/// a Name/ThreeLetterCode change with an +/// (mapped to 409 by the global handler) in that window; other fields (colors, +/// jersey style, logo) stay editable. Once the team's current tournament is not +/// Ongoing, the identity is editable again. +/// +/// Exercised as direct-controller tests with the REAL +/// and from the test host, passing +/// as null! — the PUT branch +/// never touches it, and the full HTTP host cannot construct that helper without +/// live Supabase network access (same pre-existing testability gap documented in +/// ). +/// +public class TeamIdentityFreezeTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TeamIdentityFreezeTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task UpdateTeam_ChangeName_WhileTournamentOngoing_IsRejectedAndKeepsName() + { + const string originalName = "Original Name"; + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + IMapper mapper = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Team team = await SeedTeamAsync(db, tournament.Id, originalName, "ORI"); + + TeamController controller = new(teamService, null!, mapper); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => controller.UpdateTeam(team.Id, new UpdateTeamRequest { Name = "Changed Name", ThreeLetterCode = "ORI" })); + Assert.Equal(ErrorMessages.Team.IdentityFrozenWhileOngoing, ex.Message); + + Team reloaded = await db.Teams.AsNoTracking().SingleAsync(t => t.Id == team.Id); + Assert.Equal(originalName, reloaded.Name); + } + + [Fact] + public async Task UpdateTeam_ChangeThreeLetterCode_WhileTournamentOngoing_IsRejectedAndKeepsCode() + { + const string originalCode = "ORI"; + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + IMapper mapper = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Team team = await SeedTeamAsync(db, tournament.Id, "Some Name", originalCode); + + TeamController controller = new(teamService, null!, mapper); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => controller.UpdateTeam(team.Id, new UpdateTeamRequest { Name = "Some Name", ThreeLetterCode = "NEW" })); + Assert.Equal(ErrorMessages.Team.IdentityFrozenWhileOngoing, ex.Message); + + Team reloaded = await db.Teams.AsNoTracking().SingleAsync(t => t.Id == team.Id); + Assert.Equal(originalCode, reloaded.ThreeLetterCode); + } + + [Fact] + public async Task UpdateTeam_ChangeColorsOnly_WhileTournamentOngoing_Succeeds() + { + const string originalName = "Keep Name"; + const string originalCode = "KEE"; + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + IMapper mapper = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Team team = await SeedTeamAsync(db, tournament.Id, originalName, originalCode); + + TeamController controller = new(teamService, null!, mapper); + + // Same identity (name + code unchanged), only the shirt color changes: + // the identity freeze must not block this. + ActionResult result = await controller.UpdateTeam( + team.Id, + new UpdateTeamRequest { Name = originalName, ThreeLetterCode = originalCode, ShirtColor = "#123456" }); + + Assert.IsType(result); + + Team reloaded = await db.Teams.AsNoTracking().SingleAsync(t => t.Id == team.Id); + Assert.Equal("#123456", reloaded.ShirtColor); + Assert.Equal(originalName, reloaded.Name); + Assert.Equal(originalCode, reloaded.ThreeLetterCode); + } + + [Fact] + public async Task UpdateTeam_ChangeName_WhileTournamentNotOngoing_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + IMapper mapper = scope.ServiceProvider.GetRequiredService(); + + // RegistrationClosed (not started): identity is still editable. + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Team team = await SeedTeamAsync(db, tournament.Id, "Before", "BEF"); + + TeamController controller = new(teamService, null!, mapper); + + ActionResult result = await controller.UpdateTeam( + team.Id, + new UpdateTeamRequest { Name = "After", ThreeLetterCode = "AFT", ShirtColor = "Red" }); + + Assert.IsType(result); + + Team reloaded = await db.Teams.AsNoTracking().SingleAsync(t => t.Id == team.Id); + Assert.Equal("After", reloaded.Name); + Assert.Equal("AFT", reloaded.ThreeLetterCode); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Team identity freeze test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task SeedTeamAsync( + ApplicationDBContext db, Guid tournamentId, string name, string threeLetterCode) + { + Team team = new() + { + Name = name, + Slug = $"team-{Guid.NewGuid()}", + ThreeLetterCode = threeLetterCode, + LogoUrl = "https://example.test/logo.png", + ShirtColor = "Red", + TournamentId = tournamentId, + Players = [], + CreatedBy = "test", + }; + + db.Teams.Add(team); + await db.SaveChangesAsync(); + + return team; + } +} diff --git a/Club12-Backend/API.Tests/TeamMatchMapperTests.cs b/Club12-Backend/API.Tests/TeamMatchMapperTests.cs new file mode 100644 index 0000000..7461260 --- /dev/null +++ b/Club12-Backend/API.Tests/TeamMatchMapperTests.cs @@ -0,0 +1,126 @@ +using Application.DTOs.Team.Response; +using Application.Utils.Helper.TeamProfile; + +using Domain.Entities.Models; +using Domain.Enums; + +using MatchType = Domain.Enums.MatchType; + +namespace API.Tests; + +/// +/// Pure-mapping tests for : the win/loss result, +/// home/away score orientation and opponent resolution are all relative to the +/// team, so they are exercised here without a database. +/// +public class TeamMatchMapperTests +{ + private static Team MakeTeam(string name) => new() + { + Id = Guid.NewGuid(), + Name = name, + Slug = name.ToLowerInvariant(), + ThreeLetterCode = name[..3].ToUpperInvariant(), + LogoUrl = $"https://example.test/{name}.png", + ShirtColor = "Red", + Players = [], + CreatedBy = "test", + }; + + [Fact] + public void Map_FinishedHomeWin_OrientsScoresAndResultToTeam() + { + Team home = MakeTeam("HomeTeam"); + Team visitor = MakeTeam("VisitorTeam"); + Match match = new() + { + Type = MatchType.Regular, + Slug = "m1", + MatchDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + IsFinished = true, + Status = MatchStatus.Played, + HomeTeam = home, + HomeTeamId = home.Id, + VisitorTeam = visitor, + VisitorTeamId = visitor.Id, + HomeScore = 90, + VisitorScore = 80, + WinningTeamId = home.Id, + Venue = new Venue { Name = "Main Arena", Slug = "main-arena", Address = "123 St", CreatedBy = "test" }, + CreatedBy = "test", + }; + + TeamMatchResponse result = TeamMatchMapper.Map(match, home.Id); + + Assert.True(result.IsHome); + Assert.Equal(home.Id == match.HomeTeamId, result.IsHome); + Assert.Equal(visitor.Id, result.OpponentTeamId); + Assert.Equal("VisitorTeam", result.OpponentName); + Assert.Equal(90, result.TeamScore); + Assert.Equal(80, result.OpponentScore); + Assert.Equal("W", result.Result); + Assert.Equal("Played", result.Status); + Assert.Equal("Main Arena", result.VenueName); + Assert.True(result.IsFinished); + } + + [Fact] + public void Map_FinishedAwayLoss_FlipsScoresAndReturnsLoss() + { + Team home = MakeTeam("HomeTeam"); + Team visitor = MakeTeam("VisitorTeam"); + Match match = new() + { + Type = MatchType.Regular, + Slug = "m2", + MatchDate = new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc), + IsFinished = true, + Status = MatchStatus.Played, + HomeTeam = home, + HomeTeamId = home.Id, + VisitorTeam = visitor, + VisitorTeamId = visitor.Id, + HomeScore = 70, + VisitorScore = 60, + WinningTeamId = home.Id, + CreatedBy = "test", + }; + + TeamMatchResponse result = TeamMatchMapper.Map(match, visitor.Id); + + Assert.False(result.IsHome); + Assert.Equal(home.Id, result.OpponentTeamId); + Assert.Equal(60, result.TeamScore); + Assert.Equal(70, result.OpponentScore); + Assert.Equal("L", result.Result); + Assert.Null(result.VenueName); + } + + [Fact] + public void Map_NotFinished_HasNullResultAndScores() + { + Team home = MakeTeam("HomeTeam"); + Team visitor = MakeTeam("VisitorTeam"); + Match match = new() + { + Type = MatchType.Regular, + Slug = "m3", + MatchDate = new DateTime(2025, 1, 3, 0, 0, 0, DateTimeKind.Utc), + IsFinished = false, + Status = MatchStatus.Scheduled, + HomeTeam = home, + HomeTeamId = home.Id, + VisitorTeam = visitor, + VisitorTeamId = visitor.Id, + CreatedBy = "test", + }; + + TeamMatchResponse result = TeamMatchMapper.Map(match, home.Id); + + Assert.Null(result.Result); + Assert.Null(result.TeamScore); + Assert.Null(result.OpponentScore); + Assert.Equal("Scheduled", result.Status); + Assert.False(result.IsFinished); + } +} diff --git a/Club12-Backend/API.Tests/TeamProfileServiceTests.cs b/Club12-Backend/API.Tests/TeamProfileServiceTests.cs new file mode 100644 index 0000000..c21c004 --- /dev/null +++ b/Club12-Backend/API.Tests/TeamProfileServiceTests.cs @@ -0,0 +1,399 @@ +using Application.DTOs.Team.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; + +/// +/// Exercises the three read-only team-profile aggregations added to +/// — summary standing row, matches (home or +/// visitor), and tournament participations — against a real SQLite-backed host, +/// seeding focused fixtures per test (same style as DivisionStandingsTests). +/// The controller's 404 guard is covered by the unknown-team resolution test. +/// +public class TeamProfileServiceTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TeamProfileServiceTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetTeamSummaryAsync_TeamInGroupStandings_ReturnsRankedRow() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 3); + Division division = await SeedDivisionAsync(db, tournament); + Stage groupStage = await SeedGroupStageAsync(db, division, tournament); + + // A beats B and C; B beats C. Standings: A (2W, 4pts) > B (1W1L, 3pts) > C (0W2L, 2pts). + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], 90, 80); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[2], 100, 70); + await SeedFinishedMatchAsync(db, groupStage, teams[1], teams[2], 85, 84); + + TeamSummaryResponse? summary = await teamService.GetTeamSummaryAsync(teams[0].Id, tournament.Id); + + Assert.NotNull(summary); + Assert.Equal(division.Id, summary!.DivisionId); + Assert.Equal(division.Name, summary.DivisionName); + Assert.Equal(1, summary.Position); + Assert.Equal(3, summary.TotalTeams); + Assert.Equal(2, summary.Played); + Assert.Equal(2, summary.Wins); + Assert.Equal(0, summary.Losses); + Assert.Equal(190, summary.PointsFor); + Assert.Equal(150, summary.PointsAgainst); + Assert.Equal(40, summary.PointsDifference); + Assert.Equal(4, summary.Points); + + // Sanity: the middle team ranks second in the same table. + TeamSummaryResponse? middle = await teamService.GetTeamSummaryAsync(teams[1].Id, tournament.Id); + Assert.NotNull(middle); + Assert.Equal(2, middle!.Position); + } + + [Fact] + public async Task GetTeamSummaryAsync_TeamNotInAnyGroupStanding_ReturnsNull() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 3); + Division division = await SeedDivisionAsync(db, tournament); + Stage groupStage = await SeedGroupStageAsync(db, division, tournament); + + // Only A and B play; C never appears in a finished group match. + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], 90, 80); + + TeamSummaryResponse? summary = await teamService.GetTeamSummaryAsync(teams[2].Id, tournament.Id); + + Assert.Null(summary); + } + + [Fact] + public async Task GetTeamMatchesAsync_OrdersByDate_AndDerivesResultAndOrientation() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 3); + Division division = await SeedDivisionAsync(db, tournament); + Stage groupStage = await SeedGroupStageAsync(db, division, tournament); + Venue venue = await SeedVenueAsync(db); + + DateTime day1 = tournament.StartDate.AddDays(1); + DateTime day2 = tournament.StartDate.AddDays(2); + DateTime day3 = tournament.StartDate.AddDays(3); + + // Seeded out of order to prove ordering: home win (day1), scheduled home (day2), away loss (day3). + // day3: C hosts A and wins 70-65, so A (visitor) loses. + await SeedFinishedMatchAsync(db, groupStage, teams[2], teams[0], 70, 65, day3, venue); + await SeedFinishedMatchAsync(db, groupStage, teams[0], teams[1], 90, 80, day1, venue); + await SeedScheduledMatchAsync(db, groupStage, teams[0], teams[2], day2); + + List matches = await teamService.GetTeamMatchesAsync(teams[0].Id, tournament.Id); + + Assert.Equal(3, matches.Count); + Assert.Equal(day1, matches[0].MatchDate); + Assert.Equal(day2, matches[1].MatchDate); + Assert.Equal(day3, matches[2].MatchDate); + + // day1: A home win vs B. + Assert.True(matches[0].IsHome); + Assert.Equal(teams[1].Id, matches[0].OpponentTeamId); + Assert.Equal(90, matches[0].TeamScore); + Assert.Equal(80, matches[0].OpponentScore); + Assert.Equal("W", matches[0].Result); + Assert.Equal(venue.Name, matches[0].VenueName); + + // day2: scheduled, no result. + Assert.False(matches[1].IsFinished); + Assert.Null(matches[1].Result); + Assert.Equal("Scheduled", matches[1].Status); + + // day3: A visitor loss vs C (home C 70, visitor A 65). + Assert.False(matches[2].IsHome); + Assert.Equal(teams[2].Id, matches[2].OpponentTeamId); + Assert.Equal(65, matches[2].TeamScore); + Assert.Equal(70, matches[2].OpponentScore); + Assert.Equal("L", matches[2].Result); + } + + [Fact] + public async Task GetTeamMatchesAsync_NoMatches_ReturnsEmptyList() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 1); + + List matches = await teamService.GetTeamMatchesAsync(teams[0].Id, tournament.Id); + + Assert.Empty(matches); + } + + [Fact] + public async Task GetTeamParticipationsAsync_ListsNewestFirst_WithSeasonInfoAndIsCurrent() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Season season2024 = await SeedSeasonAsync(db, "Temporada 2024", 2024); + Season season2025 = await SeedSeasonAsync(db, "Temporada 2025", 2025); + + Tournament tournament2024 = await SeedTournamentAsync(db, season2024, TournamentCategory.Masculine); + Tournament tournament2025 = await SeedTournamentAsync(db, season2025, TournamentCategory.Feminine); + Tournament tournamentNoSeason = await SeedTournamentAsync(db); + + // The team's current pointer is the 2025 tournament. + Team team = (await SeedTeamsAsync(db, tournament2025, 1))[0]; + + await SeedRegistrationAsync(db, team, tournament2024); + await SeedRegistrationAsync(db, team, tournament2025); + await SeedRegistrationAsync(db, team, tournamentNoSeason); + + List participations = + await teamService.GetTeamParticipationsAsync(team.Id, team.TournamentId); + + Assert.Equal(3, participations.Count); + + // Newest first: 2025, then 2024, then the season-less one last. + Assert.Equal(tournament2025.Id, participations[0].TournamentId); + Assert.True(participations[0].IsCurrent); + Assert.Equal("Temporada 2025", participations[0].SeasonName); + Assert.Equal(2025, participations[0].Year); + Assert.Equal(season2025.Id, participations[0].SeasonId); + Assert.Equal(nameof(TournamentCategory.Feminine), participations[0].Category); + + Assert.Equal(tournament2024.Id, participations[1].TournamentId); + Assert.False(participations[1].IsCurrent); + Assert.Equal(2024, participations[1].Year); + + Assert.Equal(tournamentNoSeason.Id, participations[2].TournamentId); + Assert.Null(participations[2].Year); + Assert.Null(participations[2].SeasonName); + Assert.Null(participations[2].SeasonId); + } + + [Fact] + public async Task GetTeamByIdOrSlugAsync_UnknownTeam_ReturnsNull_ForControllerNotFound() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Assert.Null(await teamService.GetTeamByIdOrSlugAsync(Guid.NewGuid().ToString())); + Assert.Null(await teamService.GetTeamByIdOrSlugAsync($"unknown-slug-{Guid.NewGuid()}")); + } + + private static async Task SeedTournamentAsync( + ApplicationDBContext db, Season? season = null, TournamentCategory category = TournamentCategory.Masculine) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Team profile test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = startDate.AddDays(-1), + StartDate = startDate, + Category = category, + SeasonId = season?.Id, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task SeedSeasonAsync(ApplicationDBContext db, string name, int year) + { + Season season = new() + { + Name = name, + Slug = $"season-{Guid.NewGuid()}", + Year = year, + CreatedBy = "test", + }; + + db.Seasons.Add(season); + await db.SaveChangesAsync(); + + return season; + } + + private static async Task> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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(30), + DivisionId = division.Id, + Division = division, + Matches = [], + CreatedBy = "test", + }; + + db.Stages.Add(stage); + await db.SaveChangesAsync(); + + return stage; + } + + private static async Task SeedVenueAsync(ApplicationDBContext db) + { + Venue venue = new() + { + Name = $"Venue-{Guid.NewGuid()}", + Slug = $"venue-{Guid.NewGuid()}", + Address = "123 Test Street", + CreatedBy = "test", + }; + + db.Venues.Add(venue); + await db.SaveChangesAsync(); + + return venue; + } + + private static async Task SeedFinishedMatchAsync( + ApplicationDBContext db, Stage stage, Team home, Team visitor, int homeScore, int visitorScore, + DateTime? matchDate = null, Venue? venue = null) + { + Match match = new() + { + MatchDate = 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, + Status = MatchStatus.Played, + WinningTeam = homeScore > visitorScore ? home : visitor, + WinningTeamId = homeScore > visitorScore ? home.Id : visitor.Id, + Stage = stage, + StageId = stage.Id, + VenueId = venue?.Id, + Venue = venue, + CreatedBy = "test", + }; + + db.Matches.Add(match); + await db.SaveChangesAsync(); + } + + private static async Task SeedScheduledMatchAsync( + ApplicationDBContext db, Stage stage, Team home, Team visitor, DateTime matchDate) + { + Match match = new() + { + MatchDate = matchDate, + Type = MatchType.Regular, + 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(); + } + + private static async Task SeedRegistrationAsync(ApplicationDBContext db, Team team, Tournament tournament) + { + TeamTournamentRegistration registration = new() + { + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }; + + db.TeamTournamentRegistrations.Add(registration); + await db.SaveChangesAsync(); + } +} diff --git a/Club12-Backend/API.Tests/TeamServiceRegisterTests.cs b/Club12-Backend/API.Tests/TeamServiceRegisterTests.cs new file mode 100644 index 0000000..16b5245 --- /dev/null +++ b/Club12-Backend/API.Tests/TeamServiceRegisterTests.cs @@ -0,0 +1,198 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Characterization tests for TeamService.RegisterTeamsToTournamentAsync +/// (TeamService.cs:115-129), which reconciles a tournament's team roster +/// against a submitted list of team IDs (add/unassign/keep/reassign). These +/// tests pin the currently-correct behavior of the existing, unmodified +/// service — pure test additions, no production code changes. +/// +/// Mutations are verified by re-querying `ApplicationDBContext` in a fresh DI +/// scope (not the seeding scope's change tracker), matching the convention +/// used by PlayerSanctionAppealTests.ReadAppealStatusAsync. +/// +public class TeamServiceRegisterTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TeamServiceRegisterTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task RegisterTeamsToTournamentAsync_UnassignedTeam_IsRegistered() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb); + Team team = await SeedTeamAsync(seedDb, tournamentId: null); + + await RegisterAsync(tournament, [team.Id]); + + Team? persisted = await ReadTeamAsync(team.Id); + Assert.NotNull(persisted); + Assert.Equal(tournament.Id, persisted!.TournamentId); + } + + /// + /// team.Id is deliberately omitted from the registration list. + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_DroppedTeam_IsUnassigned() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb); + Team team = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + + await RegisterAsync(tournament, []); + + Team? persisted = await ReadTeamAsync(team.Id); + Assert.NotNull(persisted); + Assert.Null(persisted!.TournamentId); + } + + [Fact] + public async Task RegisterTeamsToTournamentAsync_EmptyList_UnassignsAllCurrentMembers() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb); + Team teamOne = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + Team teamTwo = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + + await RegisterAsync(tournament, []); + + Team? persistedOne = await ReadTeamAsync(teamOne.Id); + Team? persistedTwo = await ReadTeamAsync(teamTwo.Id); + Assert.Null(persistedOne!.TournamentId); + Assert.Null(persistedTwo!.TournamentId); + } + + [Fact] + public async Task RegisterTeamsToTournamentAsync_ExistingMember_StaysRegistered() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb); + Team team = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + + await RegisterAsync(tournament, [team.Id]); + + Team? persisted = await ReadTeamAsync(team.Id); + Assert.NotNull(persisted); + Assert.Equal(tournament.Id, persisted!.TournamentId); + } + + [Fact] + public async Task RegisterTeamsToTournamentAsync_CrossTournamentTeam_IsReassigned() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament targetTournament = await SeedTournamentAsync(seedDb); + Tournament otherTournament = await SeedTournamentAsync(seedDb); + Team team = await SeedTeamAsync(seedDb, tournamentId: otherTournament.Id); + + await RegisterAsync(targetTournament, [team.Id]); + + Team? persisted = await ReadTeamAsync(team.Id); + Assert.NotNull(persisted); + Assert.Equal(targetTournament.Id, persisted!.TournamentId); + } + + /// + /// uninvolvedTeam belongs to a different tournament and is deliberately + /// left out of the registration list, so it must remain assigned to its + /// original (non-target) tournament rather than becoming unassigned. + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_UninvolvedTeam_IsUntouched() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament targetTournament = await SeedTournamentAsync(seedDb); + Tournament otherTournament = await SeedTournamentAsync(seedDb); + Team uninvolvedTeam = await SeedTeamAsync(seedDb, tournamentId: otherTournament.Id); + Team unrelatedTeam = await SeedTeamAsync(seedDb, tournamentId: null); + + await RegisterAsync(targetTournament, [unrelatedTeam.Id]); + + Team? persisted = await ReadTeamAsync(uninvolvedTeam.Id); + Assert.NotNull(persisted); + Assert.Equal(otherTournament.Id, persisted!.TournamentId); + } + + private async Task RegisterAsync(Tournament tournament, List teamIds) + { + using IServiceScope actScope = _factory.Services.CreateScope(); + ITeamService teamService = actScope.ServiceProvider.GetRequiredService(); + await teamService.RegisterTeamsToTournamentAsync(tournament, teamIds); + } + + private async Task ReadTeamAsync(Guid teamId) + { + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext db = verifyScope.ServiceProvider.GetRequiredService(); + return await db.Teams.AsNoTracking().SingleOrDefaultAsync(t => t.Id == teamId); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Team registration 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; + } + + private static async Task 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; + } +} diff --git a/Club12-Backend/API.Tests/TeamStaffEndpointTests.cs b/Club12-Backend/API.Tests/TeamStaffEndpointTests.cs new file mode 100644 index 0000000..f5928cb --- /dev/null +++ b/Club12-Backend/API.Tests/TeamStaffEndpointTests.cs @@ -0,0 +1,173 @@ +using Application.DTOs.TeamStaff.Request; +using Application.DTOs.TeamStaff.Response; + +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; + +/// +/// Integration coverage for the team-staff (cuerpo técnico) endpoints: +/// create/list/delete through real HTTP round trips and the AdminOrOwner +/// gating, mirroring PointDeductionEndpointTests. +/// +public class TeamStaffEndpointTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TeamStaffEndpointTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateThenList_ReturnsStaff_WithTeamNameAndRole() + { + (Team team, Tournament tournament) = await SeedTeamAndTournamentAsync(); + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage create = await client.PostAsJsonAsync( + $"api/teams/{team.Id}/staff", + new CreateTeamStaffRequest { FullName = "Carlos Gómez", Role = TeamStaffRole.Coach, TournamentId = tournament.Id }); + + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + TeamStaffResponse? created = await create.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal(team.Id, created!.TeamId); + Assert.Equal(tournament.Id, created.TournamentId); + Assert.Equal("Carlos Gómez", created.FullName); + Assert.Equal(nameof(TeamStaffRole.Coach), created.Role); + Assert.Equal(team.Name, created.TeamName); + + List? listed = await client.GetFromJsonAsync>( + $"api/teams/{team.Id}/staff?tournamentId={tournament.Id}"); + Assert.NotNull(listed); + Assert.Single(listed!); + Assert.Equal(created.Id, listed![0].Id); + } + + [Fact] + public async Task Get_WithNoStaff_ReturnsEmptyList() + { + (Team team, Tournament tournament) = await SeedTeamAndTournamentAsync(); + HttpClient client = _factory.CreateClient(); + + List? listed = await client.GetFromJsonAsync>( + $"api/teams/{team.Id}/staff?tournamentId={tournament.Id}"); + + Assert.NotNull(listed); + Assert.Empty(listed!); + } + + [Fact] + public async Task Delete_RemovesStaff_FromSubsequentList() + { + (Team team, Tournament tournament) = await SeedTeamAndTournamentAsync(); + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner); + + HttpResponseMessage create = await client.PostAsJsonAsync( + $"api/teams/{team.Id}/staff", + new CreateTeamStaffRequest { FullName = "Javier Coronel", Role = TeamStaffRole.AssistantCoach, TournamentId = tournament.Id }); + TeamStaffResponse created = (await create.Content.ReadFromJsonAsync())!; + + HttpResponseMessage delete = await client.DeleteAsync($"api/staff/{created.Id}"); + Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); + + List? listed = await client.GetFromJsonAsync>( + $"api/teams/{team.Id}/staff?tournamentId={tournament.Id}"); + Assert.Empty(listed!); + } + + [Fact] + public async Task Create_Anonymous_ReturnsUnauthorized() + { + HttpClient client = _factory.CreateClient(); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/teams/{Guid.NewGuid()}/staff", + new CreateTeamStaffRequest { FullName = "x", Role = TeamStaffRole.Coach, TournamentId = Guid.NewGuid() }); + + 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/teams/{Guid.NewGuid()}/staff", + new CreateTeamStaffRequest { FullName = "x", Role = TeamStaffRole.Coach, TournamentId = Guid.NewGuid() }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Create_UnknownTeam_ReturnsNotFound() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/teams/{Guid.NewGuid()}/staff", + new CreateTeamStaffRequest { FullName = "x", Role = TeamStaffRole.Coach, TournamentId = Guid.NewGuid() }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Create_UnknownTournament_ReturnsNotFound() + { + (Team team, _) = await SeedTeamAndTournamentAsync(); + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/teams/{team.Id}/staff", + new CreateTeamStaffRequest { FullName = "x", Role = TeamStaffRole.Coach, TournamentId = Guid.NewGuid() }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private async Task<(Team, Tournament)> SeedTeamAndTournamentAsync() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + Tournament tournament = new() + { + Description = "Team staff 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(); + + 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, tournament); + } +} diff --git a/Club12-Backend/API.Tests/TeamTournamentDivisionDeleteIntegrityTests.cs b/Club12-Backend/API.Tests/TeamTournamentDivisionDeleteIntegrityTests.cs new file mode 100644 index 0000000..2c469fc --- /dev/null +++ b/Club12-Backend/API.Tests/TeamTournamentDivisionDeleteIntegrityTests.cs @@ -0,0 +1,443 @@ +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; + +/// +/// Integrity tests for team, tournament and division deletion — the global +/// extension of the player/venue delete-integrity principle. Deleting a +/// competitive entity that owns or is referenced by real history must BLOCK +/// with a clear 409 (InvalidOperationException) instead of silently orphaning +/// data or cascading history away; an entity with no such dependents is still +/// removed cleanly. Exercised through the real services/repositories against +/// the SQLite-backed integration host. +/// +public class TeamTournamentDivisionDeleteIntegrityTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TeamTournamentDivisionDeleteIntegrityTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + // ---------- Team ---------- + + [Fact] + public async Task DeleteTeam_WithTournamentRegistration_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + (Team team, _, Guid tournamentId) = await SeedTeamAndStageAsync(db); + + db.Set().Add(new TeamTournamentRegistration + { + TeamId = team.Id, + TournamentId = tournamentId, + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => teamService.DeleteTeamAsync(team.Id)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Teams.AnyAsync(t => t.Id == team.Id)); + } + + [Fact] + public async Task DeleteTeam_WithMatchHistory_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + (Team team, Stage stage, _) = await SeedTeamAndStageAsync(db); + await SeedMatchAsync(db, stage, homeTeamId: team.Id, isFinished: true); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => teamService.DeleteTeamAsync(team.Id)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Teams.AnyAsync(t => t.Id == team.Id)); + } + + [Fact] + public async Task DeleteTeam_WithPointDeduction_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + (Team team, _, _) = await SeedTeamAndStageAsync(db); + Division division = await db.Divisions.FirstAsync(d => d.TournamentId == team.TournamentId); + + db.Set().Add(new TeamPointDeduction + { + DivisionId = division.Id, + TeamId = team.Id, + Points = 3, + Reason = "Test deduction", + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => teamService.DeleteTeamAsync(team.Id)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Teams.AnyAsync(t => t.Id == team.Id)); + } + + [Fact] + public async Task DeleteTeam_WithPlayerHavingIndividualSanction_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + (Team team, Stage stage, _) = await SeedTeamAndStageAsync(db); + Match match = await SeedMatchAsync(db, stage, homeTeamId: null, isFinished: false); + + Player player = new() + { + FirstName = "Carlos", + LastName = "Lopez", + Slug = $"player-{Guid.NewGuid()}", + DocumentNumber = Guid.NewGuid().ToString("N")[..8], + IsSanctioned = true, + BirthDate = DateTime.UtcNow.AddYears(-20), + SocialSecurity = "OSDE", + Team = team, + TeamId = team.Id, + CreatedBy = "test", + }; + db.Set().Add(player); + await db.SaveChangesAsync(); + + db.Set().Add(new PlayerSanction + { + Duration = 1, + IssuedDate = DateTime.UtcNow, + Description = "Test sanction", + SubjectType = Domain.Enums.SanctionSubjectType.Player, + Player = player, + PlayerId = player.Id, + Match = match, + MatchId = match.Id, + Slug = $"sanction-{Guid.NewGuid()}", + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + // Team-level checks (hasSanctions filters on PlayerSanction.TeamId, + // which is null for a Player-subject sanction) must not miss this — + // Team.Players cascades, which would otherwise silently wipe the + // player and their sanction along with the team. + InvalidOperationException ex = await Assert.ThrowsAsync( + () => teamService.DeleteTeamAsync(team.Id)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Teams.AnyAsync(t => t.Id == team.Id)); + Assert.True(await db.Set().AnyAsync(p => p.Id == player.Id)); + } + + [Fact] + public async Task DeleteTeam_WithoutDependents_RemovesTeam() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + 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 = null, + Players = [], + CreatedBy = "test", + }; + db.Teams.Add(team); + await db.SaveChangesAsync(); + + await teamService.DeleteTeamAsync(team.Id); + + Assert.False(await db.Teams.AnyAsync(t => t.Id == team.Id)); + } + + // ---------- Tournament ---------- + + [Fact] + public async Task DeleteTournament_Ongoing_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + (_, _, Guid tournamentId) = await SeedTeamAndStageAsync(db); + Tournament tournament = await db.Tournaments.FirstAsync(t => t.Id == tournamentId); + tournament.Status = TournamentStatus.Ongoing; + await db.SaveChangesAsync(); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => tournamentService.DeleteTournamentAsync(tournamentId)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Tournaments.AnyAsync(t => t.Id == tournamentId)); + } + + [Fact] + public async Task DeleteTournament_WithPlayedMatch_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + (_, Stage stage, Guid tournamentId) = await SeedTeamAndStageAsync(db); + await SeedMatchAsync(db, stage, homeTeamId: null, isFinished: true); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => tournamentService.DeleteTournamentAsync(tournamentId)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Tournaments.AnyAsync(t => t.Id == tournamentId)); + } + + [Fact] + public async Task DeleteTournament_OpenForRegistrationWithoutHistory_RemovesTournamentAndCascadesStructure() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + (Team team, Stage stage, Guid tournamentId) = await SeedTeamAndStageAsync(db); + Tournament tournament = await db.Tournaments.FirstAsync(t => t.Id == tournamentId); + tournament.Status = TournamentStatus.OpenForRegistration; + await db.SaveChangesAsync(); + + Guid divisionId = stage.DivisionId; + + await tournamentService.DeleteTournamentAsync(tournamentId); + + Assert.False(await db.Tournaments.AnyAsync(t => t.Id == tournamentId)); + // Owned structure cascaded away. + Assert.False(await db.Divisions.AnyAsync(d => d.Id == divisionId)); + Assert.False(await db.Stages.AnyAsync(s => s.Id == stage.Id)); + // The team identity survives; its current-season pointer was cleared. + Team? survivingTeam = await db.Teams.FirstOrDefaultAsync(t => t.Id == team.Id); + Assert.NotNull(survivingTeam); + Assert.Null(survivingTeam!.TournamentId); + } + + // ---------- Division ---------- + + [Fact] + public async Task DeleteDivision_WithPlayedMatch_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + (_, Stage stage, _) = await SeedTeamAndStageAsync(db); + await SeedMatchAsync(db, stage, homeTeamId: null, isFinished: true); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => divisionService.DeleteDivisionAsync(stage.DivisionId)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Divisions.AnyAsync(d => d.Id == stage.DivisionId)); + } + + [Fact] + public async Task DeleteDivision_WithPointDeduction_IsBlocked() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + (Team team, Stage stage, _) = await SeedTeamAndStageAsync(db); + + db.Set().Add(new TeamPointDeduction + { + DivisionId = stage.DivisionId, + TeamId = team.Id, + Points = 2, + Reason = "Test deduction", + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => divisionService.DeleteDivisionAsync(stage.DivisionId)); + + Assert.Contains("No se puede eliminar", ex.Message); + Assert.True(await db.Divisions.AnyAsync(d => d.Id == stage.DivisionId)); + } + + [Fact] + public async Task DeleteDivision_WithoutHistory_RemovesDivisionAndStages() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + (_, Stage stage, _) = await SeedTeamAndStageAsync(db); + Guid divisionId = stage.DivisionId; + + await divisionService.DeleteDivisionAsync(divisionId); + + Assert.False(await db.Divisions.AnyAsync(d => d.Id == divisionId)); + // Empty stages cascade away with the division. + Assert.False(await db.Stages.AnyAsync(s => s.Id == stage.Id)); + } + + [Fact] + public async Task DeleteDivision_TournamentOngoing_IsBlockedEvenWithoutPlayedMatches() + { + // Regression: a division with zero played matches and zero point + // deductions used to be deletable at ANY tournament status — including + // Ongoing, where Stages/Matches are cascade-deleted at the DB level, + // silently wiping a live, scheduled-but-unplayed fixture. + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + (_, Stage stage, _) = await SeedTeamAndStageAsync(db, TournamentStatus.Ongoing); + await SeedMatchAsync(db, stage, homeTeamId: null, isFinished: false); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => divisionService.DeleteDivisionAsync(stage.DivisionId)); + + Assert.Contains("el torneo ya arrancó", ex.Message); + Assert.True(await db.Divisions.AnyAsync(d => d.Id == stage.DivisionId)); + Assert.True(await db.Stages.AnyAsync(s => s.Id == stage.Id)); + } + + [Fact] + public async Task DeleteDivision_TournamentCanceled_IsBlockedEvenWithoutPlayedMatches() + { + // Regression: a Canceled tournament's divisions used to remain fully + // deletable (only Ongoing/Finished locked the structure), so a + // scheduled-but-unplayed fixture under a canceled tournament could + // still be cascade-wiped even though the tournament's structure must + // stay frozen once it is dead. + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + (_, Stage stage, _) = await SeedTeamAndStageAsync(db, TournamentStatus.Canceled); + await SeedMatchAsync(db, stage, homeTeamId: null, isFinished: false); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => divisionService.DeleteDivisionAsync(stage.DivisionId)); + + Assert.Contains("fue cancelado", ex.Message); + Assert.True(await db.Divisions.AnyAsync(d => d.Id == stage.DivisionId)); + Assert.True(await db.Stages.AnyAsync(s => s.Id == stage.Id)); + } + + // ---------- seeding ---------- + + private static async Task<(Team Team, Stage Stage, Guid TournamentId)> SeedTeamAndStageAsync( + ApplicationDBContext db, TournamentStatus status = TournamentStatus.OpenForRegistration) + { + 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, + Status = status, + 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 SeedMatchAsync(ApplicationDBContext db, Stage stage, Guid? homeTeamId, bool isFinished) + { + Match match = new() + { + MatchDate = stage.StartDate, + Type = MatchType.Regular, + Slug = $"match-{Guid.NewGuid()}", + IsFinished = isFinished, + Status = isFinished ? MatchStatus.Played : MatchStatus.Scheduled, + Stage = stage, + StageId = stage.Id, + HomeTeamId = homeTeamId, + VenueId = null, + CreatedBy = "test", + }; + + db.Matches.Add(match); + await db.SaveChangesAsync(); + + return match; + } +} diff --git a/Club12-Backend/API.Tests/TeamTournamentRegistrationTests.cs b/Club12-Backend/API.Tests/TeamTournamentRegistrationTests.cs new file mode 100644 index 0000000..3429a98 --- /dev/null +++ b/Club12-Backend/API.Tests/TeamTournamentRegistrationTests.cs @@ -0,0 +1,471 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Team.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; + +/// +/// Verifies season-scoped tournament participation: TeamTournamentRegistration +/// is the source of truth for "was this team registered to this tournament +/// during this season" (HU-98 / R2 / INC-2 — "Colón SF 2026" and +/// "Colón SF 2027" are two distinct, independently preserved +/// registrations), decoupled from Team.TournamentId (a denormalized +/// "current season" pointer) the same way PlayerTeamRegistration decouples +/// roster membership from Player.TeamId. Exercises the real ITeamService +/// capabilities through a DI scope against the CustomWebApplicationFactory's +/// SQLite-backed ApplicationDBContext, matching this project's established +/// integration-test style (see PlayerTeamRegistrationTests). +/// +public class TeamTournamentRegistrationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TeamTournamentRegistrationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// Replicates, as an EF/LINQ query, the exact two-step backfill join + /// performed by the AddTeamTournamentRegistrationTable migration's raw + /// SQL: + /// (1) StageTeamMatches JOIN Stages JOIN Divisions — recovers real + /// historical participation even for a team whose current pointer + /// has since moved elsewhere; + /// (2) Teams WHERE TournamentId IS NOT NULL — covers a team that is + /// currently registered but was never staged (no StageTeamMatch + /// rows yet), guarded so it does not duplicate a pair step (1) + /// already produced. + /// This test pins that combined logic inside the automated suite, + /// mirroring PlayerTeamRegistrationTests.Backfill_JoinLogic. + /// + [Fact] + public async Task Backfill_JoinLogic_RecoversHistoricalAndCurrentParticipationIdempotently() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament season2026 = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Tournament season2027 = await SeedTournamentAsync(seedDb, "Colón SF 2027"); + + // Team played real matches under season2026 (StageTeamMatch history) + // but its denormalized pointer has since moved to season2027 — step + // (1) must still recover the season2026 participation from history. + Team reassignedTeam = await SeedTeamAsync(seedDb, season2027.Id); + Division division2026 = await SeedDivisionAsync(seedDb, season2026); + Stage stage2026 = await SeedStageAsync(seedDb, division2026); + await SeedStageTeamMatchAsync(seedDb, stage2026, reassignedTeam); + + // Team is currently registered to season2027 but has no + // StageTeamMatch rows yet (not staged) — step (2) must still cover + // it via the current pointer. + Team unstagedTeam = await SeedTeamAsync(seedDb, season2027.Id); + + // Step (1): StageTeamMatch -> Stage -> Division join. + List<(Guid TeamId, Guid TournamentId)> historicalRows = await seedDb.StageTeamMatches + .Join(seedDb.Stages, stm => stm.StageId, s => s.Id, (stm, s) => new { stm, s }) + .Join(seedDb.Divisions, x => x.s.DivisionId, d => d.Id, (x, d) => new { x.stm, d }) + .Select(x => new ValueTuple(x.stm.TeamId, x.d.TournamentId)) + .Distinct() + .ToListAsync(); + + Assert.Contains(historicalRows, r => r.TeamId == reassignedTeam.Id && r.TournamentId == season2026.Id); + Assert.DoesNotContain(historicalRows, r => r.TeamId == unstagedTeam.Id); + + // Step (2): Teams.TournamentId IS NOT NULL, NOT EXISTS-guarded + // against pairs step (1) already produced. + HashSet<(Guid TeamId, Guid TournamentId)> historicalSet = [.. historicalRows]; + + List<(Guid TeamId, Guid TournamentId)> pointerRows = await seedDb.Teams + .Where(t => t.TournamentId != null) + .Select(t => new ValueTuple(t.Id, t.TournamentId!.Value)) + .ToListAsync(); + + List<(Guid TeamId, Guid TournamentId)> newFromStep2 = [.. pointerRows.Where(r => !historicalSet.Contains(r))]; + + Assert.Contains(newFromStep2, r => r.TeamId == unstagedTeam.Id && r.TournamentId == season2027.Id); + // reassignedTeam's current pointer (season2027) is a NEW pair on top + // of its recovered season2026 history — both must be preserved, + // never collapsed into one. + Assert.Contains(newFromStep2, r => r.TeamId == reassignedTeam.Id && r.TournamentId == season2027.Id); + Assert.Contains(historicalRows, r => r.TeamId == reassignedTeam.Id && r.TournamentId == season2026.Id); + + // Combined result set (what both INSERT ... SELECT statements + // together produce) has exactly one row per distinct (team, + // tournament) pair — re-running step (2) a second time changes + // nothing further, since every pointerRow is already covered by + // either historicalSet or the first newFromStep2 pass. + HashSet<(Guid TeamId, Guid TournamentId)> combined = [.. historicalRows, .. newFromStep2]; + List<(Guid TeamId, Guid TournamentId)> secondPassNew = [.. pointerRows.Where(r => !combined.Contains(r))]; + Assert.Empty(secondPassNew); + } + + /// + /// A team listed for a tournament it was never registered to gains a + /// registration to THAT tournament, and its denormalized current-season + /// pointer is refreshed to match — the join table and pointer agree for a + /// freshly-registered team. + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_NewTeam_CreatesRegistrationAndUpdatesPointer() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Team team = await SeedTeamAsync(seedDb, tournamentId: null); + + await RegisterAsync(tournament, [team.Id]); + + List registrations = await ReadRegistrationsAsync(team.Id); + TeamTournamentRegistration only = Assert.Single(registrations); + Assert.Equal(tournament.Id, only.TournamentId); + + Team? persisted = await ReadTeamAsync(team.Id); + Assert.Equal(tournament.Id, persisted!.TournamentId); + } + + /// + /// A team already registered to the target tournament and included again + /// is not duplicated — the upsert keeps the single existing row (the + /// unique (TeamId, TournamentId) index would otherwise reject a second). + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_ExistingMember_StaysRegistered_NotDuplicated() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Team team = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + await SeedRegistrationAsync(seedDb, team, tournament); + + await RegisterAsync(tournament, [team.Id]); + + List registrations = await ReadRegistrationsAsync(team.Id); + TeamTournamentRegistration only = Assert.Single(registrations); + Assert.Equal(tournament.Id, only.TournamentId); + } + + /// + /// Registering a team already registered to another tournament ADDS a + /// second registration for the target — "Colón SF 2026" and + /// "Colón SF 2027" coexist as two independently preserved rows; the first + /// is never overwritten. + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_TeamInDifferentTournament_GainsSecondRegistration_KeepsFirst() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament season2026 = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Tournament season2027 = await SeedTournamentAsync(seedDb, "Colón SF 2027"); + Team team = await SeedTeamAsync(seedDb, tournamentId: season2026.Id); + await SeedRegistrationAsync(seedDb, team, season2026); + + await RegisterAsync(season2027, [team.Id]); + + List registrations = await ReadRegistrationsAsync(team.Id); + Assert.Equal(2, registrations.Count); + Assert.Contains(registrations, r => r.TournamentId == season2026.Id); + Assert.Contains(registrations, r => r.TournamentId == season2027.Id); + } + + /// + /// Dropping a team from the target tournament removes ONLY that + /// tournament's registration — its registration in a different tournament + /// survives, so history is never erased. + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_DroppedTeam_RemovesOnlyTargetTournamentRegistration_KeepsOthers() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament season2026 = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Tournament season2027 = await SeedTournamentAsync(seedDb, "Colón SF 2027"); + Team team = await SeedTeamAsync(seedDb, tournamentId: season2027.Id); + await SeedRegistrationAsync(seedDb, team, season2026); + await SeedRegistrationAsync(seedDb, team, season2027); + + // Reconcile season2027 without this team → drop only its 2027 row. + await RegisterAsync(season2027, []); + + List registrations = await ReadRegistrationsAsync(team.Id); + TeamTournamentRegistration only = Assert.Single(registrations); + Assert.Equal(season2026.Id, only.TournamentId); + } + + /// + /// An empty list clears only the target tournament's members: a team + /// registered to another tournament is entirely untouched (its + /// registration AND its pointer survive). + /// + [Fact] + public async Task RegisterTeamsToTournamentAsync_EmptyList_ClearsOnlyTargetTournamentMembers_KeepsOtherTournaments() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament target = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Tournament other = await SeedTournamentAsync(seedDb, "Colón SF 2027"); + + Team targetMember = await SeedTeamAsync(seedDb, tournamentId: target.Id); + await SeedRegistrationAsync(seedDb, targetMember, target); + + Team otherMember = await SeedTeamAsync(seedDb, tournamentId: other.Id); + await SeedRegistrationAsync(seedDb, otherMember, other); + + await RegisterAsync(target, []); + + Assert.Empty(await ReadRegistrationsAsync(targetMember.Id)); + Assert.Null((await ReadTeamAsync(targetMember.Id))!.TournamentId); + + List otherRegistrations = await ReadRegistrationsAsync(otherMember.Id); + TeamTournamentRegistration only = Assert.Single(otherRegistrations); + Assert.Equal(other.Id, only.TournamentId); + Assert.Equal(other.Id, (await ReadTeamAsync(otherMember.Id))!.TournamentId); + } + + /// + /// GetAllTeamsAsync(TournamentId) resolves participation through the + /// TeamTournamentRegistration join, NOT Team.TournamentId: a team whose + /// current pointer has moved to a newer season still appears when listing + /// the older season it remains registered to, and the same team appears in + /// BOTH seasons' listings. + /// + [Fact] + public async Task GetAllTeamsAsync_TournamentIdFilter_ResolvesViaJoin_TeamAppearsInEverySeasonItIsRegisteredIn() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament season2026 = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Tournament season2027 = await SeedTournamentAsync(seedDb, "Colón SF 2027"); + + // Pointer currently on 2027, but registered to BOTH seasons. + Team team = await SeedTeamAsync(seedDb, tournamentId: season2027.Id); + await SeedRegistrationAsync(seedDb, team, season2026); + await SeedRegistrationAsync(seedDb, team, season2027); + + PaginatedResponse in2026 = await GetAllTeamsAsync(new GetTeamsFilteredRequest { TournamentId = season2026.Id }); + PaginatedResponse in2027 = await GetAllTeamsAsync(new GetTeamsFilteredRequest { TournamentId = season2027.Id }); + + Assert.Contains(in2026.Items, t => t.Id == team.Id); + Assert.Contains(in2027.Items, t => t.Id == team.Id); + } + + /// + /// A team registered to a tournament but whose pointer moved elsewhere is + /// NOT found by that pointer's tournament unless it is also registered + /// there — proves the filter is the join, not the FK. + /// + [Fact] + public async Task GetAllTeamsAsync_TournamentIdFilter_ExcludesTeamRegisteredElsewhereEvenIfPointerMatches() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament registeredSeason = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Tournament pointerSeason = await SeedTournamentAsync(seedDb, "Colón SF 2027"); + + // Pointer says 2027, but the only registration is for 2026. + Team team = await SeedTeamAsync(seedDb, tournamentId: pointerSeason.Id); + await SeedRegistrationAsync(seedDb, team, registeredSeason); + + PaginatedResponse byPointer = await GetAllTeamsAsync(new GetTeamsFilteredRequest { TournamentId = pointerSeason.Id }); + PaginatedResponse byRegistration = await GetAllTeamsAsync(new GetTeamsFilteredRequest { TournamentId = registeredSeason.Id }); + + Assert.DoesNotContain(byPointer.Items, t => t.Id == team.Id); + Assert.Contains(byRegistration.Items, t => t.Id == team.Id); + } + + /// + /// The StageId special-case still works AND-combined with the + /// TournamentId join filter: a team registered to the tournament and + /// staged in the given stage is returned; a team registered but not + /// staged is excluded. + /// + [Fact] + public async Task GetAllTeamsAsync_StageIdAndTournamentIdCombined_StillHonorsStageSpecialCase() + { + using IServiceScope seedScope = _factory.Services.CreateScope(); + ApplicationDBContext seedDb = seedScope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(seedDb, "Colón SF 2026"); + Division division = await SeedDivisionAsync(seedDb, tournament); + Stage stage = await SeedStageAsync(seedDb, division); + + Team stagedTeam = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + await SeedRegistrationAsync(seedDb, stagedTeam, tournament); + await SeedStageTeamMatchAsync(seedDb, stage, stagedTeam); + + Team unstagedTeam = await SeedTeamAsync(seedDb, tournamentId: tournament.Id); + await SeedRegistrationAsync(seedDb, unstagedTeam, tournament); + + PaginatedResponse result = await GetAllTeamsAsync(new GetTeamsFilteredRequest + { + TournamentId = tournament.Id, + StageId = stage.Id, + }); + + Assert.Contains(result.Items, t => t.Id == stagedTeam.Id); + Assert.DoesNotContain(result.Items, t => t.Id == unstagedTeam.Id); + } + + private async Task RegisterAsync(Tournament tournament, List teamIds) + { + using IServiceScope actScope = _factory.Services.CreateScope(); + ITeamService teamService = actScope.ServiceProvider.GetRequiredService(); + await teamService.RegisterTeamsToTournamentAsync(tournament, teamIds); + } + + private async Task> GetAllTeamsAsync(GetTeamsFilteredRequest filter) + { + using IServiceScope actScope = _factory.Services.CreateScope(); + ITeamService teamService = actScope.ServiceProvider.GetRequiredService(); + return await teamService.GetAllTeamsAsync(filter); + } + + private async Task> ReadRegistrationsAsync(Guid teamId) + { + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext db = verifyScope.ServiceProvider.GetRequiredService(); + return await db.TeamTournamentRegistrations + .AsNoTracking() + .Where(r => r.TeamId == teamId) + .ToListAsync(); + } + + private async Task ReadTeamAsync(Guid teamId) + { + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext db = verifyScope.ServiceProvider.GetRequiredService(); + return await db.Teams.AsNoTracking().SingleOrDefaultAsync(t => t.Id == teamId); + } + + private static async Task 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, + Status = TournamentStatus.OpenForRegistration, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task 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 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 SeedStageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(10); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Stage-{Guid.NewGuid()}", + StageType = StageType.Group, + 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 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(); + } + + private static async Task SeedRegistrationAsync( + ApplicationDBContext db, Team team, Tournament tournament) + { + TeamTournamentRegistration registration = new() + { + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }; + + db.TeamTournamentRegistrations.Add(registration); + await db.SaveChangesAsync(); + + return registration; + } +} diff --git a/Club12-Backend/API.Tests/TestAuthHelper.cs b/Club12-Backend/API.Tests/TestAuthHelper.cs new file mode 100644 index 0000000..6ad92a7 --- /dev/null +++ b/Club12-Backend/API.Tests/TestAuthHelper.cs @@ -0,0 +1,63 @@ +using Microsoft.IdentityModel.Tokens; + +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; + +namespace API.Tests; + +/// +/// Issues test-only JWTs signed with the same key/issuer/audience +/// CustomWebApplicationFactory configures via environment variables, so +/// integration tests can exercise [Authorize(Roles = ...)]-gated +/// endpoints through a real HTTP round trip instead of hitting them +/// anonymously. +/// +public static class TestAuthHelper +{ + private const string SigningKey = "test-signing-key-at-least-32-characters-long"; + private const string Issuer = "club12-tests"; + private const string Audience = "club12-tests"; + + /// + /// Builds a signed JWT for a fake user with the given role(s). + /// + public static string CreateToken(params string[] roles) + { + JwtSecurityTokenHandler tokenHandler = new(); + byte[] key = Encoding.UTF8.GetBytes(SigningKey); + + List claims = + [ + new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), + new(ClaimTypes.Email, "test-user@club12.test"), + .. roles.Select(role => new Claim(ClaimTypes.Role, role)), + ]; + +#pragma warning disable S6781 + SecurityTokenDescriptor tokenDescriptor = new() + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddHours(1), + Issuer = Issuer, + Audience = Audience, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature), + }; +#pragma warning restore S6781 + + SecurityToken token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + /// + /// Builds an HttpClient from the factory with a Bearer token for the + /// given role(s) already attached. + /// + public static HttpClient CreateAuthenticatedClient(this CustomWebApplicationFactory factory, params string[] roles) + { + HttpClient client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateToken(roles)); + return client; + } +} diff --git a/Club12-Backend/API.Tests/TournamentAddFullDivisionTests.cs b/Club12-Backend/API.Tests/TournamentAddFullDivisionTests.cs new file mode 100644 index 0000000..fdb87e8 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentAddFullDivisionTests.cs @@ -0,0 +1,185 @@ +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; + +using System.Net; +using System.Net.Http.Json; + +namespace API.Tests; + +/// +/// HU-31/HU-112: POST api/tournaments/{tournamentId}/divisions/full adds ONE +/// division (with its group stage, cups and playoff mappings) to an +/// already-existing tournament in a single transaction — the same structure +/// guarantee a wizard-created division gets, instead of the bare division the +/// granular POST api/divisions endpoint leaves behind (no stages, no cups). +/// +public class TournamentAddFullDivisionTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentAddFullDivisionTests(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 CreateFullDivisionRequest BuildDivisionRequest(TournamentCategory category) => new() + { + Name = $"Zona {Guid.NewGuid():N}", + Category = category, + PointsForWin = 2, + PointsForLoss = 1, + PlayoffMappings = + [ + new PlayoffMappingRequest { FromPosition = 1, ToPosition = 4, Destination = "Copa Oro" }, + ], + Stages = [GroupStage()], + }; + + [Fact] + public async Task AddFullDivisionAsync_PersistsDivisionWithStagesAndPlayoffMappingsAtomically() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + Guid createdDivisionId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ITournamentService tournamentService = + scope.ServiceProvider.GetRequiredService(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await db.Tournaments.SingleAsync(t => t.Id == tournamentId); + Division created = await tournamentService.AddFullDivisionAsync( + tournament, BuildDivisionRequest(TournamentCategory.Masculine)); + + createdDivisionId = created.Id; + } + + // Verify in a FRESH scope/context that the whole graph is on disk. + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + + Division division = await verifyDb.Divisions + .Include(d => d.PlayoffMappings) + .SingleAsync(d => d.Id == createdDivisionId); + + Assert.Equal(tournamentId, division.TournamentId); + Assert.Single(division.PlayoffMappings); + Assert.Equal(1, await verifyDb.Stages.CountAsync(s => s.DivisionId == createdDivisionId)); + } + + /// + /// Structural guard (HU-31): a division cannot be added once registration + /// closes. Mapped to 409 by the global handler (InvalidOperationException). + /// + [Fact] + public async Task AddFullDivision_TournamentNotOpenForRegistration_IsRejected() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/divisions/full", + BuildDivisionRequest(TournamentCategory.Masculine)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await verifyDb.Divisions.AsNoTracking().CountAsync(d => d.TournamentId == tournamentId)); + } + + /// + /// HU-48: a division's category must match its tournament's — mapped to + /// 409, and nothing (not even the division shell) is left behind. + /// + [Fact] + public async Task AddFullDivision_CategoryMismatch_IsRejectedAndPersistsNothing() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/divisions/full", + BuildDivisionRequest(TournamentCategory.Feminine)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await verifyDb.Divisions.AsNoTracking().CountAsync(d => d.TournamentId == tournamentId)); + } + + [Fact] + public async Task AddFullDivision_TournamentNotFound_ReturnsNotFound() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{Guid.NewGuid()}/divisions/full", + BuildDivisionRequest(TournamentCategory.Masculine)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Add-full-division test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = startDate.AddDays(-1), + StartDate = startDate, + Category = TournamentCategory.Masculine, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } +} diff --git a/Club12-Backend/API.Tests/TournamentCancelCascadeTests.cs b/Club12-Backend/API.Tests/TournamentCancelCascadeTests.cs new file mode 100644 index 0000000..eed2269 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentCancelCascadeTests.cs @@ -0,0 +1,183 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies the cascade runs +/// when a tournament moves to or is +/// force-closed to while matches are +/// still pending: every not-yet-finished match becomes +/// , so no "still to be played" fixture is +/// left dangling under a dead tournament. Already-finished matches (real +/// recorded results) are never touched. +/// +public class TournamentCancelCascadeTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentCancelCascadeTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ChangeStatusAsync_ToCanceled_CancelsPendingMatchesButKeepsFinishedOnesUntouched() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + Match finishedMatch = await SeedMatchAsync(db, stage, isFinished: true, status: MatchStatus.Played); + Match pendingMatch = await SeedMatchAsync(db, stage, isFinished: false, status: MatchStatus.Scheduled); + Match suspendedMatch = await SeedMatchAsync(db, stage, isFinished: false, status: MatchStatus.Suspended); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Canceled); + + Tournament reloadedTournament = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Canceled, reloadedTournament.Status); + + Match reloadedFinished = await db.Matches.AsNoTracking().SingleAsync(m => m.Id == finishedMatch.Id); + Assert.Equal(MatchStatus.Played, reloadedFinished.Status); + Assert.True(reloadedFinished.IsFinished); + + Match reloadedPending = await db.Matches.AsNoTracking().SingleAsync(m => m.Id == pendingMatch.Id); + Assert.Equal(MatchStatus.Canceled, reloadedPending.Status); + Assert.False(reloadedPending.IsFinished); + + Match reloadedSuspended = await db.Matches.AsNoTracking().SingleAsync(m => m.Id == suspendedMatch.Id); + Assert.Equal(MatchStatus.Canceled, reloadedSuspended.Status); + Assert.False(reloadedSuspended.IsFinished); + } + + [Fact] + public async Task ChangeStatusAsync_ToFinishedWithPendingMatches_CancelsThePendingOnes() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + Match pendingMatch = await SeedMatchAsync(db, stage, isFinished: false, status: MatchStatus.Scheduled); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Finished); + + Tournament reloadedTournament = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Finished, reloadedTournament.Status); + + Match reloadedPending = await db.Matches.AsNoTracking().SingleAsync(m => m.Id == pendingMatch.Id); + Assert.Equal(MatchStatus.Canceled, reloadedPending.Status); + } + + [Fact] + public async Task ChangeStatusAsync_ToCanceled_WithNoPendingMatches_IsANoOp() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Canceled); + + Tournament reloadedTournament = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Canceled, reloadedTournament.Status); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Cancel cascade test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task 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 SeedGroupStageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{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 SeedMatchAsync( + ApplicationDBContext db, Stage stage, bool isFinished, MatchStatus status) + { + Match match = new() + { + Slug = $"match-{Guid.NewGuid()}", + Type = Domain.Enums.MatchType.Regular, + StageId = stage.Id, + MatchDate = DateTime.UtcNow.Date.AddDays(31), + IsFinished = isFinished, + Status = status, + CreatedBy = "test", + }; + + db.Matches.Add(match); + await db.SaveChangesAsync(); + + return match; + } +} diff --git a/Club12-Backend/API.Tests/TournamentCategoryTests.cs b/Club12-Backend/API.Tests/TournamentCategoryTests.cs new file mode 100644 index 0000000..fd7cc39 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentCategoryTests.cs @@ -0,0 +1,187 @@ +using Application.DTOs.Tournament.Request; +using Application.DTOs.Tournament.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; + +namespace API.Tests; + +/// +/// HU-48: femenino is, by design, a SEPARATE tournament. A tournament carries +/// a (the source of truth, surfaced on the +/// response) and every division under it must share that category — a single +/// tournament can never mix feminine and masculine divisions. These tests +/// cover the persisted/surfaced category and the create/update invariant that +/// rejects a mix at the service layer. +/// +public class TournamentCategoryTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + // The API serializes enums as strings (JsonStringEnumConverter), so + // response reads must use the same convention to parse Status/Category. + private static readonly JsonSerializerOptions JsonOptions = + new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + + public TournamentCategoryTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateTournament_Feminine_PersistsAndSurfacesCategoryOnResponse() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner); + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + CreateTournamentRequest request = new() + { + Name = $"Femenino-{Guid.NewGuid()}", + Description = "HU-48 feminine tournament", + StartDate = start, + TeamRegistrationDeadline = start.AddDays(-1), + Category = TournamentCategory.Feminine, + }; + + HttpResponseMessage createResponse = await client.PostAsJsonAsync("api/tournaments", request); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + TournamentResponse? created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(created); + Assert.Equal(TournamentCategory.Feminine, created!.Category); + + // Surfaces on a subsequent read too (real round trip, not just the + // create echo). + TournamentResponse? fetched = await client.GetFromJsonAsync( + $"api/tournaments/{created.Id}", JsonOptions); + Assert.NotNull(fetched); + Assert.Equal(TournamentCategory.Feminine, fetched!.Category); + } + + [Fact] + public async Task CreateTournament_DefaultsToMasculineWhenOmitted() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Owner); + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + // Category intentionally omitted from the JSON payload. + var payload = new + { + Name = $"Default-{Guid.NewGuid()}", + Description = "Category omitted", + StartDate = start, + TeamRegistrationDeadline = start.AddDays(-1), + }; + + HttpResponseMessage createResponse = await client.PostAsJsonAsync("api/tournaments", payload); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + TournamentResponse? created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(created); + Assert.Equal(TournamentCategory.Masculine, created!.Category); + } + + [Fact] + public async Task CreateDivisionAsync_CategoryMatchesTournament_SucceedsAndPersists() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentCategory.Feminine); + + Division division = BuildDivision(tournament, TournamentCategory.Feminine); + + Division created = await divisionService.CreateDivisionAsync(division); + + Assert.NotEqual(Guid.Empty, created.Id); + + Division persisted = await db.Divisions.AsNoTracking().SingleAsync(d => d.Id == created.Id); + Assert.Equal(TournamentCategory.Feminine, persisted.Category); + } + + [Fact] + public async Task CreateDivisionAsync_FeminineDivisionInMasculineTournament_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentCategory.Masculine); + + Division division = BuildDivision(tournament, TournamentCategory.Feminine); + + await Assert.ThrowsAsync( + () => divisionService.CreateDivisionAsync(division)); + + Assert.Equal(0, await db.Divisions.CountAsync(d => d.TournamentId == tournament.Id)); + } + + [Fact] + public async Task CreateDivisionAsync_MasculineDivisionInFeminineTournament_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentCategory.Feminine); + + Division division = BuildDivision(tournament, TournamentCategory.Masculine); + + await Assert.ThrowsAsync( + () => divisionService.CreateDivisionAsync(division)); + + Assert.Equal(0, await db.Divisions.CountAsync(d => d.TournamentId == tournament.Id)); + } + + private static Division BuildDivision(Tournament tournament, TournamentCategory category) + { + return new Division + { + Name = $"Division-{Guid.NewGuid()}", + Slug = string.Empty, + Tournament = tournament, + TournamentId = tournament.Id, + Category = category, + Stages = [], + CreatedBy = "test", + }; + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentCategory category) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "HU-48 category test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + // Structural changes (division create) require OpenForRegistration, + // so the category invariant is reached rather than the HU-31 guard. + Status = TournamentStatus.OpenForRegistration, + Category = category, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } +} diff --git a/Club12-Backend/API.Tests/TournamentCloningEndToEndTests.cs b/Club12-Backend/API.Tests/TournamentCloningEndToEndTests.cs new file mode 100644 index 0000000..0237131 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentCloningEndToEndTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Application.DTOs.Divisions.Request; +using Application.DTOs.Tournament.Request; +using Application.DTOs.Tournament.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// End-to-end proof of the tournament-cloning pipeline (Phase 7): a real +/// -produced tournament — multi-division, +/// cross-division cup, cup playoff mappings — is read through the real +/// GET {idOrSlug}/structure endpoint, reverse-mapped per the D1 rules (the +/// same rules `cloneWizard.ts` implements on the frontend: regular-cup +/// qualifiers from the PlayoffMapping span, cross-cup qualifiers from +/// groupCount x QualifiersPerGroup), edited (one zone dropped, mirroring an +/// organizer editing the wizard before submit), given an explicit DIFFERENT +/// category than the source, and submitted through the real, unchanged +/// POST /full transaction. Confirms the created tournament's structure +/// matches the edited/reconstructed request, the category is the chosen one +/// (never the source's), and every division starts with zero team +/// registrations and zero matches. +/// +public class TournamentCloningEndToEndTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + private static readonly JsonSerializerOptions JsonOptions = + new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + + public TournamentCloningEndToEndTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static List BuildVenues() => + [ + new() { Slug = "clon-cancha-1", CreatedBy = "test", Name = "Cancha Clon 1", Address = "Calle 1" }, + new() { Slug = "clon-cancha-2", CreatedBy = "test", Name = "Cancha Clon 2", Address = "Calle 2" }, + ]; + + private static SampleTournamentBuilder.TournamentDefinition BuildSourceDefinition() + { + static (string[] Names, string[] Codes, string[] Colors) Roster(string prefix, int count) => ( + [.. Enumerable.Range(1, count).Select(i => $"{prefix} {i}")], + [.. Enumerable.Range(1, count).Select(i => $"{prefix[..2].ToUpperInvariant()}{i:00}")], + [.. Enumerable.Range(1, count).Select(_ => "#222222")]); + + (string[] namesA, string[] codesA, string[] colorsA) = Roster("Equipo A", 4); + (string[] namesB, string[] codesB, string[] colorsB) = Roster("Equipo B", 4); + + return new( + Name: "Apertura Clonable", + Description: "Torneo fuente para el flujo de clonado.", + 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, 3, 1, 0, 0, 0, DateTimeKind.Utc), + Divisions: + [ + new("Zona A", namesA, codesA, colorsA, + [new SampleTournamentBuilder.PlayoffCupDefinition("Copa Oro", FromPosition: 1, ToPosition: 4, BestOf: 3)]), + new("Zona B", namesB, codesB, colorsB, + [new SampleTournamentBuilder.PlayoffCupDefinition("Copa Plata", FromPosition: 1, ToPosition: 4, BestOf: 1)]), + ], + CrossCup: new SampleTournamentBuilder.CrossCupDefinition( + DivisionName: "Copa Cruzada", GroupCount: 2, QualifiersPerGroup: 1, RoundRobinLegs: 1, FinalsBestOf: 1), + Category: TournamentCategory.Masculine); + } + + /// + /// Reconstructs one division's request from its structure tree per the D1 + /// rules: a regular cup's qualifier count comes from its PlayoffMapping + /// span; the cross cup's pooled bracket carries none, since it is never + /// seeded by a position-range mapping. Every stage gets a fresh sequential + /// date window, mirroring `submitWizard.ts`'s own date-building — the + /// source's own dates never carry over (HU-cloning: dates always blank + /// until the organizer re-enters them). + /// + private static CreateFullDivisionRequest ReconstructDivision( + DivisionStructureResponse source, TournamentCategory chosenCategory, DateTime anchor) + { + List stages = []; + DateTime cursor = anchor; + + foreach (StageStructureResponse stage in source.Stages) + { + DateTime end = cursor.AddDays(7); + stages.Add(new CreateFullStageRequest + { + Name = stage.Name, + StageType = stage.StageType, + IsElimination = stage.IsElimination, + StartDate = cursor, + EndDate = end, + BracketName = stage.BracketName, + BestOf = stage.BestOf, + RoundRobinLegs = stage.RoundRobinLegs, + }); + cursor = end; + } + + return new CreateFullDivisionRequest + { + Name = source.Name, + IsCrossDivisionCup = source.IsCrossDivisionCup, + PointsForWin = source.PointsForWin, + PointsForLoss = source.PointsForLoss, + QualifiersPerGroup = source.QualifiersPerGroup, + Category = chosenCategory, + PlayoffMappings = [.. source.PlayoffMappings.Select(mapping => new PlayoffMappingRequest + { + FromPosition = mapping.FromPosition, + ToPosition = mapping.ToPosition, + Destination = mapping.Destination, + })], + Stages = stages, + }; + } + + [Fact] + public async Task CloningPipeline_SeedStructureEditSubmit_CreatesFaithfulTournamentWithZeroInstanceData() + { + // 1. Seed a real, non-trivial source tournament (two regular cup + // divisions plus a cross-division cup) via the same builder the + // sample-data seeder uses. + int playerCounter = 0; + SampleTournamentBuilder.BuildResult built = SampleTournamentBuilder.Build( + BuildSourceDefinition(), BuildVenues(), ref playerCounter, includePlayoffs: true); + + Guid sourceTournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + db.Tournaments.Add(built.Tournament); + await db.SaveChangesAsync(); + sourceTournamentId = built.Tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + // 2. Read the source's structure through the real endpoint — the exact + // contract the frontend's "Clonar torneo" action hits. + HttpResponseMessage structureResponse = + await client.GetAsync($"api/tournaments/{sourceTournamentId}/structure"); + Assert.Equal(HttpStatusCode.OK, structureResponse.StatusCode); + + TournamentStructureResponse structure = + (await structureResponse.Content.ReadFromJsonAsync(JsonOptions))!; + + Assert.Equal(3, structure.Divisions.Count); + + // 3. Reverse-map per D1 (mirrors cloneWizard.ts), choosing a category + // DIFFERENT from the source's (Masculine) to prove it is an + // explicit organizer choice, never silently inherited — then EDIT + // the wizard state by dropping "Zona B" entirely, mirroring an + // organizer deleting a zone before submitting. + const TournamentCategory chosenCategory = TournamentCategory.Feminine; + DateTime newStartDate = new(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime newDeadline = newStartDate.AddDays(-14); + + List editedDivisions = + [.. structure.Divisions.Where(division => division.Name != "Zona B")]; + Assert.Equal(2, editedDivisions.Count); + + CreateFullTournamentRequest cloneRequest = new() + { + Name = $"{structure.Name} (copia)", + Description = structure.Description ?? string.Empty, + StartDate = newStartDate, + TeamRegistrationDeadline = newDeadline, + Category = chosenCategory, + Divisions = [.. editedDivisions.Select( + division => ReconstructDivision(division, chosenCategory, newStartDate))], + }; + + // 4. Submit through the SAME unchanged /full creation transaction. + HttpResponseMessage createResponse = + await client.PostAsJsonAsync("api/tournaments/full", cloneRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + TournamentResponse created = + (await createResponse.Content.ReadFromJsonAsync(JsonOptions))!; + + // 5. Verify the created tournament, in a FRESH scope, matches the + // edited wizard state — not the original source structure. + using IServiceScope verifyScope = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + + Tournament createdTournament = await verifyDb.Tournaments + .Include(t => t.Divisions).ThenInclude(d => d.Stages) + .Include(t => t.Divisions).ThenInclude(d => d.PlayoffMappings) + .Include(t => t.Divisions).ThenInclude(d => d.DivisionTeamRegistrations) + .SingleAsync(t => t.Id == created.Id); + + // Dates were required and honored — never carried over blank/from the source. + Assert.Equal(newStartDate, createdTournament.StartDate); + Assert.Equal(newDeadline, createdTournament.TeamRegistrationDeadline); + // The chosen category applies, NOT the source's Masculine. + Assert.Equal(TournamentCategory.Feminine, createdTournament.Category); + + // The edit (deleting "Zona B") is reflected — only 2 divisions, not the original 3. + Assert.Equal(2, createdTournament.Divisions.Count); + Assert.DoesNotContain(createdTournament.Divisions, d => d.Name == "Zona B"); + + Division zonaA = createdTournament.Divisions.Single(d => d.Name == "Zona A"); + Assert.False(zonaA.IsCrossDivisionCup); + Assert.Equal(TournamentCategory.Feminine, zonaA.Category); + Assert.Contains(zonaA.Stages, s => s.StageType == StageType.Group); + Assert.Contains(zonaA.Stages, s => s.StageType == StageType.SemiFinal && s.BracketName == "Copa Oro"); + Assert.Contains(zonaA.Stages, s => s.StageType == StageType.Final && s.BracketName == "Copa Oro"); + DivisionPlayoffMapping zonaAMapping = Assert.Single(zonaA.PlayoffMappings); + Assert.Equal(1, zonaAMapping.FromPosition); + Assert.Equal(4, zonaAMapping.ToPosition); + Assert.Equal("Copa Oro", zonaAMapping.Destination); + + Division crossCup = createdTournament.Divisions.Single(d => d.IsCrossDivisionCup); + Assert.Equal("Copa Cruzada", crossCup.Name); + Assert.Equal(1, crossCup.QualifiersPerGroup); + Assert.Equal(2, crossCup.Stages.Count(s => s.StageType == StageType.Group)); + Assert.Contains(crossCup.Stages, s => s.StageType == StageType.Final); + + // Zero instance data: every created division starts with no team + // registrations at all, and no matches were generated (structure-only + // creation, exactly like a from-scratch wizard run). + Assert.All(createdTournament.Divisions, d => Assert.Empty(d.DivisionTeamRegistrations)); + int matchCount = await verifyDb.Matches + .CountAsync(m => createdTournament.Divisions.Select(d => d.Id).Contains(m.Stage.DivisionId)); + Assert.Equal(0, matchCount); + } +} diff --git a/Club12-Backend/API.Tests/TournamentCompletabilityGuardTests.cs b/Club12-Backend/API.Tests/TournamentCompletabilityGuardTests.cs new file mode 100644 index 0000000..ba8c183 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentCompletabilityGuardTests.cs @@ -0,0 +1,458 @@ +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; + +namespace API.Tests; + +/// +/// HU-109: the completability guard makes it impossible to START a tournament +/// (transition to Ongoing) in a state that could never be completed, and the +/// read-only GET .../completability endpoint previews the same issues. Service +/// paths are exercised directly; the query endpoint through a real HTTP round +/// trip (TournamentController has no SupabaseHelper dependency). +/// +public class TournamentCompletabilityGuardTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentCompletabilityGuardTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ChangeStatusIntoOngoing_ZoneTooFewTeams_IsBlockedAndKeepsStatus() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + Team team = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, team); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing)); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.RegistrationClosed, reloaded.Status); + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == group.Id)); + } + + [Fact] + public async Task ChangeStatusIntoOngoing_Completable_StartsAndGeneratesFixture() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + for (int i = 0; i < 4; i++) + { + Team team = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, team); + await SeedPlayersAsync(db, team, tournament, count: 5); + } + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Ongoing, reloaded.Status); + // 4 teams, single round-robin => 4*3/2 = 6 matches. + Assert.Equal(6, await db.Matches.CountAsync(m => m.StageId == group.Id)); + } + + [Fact] + public async Task GetCompletability_InvalidTournament_ReturnsIssuesAndCannotStart() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + Team team = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, team); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{tournamentId}/completability"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + CompletabilityResponseDto? body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.CanStart); + Assert.Contains(body.Issues, i => i.Code == "ZoneTooFewTeams"); + } + + [Fact] + public async Task GetCompletability_ValidTournament_ReturnsCanStartWithNoIssues() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + for (int i = 0; i < 2; i++) + { + Team team = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, team); + await SeedPlayersAsync(db, team, tournament, count: 5); + } + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{tournamentId}/completability"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + CompletabilityResponseDto? body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.CanStart); + Assert.Empty(body.Issues); + } + + [Fact] + public async Task ChangeStatusIntoOngoing_TeamTooFewPlayers_IsBlockedAndKeepsStatus() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + + Team fullTeam = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, fullTeam); + await SeedPlayersAsync(db, fullTeam, tournament, count: 5); + + Team shortTeam = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, shortTeam); + await SeedPlayersAsync(db, shortTeam, tournament, count: 3); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing)); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.RegistrationClosed, reloaded.Status); + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == group.Id)); + } + + [Fact] + public async Task ChangeStatusIntoOngoing_TeamRegisteredButNotHabilitado_IsBlockedAndKeepsStatus() + { + // Regression for the completability guard counting raw roster size + // instead of HABILITADO players: a team can have plenty of registered + // players and still be blocked from starting if none of them have an + // approved medical record with a real stored file. + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + + Team fullTeam = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, fullTeam); + await SeedPlayersAsync(db, fullTeam, tournament, count: 5); + + Team notHabilitadoTeam = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, notHabilitadoTeam); + await SeedPlayersAsync(db, notHabilitadoTeam, tournament, count: 8, habilitado: false); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing)); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.RegistrationClosed, reloaded.Status); + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == group.Id)); + } + + [Fact] + public async Task GetCompletability_TeamTooFewPlayers_ReturnsIssueWithPlayerCount() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division zone = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage group = await SeedGroupStageAsync(db, zone); + + Team fullTeam = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, fullTeam); + await SeedPlayersAsync(db, fullTeam, tournament, count: 5); + + Team shortTeam = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, group, shortTeam); + await SeedPlayersAsync(db, shortTeam, tournament, count: 3); + + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{tournamentId}/completability"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + CompletabilityResponseDto? body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.CanStart); + IssueDto issue = Assert.Single(body.Issues, i => i.Code == "TeamTooFewPlayers"); + Assert.Equal(3, issue.PlayerCount); + } + + [Fact] + public async Task ChangeStatusIntoOngoing_FailurePartwayThroughFixtureGeneration_RollsBackEveryDivision() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + + // Division A: a perfectly valid, completable zone — generating its + // fixture alone would succeed. + Division zoneA = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage groupA = await SeedGroupStageAsync(db, zoneA); + for (int i = 0; i < 4; i++) + { + Team team = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, groupA, team); + await SeedPlayersAsync(db, team, tournament, count: 5); + } + + // Division B: a completable group stage (so the HU-109 guard passes) + // PLUS a RoundOf16 stage — a knockout type CreateAutomatedMatchesAsync + // does not support. Fixture generation reaches this stage only after + // successfully generating division B's own group matches, so this + // also proves a same-division partial success rolls back too. + Division zoneB = await SeedDivisionAsync(db, tournament, crossCup: false); + Stage groupB = await SeedGroupStageAsync(db, zoneB); + for (int i = 0; i < 2; i++) + { + Team team = await SeedTeamAsync(db, tournament.Id); + await AssignAndEnrollAsync(db, tournament, groupB, team); + await SeedPlayersAsync(db, team, tournament, count: 5); + } + await SeedRoundOf16StageAsync(db, zoneB); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing)); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.RegistrationClosed, reloaded.Status); + // Neither division's matches survive — the whole attempt rolls back + // together: division A succeeded fully, division B's OWN group stage + // even succeeded before its RoundOf16 stage threw, but none of it sticks. + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == groupA.Id)); + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == groupB.Id)); + } + + [Fact] + public async Task GetCompletability_UnknownTournament_ReturnsNotFound() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{Guid.NewGuid()}/completability"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private sealed record CompletabilityResponseDto(bool CanStart, List Issues); + + private sealed record IssueDto(string Code, string? DivisionName, string? TeamName, int? FromPosition, int? AssignedTeams, int? PlayerCount); + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Completability test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + return tournament; + } + + private static async Task SeedDivisionAsync(ApplicationDBContext db, Tournament tournament, bool crossCup) + { + Division division = new() + { + Slug = $"division-{Guid.NewGuid()}", + Name = crossCup ? $"CrossCup-{Guid.NewGuid()}" : $"Zone-{Guid.NewGuid()}", + Tournament = tournament, + TournamentId = tournament.Id, + IsCrossDivisionCup = crossCup, + Stages = [], + CreatedBy = "test", + }; + + db.Divisions.Add(division); + await db.SaveChangesAsync(); + return division; + } + + private static async Task SeedGroupStageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{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; + } + + /// + /// A knockout stage type CreateAutomatedMatchesAsync does not support — + /// used to force a deterministic failure partway through fixture + /// generation for the rollback test. + /// + private static async Task SeedRoundOf16StageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"RoundOf16-{Guid.NewGuid()}", + StageType = StageType.RoundOf16, + IsActive = true, + IsElimination = 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 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 AssignAndEnrollAsync(ApplicationDBContext db, Tournament tournament, Stage stage, Team team) + { + db.StageTeamMatches.Add(new StageTeamMatch + { + StageId = stage.Id, + TeamId = team.Id, + CreatedBy = "test", + }); + + db.TeamTournamentRegistrations.Add(new TeamTournamentRegistration + { + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }); + + await db.SaveChangesAsync(); + } + + /// + /// Seeds HABILITADO registrations by default (Approved medical record with + /// a real stored file reference) — the completability guard now counts + /// only habilitado players, so tests exercising the "healthy" completable + /// path need registrations that actually count. Pass + /// : false to seed merely-registered players + /// that do NOT count, for the rule's own regression coverage. + /// + private static async Task SeedPlayersAsync( + ApplicationDBContext db, Team team, Tournament tournament, int count, bool habilitado = true) + { + for (int i = 0; i < count; i++) + { + Player player = new() + { + FirstName = $"Player{i}", + LastName = $"Test{Guid.NewGuid()}", + Slug = $"player-{Guid.NewGuid()}", + DocumentNumber = $"3{Random.Shared.Next(1000000, 9999999)}", + IsSanctioned = false, + BirthDate = DateTime.UtcNow.Date.AddYears(-20), + SocialSecurity = "OSDE", + Team = team, + CreatedBy = "test", + }; + db.Players.Add(player); + await db.SaveChangesAsync(); + + db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration + { + PlayerId = player.Id, + TeamId = team.Id, + TournamentId = tournament.Id, + MedicalRecordStatus = habilitado ? MedicalRecordStatus.Approved : MedicalRecordStatus.Pending, + MedicalRecordFileUrl = habilitado ? $"medical-records-storage/{Guid.NewGuid()}.pdf" : null, + CreatedBy = "test", + }); + } + + await db.SaveChangesAsync(); + } +} diff --git a/Club12-Backend/API.Tests/TournamentCompletabilityValidatorTests.cs b/Club12-Backend/API.Tests/TournamentCompletabilityValidatorTests.cs new file mode 100644 index 0000000..4e21ab5 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentCompletabilityValidatorTests.cs @@ -0,0 +1,418 @@ +using Application.DTOs.Tournament.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +using System.Collections.Generic; +using System.Linq; + +using CompletabilityValidator = Application.Utils.Helper.Tournament.TournamentCompletabilityValidator; + +namespace API.Tests; + +/// +/// HU-109: focused unit tests for the completability validator — the single +/// source of truth deciding whether a tournament can be started. Each test +/// builds an in-memory tournament graph (no database) and asserts exactly which +/// rule fires. A fully-valid tournament returns no issues (CanStart == true). +/// +public class TournamentCompletabilityValidatorTests +{ + [Fact] + public void Validate_FullyValidTournament_ReturnsNoIssues() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + Team t3 = MakeTeam("Charlie"); + Team t4 = MakeTeam("Delta"); + + AddZone(tournament, "Zone A", [t1, t2], (1, 2, "Oro")); + AddZone(tournament, "Zone B", [t3, t4], (1, 2, "Plata")); + // Cross-division cup group draws from teams already in their zones. + AddCrossCup(tournament, "Copa Club12", [t1, t3]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1), Reg(tournament, t2), Reg(tournament, t3), Reg(tournament, t4)]); + + Assert.Empty(issues); + } + + [Fact] + public void Validate_ZoneWithFewerThanTwoTeams_FiresZoneTooFewTeams() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Solo"); + AddZone(tournament, "Lonely Zone", [t1]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1)]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.ZoneTooFewTeams); + Assert.Equal("Lonely Zone", issue.DivisionName); + Assert.Equal(1, issue.AssignedTeams); + } + + [Fact] + public void Validate_EnrolledTeamNotAssignedToAnyZone_FiresTeamNotAssigned() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + Team orphan = MakeTeam("Orphan"); + AddZone(tournament, "Zone A", [t1, t2]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1), Reg(tournament, t2), Reg(tournament, orphan)]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.TeamNotAssigned); + Assert.Equal("Orphan", issue.TeamName); + } + + [Fact] + public void Validate_EnrolledTeamOnlyInCrossCup_StillFiresTeamNotAssigned() + { + // Being in the cross-division cup does NOT count as the team's zone. + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + Team cupOnly = MakeTeam("CupOnly"); + AddZone(tournament, "Zone A", [t1, t2]); + AddCrossCup(tournament, "Copa Club12", [t1, cupOnly]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1), Reg(tournament, t2), Reg(tournament, cupOnly)]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.TeamNotAssigned); + Assert.Equal("CupOnly", issue.TeamName); + } + + [Fact] + public void Validate_TeamAssignedToTwoZones_FiresTeamInMultipleZones() + { + Tournament tournament = NewTournament(); + Team shared = MakeTeam("Shared"); + Team t2 = MakeTeam("Bravo"); + Team t3 = MakeTeam("Charlie"); + AddZone(tournament, "Zone A", [shared, t2]); + AddZone(tournament, "Zone B", [shared, t3]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, shared), Reg(tournament, t2), Reg(tournament, t3)]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.TeamInMultipleZones); + Assert.Equal("Shared", issue.TeamName); + } + + [Fact] + public void Validate_PlayoffRangeBeyondAssignedTeams_FiresPlayoffRangeExceedsTeams() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + // Zone has 2 teams but a mapping seeds a cup from position 3. + AddZone(tournament, "Zone A", [t1, t2], (1, 2, "Oro"), (3, 4, "Plata")); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1), Reg(tournament, t2)]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.PlayoffRangeExceedsTeams); + Assert.Equal("Zone A", issue.DivisionName); + Assert.Equal(3, issue.FromPosition); + Assert.Equal(2, issue.AssignedTeams); + } + + [Fact] + public void Validate_CrossCupGroupWithFewerThanTwoTeams_FiresCrossCupGroupTooFewTeams() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + AddZone(tournament, "Zone A", [t1, t2]); + // Cross-cup group with a single assigned team. + AddCrossCup(tournament, "Copa Club12", [t1]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1), Reg(tournament, t2)]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.CrossCupGroupTooFewTeams); + Assert.Equal("Copa Club12", issue.DivisionName); + Assert.Equal(1, issue.AssignedTeams); + } + + [Fact] + public void Validate_TeamWithFewerThanFourHabilitadoPlayers_FiresTeamTooFewPlayers() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + AddZone(tournament, "Zone A", [t1, t2]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, + [Reg(tournament, t1), Reg(tournament, t2)], + playerCountsByTeam: new Dictionary { [t1.Id] = 4, [t2.Id] = 3 }); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.TeamTooFewPlayers); + Assert.Equal("Bravo", issue.TeamName); + Assert.Equal(3, issue.PlayerCount); + } + + [Fact] + public void Validate_TeamMissingFromPlayerCounts_TreatedAsZeroPlayers_FiresTeamTooFewPlayers() + { + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + AddZone(tournament, "Zone A", [t1, MakeTeam("Bravo")]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, + [Reg(tournament, t1)], + playerCountsByTeam: new Dictionary()); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.TeamTooFewPlayers); + Assert.Equal("Alpha", issue.TeamName); + Assert.Equal(0, issue.PlayerCount); + } + + [Fact] + public void Validate_PlayerCountsOmitted_SkipsTeamTooFewPlayersRule() + { + // Callers that don't load player-count data (e.g. other unit tests) + // must not be affected by this rule. + Tournament tournament = NewTournament(); + Team t1 = MakeTeam("Alpha"); + Team t2 = MakeTeam("Bravo"); + AddZone(tournament, "Zone A", [t1, t2]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [Reg(tournament, t1), Reg(tournament, t2)]); + + Assert.Empty(issues); + } + + [Fact] + public void Validate_EmptyTournament_ReturnsNoIssues() + { + // No divisions and no enrolled teams: nothing to complete, nothing blocks. + Tournament tournament = NewTournament(); + + IReadOnlyList issues = CompletabilityValidator.Validate(tournament, []); + + Assert.Empty(issues); + } + + [Fact] + public void Validate_SubGroupBelowMinimum_ReportsSubGroupTooFewTeams() + { + Tournament tournament = NewTournament(); + List groupA = [.. Enumerable.Range(0, 4).Select(i => MakeTeam($"A{i}"))]; + List groupB = [.. Enumerable.Range(0, 3).Select(i => MakeTeam($"B{i}"))]; + AddZoneWithSubGroups(tournament, "Zone A", [groupA, groupB]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [.. groupA.Concat(groupB).Select(team => Reg(tournament, team))]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.SubGroupTooFewTeams); + Assert.Equal("Zone A", issue.DivisionName); + Assert.Equal(3, issue.AssignedTeams); + } + + [Fact] + public void Validate_SubGroupsBalancedAndAboveMinimum_NoIssue() + { + Tournament tournament = NewTournament(); + List groupA = [.. Enumerable.Range(0, 4).Select(i => MakeTeam($"A{i}"))]; + List groupB = [.. Enumerable.Range(0, 4).Select(i => MakeTeam($"B{i}"))]; + AddZoneWithSubGroups(tournament, "Zone A", [groupA, groupB]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [.. groupA.Concat(groupB).Select(team => Reg(tournament, team))]); + + Assert.DoesNotContain(issues, i => i.Code == CompletabilityIssueCodes.SubGroupTooFewTeams); + } + + [Fact] + public void Validate_HandEditedImbalanceAcrossSubGroups_ReportsIssue() + { + Tournament tournament = NewTournament(); + List groupA = [.. Enumerable.Range(0, 4).Select(i => MakeTeam($"A{i}"))]; + List groupB = [.. Enumerable.Range(0, 6).Select(i => MakeTeam($"B{i}"))]; + AddZoneWithSubGroups(tournament, "Zone A", [groupA, groupB]); + + IReadOnlyList issues = CompletabilityValidator.Validate( + tournament, [.. groupA.Concat(groupB).Select(team => Reg(tournament, team))]); + + CompletabilityIssue issue = Assert.Single(issues, i => i.Code == CompletabilityIssueCodes.SubGroupTooFewTeams); + Assert.Equal("Zone A", issue.DivisionName); + } + + // ---- in-memory graph builders ---- + + private static Tournament NewTournament() + { + return new Tournament + { + Id = Guid.NewGuid(), + Name = "Test", + Description = string.Empty, + Slug = "test", + TeamRegistrationDeadline = default, + StartDate = default, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + } + + private static Team MakeTeam(string name) + { + return new Team + { + Id = Guid.NewGuid(), + Name = name, + Slug = name.ToLowerInvariant(), + ThreeLetterCode = "TMX", + LogoUrl = string.Empty, + ShirtColor = string.Empty, + Players = [], + CreatedBy = "test", + }; + } + + private static void AddZone( + Tournament tournament, + string name, + IReadOnlyList groupTeams, + params (int From, int To, string Destination)[] mappings) + { + AddDivision(tournament, name, groupTeams, crossCup: false, mappings); + } + + private static void AddCrossCup(Tournament tournament, string name, IReadOnlyList groupTeams) + { + AddDivision(tournament, name, groupTeams, crossCup: true, []); + } + + /// + /// Builds a regular division with one Group stage per sub-group, each seeded with its own teams. + /// + private static void AddZoneWithSubGroups( + Tournament tournament, string name, List> subGroupTeams) + { + Division division = new() + { + Id = Guid.NewGuid(), + Name = name, + Slug = name.ToLowerInvariant(), + Tournament = tournament, + TournamentId = tournament.Id, + IsCrossDivisionCup = false, + Stages = [], + CreatedBy = "test", + PlayoffMappings = [], + }; + + for (int i = 0; i < subGroupTeams.Count; i++) + { + Stage group = new() + { + Id = Guid.NewGuid(), + Name = $"Grupo {(char) ('A' + i)}", + Slug = $"grupo-{name.ToLowerInvariant()}-{i}", + StageType = StageType.Group, + IsActive = true, + StartDate = default, + EndDate = default, + DivisionId = division.Id, + Division = division, + Matches = [], + StageTeamMatches = [], + CreatedBy = "test", + }; + + foreach (Team team in subGroupTeams[i]) + { + group.StageTeamMatches.Add(new StageTeamMatch + { + StageId = group.Id, + TeamId = team.Id, + Team = team, + CreatedBy = "test", + }); + } + + division.Stages.Add(group); + } + + tournament.Divisions.Add(division); + } + + private static void AddDivision( + Tournament tournament, + string name, + IReadOnlyList groupTeams, + bool crossCup, + (int From, int To, string Destination)[] mappings) + { + Division division = new() + { + Id = Guid.NewGuid(), + Name = name, + Slug = name.ToLowerInvariant(), + Tournament = tournament, + TournamentId = tournament.Id, + IsCrossDivisionCup = crossCup, + Stages = [], + CreatedBy = "test", + PlayoffMappings = [.. mappings.Select(m => new DivisionPlayoffMapping + { + FromPosition = m.From, + ToPosition = m.To, + Destination = m.Destination, + CreatedBy = "test", + })], + }; + + Stage group = new() + { + Id = Guid.NewGuid(), + Name = $"Group {name}", + Slug = $"group-{name.ToLowerInvariant()}", + StageType = StageType.Group, + IsActive = true, + StartDate = default, + EndDate = default, + DivisionId = division.Id, + Division = division, + Matches = [], + StageTeamMatches = [], + CreatedBy = "test", + }; + + foreach (Team team in groupTeams) + { + group.StageTeamMatches.Add(new StageTeamMatch + { + StageId = group.Id, + TeamId = team.Id, + Team = team, + CreatedBy = "test", + }); + } + + division.Stages.Add(group); + tournament.Divisions.Add(division); + } + + private static TeamTournamentRegistration Reg(Tournament tournament, Team team) + { + return new TeamTournamentRegistration + { + TeamId = team.Id, + Team = team, + TournamentId = tournament.Id, + CreatedBy = "test", + }; + } +} diff --git a/Club12-Backend/API.Tests/TournamentEnrollTeamTests.cs b/Club12-Backend/API.Tests/TournamentEnrollTeamTests.cs new file mode 100644 index 0000000..10c32a6 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentEnrollTeamTests.cs @@ -0,0 +1,445 @@ +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; + +/// +/// HU-107: with a tournament OpenForRegistration, the admin enrolls teams into +/// the registration phase through POST api/tournaments/{tournamentId}/enroll-team. +/// Two modes: create a brand-new team, or enroll an existing club (from another +/// season) optionally copying its roster as an editable base for the new season. +/// Exercised as real HTTP round trips through CustomWebApplicationFactory +/// (TournamentController has no SupabaseHelper dependency). +/// +public class TournamentEnrollTeamTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentEnrollTeamTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// New-team path: a brand-new team is created and registered to the + /// tournament in one shot; its roster starts empty. + /// + [Fact] + public async Task EnrollTeam_NewTeam_CreatesTeamRegistersItAndStartsEmpty() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + string teamName = $"New Club {Guid.NewGuid():N}"; + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { newTeamName = teamName }); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + EnrolledTeamResponse? body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(teamName, body!.Name); + Assert.Empty(body.Players); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.True(await verifyDb.TeamTournamentRegistrations + .AsNoTracking() + .AnyAsync(r => r.TeamId == body.Id && r.TournamentId == tournamentId)); + } + + /// + /// Existing-team path: the same Team is enrolled to a new season via a new + /// TeamTournamentRegistration — no duplicate Team identity is created (HU-99). + /// + [Fact] + public async Task EnrollTeam_ExistingTeam_RegistersSameTeamToTournament() + { + Guid tournamentId; + Guid existingTeamId; + int teamsBefore; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament previousSeason = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Tournament newSeason = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Team team = await SeedTeamAsync(db, previousSeason.Id); + tournamentId = newSeason.Id; + existingTeamId = team.Id; + teamsBefore = await db.Teams.CountAsync(); + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { existingTeamId }); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + EnrolledTeamResponse? body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(existingTeamId, body!.Id); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + // No new Team identity was minted. + Assert.Equal(teamsBefore, await verifyDb.Teams.AsNoTracking().CountAsync()); + Assert.True(await verifyDb.TeamTournamentRegistrations + .AsNoTracking() + .AnyAsync(r => r.TeamId == existingTeamId && r.TournamentId == tournamentId)); + } + + /// + /// Existing-team + copy roster: every player from the source season gets a + /// fresh season-scoped registration on THIS tournament, reusing the same + /// Player; the medical approval is NOT inherited — it starts Pending (HU-59). + /// + [Fact] + public async Task EnrollTeam_ExistingTeamWithCopyRoster_CopiesPlayersButNotMedicalApproval() + { + Guid targetTournamentId; + Guid sourceTournamentId; + Guid teamId; + Guid playerId; + + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament sourceSeason = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Tournament targetSeason = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Team team = await SeedTeamAsync(db, sourceSeason.Id); + Player player = await SeedPlayerAsync(db, team); + + PlayerTeamRegistration registration = new() + { + PlayerId = player.Id, + TeamId = team.Id, + TournamentId = sourceSeason.Id, + MedicalRecordStatus = MedicalRecordStatus.Approved, + MedicalRecordFileUrl = "medical-records/prev.pdf", + MedicalRecordFileName = "prev.pdf", + JerseyNumber = 10, + CreatedBy = "test", + }; + db.PlayerTeamRegistrations.Add(registration); + await db.SaveChangesAsync(); + + sourceTournamentId = sourceSeason.Id; + targetTournamentId = targetSeason.Id; + teamId = team.Id; + playerId = player.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{targetTournamentId}/enroll-team", + new { existingTeamId = teamId, copyRosterFromTournamentId = sourceTournamentId }); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + EnrolledTeamResponse? body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Contains(body!.Players, p => p.Id == playerId); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + PlayerTeamRegistration copied = await verifyDb.PlayerTeamRegistrations + .AsNoTracking() + .SingleAsync(r => r.TeamId == teamId && r.TournamentId == targetTournamentId && r.PlayerId == playerId); + + Assert.Equal(MedicalRecordStatus.Pending, copied.MedicalRecordStatus); + Assert.Null(copied.MedicalRecordFileUrl); + Assert.Null(copied.MedicalRecordFileName); + Assert.Null(copied.JerseyNumber); + } + + /// + /// Enrolling a second team must not drop the first: enroll is additive, not + /// a whole-tournament reconcile that would unregister the rest of the roster. + /// + [Fact] + public async Task EnrollTeam_SecondTeam_KeepsFirstTeamEnrolled() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + EnrolledTeamResponse? first = await (await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { newTeamName = $"First {Guid.NewGuid():N}" })).Content.ReadFromJsonAsync(); + + EnrolledTeamResponse? second = await (await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { newTeamName = $"Second {Guid.NewGuid():N}" })).Content.ReadFromJsonAsync(); + + Assert.NotNull(first); + Assert.NotNull(second); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.True(await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == first!.Id && r.TournamentId == tournamentId)); + Assert.True(await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == second!.Id && r.TournamentId == tournamentId)); + } + + /// + /// Structural guard (HU-31): teams cannot be enrolled once registration + /// closes. Mapped to 409 by the global handler (InvalidOperationException). + /// + [Fact] + public async Task EnrollTeam_TournamentNotOpenForRegistration_IsRejected() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { newTeamName = $"New {Guid.NewGuid():N}" }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .CountAsync(r => r.TournamentId == tournamentId)); + } + + /// + /// Integrity guard: teams cannot be enrolled once the tournament is Ongoing + /// (en curso) — the roster is frozen after it starts. Mapped to 409 by the + /// global handler (InvalidOperationException). + /// + [Fact] + public async Task EnrollTeam_TournamentOngoing_IsRejected() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { newTeamName = $"New {Guid.NewGuid():N}" }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .CountAsync(r => r.TournamentId == tournamentId)); + } + + /// + /// Exactly one of ExistingTeamId / NewTeamName must be provided — both is a 400. + /// + [Fact] + public async Task EnrollTeam_BothExistingTeamIdAndNewTeamName_ReturnsBadRequest() + { + Guid tournamentId; + Guid existingTeamId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Team team = await SeedTeamAsync(db, tournamentId: null); + tournamentId = tournament.Id; + existingTeamId = team.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { existingTeamId, newTeamName = "Ambiguous" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Neither ExistingTeamId nor NewTeamName provided is a 400. + /// + [Fact] + public async Task EnrollTeam_NeitherExistingTeamIdNorNewTeamName_ReturnsBadRequest() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// A team can be enrolled only once per tournament — a clean 409, not a raw + /// DB error, when it is already enrolled. + /// + [Fact] + public async Task EnrollTeam_TeamAlreadyEnrolled_ReturnsConflict() + { + Guid tournamentId; + Guid existingTeamId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Team team = await SeedTeamAsync(db, tournament.Id); + db.TeamTournamentRegistrations.Add(new TeamTournamentRegistration + { + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + tournamentId = tournament.Id; + existingTeamId = team.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { existingTeamId }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + /// + /// Enrolling an existing team that does not exist yields a 404. + /// + [Fact] + public async Task EnrollTeam_ExistingTeamNotFound_ReturnsNotFound() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.PostAsJsonAsync( + $"api/tournaments/{tournamentId}/enroll-team", + new { existingTeamId = Guid.NewGuid() }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private sealed record EnrolledTeamResponse(Guid Id, string Name, Guid? TournamentId, List Players); + + private sealed record PlayerRef(Guid Id); + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Enroll-team 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 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 SeedPlayerAsync(ApplicationDBContext db, Team team) + { + Player player = new() + { + FirstName = "First", + LastName = $"Last-{Guid.NewGuid()}", + Slug = $"player-{Guid.NewGuid()}", + DocumentNumber = Guid.NewGuid().ToString("N")[..8], + IsSanctioned = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + SocialSecurity = "OSDE", + Team = team, + TeamId = team.Id, + PlayerTeamRegistrations = [], + CreatedBy = "test", + }; + + db.Players.Add(player); + await db.SaveChangesAsync(); + + return player; + } +} diff --git a/Club12-Backend/API.Tests/TournamentRevertGuardTests.cs b/Club12-Backend/API.Tests/TournamentRevertGuardTests.cs new file mode 100644 index 0000000..7b3ebd7 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentRevertGuardTests.cs @@ -0,0 +1,157 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Integrity guard for reverting an Ongoing tournament back to draft +/// (Ongoing -> RegistrationClosed via +/// ): the revert is allowed +/// only while nothing has been played. Any finished match (Status Played or +/// WalkOver, i.e. == true) carries results that a +/// fixture teardown would lose, so the revert is rejected (mapped to 409). With +/// no played match the revert succeeds and the (unplayed) fixture is torn down. +/// +public class TournamentRevertGuardTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentRevertGuardTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData(MatchStatus.Played)] + [InlineData(MatchStatus.WalkOver)] + public async Task ChangeStatusAsync_RevertOngoingToDraft_WithPlayedMatch_ThrowsAndKeepsOngoing( + MatchStatus playedStatus) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + await SeedMatchAsync(db, stage, isFinished: true, status: playedStatus); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.RegistrationClosed)); + + // Status unchanged and the played match is untouched. + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Ongoing, reloaded.Status); + Assert.Equal(1, await db.Matches.AsNoTracking().CountAsync(m => m.StageId == stage.Id)); + } + + [Fact] + public async Task ChangeStatusAsync_RevertOngoingToDraft_WithNoPlayedMatch_SucceedsAndTearsDownFixture() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + // An unplayed (scheduled) fixture: reverting is safe and tears it down. + await SeedMatchAsync(db, stage, isFinished: false, status: MatchStatus.Scheduled); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.RegistrationClosed); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.RegistrationClosed, reloaded.Status); + Assert.Equal(0, await db.Matches.AsNoTracking().CountAsync(m => m.StageId == stage.Id)); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Revert guard test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task 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 SeedGroupStageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{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 SeedMatchAsync( + ApplicationDBContext db, Stage stage, bool isFinished, MatchStatus status) + { + Match match = new() + { + Slug = $"match-{Guid.NewGuid()}", + Type = Domain.Enums.MatchType.Regular, + StageId = stage.Id, + MatchDate = DateTime.UtcNow.Date.AddDays(31), + IsFinished = isFinished, + Status = status, + CreatedBy = "test", + }; + + db.Matches.Add(match); + await db.SaveChangesAsync(); + } +} diff --git a/Club12-Backend/API.Tests/TournamentServiceStateMachineTests.cs b/Club12-Backend/API.Tests/TournamentServiceStateMachineTests.cs new file mode 100644 index 0000000..b2dc0d9 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentServiceStateMachineTests.cs @@ -0,0 +1,277 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Service-level tests for the tournament state machine (HU-35) and the +/// fixture trigger (HU-37 / HU-64 / HU-108): +/// enforces forward-only +/// transitions and, on the tournament STARTING (transition into Ongoing), +/// auto-generates the fixture for every stage of every division exactly once. +/// Closing registration (RegistrationClosed) no longer generates the fixture. +/// +public class TournamentServiceStateMachineTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentServiceStateMachineTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData(TournamentStatus.Scheduled, TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.OpenForRegistration, TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.RegistrationClosed, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Ongoing, TournamentStatus.Finished)] + [InlineData(TournamentStatus.Ongoing, TournamentStatus.Canceled)] + public async Task ChangeStatusAsync_ValidTransition_PersistsNewStatus( + TournamentStatus from, TournamentStatus to) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, from); + + await tournamentService.ChangeStatusAsync(tournament.Id, to); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(to, reloaded.Status); + } + + [Fact] + public async Task ChangeStatusAsync_SameStatus_IsNoOp() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Ongoing, reloaded.Status); + } + + [Theory] + // Skipping forward. + [InlineData(TournamentStatus.Scheduled, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.OpenForRegistration, TournamentStatus.Ongoing)] + // Backward. + [InlineData(TournamentStatus.RegistrationClosed, TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.Ongoing, TournamentStatus.Scheduled)] + // Out of terminal state. + [InlineData(TournamentStatus.Finished, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Canceled, TournamentStatus.OpenForRegistration)] + public async Task ChangeStatusAsync_InvalidTransition_ThrowsAndKeepsStatus( + TournamentStatus from, TournamentStatus to) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, from); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(tournament.Id, to)); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(from, reloaded.Status); + } + + [Fact] + public async Task ChangeStatusAsync_UnknownTournament_ThrowsKeyNotFound() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + await Assert.ThrowsAsync( + () => tournamentService.ChangeStatusAsync(Guid.NewGuid(), TournamentStatus.OpenForRegistration)); + } + + [Fact] + public async Task ChangeStatusAsync_IntoRegistrationClosed_DoesNotGenerateAnyMatches() + { + // HU-108: closing registration only freezes the roster (teams are later + // assigned to divisions). The fixture is NOT generated here anymore — it + // is generated when the tournament starts (Ongoing). + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + await SeedAndAssignTeamsAsync(db, tournament, stage, teamCount: 4); + + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == stage.Id)); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.RegistrationClosed); + + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == stage.Id)); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.RegistrationClosed, reloaded.Status); + } + + [Fact] + public async Task ChangeStatusAsync_IntoOngoing_AutoGeneratesFixtureForEveryStage() + { + // HU-108: starting the tournament (RegistrationClosed -> Ongoing) is the + // canonical fixture trigger. + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + await SeedAndAssignTeamsAsync(db, tournament, stage, teamCount: 4); + + Assert.Equal(0, await db.Matches.CountAsync(m => m.StageId == stage.Id)); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing); + + // 4 teams, single round-robin => 4*3/2 = 6 matches, all seeded with a + // real home/visitor pairing. + List matches = await db.Matches.AsNoTracking().Where(m => m.StageId == stage.Id).ToListAsync(); + Assert.Equal(6, matches.Count); + Assert.All(matches, m => + { + Assert.NotNull(m.HomeTeamId); + Assert.NotNull(m.VisitorTeamId); + }); + + Tournament reloaded = await db.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournament.Id); + Assert.Equal(TournamentStatus.Ongoing, reloaded.Status); + } + + [Fact] + public async Task ChangeStatusAsync_IntoOngoing_DoesNotRegenerateStageThatAlreadyHasMatches() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + IMatchService matchService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + await SeedAndAssignTeamsAsync(db, tournament, stage, teamCount: 4); + + // Pre-generate the fixture for this stage (mirrors the still-supported + // manual generation path). Starting the tournament must NOT double it. + List preGenerated = await matchService.CreateAutomatedMatchesAsync(stage.Id); + Assert.Equal(6, preGenerated.Count); + + await tournamentService.ChangeStatusAsync(tournament.Id, TournamentStatus.Ongoing); + + Assert.Equal(6, await db.Matches.CountAsync(m => m.StageId == stage.Id)); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "State machine test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task 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 SeedGroupStageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{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 SeedAndAssignTeamsAsync( + ApplicationDBContext db, Tournament tournament, Stage stage, int teamCount) + { + List teams = []; + for (int i = 0; i < teamCount; 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 = tournament.Id, + Players = [], + CreatedBy = "test", + }); + } + + db.Teams.AddRange(teams); + await db.SaveChangesAsync(); + + db.StageTeamMatches.AddRange(teams.Select(t => new StageTeamMatch + { + StageId = stage.Id, + TeamId = t.Id, + CreatedBy = "test", + })); + await db.SaveChangesAsync(); + } +} diff --git a/Club12-Backend/API.Tests/TournamentStatusLabelTests.cs b/Club12-Backend/API.Tests/TournamentStatusLabelTests.cs new file mode 100644 index 0000000..fc1bcc9 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentStatusLabelTests.cs @@ -0,0 +1,26 @@ +using Application.Utils.Extensions; + +using Domain.Enums; + +namespace API.Tests; + +/// +/// Pins the Spanish, user-facing labels for every TournamentStatus. The enum +/// stays in English (a stable, persisted code identifier) but any text shown to +/// users — the audit-trail Detail column among them — must be Spanish. +/// +public class TournamentStatusLabelTests +{ + [Theory] + [InlineData(TournamentStatus.Scheduled, "Programado")] + [InlineData(TournamentStatus.OpenForRegistration, "Inscripción abierta")] + [InlineData(TournamentStatus.RegistrationClosed, "Inscripción cerrada")] + [InlineData(TournamentStatus.Ongoing, "En curso")] + [InlineData(TournamentStatus.Finished, "Finalizado")] + [InlineData(TournamentStatus.Canceled, "Cancelado")] + public void ToSpanishLabel_MapsEveryStatus_ToItsSpanishLabel( + TournamentStatus status, string expected) + { + Assert.Equal(expected, status.ToSpanishLabel()); + } +} diff --git a/Club12-Backend/API.Tests/TournamentStatusTransitionRulesTests.cs b/Club12-Backend/API.Tests/TournamentStatusTransitionRulesTests.cs new file mode 100644 index 0000000..05bb819 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentStatusTransitionRulesTests.cs @@ -0,0 +1,84 @@ +using Domain.Enums; + +namespace API.Tests; + +/// +/// Pure state-machine tests for +/// (HU-35): the forward-only lifecycle +/// Scheduled -> OpenForRegistration -> RegistrationClosed -> Ongoing -> +/// Finished, with Canceled reachable from any non-terminal state and +/// Finished/Canceled terminal. +/// +public class TournamentStatusTransitionRulesTests +{ + [Theory] + [InlineData(TournamentStatus.Scheduled, TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.OpenForRegistration, TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.RegistrationClosed, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Ongoing, TournamentStatus.Finished)] + // Revert-to-draft: un-starting a tournament back to RegistrationClosed. + [InlineData(TournamentStatus.Ongoing, TournamentStatus.RegistrationClosed)] + public void IsValidTransition_ForwardHappyPath_IsAllowed(TournamentStatus from, TournamentStatus to) + { + Assert.True(TournamentStatusTransitions.IsValidTransition(from, to)); + } + + [Theory] + [InlineData(TournamentStatus.Scheduled)] + [InlineData(TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.Ongoing)] + public void IsValidTransition_CancelFromAnyNonTerminal_IsAllowed(TournamentStatus from) + { + Assert.True(TournamentStatusTransitions.IsValidTransition(from, TournamentStatus.Canceled)); + } + + [Theory] + // Skipping a step forward. + [InlineData(TournamentStatus.Scheduled, TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.Scheduled, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.OpenForRegistration, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.RegistrationClosed, TournamentStatus.Finished)] + // Going backward. + [InlineData(TournamentStatus.OpenForRegistration, TournamentStatus.Scheduled)] + [InlineData(TournamentStatus.RegistrationClosed, TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.Finished, TournamentStatus.Ongoing)] + public void IsValidTransition_SkippingOrBackward_IsRejected(TournamentStatus from, TournamentStatus to) + { + Assert.False(TournamentStatusTransitions.IsValidTransition(from, to)); + } + + [Theory] + [InlineData(TournamentStatus.Finished, TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.Finished, TournamentStatus.Canceled)] + [InlineData(TournamentStatus.Canceled, TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Canceled, TournamentStatus.OpenForRegistration)] + public void IsValidTransition_OutOfTerminalState_IsRejected(TournamentStatus from, TournamentStatus to) + { + Assert.False(TournamentStatusTransitions.IsValidTransition(from, to)); + } + + [Theory] + [InlineData(TournamentStatus.Scheduled)] + [InlineData(TournamentStatus.OpenForRegistration)] + [InlineData(TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Finished)] + [InlineData(TournamentStatus.Canceled)] + public void IsValidTransition_ToSameStatus_IsTreatedAsAllowedNoOp(TournamentStatus status) + { + Assert.True(TournamentStatusTransitions.IsValidTransition(status, status)); + } + + [Theory] + [InlineData(TournamentStatus.Finished, true)] + [InlineData(TournamentStatus.Canceled, true)] + [InlineData(TournamentStatus.Scheduled, false)] + [InlineData(TournamentStatus.OpenForRegistration, false)] + [InlineData(TournamentStatus.RegistrationClosed, false)] + [InlineData(TournamentStatus.Ongoing, false)] + public void IsTerminal_ReturnsTrueOnlyForFinishedAndCanceled(TournamentStatus status, bool expected) + { + Assert.Equal(expected, TournamentStatusTransitions.IsTerminal(status)); + } +} diff --git a/Club12-Backend/API.Tests/TournamentStructuralEditGuardTests.cs b/Club12-Backend/API.Tests/TournamentStructuralEditGuardTests.cs new file mode 100644 index 0000000..7aa67f1 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentStructuralEditGuardTests.cs @@ -0,0 +1,186 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// HU-31: structural changes (creating/editing divisions, adding/removing team +/// registrations) are only permitted while a tournament is +/// . Every other status +/// freezes the structure at the service layer. +/// +public class TournamentStructuralEditGuardTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentStructuralEditGuardTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData(TournamentStatus.Scheduled)] + [InlineData(TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Finished)] + [InlineData(TournamentStatus.Canceled)] + public async Task CreateDivisionAsync_TournamentNotOpenForRegistration_Throws(TournamentStatus status) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, status); + + Division division = new() + { + Name = $"Division-{Guid.NewGuid()}", + Slug = string.Empty, + Tournament = tournament, + TournamentId = tournament.Id, + Stages = [], + CreatedBy = "test", + }; + + await Assert.ThrowsAsync( + () => divisionService.CreateDivisionAsync(division)); + + Assert.Equal(0, await db.Divisions.CountAsync(d => d.TournamentId == tournament.Id)); + } + + [Fact] + public async Task CreateDivisionAsync_TournamentOpenForRegistration_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + + Division division = new() + { + Name = $"Division-{Guid.NewGuid()}", + Slug = string.Empty, + Tournament = tournament, + TournamentId = tournament.Id, + Stages = [], + CreatedBy = "test", + }; + + Division created = await divisionService.CreateDivisionAsync(division); + + Assert.NotEqual(Guid.Empty, created.Id); + Assert.Equal(1, await db.Divisions.CountAsync(d => d.TournamentId == tournament.Id)); + } + + [Fact] + public async Task UpdateDivisionAsync_TournamentNotOpenForRegistration_Throws() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Division division = new() + { + Name = $"Division-{Guid.NewGuid()}", + Slug = $"division-{Guid.NewGuid()}", + Tournament = tournament, + TournamentId = tournament.Id, + Stages = [], + CreatedBy = "test", + }; + db.Divisions.Add(division); + await db.SaveChangesAsync(); + + division.Name = "Renamed"; + + await Assert.ThrowsAsync( + () => divisionService.UpdateDivisionAsync(division)); + } + + [Theory] + [InlineData(TournamentStatus.Scheduled)] + [InlineData(TournamentStatus.RegistrationClosed)] + [InlineData(TournamentStatus.Ongoing)] + [InlineData(TournamentStatus.Finished)] + [InlineData(TournamentStatus.Canceled)] + public async Task RegisterTeamsToTournamentAsync_TournamentNotOpenForRegistration_Throws(TournamentStatus status) + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, status); + Team team = await SeedTeamAsync(db); + + await Assert.ThrowsAsync( + () => teamService.RegisterTeamsToTournamentAsync(tournament, [team.Id])); + + Assert.Equal(0, await db.TeamTournamentRegistrations.CountAsync(r => r.TournamentId == tournament.Id)); + } + + [Fact] + public async Task RegisterTeamsToTournamentAsync_TournamentOpenForRegistration_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + ITeamService teamService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Team team = await SeedTeamAsync(db); + + await teamService.RegisterTeamsToTournamentAsync(tournament, [team.Id]); + + Assert.Equal(1, await db.TeamTournamentRegistrations.CountAsync(r => r.TournamentId == tournament.Id)); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Structural guard test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + private static async Task SeedTeamAsync(ApplicationDBContext db) + { + Team team = new() + { + Name = $"Team-{Guid.NewGuid()}", + Slug = $"team-{Guid.NewGuid()}", + ThreeLetterCode = "TMX", + LogoUrl = "https://example.com/logo.png", + ShirtColor = "Red", + Players = [], + CreatedBy = "test", + }; + + db.Teams.Add(team); + await db.SaveChangesAsync(); + + return team; + } +} diff --git a/Club12-Backend/API.Tests/TournamentStructureEndpointTests.cs b/Club12-Backend/API.Tests/TournamentStructureEndpointTests.cs new file mode 100644 index 0000000..ef4f10b --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentStructureEndpointTests.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Application.DTOs.Tournament.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// HU-cloning: GET api/tournaments/{idOrSlug}/structure returns the source +/// tournament's full cloneable structure tree, resolving id-or-slug the same +/// way GetTournamentById already does. +/// +public class TournamentStructureEndpointTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + // The API serializes enums as strings (JsonStringEnumConverter), so + // response reads must use the same convention to parse Category. + private static readonly JsonSerializerOptions JsonOptions = + new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + + public TournamentStructureEndpointTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + Tournament tournament = new() + { + Description = "Endpoint structure test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = DateTime.UtcNow.Date.AddDays(29), + StartDate = DateTime.UtcNow.Date.AddDays(30), + Category = TournamentCategory.Masculine, + Status = TournamentStatus.OpenForRegistration, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + Division zone = new() + { + Name = "Zona A", + Slug = $"zona-a-{Guid.NewGuid()}", + Tournament = tournament, + TournamentId = tournament.Id, + Category = tournament.Category, + Stages = [], + CreatedBy = "test", + }; + zone.Stages.Add(new Stage + { + Name = "Fase de Grupos", + Slug = $"fase-de-grupos-{Guid.NewGuid()}", + StageType = StageType.Group, + IsActive = true, + IsElimination = false, + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date.AddDays(7), + DivisionId = zone.Id, + Division = zone, + Matches = [], + Order = 0, + CreatedBy = "test", + }); + tournament.Divisions.Add(zone); + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + return tournament; + } + + [Fact] + public async Task GetTournamentStructure_ById_ReturnsFullTree() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + tournamentId = (await SeedTournamentAsync(db)).Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{tournamentId}/structure"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + TournamentStructureResponse? body = + await response.Content.ReadFromJsonAsync(JsonOptions); + + Assert.NotNull(body); + DivisionStructureResponse division = Assert.Single(body!.Divisions); + Assert.Equal("Zona A", division.Name); + Assert.Single(division.Stages); + } + + [Fact] + public async Task GetTournamentStructure_BySlug_ReturnsFullTree() + { + string slug; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + slug = (await SeedTournamentAsync(db)).Slug; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{slug}/structure"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetTournamentStructure_TournamentNotFound_ReturnsNotFound() + { + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + HttpResponseMessage response = await client.GetAsync($"api/tournaments/{Guid.NewGuid()}/structure"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/Club12-Backend/API.Tests/TournamentStructureTests.cs b/Club12-Backend/API.Tests/TournamentStructureTests.cs new file mode 100644 index 0000000..c86f904 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentStructureTests.cs @@ -0,0 +1,234 @@ +using System.Reflection; + +using Application.DTOs.Tournament.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; + +/// +/// HU-cloning: GetTournamentStructureAsync assembles the full cloneable tree +/// (Tournament -> Divisions -> Stages/PlayoffMappings) via the two-step load +/// EvaluateCompletabilityAsync already uses — IGenericRepository's includes +/// only support a single level of .Include(), so Tournament.Divisions.Stages +/// cannot be expressed directly. +/// +public class TournamentStructureTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentStructureTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + private static Tournament NewTournament(TournamentCategory category = TournamentCategory.Masculine) => new() + { + Description = "Structure test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = DateTime.UtcNow.Date.AddDays(29), + StartDate = DateTime.UtcNow.Date.AddDays(30), + Category = category, + Status = TournamentStatus.OpenForRegistration, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + private static Division NewDivision(Tournament tournament, string name, bool isCrossDivisionCup = false, int qualifiersPerGroup = 1) => new() + { + Name = name, + Slug = $"{name}-{Guid.NewGuid()}", + Tournament = tournament, + TournamentId = tournament.Id, + Category = tournament.Category, + IsCrossDivisionCup = isCrossDivisionCup, + QualifiersPerGroup = qualifiersPerGroup, + Stages = [], + CreatedBy = "test", + }; + + private static Stage NewStage( + Division division, string name, StageType type, int order, + string? bracketName = null, int bestOf = 1, int roundRobinLegs = 1) => new() + { + Name = name, + Slug = $"{name}-{Guid.NewGuid()}", + StageType = type, + IsActive = true, + IsElimination = type != StageType.Group, + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date.AddDays(7), + DivisionId = division.Id, + Division = division, + BracketName = bracketName, + BestOf = bestOf, + RoundRobinLegs = roundRobinLegs, + Matches = [], + Order = order, + CreatedBy = "test", + }; + + [Fact] + public async Task GetTournamentStructureAsync_SingleZoneTournament_AssemblesGroupStageAndCupMapping() + { + Tournament tournament = NewTournament(); + Division zone = NewDivision(tournament, "Zona A"); + zone.Stages.Add(NewStage(zone, "Fase de Grupos", StageType.Group, 0)); + zone.Stages.Add(NewStage(zone, "Semifinal Copa Oro", StageType.SemiFinal, 1, "Copa Oro")); + zone.Stages.Add(NewStage(zone, "Final Copa Oro", StageType.Final, 2, "Copa Oro")); + zone.PlayoffMappings.Add(new DivisionPlayoffMapping + { + DivisionId = zone.Id, + Division = zone, + FromPosition = 1, + ToPosition = 4, + Destination = "Copa Oro", + CreatedBy = "test", + }); + tournament.Divisions.Add(zone); + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id); + + Assert.NotNull(structure); + Division loadedZone = Assert.Single(structure!.Divisions); + Assert.Equal(3, loadedZone.Stages.Count); + Assert.Contains(loadedZone.Stages, s => s.StageType == StageType.Group && s.BracketName == null); + DivisionPlayoffMapping mapping = Assert.Single(loadedZone.PlayoffMappings); + Assert.Equal("Copa Oro", mapping.Destination); + Assert.Equal(4, mapping.ToPosition - mapping.FromPosition + 1); + } + + [Fact] + public async Task GetTournamentStructureAsync_DivisionWithSubGroups_IncludesEveryGroupStage() + { + Tournament tournament = NewTournament(); + Division zone = NewDivision(tournament, "Zona B"); + zone.Stages.Add(NewStage(zone, "Grupo A", StageType.Group, 0, roundRobinLegs: 2)); + zone.Stages.Add(NewStage(zone, "Grupo B", StageType.Group, 1, roundRobinLegs: 2)); + zone.Stages.Add(NewStage(zone, "Grupo C", StageType.Group, 2, roundRobinLegs: 2)); + tournament.Divisions.Add(zone); + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id); + + List groupStages = [.. Assert.Single(structure!.Divisions).Stages + .Where(s => s.StageType == StageType.Group)]; + Assert.Equal(3, groupStages.Count); + Assert.All(groupStages, s => Assert.Equal(2, s.RoundRobinLegs)); + } + + [Fact] + public async Task GetTournamentStructureAsync_CrossDivisionCup_IncludesGroupsAndQualifiersPerGroup() + { + Tournament tournament = NewTournament(); + Division crossCup = NewDivision(tournament, "Copa Cruzada", isCrossDivisionCup: true, qualifiersPerGroup: 2); + crossCup.Stages.Add(NewStage(crossCup, "Grupo 1", StageType.Group, 0)); + crossCup.Stages.Add(NewStage(crossCup, "Grupo 2", StageType.Group, 1)); + crossCup.Stages.Add(NewStage(crossCup, "Semifinal Playoffs", StageType.SemiFinal, 2, "Playoffs")); + crossCup.Stages.Add(NewStage(crossCup, "Final Playoffs", StageType.Final, 3, "Playoffs")); + tournament.Divisions.Add(crossCup); + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id); + + Division loadedCrossCup = Assert.Single(structure!.Divisions); + Assert.True(loadedCrossCup.IsCrossDivisionCup); + Assert.Equal(2, loadedCrossCup.QualifiersPerGroup); + Assert.Equal(2, loadedCrossCup.Stages.Count(s => s.StageType == StageType.Group)); + Assert.Empty(loadedCrossCup.PlayoffMappings); + } + + [Fact] + public async Task GetTournamentStructureAsync_PlayoffsOnlyDivision_HasNoGroupStageButKeepsBracket() + { + Tournament tournament = NewTournament(); + Division playoffsOnly = NewDivision(tournament, "Reducido"); + playoffsOnly.Stages.Add(NewStage(playoffsOnly, "Semifinal Copa Only", StageType.SemiFinal, 0, "Copa Only")); + playoffsOnly.Stages.Add(NewStage(playoffsOnly, "Final Copa Only", StageType.Final, 1, "Copa Only")); + playoffsOnly.PlayoffMappings.Add(new DivisionPlayoffMapping + { + DivisionId = playoffsOnly.Id, + Division = playoffsOnly, + FromPosition = 1, + ToPosition = 8, + Destination = "Copa Only", + CreatedBy = "test", + }); + tournament.Divisions.Add(playoffsOnly); + + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id); + + Division loadedDivision = Assert.Single(structure!.Divisions); + Assert.DoesNotContain(loadedDivision.Stages, s => s.StageType == StageType.Group); + Assert.Equal(2, loadedDivision.Stages.Count); + Assert.Equal(8, Assert.Single(loadedDivision.PlayoffMappings).ToPosition); + } + + [Fact] + public async Task GetTournamentStructureAsync_TournamentNotFound_ReturnsNull() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ITournamentService tournamentService = scope.ServiceProvider.GetRequiredService(); + + Tournament? structure = await tournamentService.GetTournamentStructureAsync(Guid.NewGuid()); + + Assert.Null(structure); + } + + /// + /// Structural guard: the response DTO tree carries STRUCTURE ONLY. This + /// reflects over the response types instead of a live tournament, so it + /// fails the moment any of the three types is ever widened to expose + /// rosters, matches, standings, sanctions, audit, or DrawnAt. + /// + [Fact] + public void TournamentStructureResponseTree_NeverExposesInstanceDataFields() + { + string[] forbiddenNames = + [ + "Teams", "Positions", "Matches", "MatchSeries", "DivisionTeamRegistrations", + "TeamTournamentRegistrations", "StageTeamMatches", "Sanctions", "DrawnAt", + ]; + + foreach (Type type in new[] + { + typeof(TournamentStructureResponse), typeof(DivisionStructureResponse), typeof(StageStructureResponse), + }) + { + IEnumerable propertyNames = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name); + Assert.Empty(propertyNames.Intersect(forbiddenNames)); + } + } +} diff --git a/Club12-Backend/API.Tests/TournamentUnenrollTeamTests.cs b/Club12-Backend/API.Tests/TournamentUnenrollTeamTests.cs new file mode 100644 index 0000000..9859d00 --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentUnenrollTeamTests.cs @@ -0,0 +1,298 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using System.Net; + +namespace API.Tests; + +/// +/// HU-107 gap: DELETE api/tournaments/{tournamentId}/teams/{teamId} removes a +/// team from a tournament while it is still OpenForRegistration or +/// RegistrationClosed. Only THIS tournament's footprint (season registration, +/// this season's roster registrations, and stage assignments) is removed; other +/// seasons are untouched. Exercised as real HTTP round trips through +/// CustomWebApplicationFactory. +/// +public class TournamentUnenrollTeamTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentUnenrollTeamTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Unenroll_EnrolledTeam_RemovesRegistrationRosterAssignmentsAndClearsPointer() + { + Guid tournamentId; + Guid teamId; + Guid stageId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Division division = await SeedDivisionAsync(db, tournament); + Stage stage = await SeedGroupStageAsync(db, division); + Team team = await SeedTeamAsync(db, tournament.Id); + Player player = await SeedPlayerAsync(db, team); + await EnrollAsync(db, tournament, stage, team, player); + + tournamentId = tournament.Id; + teamId = team.Id; + stageId = stage.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.DeleteAsync($"api/tournaments/{tournamentId}/teams/{teamId}"); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.False(await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == teamId && r.TournamentId == tournamentId)); + Assert.False(await verifyDb.PlayerTeamRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == teamId && r.TournamentId == tournamentId)); + Assert.False(await verifyDb.StageTeamMatches.AsNoTracking() + .AnyAsync(m => m.TeamId == teamId && m.StageId == stageId)); + + Team reloaded = await verifyDb.Teams.AsNoTracking().SingleAsync(t => t.Id == teamId); + Assert.Null(reloaded.TournamentId); + } + + [Fact] + public async Task Unenroll_TournamentOngoing_ReturnsConflictAndKeepsRegistration() + { + Guid tournamentId; + Guid teamId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.Ongoing); + Team team = await SeedTeamAsync(db, tournament.Id); + db.TeamTournamentRegistrations.Add(new TeamTournamentRegistration + { + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }); + await db.SaveChangesAsync(); + + tournamentId = tournament.Id; + teamId = team.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.DeleteAsync($"api/tournaments/{tournamentId}/teams/{teamId}"); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + Assert.True(await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == teamId && r.TournamentId == tournamentId)); + } + + [Fact] + public async Task Unenroll_TeamNotEnrolled_ReturnsNotFound() + { + Guid tournamentId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament tournament = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + tournamentId = tournament.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.DeleteAsync($"api/tournaments/{tournamentId}/teams/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Unenroll_OnlyRemovesThisSeason_OtherSeasonUntouched() + { + Guid targetTournamentId; + Guid otherTournamentId; + Guid teamId; + Guid otherStageId; + using (IServiceScope scope = _factory.Services.CreateScope()) + { + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + Tournament target = await SeedTournamentAsync(db, TournamentStatus.RegistrationClosed); + Tournament other = await SeedTournamentAsync(db, TournamentStatus.OpenForRegistration); + Team team = await SeedTeamAsync(db, target.Id); + Player player = await SeedPlayerAsync(db, team); + + Division targetDivision = await SeedDivisionAsync(db, target); + Stage targetStage = await SeedGroupStageAsync(db, targetDivision); + await EnrollAsync(db, target, targetStage, team, player); + + Division otherDivision = await SeedDivisionAsync(db, other); + Stage otherStage = await SeedGroupStageAsync(db, otherDivision); + await EnrollAsync(db, other, otherStage, team, player); + + targetTournamentId = target.Id; + otherTournamentId = other.Id; + teamId = team.Id; + otherStageId = otherStage.Id; + } + + HttpClient client = _factory.CreateAuthenticatedClient(Roles.Admin); + + HttpResponseMessage response = await client.DeleteAsync($"api/tournaments/{targetTournamentId}/teams/{teamId}"); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + using IServiceScope verify = _factory.Services.CreateScope(); + ApplicationDBContext verifyDb = verify.ServiceProvider.GetRequiredService(); + // Target season fully removed. + Assert.False(await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == teamId && r.TournamentId == targetTournamentId)); + // Other season completely intact. + Assert.True(await verifyDb.TeamTournamentRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == teamId && r.TournamentId == otherTournamentId)); + Assert.True(await verifyDb.PlayerTeamRegistrations.AsNoTracking() + .AnyAsync(r => r.TeamId == teamId && r.TournamentId == otherTournamentId)); + Assert.True(await verifyDb.StageTeamMatches.AsNoTracking() + .AnyAsync(m => m.TeamId == teamId && m.StageId == otherStageId)); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db, TournamentStatus status) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Unenroll test tournament", + Name = $"Tournament-{Guid.NewGuid()}", + Slug = $"tournament-{Guid.NewGuid()}", + TeamRegistrationDeadline = start.AddDays(-1), + StartDate = start, + Status = status, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + db.Tournaments.Add(tournament); + await db.SaveChangesAsync(); + return tournament; + } + + private static async Task SeedDivisionAsync(ApplicationDBContext db, Tournament tournament) + { + Division division = new() + { + Slug = $"division-{Guid.NewGuid()}", + Name = $"Zone-{Guid.NewGuid()}", + Tournament = tournament, + TournamentId = tournament.Id, + Stages = [], + CreatedBy = "test", + }; + + db.Divisions.Add(division); + await db.SaveChangesAsync(); + return division; + } + + private static async Task SeedGroupStageAsync(ApplicationDBContext db, Division division) + { + DateTime start = DateTime.UtcNow.Date.AddDays(30); + + Stage stage = new() + { + Slug = $"stage-{Guid.NewGuid()}", + Name = $"Group-{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 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 SeedPlayerAsync(ApplicationDBContext db, Team team) + { + Player player = new() + { + FirstName = "First", + LastName = $"Last-{Guid.NewGuid()}", + Slug = $"player-{Guid.NewGuid()}", + DocumentNumber = Guid.NewGuid().ToString("N")[..8], + IsSanctioned = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + SocialSecurity = "OSDE", + Team = team, + TeamId = team.Id, + PlayerTeamRegistrations = [], + CreatedBy = "test", + }; + + db.Players.Add(player); + await db.SaveChangesAsync(); + return player; + } + + private static async Task EnrollAsync( + ApplicationDBContext db, Tournament tournament, Stage stage, Team team, Player player) + { + db.TeamTournamentRegistrations.Add(new TeamTournamentRegistration + { + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }); + + db.PlayerTeamRegistrations.Add(new PlayerTeamRegistration + { + PlayerId = player.Id, + TeamId = team.Id, + TournamentId = tournament.Id, + CreatedBy = "test", + }); + + db.StageTeamMatches.Add(new StageTeamMatch + { + StageId = stage.Id, + TeamId = team.Id, + CreatedBy = "test", + }); + + await db.SaveChangesAsync(); + } +} diff --git a/Club12-Backend/API.Tests/TournamentUpdateImmutableFieldsTests.cs b/Club12-Backend/API.Tests/TournamentUpdateImmutableFieldsTests.cs new file mode 100644 index 0000000..6b4cc9b --- /dev/null +++ b/Club12-Backend/API.Tests/TournamentUpdateImmutableFieldsTests.cs @@ -0,0 +1,66 @@ +using Application.DTOs.Tournament.Request; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// A tournament's drives when it is +/// understood to have happened (season grouping, champions history, calendar) +/// and must never move after creation, regardless of status — the frontend +/// edit form never exposes it, but that alone is not a real guard against a +/// direct API call. Verifies CreateMap<UpdateTournamentRequest, Tournament>() +/// ignores the same way it already ignores +/// and . +/// +public class TournamentUpdateImmutableFieldsTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public TournamentUpdateImmutableFieldsTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void MappingUpdateRequestOntoTournament_DifferentStartDate_LeavesOriginalStartDateUnchanged() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IMapper mapper = scope.ServiceProvider.GetRequiredService(); + + DateTime originalStartDate = new(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); + + Tournament tournament = new() + { + Name = "Original", + Description = "Original", + Slug = "original", + TeamRegistrationDeadline = originalStartDate.AddDays(-1), + StartDate = originalStartDate, + Status = TournamentStatus.OpenForRegistration, + Category = TournamentCategory.Masculine, + Divisions = [], + Teams = [], + CreatedBy = "test", + }; + + UpdateTournamentRequest request = new() + { + Name = "Renamed", + Description = "Renamed", + TeamRegistrationDeadline = originalStartDate.AddDays(-1), + StartDate = originalStartDate.AddYears(1), + Status = TournamentStatus.Canceled, + }; + + mapper.Map(request, tournament); + + Assert.Equal(originalStartDate, tournament.StartDate); + Assert.Equal("Renamed", tournament.Name); + } +} diff --git a/Club12-Backend/API.Tests/UnassignedTeamsTests.cs b/Club12-Backend/API.Tests/UnassignedTeamsTests.cs new file mode 100644 index 0000000..a0030a6 --- /dev/null +++ b/Club12-Backend/API.Tests/UnassignedTeamsTests.cs @@ -0,0 +1,185 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Persistance; + +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies IDivisionService.GetUnassignedTeamsAsync surfaces registered +/// teams that don't yet belong to any division — the "a team must be in a +/// division if it's part of the tournament" completeness rule, checked as +/// a readiness signal (not a hard block) so an admin can build the +/// tournament up incrementally. +/// +public class UnassignedTeamsTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public UnassignedTeamsTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetUnassignedTeamsAsync_TeamNeverAssignedToAnyStage_IsReturned() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 2); + Division zoneA = await SeedDivisionAsync(db, tournament); + Stage zoneAStage = await SeedStageAsync(db, zoneA, tournament); + await AssignTeamAsync(db, zoneAStage, teams[0]); + + List unassigned = await divisionService.GetUnassignedTeamsAsync(tournament.Id); + + Team onlyResult = Assert.Single(unassigned); + Assert.Equal(teams[1].Id, onlyResult.Id); + } + + [Fact] + public async Task GetUnassignedTeamsAsync_EveryTeamAssigned_ReturnsEmpty() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 2); + Division zoneA = await SeedDivisionAsync(db, tournament); + Stage zoneAStage = await SeedStageAsync(db, zoneA, tournament); + await AssignTeamAsync(db, zoneAStage, teams[0]); + await AssignTeamAsync(db, zoneAStage, teams[1]); + + List unassigned = await divisionService.GetUnassignedTeamsAsync(tournament.Id); + + Assert.Empty(unassigned); + } + + [Fact] + public async Task GetUnassignedTeamsAsync_TeamOnlyInCrossDivisionCup_StillCountsAsAssigned() + { + using IServiceScope scope = _factory.Services.CreateScope(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + IDivisionService divisionService = scope.ServiceProvider.GetRequiredService(); + + Tournament tournament = await SeedTournamentAsync(db); + List teams = await SeedTeamsAsync(db, tournament, 1); + Division cup = await SeedDivisionAsync(db, tournament, isCrossDivisionCup: true); + Stage cupStage = await SeedStageAsync(db, cup, tournament); + await AssignTeamAsync(db, cupStage, teams[0]); + + List unassigned = await divisionService.GetUnassignedTeamsAsync(tournament.Id); + + Assert.Empty(unassigned); + } + + private static async Task SeedTournamentAsync(ApplicationDBContext db) + { + DateTime startDate = DateTime.UtcNow.Date.AddDays(30); + + Tournament tournament = new() + { + Description = "Unassigned-teams 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> SeedTeamsAsync(ApplicationDBContext db, Tournament tournament, int count) + { + List 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 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 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 AssignTeamAsync(ApplicationDBContext db, Stage stage, Team team) + { + db.StageTeamMatches.Add(new StageTeamMatch + { + StageId = stage.Id, + TeamId = team.Id, + CreatedBy = "test", + }); + + await db.SaveChangesAsync(); + } +} diff --git a/Club12-Backend/API.Tests/UploadFromFormBindingTests.cs b/Club12-Backend/API.Tests/UploadFromFormBindingTests.cs new file mode 100644 index 0000000..be03f0f --- /dev/null +++ b/Club12-Backend/API.Tests/UploadFromFormBindingTests.cs @@ -0,0 +1,55 @@ +using API.Controllers; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System.Linq; +using System.Reflection; + +namespace API.Tests; + +/// +/// Guards the multipart-upload contract (QA wave 1, Bug 1): every controller +/// action that accepts a file-bearing request DTO must bind it from the form +/// ([FromForm]). Under [ApiController], binding-source inference +/// treats a user-defined complex type as [FromBody] (JSON) unless it is +/// explicitly annotated — so a missing [FromForm] makes the endpoint +/// reject the frontend's multipart/form-data upload with HTTP 415 +/// (Unsupported Media Type). These endpoints cannot be exercised through the +/// full HTTP host (their controllers take a constructor-injected SupabaseHelper +/// whose ctor opens a live Supabase connection — the same pre-existing +/// testability gap documented in SupabaseDependentControllerNotFoundTests), so +/// the contract is verified at the binding-metadata layer instead. +/// +public class UploadFromFormBindingTests +{ + public static readonly TheoryData UploadActions = new() + { + { typeof(TeamController), nameof(TeamController.CreateTeam), "teamRequest" }, + { typeof(TeamController), nameof(TeamController.UpdateTeamLogo), "logoRequest" }, + { typeof(BlogPostController), nameof(BlogPostController.CreateBlogPost), "blogPostRequest" }, + { typeof(BlogPostController), nameof(BlogPostController.UpdateBlogPostPhoto), "photoRequest" }, + { typeof(VenueController), nameof(VenueController.CreateVenue), "venueRequest" }, + { typeof(MedicalRecordController), nameof(MedicalRecordController.UploadMedicalRecord), "request" }, + }; + + [Theory] + [MemberData(nameof(UploadActions))] + public void UploadAction_FileBearingParameter_IsBoundFromForm( + System.Type controllerType, string actionName, string parameterName) + { + MethodInfo action = controllerType.GetMethod(actionName)!; + Assert.NotNull(action); + + ParameterInfo parameter = action.GetParameters().Single(p => p.Name == parameterName); + + // The request DTO carries an IFormFile property, so the endpoint only + // works when the parameter is bound from the multipart form. + Assert.Contains(parameter.ParameterType.GetProperties(), + property => property.PropertyType == typeof(IFormFile)); + + FromFormAttribute? fromForm = parameter.GetCustomAttribute(); + Assert.True(fromForm is not null, + $"{controllerType.Name}.{actionName}({parameterName}) must be [FromForm] to accept multipart/form-data uploads (otherwise ApiController infers [FromBody] and returns HTTP 415)."); + } +} diff --git a/Club12-Backend/API.Tests/UserRoleChangeTests.cs b/Club12-Backend/API.Tests/UserRoleChangeTests.cs new file mode 100644 index 0000000..bdbd706 --- /dev/null +++ b/Club12-Backend/API.Tests/UserRoleChangeTests.cs @@ -0,0 +1,259 @@ +using Application.DTOs.User.Request; +using Application.DTOs.User.Response; +using Application.Interfaces.Services; + +using Domain.Enums; + +using Infrastructure.Identity; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies IUserManagementService.UpdateAsync's Role handling — the +/// capability that replaces raw "insert a row into AspNetUserRoles" access +/// with a real, authorized role-change path. Covers the two callers allowed +/// to change roles at all (ADMIN, OWNER), the privilege-escalation guards +/// (a caller can never grant a role beyond its own assignment policy, and +/// nobody — not even ADMIN — can change their own role), and the Identity +/// invariant that a user ends up in exactly one role after the change. +/// +/// HU-05: the role model is now just the two operator accounts (Owner, +/// Admin IT) plus the technical Guest role, so these tests exercise role +/// changes between Owner and Admin and use Guest as the "non-privileged +/// caller" that must be rejected. +/// +/// +public class UserRoleChangeTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public UserRoleChangeTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task UpdateAsync_AdminChangesAnyUsersRole_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid adminId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.ADMIN); + (Guid targetId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + + UserResponse result = await userManagementService.UpdateAsync( + Roles.Admin, adminId, targetId, + new UpdateUserRequest { Role = UserRoleType.ADMIN }); + + Assert.Equal(Roles.Admin, result.Role); + } + + [Fact] + public async Task UpdateAsync_OwnerChangesOwnSubordinatesRole_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid ownerId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + (Guid subordinateId, ApplicationUser subordinate) = + await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + subordinate.CreatedByOwnerId = ownerId; + await userManager.UpdateAsync(subordinate); + + UserResponse result = await userManagementService.UpdateAsync( + Roles.Owner, ownerId, subordinateId, + new UpdateUserRequest { Role = UserRoleType.ADMIN }); + + Assert.Equal(Roles.Admin, result.Role); + } + + [Fact] + public async Task UpdateAsync_NonAdminNonOwnerCaller_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid callerId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.GUEST); + (Guid targetId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + + await Assert.ThrowsAsync(() => + userManagementService.UpdateAsync( + Roles.Guest, callerId, targetId, + new UpdateUserRequest { Role = UserRoleType.OWNER })); + } + + /// + /// OWNER is a super-admin role, same as ADMIN — it is never scoped to + /// only the users/roles it personally created. Targeting a user it + /// didn't create, and assigning any role (including OWNER/ADMIN), must + /// both succeed. + /// + [Fact] + public async Task UpdateAsync_OwnerTargetingUserTheyDidNotCreate_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid ownerId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + // Not created by this owner (CreatedByOwnerId left null). + (Guid targetId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + + UserResponse result = await userManagementService.UpdateAsync( + Roles.Owner, ownerId, targetId, + new UpdateUserRequest { Role = UserRoleType.ADMIN }); + + Assert.Equal(Roles.Admin, result.Role); + } + + [Fact] + public async Task UpdateAsync_OwnerAssigningAnyRole_Succeeds() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid ownerId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + (Guid subordinateId, ApplicationUser subordinate) = + await SeedUserWithRoleAsync(userManager, UserRoleType.ADMIN); + subordinate.CreatedByOwnerId = ownerId; + await userManager.UpdateAsync(subordinate); + + UserResponse result = await userManagementService.UpdateAsync( + Roles.Owner, ownerId, subordinateId, + new UpdateUserRequest { Role = UserRoleType.OWNER }); + + Assert.Equal(Roles.Owner, result.Role); + } + + [Fact] + public async Task UpdateAsync_InvalidRoleValue_IsRejected() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid adminId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.ADMIN); + (Guid targetId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + + await Assert.ThrowsAsync(() => + userManagementService.UpdateAsync( + Roles.Admin, adminId, targetId, + new UpdateUserRequest { Role = (UserRoleType) 999 })); + } + + /// + /// Closes the self-escalation vector this task exists to fix: even the + /// most privileged caller (ADMIN) cannot use their own privilege to grant + /// themselves a higher role while editing their own profile. + /// + [Fact] + public async Task UpdateAsync_CallerChangingOwnRole_IsRejectedEvenForAdmin() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid adminId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.ADMIN); + + await Assert.ThrowsAsync(() => + userManagementService.UpdateAsync( + Roles.Admin, adminId, adminId, + new UpdateUserRequest { Role = UserRoleType.ADMIN })); + } + + [Fact] + public async Task UpdateAsync_RoleChange_ReplacesOldRoleSoUserEndsUpInExactlyOneRole() + { + Guid adminId; + Guid targetId; + using (IServiceScope seedScope = _factory.Services.CreateScope()) + { + UserManager seedUserManager = + seedScope.ServiceProvider.GetRequiredService>(); + (adminId, _) = await SeedUserWithRoleAsync(seedUserManager, UserRoleType.ADMIN); + (targetId, _) = await SeedUserWithRoleAsync(seedUserManager, UserRoleType.OWNER); + } + + using (IServiceScope actScope = _factory.Services.CreateScope()) + { + IUserManagementService userManagementService = + actScope.ServiceProvider.GetRequiredService(); + + await userManagementService.UpdateAsync( + Roles.Admin, adminId, targetId, + new UpdateUserRequest { Role = UserRoleType.ADMIN }); + } + + using IServiceScope verifyScope = _factory.Services.CreateScope(); + UserManager verifyUserManager = + verifyScope.ServiceProvider.GetRequiredService>(); + ApplicationUser? persistedTarget = await verifyUserManager.FindByIdAsync(targetId.ToString()); + Assert.NotNull(persistedTarget); + + IList rolesAfterChange = await verifyUserManager.GetRolesAsync(persistedTarget!); + + Assert.Single(rolesAfterChange); + Assert.Equal(Roles.Admin, rolesAfterChange[0]); + } + + [Fact] + public async Task UpdateAsync_ProfileFieldsOnly_DoesNotTouchRole() + { + using IServiceScope scope = _factory.Services.CreateScope(); + UserManager userManager = + scope.ServiceProvider.GetRequiredService>(); + IUserManagementService userManagementService = + scope.ServiceProvider.GetRequiredService(); + + (Guid adminId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.ADMIN); + (Guid targetId, _) = await SeedUserWithRoleAsync(userManager, UserRoleType.OWNER); + + UserResponse result = await userManagementService.UpdateAsync( + Roles.Admin, adminId, targetId, + new UpdateUserRequest { Username = $"renamed-{Guid.NewGuid():N}" }); + + Assert.Equal(Roles.Owner, result.Role); + } + + private static async Task<(Guid Id, ApplicationUser User)> SeedUserWithRoleAsync( + UserManager userManager, UserRoleType role) + { + string uniqueEmail = $"role-change-test-{Guid.NewGuid()}@test.local"; + + ApplicationUser user = new() + { + UserName = uniqueEmail, + Email = uniqueEmail, + EmailConfirmed = true, + }; + + IdentityResult createResult = await userManager.CreateAsync(user, "Test-Passw0rd!1"); + Assert.True(createResult.Succeeded, string.Join(" | ", createResult.Errors.Select(e => e.Description))); + + IdentityResult roleResult = await userManager.AddToRoleAsync(user, role.ToRoleName()); + Assert.True(roleResult.Succeeded, string.Join(" | ", roleResult.Errors.Select(e => e.Description))); + + return (user.Id, user); + } +} diff --git a/Club12-Backend/API.Tests/VenueLocationTests.cs b/Club12-Backend/API.Tests/VenueLocationTests.cs new file mode 100644 index 0000000..69650e6 --- /dev/null +++ b/Club12-Backend/API.Tests/VenueLocationTests.cs @@ -0,0 +1,73 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using Infrastructure.Persistance; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies a Venue can carry optional geographic coordinates (Latitude / +/// Longitude). Coordinates are persisted alongside the venue and round-trip +/// through the service layer, so the public map link can be built from them. +/// Exercised at the service layer for the same Supabase-boot reason documented +/// in VenueServiceSlugTests. +/// +public class VenueLocationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public VenueLocationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateVenueAsync_PersistsLatitudeAndLongitude() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + Venue created = await venueService.CreateVenueAsync(new Venue + { + Name = $"Cancha Geo {Guid.NewGuid():N}", + Slug = null!, + Address = "Av. Siempre Viva 742", + Latitude = -34.603722, + Longitude = -58.381592, + CreatedBy = "test", + }); + + Venue? persisted = await db.Venues.AsNoTracking().FirstOrDefaultAsync(v => v.Id == created.Id); + + Assert.NotNull(persisted); + Assert.Equal(-34.603722, persisted!.Latitude); + Assert.Equal(-58.381592, persisted.Longitude); + } + + [Fact] + public async Task CreateVenueAsync_WithoutCoordinates_LeavesThemNull() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + + Venue created = await venueService.CreateVenueAsync(new Venue + { + Name = $"Cancha SinGeo {Guid.NewGuid():N}", + Slug = null!, + Address = "Calle Falsa 123", + CreatedBy = "test", + }); + + Venue? persisted = await db.Venues.AsNoTracking().FirstOrDefaultAsync(v => v.Id == created.Id); + + Assert.NotNull(persisted); + Assert.Null(persisted!.Latitude); + Assert.Null(persisted.Longitude); + } +} diff --git a/Club12-Backend/API.Tests/VenueServiceSlugTests.cs b/Club12-Backend/API.Tests/VenueServiceSlugTests.cs new file mode 100644 index 0000000..00858fb --- /dev/null +++ b/Club12-Backend/API.Tests/VenueServiceSlugTests.cs @@ -0,0 +1,124 @@ +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using Microsoft.Extensions.DependencyInjection; + +namespace API.Tests; + +/// +/// Verifies Venue's slug support: VenueService.CreateVenueAsync generates a +/// unique slug from the venue's Name, and GetVenueByIdOrSlugAsync resolves a +/// venue by either its GUID id or its slug. Exercised at the service layer +/// (resolved directly from DI) rather than through VenueController, because +/// the controller's constructor-injected SupabaseHelper eagerly opens a +/// Supabase Realtime websocket connection and cannot boot in a sandboxed test +/// environment — the same testability gap covered by BlogPostServiceSlugTests. +/// +public class VenueServiceSlugTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public VenueServiceSlugTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateVenueAsync_GeneratesSlugFromName() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + Venue created = await venueService.CreateVenueAsync(new Venue + { + Name = $"Polideportivo Ñandú {Guid.NewGuid():N}", + Slug = null!, + Address = "Av. Siempre Viva 1234", + CreatedBy = "test", + }); + + Assert.False(string.IsNullOrWhiteSpace(created.Slug)); + Assert.DoesNotContain(' ', created.Slug); + Assert.Equal(created.Slug, created.Slug.ToLowerInvariant()); + } + + [Fact] + public async Task CreateVenueAsync_DuplicateName_AppendsSuffixToSlug() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + string sharedName = $"Cancha Compartida {Guid.NewGuid():N}"; + + Venue first = await venueService.CreateVenueAsync(new Venue + { + Name = sharedName, + Slug = null!, + Address = "Calle Uno 1", + CreatedBy = "test", + }); + + Venue second = await venueService.CreateVenueAsync(new Venue + { + Name = sharedName, + Slug = null!, + Address = "Calle Dos 2", + CreatedBy = "test", + }); + + Assert.NotEqual(first.Slug, second.Slug); + Assert.Equal($"{first.Slug}-2", second.Slug); + } + + [Fact] + public async Task GetVenueByIdOrSlugAsync_ResolvesByGuidId() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + Venue created = await venueService.CreateVenueAsync(new Venue + { + Name = $"Estadio Por Id {Guid.NewGuid():N}", + Slug = null!, + Address = "Ruta 5 km 12", + CreatedBy = "test", + }); + + Venue? found = await venueService.GetVenueByIdOrSlugAsync(created.Id.ToString()); + + Assert.NotNull(found); + Assert.Equal(created.Id, found!.Id); + } + + [Fact] + public async Task GetVenueByIdOrSlugAsync_ResolvesBySlug() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + Venue created = await venueService.CreateVenueAsync(new Venue + { + Name = $"Estadio Por Slug {Guid.NewGuid():N}", + Slug = null!, + Address = "Ruta 5 km 12", + CreatedBy = "test", + }); + + Venue? found = await venueService.GetVenueByIdOrSlugAsync(created.Slug); + + Assert.NotNull(found); + Assert.Equal(created.Id, found!.Id); + } + + [Fact] + public async Task GetVenueByIdOrSlugAsync_UnknownSlug_ReturnsNull() + { + using IServiceScope scope = _factory.Services.CreateScope(); + IVenueService venueService = scope.ServiceProvider.GetRequiredService(); + + Venue? found = await venueService.GetVenueByIdOrSlugAsync($"unknown-slug-{Guid.NewGuid():N}"); + + Assert.Null(found); + } +} diff --git a/Club12-Backend/API/API.csproj b/Club12-Backend/API/API.csproj new file mode 100644 index 0000000..ba04e00 --- /dev/null +++ b/Club12-Backend/API/API.csproj @@ -0,0 +1,32 @@ + + + + NU1903 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + net8.0 + enable + true + + \ No newline at end of file diff --git a/Club12-Backend/API/AutoMapperProfiles/AuditLogProfile.cs b/Club12-Backend/API/AutoMapperProfiles/AuditLogProfile.cs new file mode 100644 index 0000000..71f4995 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/AuditLogProfile.cs @@ -0,0 +1,17 @@ +using Application.DTOs.AuditLogs.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class AuditLogProfile : Profile +{ + public AuditLogProfile() + { + _ = CreateMap() + .ForMember(dest => dest.Action, opt => opt.MapFrom(src => src.Action.ToString())) + .ForMember(dest => dest.Timestamp, opt => opt.MapFrom(src => src.DateCreated)); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/BlogPostProfile.cs b/Club12-Backend/API/AutoMapperProfiles/BlogPostProfile.cs new file mode 100644 index 0000000..349c73b --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/BlogPostProfile.cs @@ -0,0 +1,28 @@ +using Application.DTOs.BlogPosts.Request; +using Application.DTOs.BlogPosts.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class BlogPostProfile : Profile +{ + public BlogPostProfile() + { + _ = CreateMap(); + + _ = CreateMap() + .ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => src.DateCreated)) + .ReverseMap(); + + _ = CreateMap() + // Null IsPublished on the request means leave unchanged, so only overwrite the entity's flag when the caller sent a value. + .ForMember(dest => dest.IsPublished, opt => + { + opt.PreCondition(src => src.IsPublished.HasValue); + opt.MapFrom(src => src.IsPublished!.Value); + }); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/DivisionProfile.cs b/Club12-Backend/API/AutoMapperProfiles/DivisionProfile.cs new file mode 100644 index 0000000..23c6119 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/DivisionProfile.cs @@ -0,0 +1,43 @@ +using Application.DTOs.Divisions.Request; +using Application.DTOs.Divisions.Response; +using Application.DTOs.Tournament.Response; +using Application.Utils.Helper.Playoff; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class DivisionProfile : Profile +{ + public DivisionProfile() + { + _ = CreateMap() + // QualificationRanges has no Division counterpart, so it is computed here from PlayoffMappings and ignored on the reverse map. + .ForMember( + dest => dest.QualificationRanges, + opt => opt.MapFrom(src => QualificationRangeBuilder.Build(src.PlayoffMappings))) + // TournamentSlug is resolved from the Tournament navigation, degrading to null when it was not included. + .ForMember( + dest => dest.TournamentSlug, + opt => opt.MapFrom(src => src.Tournament != null ? src.Tournament.Slug : null)) + .ReverseMap(); + + _ = CreateMap() + .ReverseMap(); + + _ = CreateMap(); + _ = CreateMap(); + + // Tournament cloning (HU-cloning): Stages resolves to StageStructureResponse + // once StageProfile registers that map. + _ = CreateMap(); + + _ = CreateMap(); + + // TournamentId is deliberately excluded from the blind mapping, since an omitted value would map to Guid via GetValueOrDefault and silently zero out the division's tournament, so reassignment must go through DivisionService.TryAssignTournamentAsync instead. + _ = CreateMap() + .ForMember(dest => dest.TournamentId, opt => opt.Ignore()); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/MatchProfile.cs b/Club12-Backend/API/AutoMapperProfiles/MatchProfile.cs new file mode 100644 index 0000000..9429916 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/MatchProfile.cs @@ -0,0 +1,138 @@ +using Application.DTOs.Match.Request; +using Application.DTOs.Match.Response; +using Application.DTOs.Player.Response; +using Application.DTOs.Scorer.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace API.AutoMapperProfiles; + +public class MatchProfile : Profile +{ + public MatchProfile() + { + _ = CreateMap(); + + _ = CreateMap() + .ForMember(dest => dest.MatchType, opt => opt.MapFrom(src => src.Type.ToString())) + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString())) + .ForMember(dest => dest.HomeTeam, opt => opt.MapFrom(src => src.HomeTeam)) + .ForMember(dest => dest.VisitorTeam, opt => opt.MapFrom(src => src.VisitorTeam)) + .ForMember(dest => dest.WinningTeamName, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Name : null)) + .ForMember(dest => dest.WinningTeamId, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Id : (Guid?) null)) + // Score and Scorers are set here instead of via ForPath, since ForPath would force AutoMapper to instantiate HomeTeam even when src.HomeTeam is null, producing a fake team instead of an empty bracket slot. + .AfterMap((src, dest) => + { + // Each match's scorers are attributed to a team via the player's TeamId since Scorer itself has no TeamId, which requires the match to be loaded with Scorers.Player. + Guid? tournamentId = src.Stage?.Division?.TournamentId; + + if (dest.HomeTeam is not null) + { + dest.HomeTeam.Score = src.HomeScore ?? 0; + dest.HomeTeam.Scorers = ScorersForTeam(src, src.HomeTeamId); + PopulateRosterEligibility(dest.HomeTeam.Players, src.HomeTeam?.Players, tournamentId); + } + + if (dest.VisitorTeam is not null) + { + dest.VisitorTeam.Score = src.VisitorScore ?? 0; + dest.VisitorTeam.Scorers = ScorersForTeam(src, src.VisitorTeamId); + PopulateRosterEligibility(dest.VisitorTeam.Players, src.VisitorTeam?.Players, tournamentId); + } + + dest.TournamentId = tournamentId; + }); + + _ = CreateMap() + .ForMember(dest => dest.MatchType, opt => opt.MapFrom(src => src.Type.ToString())) + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString())) + .ForMember(dest => dest.HomeTeamName, opt => opt.MapFrom(src => src.HomeTeam != null ? src.HomeTeam.Name : null)) + .ForMember(dest => dest.VisitorTeamName, opt => opt.MapFrom(src => src.VisitorTeam != null ? src.VisitorTeam.Name : null)) + .ForMember(dest => dest.WinningTeamName, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Name : null)); + + _ = CreateMap(); + } + + /// + /// Builds the per-player scorer ranking for one team, attributing each scorer via Player.TeamId and summing points per player, highest first. + /// + private static List ScorersForTeam(Match match, Guid? teamId) + { + if (teamId is null) + { + return []; + } + + Guid? tournamentId = match.Stage?.Division?.TournamentId; + + return [.. match.Scorers + .Where(scorer => scorer.Player is not null && scorer.Player.TeamId == teamId.Value) + .GroupBy(scorer => scorer.PlayerId) + .Select(group => new ScorerByPlayerResponse + { + PlayerId = group.Key, + FullName = group.First().Player!.FullName, + JerseyNumber = JerseyNumberFor(group.First().Player!, tournamentId), + Points = group.Sum(scorer => scorer.Points), + }) + .OrderByDescending(scorer => scorer.Points)]; + } + + /// + /// Resolves the player's jersey number from the matching roster registration for the tournament, since Player.JerseyNumber itself is transient. + /// + private static int? JerseyNumberFor(Player player, Guid? tournamentId) + { + if (player.PlayerTeamRegistrations is null || player.PlayerTeamRegistrations.Count == 0) + { + return null; + } + + PlayerTeamRegistration? registration = tournamentId is null + ? null + : player.PlayerTeamRegistrations.FirstOrDefault(reg => reg.TournamentId == tournamentId.Value); + + return (registration ?? player.PlayerTeamRegistrations.First()).JerseyNumber; + } + + /// + /// Fills each roster player's season-scoped medical record status, habilitado flag, and jersey number from the matching PlayerTeamRegistration, since the plain Player to PublicPlayerResponse map cannot resolve them on its own. + /// + private static void PopulateRosterEligibility( + List destPlayers, ICollection? srcPlayers, Guid? tournamentId) + { + if (tournamentId is null || srcPlayers is null || srcPlayers.Count == 0) + { + return; + } + + Dictionary srcById = srcPlayers.ToDictionary(player => player.Id); + + foreach (PublicPlayerResponse destPlayer in destPlayers) + { + if (!srcById.TryGetValue(destPlayer.Id, out Player? srcPlayer) + || srcPlayer.PlayerTeamRegistrations is null) + { + continue; + } + + PlayerTeamRegistration? registration = srcPlayer.PlayerTeamRegistrations + .FirstOrDefault(reg => reg.TournamentId == tournamentId.Value); + + if (registration is null) + { + continue; + } + + destPlayer.MedicalRecordStatus = registration.MedicalRecordStatus; + destPlayer.IsHabilitado = registration.IsHabilitado; + destPlayer.JerseyNumber = registration.JerseyNumber; + } + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/MatchSeriesProfile.cs b/Club12-Backend/API/AutoMapperProfiles/MatchSeriesProfile.cs new file mode 100644 index 0000000..c41cab3 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/MatchSeriesProfile.cs @@ -0,0 +1,26 @@ +using Application.DTOs.Match.Response; +using Application.DTOs.MatchSeries.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +using System.Linq; + +namespace API.AutoMapperProfiles; + +public class MatchSeriesProfile : Profile +{ + public MatchSeriesProfile() + { + _ = CreateMap() + .ForMember(dest => dest.HomeTeamName, opt => opt.MapFrom(src => src.HomeTeam != null ? src.HomeTeam.Name : string.Empty)) + .ForMember(dest => dest.VisitorTeamName, opt => opt.MapFrom(src => src.VisitorTeam != null ? src.VisitorTeam.Name : string.Empty)) + .ForMember(dest => dest.WinningTeamName, opt => opt.MapFrom(src => src.WinningTeam != null ? src.WinningTeam.Name : null)) + .ForMember(dest => dest.Games, opt => opt.MapFrom(src => src.Matches.OrderBy(m => m.GameNumber))); + + _ = CreateMap() + .IncludeBase() + .ForMember(dest => dest.GameNumber, opt => opt.MapFrom(src => src.GameNumber ?? 0)); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PaginatedResponseConverter.cs b/Club12-Backend/API/AutoMapperProfiles/PaginatedResponseConverter.cs new file mode 100644 index 0000000..b14b2ee --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PaginatedResponseConverter.cs @@ -0,0 +1,37 @@ +using Application.DTOs.Abstract.Response; + +using AutoMapper; + +using System.Collections.Generic; + +namespace API.AutoMapperProfiles; + +/// +/// Converts a paginated response's items from the source entity type to the destination DTO type while preserving its paging metadata. +/// +/// The source item type. +/// The destination item type. +public class PaginatedResponseConverter + : ITypeConverter, PaginatedResponse> +{ + /// + /// Maps the source paginated response's items to the destination type. + /// + /// The paginated response being converted. + /// The unused destination instance supplied by AutoMapper. + /// The mapping context used to convert the items. + /// A new paginated response with mapped items and the source paging metadata. + public PaginatedResponse Convert( + PaginatedResponse source, + PaginatedResponse destination, + ResolutionContext context) + { + return new() + { + Page = source.Page, + PageSize = source.PageSize, + TotalCount = source.TotalCount, + Items = context.Mapper.Map>(source.Items) + }; + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PaginatedResponseProfile.cs b/Club12-Backend/API/AutoMapperProfiles/PaginatedResponseProfile.cs new file mode 100644 index 0000000..feb4a01 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PaginatedResponseProfile.cs @@ -0,0 +1,14 @@ +using Application.DTOs.Abstract.Response; + +using AutoMapper; + +namespace API.AutoMapperProfiles; + +public class PaginatedResponseProfile : Profile +{ + public PaginatedResponseProfile() + { + CreateMap(typeof(PaginatedResponse<>), typeof(PaginatedResponse<>)) + .ConvertUsing(typeof(PaginatedResponseConverter<,>)); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PlayerProfile.cs b/Club12-Backend/API/AutoMapperProfiles/PlayerProfile.cs new file mode 100644 index 0000000..29bd21d --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PlayerProfile.cs @@ -0,0 +1,27 @@ +using Application.DTOs.Player.Request; +using Application.DTOs.Player.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class PlayerProfile : Profile +{ + public PlayerProfile() + { + _ = CreateMap() + .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.FullName)) + .ReverseMap(); + + _ = CreateMap() + .IncludeBase(); + + _ = CreateMap(); + + _ = CreateMap(); + + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PlayerSanctionProfile.cs b/Club12-Backend/API/AutoMapperProfiles/PlayerSanctionProfile.cs new file mode 100644 index 0000000..0fcdf89 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PlayerSanctionProfile.cs @@ -0,0 +1,29 @@ +using Application.DTOs.PlayerSanction.Request; +using Application.DTOs.PlayerSanction.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class PlayerSanctionProfile : Profile +{ + public PlayerSanctionProfile() + { + _ = CreateMap(); + _ = CreateMap() + .ForMember(dest => dest.PlayerFullName, + opt => opt.MapFrom(src => src.Player != null ? src.Player.FullName : null)) + .ForMember(dest => dest.TeamName, + opt => opt.MapFrom(src => src.Team != null ? src.Team.Name : null)) + // FechasRemaining and IsActive are computed against finished rounds and populated by the controller, not mapped here. + .ForMember(dest => dest.FechasRemaining, opt => opt.Ignore()) + .ForMember(dest => dest.IsActive, opt => opt.Ignore()) + .ReverseMap() + .ForMember(dest => dest.Player, opt => opt.Ignore()) + .ForMember(dest => dest.Team, opt => opt.Ignore()); + + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PlayerStatisticProfile.cs b/Club12-Backend/API/AutoMapperProfiles/PlayerStatisticProfile.cs new file mode 100644 index 0000000..b7a254f --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PlayerStatisticProfile.cs @@ -0,0 +1,24 @@ +using Application.DTOs.PlayerStatistic.Request; +using Application.DTOs.PlayerStatistic.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +using System; + +namespace API.AutoMapperProfiles; + +public class PlayerStatisticProfile : Profile +{ + public PlayerStatisticProfile() + { + _ = CreateMap(); + + _ = CreateMap() + .ForMember(dest => dest.MatchDate, opt => opt.MapFrom(src => src.Match != null ? (DateTime?) src.Match.MatchDate : null)) + .ReverseMap(); + + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PointDeductionProfile.cs b/Club12-Backend/API/AutoMapperProfiles/PointDeductionProfile.cs new file mode 100644 index 0000000..0d56272 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PointDeductionProfile.cs @@ -0,0 +1,21 @@ +using Application.DTOs.PointDeductions.Request; +using Application.DTOs.PointDeductions.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class PointDeductionProfile : Profile +{ + public PointDeductionProfile() + { + _ = CreateMap(); + + _ = CreateMap() + .ForMember( + dest => dest.TeamName, + opt => opt.MapFrom(src => src.Team != null ? src.Team.Name : null)); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/PositionProfile.cs b/Club12-Backend/API/AutoMapperProfiles/PositionProfile.cs new file mode 100644 index 0000000..445bc2d --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/PositionProfile.cs @@ -0,0 +1,22 @@ +using Application.DTOs.Divisions.Response; +using Application.DTOs.PointDeductions.Response; +using Application.Utils.Helper.Standings; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class PositionProfile : Profile +{ + public PositionProfile() + { + _ = CreateMap(); + + _ = CreateMap(); + + // The nested Position to PositionResponse mapping above is applied automatically to each group's Positions collection. + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/SeasonProfile.cs b/Club12-Backend/API/AutoMapperProfiles/SeasonProfile.cs new file mode 100644 index 0000000..e638b63 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/SeasonProfile.cs @@ -0,0 +1,22 @@ +using Application.DTOs.Season.Request; +using Application.DTOs.Season.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class SeasonProfile : Profile +{ + public SeasonProfile() + { + _ = CreateMap(); + + _ = CreateMap(); + + _ = CreateMap(); + + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/StageProfile.cs b/Club12-Backend/API/AutoMapperProfiles/StageProfile.cs new file mode 100644 index 0000000..d885cb4 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/StageProfile.cs @@ -0,0 +1,24 @@ +using Application.DTOs.Stage.Request; +using Application.DTOs.Stage.Response; +using Application.DTOs.Tournament.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class StageProfile : Profile +{ + public StageProfile() + { + _ = CreateMap(); + _ = CreateMap() + .ReverseMap(); + _ = CreateMap(); + + // Tournament cloning (HU-cloning): additive, name-convention mapping — + // carries no dates, no DrawnAt, no match data. + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/TeamProfile.cs b/Club12-Backend/API/AutoMapperProfiles/TeamProfile.cs new file mode 100644 index 0000000..e647938 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/TeamProfile.cs @@ -0,0 +1,31 @@ +using Application.DTOs.Team.Request; +using Application.DTOs.Team.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class TeamProfile : Profile +{ + public TeamProfile() + { + _ = CreateMap() + .ForMember(dest => dest.ClubId, opt => opt.MapFrom(src => src.ClubId)) + .ForMember(dest => dest.TournamentName, opt => opt.MapFrom(src => src.Tournament != null ? src.Tournament.Name : null)) + .ReverseMap(); + + _ = CreateMap() + .ReverseMap(); + + _ = CreateMap() + .ForMember(dest => dest.ThreeLetterCode, opt => opt.MapFrom(src => src.ThreeLetterCode.ToUpper())); + + _ = CreateMap() + .ForMember(dest => dest.ThreeLetterCode, opt => opt.MapFrom(src => src.ThreeLetterCode != null ? src.ThreeLetterCode.ToUpper() : null)) + .ForMember(dest => dest.JerseyStyle, opt => opt.Condition(src => src.JerseyStyle != null)) + .ForMember(dest => dest.ShirtSecondaryColor, opt => opt.Condition(src => src.ShirtSecondaryColor != null)) + .ForMember(dest => dest.ShirtTertiaryColor, opt => opt.Condition(src => src.ShirtTertiaryColor != null)); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/TeamStaffProfile.cs b/Club12-Backend/API/AutoMapperProfiles/TeamStaffProfile.cs new file mode 100644 index 0000000..f537524 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/TeamStaffProfile.cs @@ -0,0 +1,24 @@ +using Application.DTOs.TeamStaff.Request; +using Application.DTOs.TeamStaff.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class TeamStaffProfile : Profile +{ + public TeamStaffProfile() + { + _ = CreateMap(); + + _ = CreateMap() + .ForMember( + dest => dest.Role, + opt => opt.MapFrom(src => src.Role.ToString())) + .ForMember( + dest => dest.TeamName, + opt => opt.MapFrom(src => src.Team != null ? src.Team.Name : null)); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/TournamentProfile.cs b/Club12-Backend/API/AutoMapperProfiles/TournamentProfile.cs new file mode 100644 index 0000000..910b6de --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/TournamentProfile.cs @@ -0,0 +1,36 @@ +using Application.DTOs.Tournament.Request; +using Application.DTOs.Tournament.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class TournamentProfile : Profile +{ + public TournamentProfile() + { + _ = CreateMap() + .ForMember(dest => dest.Divisions, opt => opt.MapFrom(src => src.Divisions)) + // SeasonId flows by name convention; SeasonName is resolved from the Season navigation, degrading to null when the tournament is ungrouped or the season was not included. + .ForMember(dest => dest.SeasonName, opt => opt.MapFrom(src => src.Season != null ? src.Season.Name : null)) + .ForMember(dest => dest.SeasonSlug, opt => opt.MapFrom(src => src.Season != null ? src.Season.Slug : null)) + .ReverseMap(); + + // Tournament cloning (HU-cloning): the structure-tree read is additive and + // maps by name convention only — Divisions resolves to DivisionStructureResponse + // once DivisionProfile registers that map, carrying no instance data. + _ = CreateMap(); + + _ = CreateMap(); + + _ = CreateMap() + // Status is intentionally not mapped here since a status change is a guarded state-machine transition with fixture side effects driven through TournamentService.ChangeStatusAsync, never a blind field overwrite from a generic update. + .ForMember(dest => dest.Status, opt => opt.Ignore()) + // Category is not mapped on update since a tournament's gender category is fixed at creation; flipping it later would silently mix it with divisions already created under the original category. + .ForMember(dest => dest.Category, opt => opt.Ignore()) + // StartDate is fixed at creation since it drives when the tournament is understood to have happened for season grouping and champions history, so it must never move after the fact regardless of status. + .ForMember(dest => dest.StartDate, opt => opt.Ignore()); + } +} diff --git a/Club12-Backend/API/AutoMapperProfiles/VenueProfile.cs b/Club12-Backend/API/AutoMapperProfiles/VenueProfile.cs new file mode 100644 index 0000000..e7a3c54 --- /dev/null +++ b/Club12-Backend/API/AutoMapperProfiles/VenueProfile.cs @@ -0,0 +1,21 @@ +using Application.DTOs.Venue.Request; +using Application.DTOs.Venue.Response; + +using AutoMapper; + +using Domain.Entities.Models; + +namespace API.AutoMapperProfiles; + +public class VenueProfile : Profile +{ + public VenueProfile() + { + _ = CreateMap() + .ReverseMap(); + + _ = CreateMap(); + + _ = CreateMap(); + } +} diff --git a/Club12-Backend/API/BackgroundServices/DatabaseBackupHostedService.cs b/Club12-Backend/API/BackgroundServices/DatabaseBackupHostedService.cs new file mode 100644 index 0000000..2af11f1 --- /dev/null +++ b/Club12-Backend/API/BackgroundServices/DatabaseBackupHostedService.cs @@ -0,0 +1,95 @@ +using Application.Interfaces.Backup; + +using Domain.Enums; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace API.BackgroundServices; + +/// +/// Drives the scheduled database backup by ticking a PeriodicTimer and calling the same shared IBackupOperationsService write path the manual admin endpoint uses, no-oping entirely when BackupOptions.Enabled is false. +/// +public sealed class DatabaseBackupHostedService( + IServiceScopeFactory scopeFactory, + BackupOptions options, + ILogger logger) : BackgroundService +{ + /// + /// Test-only hook that overrides the interval otherwise derived from BackupOptions.IntervalHours, letting tests use a short, deterministic interval instead of sleeping for real hours-scale durations. + /// + public TimeSpan? IntervalOverride { get; init; } + + private Task? _inFlightRun; + + /// + /// Ticks on a PeriodicTimer for the lifetime of the host; cancellation via stoppingToken during host shutdown is the expected exit path, not an error. + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!options.Enabled) + { + logger.LogInformation("Backup:Enabled is false — DatabaseBackupHostedService will not run."); + return; + } + + TimeSpan interval = IntervalOverride ?? TimeSpan.FromHours(Math.Max(options.IntervalHours, 1)); + + using PeriodicTimer timer = new(interval); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + _inFlightRun = RunBackupAttemptAsync(stoppingToken); + } + } + catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation(ex, "Backup hosted service stopping: cancellation requested."); + } + finally + { + if (_inFlightRun is not null) + { + await _inFlightRun.ConfigureAwait(false); + } + } + } + + private async Task RunBackupAttemptAsync(CancellationToken ct) + { + try + { + using IServiceScope scope = scopeFactory.CreateScope(); + IBackupOperationsService operations = scope.ServiceProvider.GetRequiredService(); + + BackupOperationResult result = await operations.CreateBackupAsync(BackupOrigin.Job, ct); + + switch (result.Outcome) + { + case BackupOperationOutcome.Busy: + logger.LogWarning("Skipping scheduled backup attempt: another backup/restore operation is already in progress."); + break; + case BackupOperationOutcome.Failed: + logger.LogError("Scheduled backup attempt failed: {Message}", result.Message); + break; + case BackupOperationOutcome.Completed: + logger.LogInformation("Scheduled backup completed: stored {StoragePath}.", result.Record?.StoragePath); + break; + case BackupOperationOutcome.NotFound: + default: + break; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Unexpected error during scheduled backup attempt."); + } + } +} diff --git a/Club12-Backend/API/Club12.http b/Club12-Backend/API/Club12.http new file mode 100644 index 0000000..2a6b516 --- /dev/null +++ b/Club12-Backend/API/Club12.http @@ -0,0 +1,6 @@ +@Server_HostAddress = http://localhost:5176 + +GET {{Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Club12-Backend/API/Controllers/AuditLogController.cs b/Club12-Backend/API/Controllers/AuditLogController.cs new file mode 100644 index 0000000..7e9ea84 --- /dev/null +++ b/Club12-Backend/API/Controllers/AuditLogController.cs @@ -0,0 +1,42 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.AuditLogs.Request; +using Application.DTOs.AuditLogs.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Read-only access to the sensitive-action audit trail, restricted to Admin or Owner so those shared accounts stay accountable. +/// +[Route("api/audit-logs")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class AuditLogController(IAuditService auditService, IMapper mapper) : ControllerBase +{ + /// + /// Lists audit entries newest first, with pagination and optional actor or action filters. + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAuditLogs( + [FromQuery] AuditLogFilteredRequest filter) + { + PaginatedResponse entries = await auditService.GetAuditLogsAsync(filter); + PaginatedResponse response = mapper.Map>(entries); + + return Ok(response); + } +} diff --git a/Club12-Backend/API/Controllers/AuthController.cs b/Club12-Backend/API/Controllers/AuthController.cs new file mode 100644 index 0000000..7326946 --- /dev/null +++ b/Club12-Backend/API/Controllers/AuthController.cs @@ -0,0 +1,173 @@ +using API.Utils.Helpers; + +using Application.DTOs.Auth.Request; +using Application.DTOs.Auth.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Authentication and account-lifecycle endpoints; login, magic links, guest sessions, and password reset are anonymous, while registering or inviting a user requires Admin or Owner. +/// +[ApiController] +[Route("api/auth")] +public class AuthController(IAuthenticationService authenticationService) : ControllerBase +{ + /// + /// Creates a fully activated account with a caller-set password; requires Admin or Owner rather than being a public self-registration endpoint. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(RegisterUserResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Register( + [FromBody] RegisterUserRequest request, CancellationToken ct) + { + string callerRole = User.FindFirst(ClaimTypes.Role)?.Value + ?? throw new UnauthorizedAccessException(ErrorMessages.Auth.RoleClaimMissing); + + Guid callerId = Guid.Parse( + User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? throw new UnauthorizedAccessException(ErrorMessages.Auth.IdClaimMissing)); + + RegisterUserResponse response = + await authenticationService.RegisterAsync(request, callerRole, callerId, ct); + + return CreatedAtAction(nameof(Register), new { userId = response.UserId }, response); + } + + /// + /// Creates a user by email only, with no password, and emails a magic activation link; requires Admin or Owner. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpPost("invite")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(InviteUserResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Invite( + [FromBody] InviteUserRequest request, CancellationToken ct) + { + string callerRole = User.FindFirst(ClaimTypes.Role)?.Value + ?? throw new UnauthorizedAccessException(ErrorMessages.Auth.RoleClaimMissing); + + Guid callerId = Guid.Parse( + User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? throw new UnauthorizedAccessException(ErrorMessages.Auth.IdClaimMissing)); + + InviteUserResponse response = + await authenticationService.InviteUserAsync(request, callerRole, callerId, ct); + + return CreatedAtAction(nameof(Invite), new { userId = response.UserId }, response); + } + + /// + /// Consumes the activation token from the invitation email, sets the user's first password, and returns a ready-to-use JWT. + /// + [AllowAnonymous] + [HttpPost("activate")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Activate( + [FromBody] ActivateAccountRequest request, CancellationToken ct) + { + return Ok(await authenticationService.ActivateAccountAsync(request, ct)); + } + + /// + /// Emails a password-reset magic link for the given email and always returns 200 so it never reveals whether the email has an account. + /// + [AllowAnonymous] + [HttpPost("password-reset/request")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RequestPasswordReset( + [FromBody] RequestPasswordResetRequest request, CancellationToken ct) + { + await authenticationService.RequestPasswordResetAsync(request, ct); + return Ok(); + } + + [AllowAnonymous] + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Login( + [FromBody] LogInUserRequest request, CancellationToken ct) + { + return Ok(await authenticationService.LoginAsync(request, ct)); + } + + [AllowAnonymous] + [HttpPost("magic-link/request")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MagicLinkResponse))] + public async Task> RequestMagicLink( + [FromBody] MagicLinkRequest request, CancellationToken ct) + { + return Ok(await authenticationService.RequestMagicLinkAsync(request, ct)); + } + + [AllowAnonymous] + [HttpPost("magic-link/login")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> MagicLinkLogin( + [FromBody] MagicLinkLoginRequest request, CancellationToken ct) + { + return Ok(await authenticationService.MagicLinkLoginAsync(request, ct)); + } + + [AllowAnonymous] + [HttpPost("guest")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))] + public async Task> Guest(CancellationToken ct) + { + return Ok(await authenticationService.GuestAsync(ct)); + } + + [AllowAnonymous] + [HttpPost("refresh")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Refresh( + [FromBody] RefreshTokenRequest request, CancellationToken ct) + { + return Ok(await authenticationService.RefreshAsync(request, ct)); + } + + /// + /// Verifies the password-reset token from the email link, sets the new password, and returns a ready-to-use JWT, logging the user in automatically. + /// + [AllowAnonymous] + [HttpPost("password-reset/confirm")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> ConfirmPasswordReset( + [FromBody] PasswordResetConfirmRequest request, CancellationToken ct) + { + return Ok(await authenticationService.ConfirmPasswordResetAsync(request, ct)); + } + + [AllowAnonymous] + [HttpPost("logout")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Logout(CancellationToken ct) + { + (string _, Guid id) = User.GetCallerClaims(); + await authenticationService.LogoutAsync(id, ct); + return NoContent(); + } +} \ No newline at end of file diff --git a/Club12-Backend/API/Controllers/BackupController.cs b/Club12-Backend/API/Controllers/BackupController.cs new file mode 100644 index 0000000..0739716 --- /dev/null +++ b/Club12-Backend/API/Controllers/BackupController.cs @@ -0,0 +1,104 @@ +using Application.DTOs.Backup.Response; +using Application.Interfaces.Backup; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Admin-only management of database backups: lists the catalog, triggers a manual backup, deletes a backup, and restores from one, with outcomes mapped explicitly to status codes. +/// +[Route("api/backups")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class BackupController(IBackupOperationsService operations) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IReadOnlyList))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAll(CancellationToken ct) + { + IReadOnlyList records = await operations.ListNewestFirstAsync(ct); + IReadOnlyList response = records.Select(BackupRecordResponse.FromEntity).ToList(); + return Ok(response); + } + + /// + /// Triggers a manual backup, returning 409 if a backup or restore is already in progress and 500 if the backup itself fails. + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BackupRecordResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Create(CancellationToken ct) + { + BackupOperationResult result = await operations.CreateBackupAsync(BackupOrigin.Manual, ct); + + return result.Outcome switch + { + BackupOperationOutcome.Completed => Ok(result.Record), + BackupOperationOutcome.Busy => Conflict(result.Message), + _ => StatusCode(StatusCodes.Status500InternalServerError, result.Message), + }; + } + + /// + /// Deletes a catalogued backup, returning 404 if no backup matches the id and 409 if a backup or restore is currently in progress. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(Guid id, CancellationToken ct) + { + BackupOperationResult result = await operations.DeleteBackupAsync(id, ct); + + return result.Outcome switch + { + BackupOperationOutcome.Completed => NoContent(), + BackupOperationOutcome.NotFound => NotFound(), + BackupOperationOutcome.Busy => Conflict(result.Message), + _ => StatusCode(StatusCodes.Status500InternalServerError, result.Message), + }; + } + + /// + /// Restores the database from a catalogued backup, returning on success the automatic safety backup record taken just before the restore, not the restored backup itself; returns 404 if no backup matches the id and 409 if a backup or restore is already in progress. + /// + [HttpPost("{id:guid}/restore")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BackupRecordResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Restore(Guid id, CancellationToken ct) + { + BackupOperationResult result = await operations.RestoreBackupAsync(id, ct); + + return result.Outcome switch + { + BackupOperationOutcome.Completed => Ok(result.Record), + BackupOperationOutcome.NotFound => NotFound(), + BackupOperationOutcome.Busy => Conflict(result.Message), + _ => StatusCode(StatusCodes.Status500InternalServerError, result.Message), + }; + } +} diff --git a/Club12-Backend/API/Controllers/BlogPostController.cs b/Club12-Backend/API/Controllers/BlogPostController.cs new file mode 100644 index 0000000..3192cb7 --- /dev/null +++ b/Club12-Backend/API/Controllers/BlogPostController.cs @@ -0,0 +1,191 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.BlogPosts.Request; +using Application.DTOs.BlogPosts.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Storage; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Threading.Tasks; + +namespace API.Controllers; + + +/// +/// Manages blog posts; reads are public while writes require Admin or Owner, with individual actions opting back out via AllowAnonymous where the whole club should be able to read. +/// +/// The blog post service. +/// The Supabase helper for storage operations. +/// The AutoMapper instance. +[Route("api/blogposts/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class BlogPostController( + IBlogPostService blogPostService, + SupabaseHelper supabaseHelper, + IMapper mapper + ) : ControllerBase +{ + /// + /// Creates a blog post from multipart form data, validating and uploading an included photo file to storage before the post is persisted. + /// + /// The blog post creation request object containing the post details. + /// The created blog post response with details of the new blog post. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(BlogPostResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateBlogPost([FromForm] CreateBlogPostRequest blogPostRequest) + { + if (blogPostRequest.PhotoFile is not null && !blogPostRequest.PhotoFile.IsValidImageFile()) + { + return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile); + } + + string? photoUrl = blogPostRequest.PhotoFile is null + ? null + : await supabaseHelper.UploadImageAsync( + blogPostRequest.PhotoFile.OpenReadStream(), + blogPostRequest.PhotoFile.FileName); + + BlogPost blogPost = mapper.Map(blogPostRequest); + blogPost.PhotoUrl = photoUrl; + + BlogPost createdBlogPost = await blogPostService.CreateBlogPostAsync(blogPost); + BlogPostResponse blogPostResponse = mapper.Map(createdBlogPost); + + return new ObjectResult(blogPostResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Updates a blog post resolved by id or by its slug, the same lookup the GET endpoint uses, including unpublished drafts so a draft can be edited before it's published. + /// + /// The GUID id or slug of the blog post to update. + /// The blog post request with updated content. + /// Returns 200 OK with the updated blog post response if the update was successful, or 400 Bad Request if the blog post was not found. + [HttpPut("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateBlogPost(string idOrSlug, [FromForm] UpdateBlogPostRequest blogPostRequest) + { + // Resolves by id or slug, matching the GET, so the admin can save a post the same way it was opened instead of forcing a GUID URL. + // includeUnpublished is true so a draft can still be edited. + BlogPost? existingPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished: true); + + if (existingPost is null) + { + return this.NotFoundProblem(nameof(BlogPost), idOrSlug); + } + + mapper.Map(blogPostRequest, existingPost); + await blogPostService.UpdateBlogPostAsync(existingPost); + + BlogPostResponse blogPostResponse = mapper.Map(existingPost); + return Ok(blogPostResponse); + } + + /// + /// Replaces a blog post's photo, validating the new file as an image and uploading it to storage before updating the post's PhotoUrl. + /// + /// The GUID id or slug of the blog post to update the photo. + /// The update blog post photo request. + /// Returns 200 OK if the photo was successfully updated. + [HttpPut("{idOrSlug}/photo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpdateBlogPostPhoto(string idOrSlug, [FromForm] UpdateBlogPostPhotoRequest photoRequest) + { + if (!photoRequest.PhotoFile.IsValidImageFile()) + { + return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile); + } + + BlogPost? blogPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished: true); + if (blogPost is null) + { + return this.NotFoundProblem(nameof(BlogPost), idOrSlug); + } + + blogPost.PhotoUrl = await supabaseHelper.UploadImageAsync( + photoRequest.PhotoFile.OpenReadStream(), + photoRequest.PhotoFile.FileName); + + await blogPostService.UpdateBlogPostAsync(blogPost); + return Ok(); + } + + /// + /// Retrieves a blog post by id or slug; Admin or Owner can also resolve unpublished drafts, and a public non-draft read increments the view counter while an Admin/Owner preview does not. + /// + /// Blog post identifier as a GUID, or slug. + /// The blog post with the specified id or slug. + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetBlogPostById(string idOrSlug) + { + // Only Admin/Owner may resolve drafts; anonymous or public callers get 404 for an unpublished post. + bool includeUnpublished = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Owner); + BlogPost? blogPost = await blogPostService.GetBlogPostByIdOrSlugAsync(idOrSlug, includeUnpublished); + + if (blogPost is null) + { + return this.NotFoundProblem(nameof(BlogPost), idOrSlug); + } + + // The view counter reflects genuine public reads only, since an Admin/Owner opening the post to preview or edit it must not inflate the count. + if (!includeUnpublished) + { + blogPost.Views++; + await blogPostService.UpdateBlogPostAsync(blogPost); + } + + BlogPostResponse blogPostResponse = mapper.Map(blogPost); + return Ok(blogPostResponse); + } + + /// + /// Deletes a blog post by its id. + /// + /// The id of the blog post to delete. + /// Returns 204 No Content if the blog post was successfully deleted, or 400 Bad Request if not found. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task DeleteBlogPostById(Guid id) + { + await blogPostService.DeleteBlogPostAsync(id); + return NoContent(); + } + + /// + /// Retrieves filtered, paginated blog posts; public and anonymous callers only see published posts while Admin or Owner also see drafts. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered blog posts. + [AllowAnonymous] + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredBlogPosts([FromQuery] GetBlogPostsFilteredRequest filterRequest) + { + // Admin/Owner also see drafts in this listing; public callers only see published posts. + bool includeUnpublished = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Owner); + PaginatedResponse paginatedPosts = await blogPostService.GetAllBlogPostsAsync(filterRequest, includeUnpublished); + PaginatedResponse response = mapper.Map>(paginatedPosts); + + return Ok(response); + } +} \ No newline at end of file diff --git a/Club12-Backend/API/Controllers/ChampionController.cs b/Club12-Backend/API/Controllers/ChampionController.cs new file mode 100644 index 0000000..a4ee955 --- /dev/null +++ b/Club12-Backend/API/Controllers/ChampionController.cs @@ -0,0 +1,42 @@ +using API.Utils; + +using Application.DTOs.Champions.Response; +using Application.Interfaces.Services; + +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Exposes the club's champions history across finished tournaments; reads are public. +/// +/// The champion service. +[Route("api/champions")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class ChampionController(IChampionService championService) : ControllerBase +{ + /// + /// Lists each division's first-place champion across every finished tournament, optionally scoped to one season, omitting divisions without a decided champion. + /// + /// Optional season filter as a GUID; when omitted, spans all seasons. + /// + /// Returns 200 OK with the champions history. + /// + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task>> GetChampionsHistory([FromQuery] Guid? seasonId) + { + List history = await championService.GetChampionsHistoryAsync(seasonId); + return Ok(history); + } +} diff --git a/Club12-Backend/API/Controllers/ClubController.cs b/Club12-Backend/API/Controllers/ClubController.cs new file mode 100644 index 0000000..cd1428c --- /dev/null +++ b/Club12-Backend/API/Controllers/ClubController.cs @@ -0,0 +1,154 @@ +using API.Utils; + +using Application.DTOs.Club.Request; +using Application.DTOs.Club.Response; +using Application.Interfaces.Services; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages the stable cross-season club identity; reading a club's history is public while the idempotent backfill is a staff maintenance action. +/// +/// The club service. +[Route("api/clubs/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class ClubController(IClubService clubService) : ControllerBase +{ + /// + /// Retrieves a club and its trajectory across seasons, including the per-season teams that belong to it and the tournaments each was registered in. + /// + /// The club's GUID id or its slug. + /// + /// Returns 200 OK with the club history when found. + /// Returns 404 Not Found when no club matches. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetClubHistory(string idOrSlug) + { + ClubHistoryResponse? history = await clubService.GetClubHistoryAsync(idOrSlug); + + if (history is null) + { + return this.NotFoundProblem(nameof(Club), idOrSlug); + } + + return Ok(history); + } + + /// + /// Idempotently links every unlinked team to a stable club, creating clubs as needed; safe to re-run since a second call reports zeros. + /// + /// Returns 200 OK with how many clubs were created and teams linked. + [HttpPost("backfill")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubBackfillResult))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> BackfillClubs() + { + ClubBackfillResult result = await clubService.BackfillClubsAsync(); + return Ok(result); + } + + /// + /// Retrieves every club's stable identity summary, used to populate the "link to parent club" picker. + /// + /// Returns 200 OK with every club, ordered by name. + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAllClubs() + { + IEnumerable clubs = await clubService.GetAllClubsAsync(); + return Ok(clubs); + } + + /// + /// Renames a club. The club's slug never changes, so its public URL stays stable. + /// + /// The club to rename. + /// The new display name. + /// + /// Returns 200 OK with the club's updated history when renamed. + /// Returns 404 Not Found when the club doesn't exist. + /// Returns 409 Conflict when the name is blank. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> RenameClub(Guid id, [FromBody] RenameClubRequest request) + { + ClubHistoryResponse history = await clubService.RenameClubAsync(id, request.Name); + return Ok(history); + } + + /// + /// Links a club as a squad of a parent institution club, so both are shown together as one institution. + /// + /// The squad club to link. + /// The parent institution club it becomes a squad of. + /// + /// Returns 200 OK with the club's updated history when linked. + /// Returns 404 Not Found when either club doesn't exist. + /// Returns 409 Conflict when the link would create a chain deeper than institution -> squads. + /// + [HttpPut("{id:guid}/parent")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> LinkClubParent(Guid id, [FromBody] LinkClubParentRequest request) + { + ClubHistoryResponse history = await clubService.LinkClubToParentAsync(id, request.ParentClubId); + return Ok(history); + } + + /// + /// Clears a club's parent institution link, if any. + /// + /// The squad club to unlink. + /// + /// Returns 200 OK with the club's updated history. + /// Returns 404 Not Found when the club doesn't exist. + /// + [HttpDelete("{id:guid}/parent")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClubHistoryResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UnlinkClubParent(Guid id) + { + ClubHistoryResponse history = await clubService.UnlinkClubParentAsync(id); + return Ok(history); + } + + /// + /// Deletes a club, blocking the delete while it still has teams or squad clubs linked to it. + /// + /// The club to delete. + /// + /// Returns 204 No Content when deleted. + /// Returns 404 Not Found when the club doesn't exist. + /// Returns 409 Conflict when it still has teams or squad clubs linked to it. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task DeleteClub(Guid id) + { + await clubService.DeleteClubAsync(id); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/DataMaintenanceController.cs b/Club12-Backend/API/Controllers/DataMaintenanceController.cs new file mode 100644 index 0000000..c80b4ef --- /dev/null +++ b/Club12-Backend/API/Controllers/DataMaintenanceController.cs @@ -0,0 +1,49 @@ +using Application.DTOs.DataMaintenance.Response; +using Application.Interfaces.Services; + +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System.Threading; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Admin-only tools for resetting tournament-domain data to a clean, realistic sample state, never touching identity. +/// +[Route("api/data-maintenance")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class DataMaintenanceController(IDataMaintenanceService dataMaintenanceService) : ControllerBase +{ + /// + /// Deletes every tournament-domain row. Identity is untouched. + /// + [HttpPost("wipe")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DataWipeResult))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Wipe(CancellationToken ct) + { + DataWipeResult result = await dataMaintenanceService.WipeSampleDataAsync(ct); + return Ok(result); + } + + /// + /// Seeds 2 complete sample tournaments, returning 409 if the database already has tournament data since it must be wiped first. + /// + [HttpPost("seed")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DataSeedResult))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Seed(CancellationToken ct) + { + DataSeedResult result = await dataMaintenanceService.SeedSampleDataAsync(ct); + return Ok(result); + } +} diff --git a/Club12-Backend/API/Controllers/DivisionController.cs b/Club12-Backend/API/Controllers/DivisionController.cs new file mode 100644 index 0000000..7b4d03d --- /dev/null +++ b/Club12-Backend/API/Controllers/DivisionController.cs @@ -0,0 +1,191 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.Divisions.Request; +using Application.DTOs.Divisions.Response; +using Application.Interfaces.Services; +using Application.Utils.Helper.Standings; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages divisions; reads are public while writes require Owner or Admin. +/// +/// The division service. +/// The AutoMapper instance. +[Route("api/divisions/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class DivisionController( + IDivisionService divisionService, + IMapper mapper + ) : ControllerBase +{ + + /// + /// Creates a new division. + /// + /// The division request. + /// The created division response. + /// Returns 201 Created with the division response if the creation was successful. + /// Returns 403 Forbidden if the user is not authenticated. + /// + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(DetailedDivisionResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateDivision(CreateDivisionRequest divisionRequest) + { + Division mappedDivision = mapper.Map(divisionRequest); + Division createdDivision = await divisionService.CreateDivisionAsync(mappedDivision); + DivisionResponse divisionResponse = mapper.Map(createdDivision); + + return CreatedAtAction(nameof(GetDivisionById), new { idOrSlug = divisionResponse.Id }, divisionResponse); + } + + /// + /// Retrieves a division by its id or its public slug. + /// + /// The GUID id or slug of the division to retrieve. + /// The division with the specified id or slug. + /// Returns 200 Ok with the division response if it was found. + /// Returns 404 Not Found if the division with the provided id or slug was not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}/detail")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedDivisionResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetDivisionById(string idOrSlug) + { + Division? division = await divisionService.GetSimpleDivisionByIdOrSlugAsync(idOrSlug); + + if (division is null) + { + return this.NotFoundProblem(nameof(Division), idOrSlug); + } + + DivisionResponse divisionResponse = mapper.Map(division); + await PopulateStandingsAsync(divisionResponse); + + return Ok(divisionResponse); + } + + /// + /// Deletes a division by its id. + /// + /// The id of the division to delete. + /// + /// Returns 200 Ok if the division was successfully deleted. + /// Returns 400 Bad Request if the division with the provided id was not found. + /// Returns 403 Forbidden if the user is not authenticated. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteDivisionById(Guid id) + { + + await divisionService.DeleteDivisionAsync(id); + return NoContent(); + + } + + /// + /// Updates a division by its id. + /// + /// The id of the division to update. + /// + /// The updated division information. If TournamentId is set, the + /// division, and everything under it, is moved to that tournament. + /// + /// + /// Returns 200 Ok with the updated division response if the update was successful. + /// Returns 404 Not Found if the division, or the target tournament when reassigning, was not found. + /// Returns 403 Forbidden if the user is not authenticated. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DivisionResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateDivisionById(Guid id, UpdateDivisionRequest divisionRequest) + { + Division? existingDivision = await divisionService.GetFullDivisionByIdAsync(id); + + if (existingDivision is null) + { + return this.NotFoundProblem(nameof(Division), id); + } + + mapper.Map(divisionRequest, existingDivision); + + if (divisionRequest.TournamentId is Guid tournamentId) + { + bool tournamentAssigned = await divisionService.TryAssignTournamentAsync(existingDivision, tournamentId); + if (!tournamentAssigned) + { + return this.NotFoundProblem(nameof(Tournament), tournamentId); + } + } + + await divisionService.UpdateDivisionAsync(existingDivision); + + DivisionResponse divisionResponse = mapper.Map(existingDivision); + return Ok(divisionResponse); + } + + /// + /// Retrieves filtered divisions with pagination. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered divisions. + [AllowAnonymous] + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredDivisions([FromQuery] GetDivisionsFilteredRequest filterRequest) + { + PaginatedResponse paginatedDivisions = await divisionService.GetAllDivisionsAsync(filterRequest); + + PaginatedResponse response = mapper.Map>(paginatedDivisions); + + // Each division's standings must be populated here too, since the divisions table's team counter reads Positions.Length, and leaving it null made every row show 0 teams even when the division was fully populated. + foreach (DivisionResponse divisionResponse in response.Items) + { + await PopulateStandingsAsync(divisionResponse); + } + + return Ok(response); + } + + /// + /// Fills a division response's standings from its Group stages, setting GroupStandings to one table per group and Positions to the pooled union across all groups so the team counter reflects every group's teams. + /// + private async Task PopulateStandingsAsync(DivisionResponse divisionResponse) + { + List groups = await divisionService.GetGroupStandingsByDivisionIdAsync(divisionResponse.Id); + + divisionResponse.GroupStandings = mapper.Map>(groups); + + List pooledPositions = [.. groups + .SelectMany(group => group.Positions) + .GroupBy(position => position.TeamId) + .Select(group => group.First())]; + + divisionResponse.Positions = mapper.Map>(pooledPositions); + } + +} diff --git a/Club12-Backend/API/Controllers/DivisionRosterController.cs b/Club12-Backend/API/Controllers/DivisionRosterController.cs new file mode 100644 index 0000000..74cef81 --- /dev/null +++ b/Club12-Backend/API/Controllers/DivisionRosterController.cs @@ -0,0 +1,110 @@ +using Application.DTOs.Divisions.Request; +using Application.DTOs.Stage.Response; +using Application.DTOs.Team.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages a division's team roster and its sub-group structure; every route requires Owner or Admin. +/// +/// Service for the division-level team roster and its sub-group structure. +/// AutoMapper instance for mapping between entities and DTOs. +[Route("api/divisions/{divisionId:guid}/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class DivisionRosterController( + IDivisionRosterService divisionRosterService, + IMapper mapper) : ControllerBase +{ + /// + /// Lists every team currently enrolled in the division, independent of any stage placement. + /// + [HttpGet("roster")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetRoster(Guid divisionId) + { + List roster = await divisionRosterService.GetRosterAsync(divisionId); + return Ok(mapper.Map>(roster)); + } + + /// + /// Enrolls one or more teams in the division's roster, returning the roster as it stands afterward. + /// + [HttpPost("roster")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task>> EnrollTeams(Guid divisionId, EnrollTeamsRequest request) + { + await divisionRosterService.EnrollTeamsAsync(divisionId, request.TeamIds); + List roster = await divisionRosterService.GetRosterAsync(divisionId); + return Ok(mapper.Map>(roster)); + } + + /// + /// Removes one or more teams from the division's roster, cascading to their current stage placements first. + /// + [HttpDelete("roster")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UnenrollTeams(Guid divisionId, UnenrollTeamsRequest request) + { + await divisionRosterService.UnenrollTeamsAsync(divisionId, request.TeamIds); + return NoContent(); + } + + /// + /// Replaces the division's sub-group stages with a new count, re-balancing the untouched roster across them. + /// + [HttpPost("sub-groups/rebuild")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task>> RebuildSubGroups(Guid divisionId, RebuildSubGroupsRequest request) + { + List stages = await divisionRosterService.RebuildSubGroupsAsync(divisionId, request.SubGroupCount); + return Ok(mapper.Map>(stages)); + } + + /// + /// Clears every current sub-group placement and re-deals the whole roster in a fresh balanced distribution. + /// + [HttpPost("roster/auto-distribute")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task AutoDistributeRoster(Guid divisionId) + { + await divisionRosterService.AutoDistributeRosterAsync(divisionId); + return NoContent(); + } + + /// + /// Manually moves one enrolled team from one sub-group to another, re-validating only the minimum sub-group size. + /// + [HttpPost("sub-groups/reassign")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task ReassignTeamToSubGroup(Guid divisionId, ReassignTeamToSubGroupRequest request) + { + await divisionRosterService.ReassignTeamToSubGroupAsync(request.TeamId, request.FromStageId, request.ToStageId); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/MaintenanceController.cs b/Club12-Backend/API/Controllers/MaintenanceController.cs new file mode 100644 index 0000000..43f1478 --- /dev/null +++ b/Club12-Backend/API/Controllers/MaintenanceController.cs @@ -0,0 +1,40 @@ +using Application.DTOs.Backup.Response; +using Application.Interfaces.Backup; + +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// Reports maintenance-mode status and lets an admin force-clear a stuck maintenance window. +/// +[Route("api/maintenance")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class MaintenanceController(IMaintenanceModeState maintenanceModeState) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MaintenanceStatusResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult GetStatus() + { + MaintenanceStatusResponse response = new( + maintenanceModeState.IsActive, maintenanceModeState.Reason, maintenanceModeState.EnteredAtUtc); + return Ok(response); + } + + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult Exit() + { + maintenanceModeState.Exit(); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/MatchController.cs b/Club12-Backend/API/Controllers/MatchController.cs new file mode 100644 index 0000000..3d8c614 --- /dev/null +++ b/Club12-Backend/API/Controllers/MatchController.cs @@ -0,0 +1,380 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; +using Application.DTOs.Match.Response; +using Application.DTOs.PlayerStatistic.Request; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages matches; reads are public but writes require Owner or Admin. +/// +/// The Match service. +/// The stage-team match service. +/// The playoff series service. +/// The player-statistic service. +/// The stage service, used for bracket-round advancement. +/// The AutoMapper instance. +[Route("api/matches/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class MatchController( + IMatchService matchService, + IStageTeamMatchService stageTeamMatchService, + IMatchSeriesService matchSeriesService, + IPlayerStatisticService playerStatisticService, + IStageService stageService, + IMapper mapper) : ControllerBase +{ + /// + /// Creates a new match. + /// + /// The match request DTO. + /// The created match response. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MinimalMatchResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateMatch(CreateMatchRequest matchRequest) + { + Match mappedMatch = mapper.Map(matchRequest); + + // A court cannot host two matches less than 2 hours apart. + if (mappedMatch.VenueId.HasValue + && await matchService.HasVenueScheduleConflictAsync( + mappedMatch.VenueId.Value, mappedMatch.MatchDate, Guid.Empty)) + { + return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict); + } + + Match createdMatch = await matchService.CreateMatchAsync(mappedMatch); + MinimalMatchResponse matchResponse = mapper.Map(createdMatch); + + return new ObjectResult(matchResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Generates automated matches for a given stage. + /// + /// The unique identifier of the stage for which matches will be generated. + /// + /// Returns a list of DetailedMatchResponse objects representing the generated matches. + /// + [HttpPost("generate/{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task GenerateMatches(Guid id) + { + List response = await matchService.CreateAutomatedMatchesAsync(stageId: id); + return Ok(mapper.Map>(response)); + } + + /// + /// Retrieves a match by its id with detailed values. + /// + /// The id of the match. + /// The match response DTO. + [AllowAnonymous] + [HttpGet("{id:guid}/detail")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMatchByIdWithScorers(Guid id) + { + Match? match = await matchService.GetMatchByIdWithScorersAsync(id); + + if (match is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + DetailedMatchResponse matchResponse = mapper.Map(match); + return Ok(matchResponse); + } + + /// + /// Retrieves a match by its id or its public slug. + /// + /// Match identifier as a GUID or slug. + /// The match response DTO. + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMatchById(string idOrSlug) + { + Match? match = await matchService.GetMatchByIdOrSlugAsync(idOrSlug); + + if (match is null) + { + return this.NotFoundProblem(nameof(Match), idOrSlug); + } + + DetailedMatchResponse matchResponse = mapper.Map(match); + return Ok(matchResponse); + } + + /// + /// Updates a match's scheduled date or venue, rejecting the change if the match already started or finished, its teams aren't assigned to the stage, or the new slot conflicts with another match. + /// + /// The id of the match to update. + /// The request containing the new match date and/or venue. + /// Returns 200 OK with the updated match, 400 Bad Request if the change is rejected, or 404 Not Found if no match matches the id. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateMatchDate(Guid id, UpdateMatchRequest updateRequest) + { + Match? existingMatch = await matchService.GetMatchByIdAsync(id); + + if (existingMatch is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + if (existingMatch.IsFinished || existingMatch.MatchDate <= DateTime.Now) + { + return this.BadRequestProblem(ErrorMessages.Match.CannotUpdateStartedOrFinished); + } + + + List teamsId = []; + + if (existingMatch.HomeTeamId.HasValue) + { + teamsId.Add(existingMatch.HomeTeamId.Value); + } + + if (existingMatch.VisitorTeamId.HasValue) + { + teamsId.Add(existingMatch.VisitorTeamId.Value); + } + + bool canUpdate = await stageTeamMatchService.AllTeamsAssignedToStage(stageId: existingMatch.StageId, TeamIds: [.. teamsId.Distinct()]); + + if (!canUpdate) + { + return this.BadRequestProblem(ErrorMessages.Match.TeamsNotAssignedToStage); + } + + mapper.Map(updateRequest, existingMatch); + + // A court cannot host two matches less than 2 hours apart. + if (existingMatch.VenueId.HasValue + && await matchService.HasVenueScheduleConflictAsync( + existingMatch.VenueId.Value, existingMatch.MatchDate, existingMatch.Id)) + { + return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict); + } + + await matchService.UpdateMatchAsync(existingMatch); + DetailedMatchResponse detailedMatch = mapper.Map(existingMatch); + return Ok(detailedMatch); + } + + /// + /// Retrieves a stage's matches grouped by matchday, using the round as the grouping key rather than the calendar date. + /// + /// The id of the stage whose fixture is requested. + /// The matches grouped and ordered by round. + [AllowAnonymous] + [HttpGet("stage/{stageId:guid}/by-round")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task>> GetStageMatchesByRound(Guid stageId) + { + List matches = await matchService.GetStageMatchesByRoundAsync(stageId); + + List rounds = [.. matches + .GroupBy(match => match.Round) + .OrderBy(group => group.Key ?? int.MaxValue) + .Select(group => new RoundMatchesResponse + { + Round = group.Key, + Matches = mapper.Map>(group.ToList()), + })]; + + return Ok(rounds); + } + + /// + /// Reprograms or suspends a match, marking it suspended and optionally moving it to a new date without changing its round or the rest of the fixture. + /// + /// The id of the match to suspend/reprogram. + /// The request with an optional new date. + /// The updated match response. + [HttpPut("{id:guid}/suspend")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SuspendMatch(Guid id, SuspendMatchRequest suspendRequest) + { + Match? existingMatch = await matchService.GetMatchByIdAsync(id); + + if (existingMatch is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + DateTime effectiveDate = suspendRequest.MatchDate ?? existingMatch.MatchDate; + + // A court cannot host two matches less than 2 hours apart. + if (existingMatch.VenueId.HasValue + && await matchService.HasVenueScheduleConflictAsync( + existingMatch.VenueId.Value, effectiveDate, existingMatch.Id)) + { + return this.BadRequestProblem(ErrorMessages.Match.VenueScheduleConflict); + } + + Match? updatedMatch = await matchService.SuspendMatchAsync(id, suspendRequest.MatchDate); + + if (updatedMatch is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + DetailedMatchResponse detailedMatch = mapper.Map(updatedMatch); + return Ok(detailedMatch); + } + + /// + /// Deletes a match by its id. + /// + /// The id of the match to delete. + /// Returns 204 No Content on success. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task DeleteMatchById(Guid id) + { + + await matchService.DeleteMatchAsync(id); + return NoContent(); + } + + /// + /// Retrieves filtered matches with pagination. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered matches. + [AllowAnonymous] + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredMatches([FromQuery] GetMatchesFilteredRequest filterRequest) + { + PaginatedResponse paginatedMatches = await matchService.GetAllMatchesAsync(filterRequest); + + PaginatedResponse response = mapper.Map>(paginatedMatches); + + return Ok(response); + } + + /// + /// Records a match's final score, which may decide a playoff series and auto-advance a bracket winner into the next round. + /// + /// The id of the match to update. + /// The request with updated scores. + /// Returns 200 OK with the updated match, or 404 Not Found if no match matches the id. + [HttpPut("{id:guid}/score")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateMatchScore(Guid id, UpdateMatchScoreRequest scoreRequest) + { + Match? updatedMatch = await matchService.LoadMatchResultAsync(id, scoreRequest.HomeScore, scoreRequest.VisitorScore); + if (updatedMatch is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + if (updatedMatch.SeriesId.HasValue) + { + await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value); + } + + // Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision. + await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId); + + DetailedMatchResponse detailedMatch = mapper.Map(updatedMatch); + return Ok(detailedMatch); + } + + /// + /// Finishes a match by loading both teams' scoring sheets in one operation, deriving the final score as the sum of each team's listed player points instead of accepting a typed score directly. + /// + /// The id of the match to finish. + /// Both teams' per-player points. + /// The finalized match. + [HttpPut("{id:guid}/result-from-sheets")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task LoadMatchResultFromSheets(Guid id, LoadMatchResultFromSheetsRequest request) + { + request.MatchId = id; + Match? updatedMatch = await playerStatisticService.LoadMatchResultFromSheetsAsync(request); + if (updatedMatch is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + if (updatedMatch.SeriesId.HasValue) + { + await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value); + } + + // Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision. + await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId); + + DetailedMatchResponse detailedMatch = mapper.Map(updatedMatch); + return Ok(detailedMatch); + } + + /// + /// Marks a match as a walkover, awarding the regulation default result to the present team. + /// + /// The id of the match to mark as a walkover. + /// The request identifying the present team. + /// The updated match response. + [HttpPut("{id:guid}/walkover")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DetailedMatchResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task LoadWalkOver(Guid id, LoadWalkOverRequest walkOverRequest) + { + Match? updatedMatch = await matchService.LoadWalkOverAsync(id, walkOverRequest.PresentTeamId, walkOverRequest.PresentTeamScore); + if (updatedMatch is null) + { + return this.NotFoundProblem(nameof(Match), id); + } + + if (updatedMatch.SeriesId.HasValue) + { + await matchSeriesService.RecalculateSeriesWinnerAsync(updatedMatch.SeriesId.Value); + } + + // Pushes a newly-decided bracket slot's winner into the next round, a no-op for group-stage matches, an undecided mid-series game, or the Final; runs after the series recalculation above so it sees the up-to-date decision. + await stageService.TryAdvanceStageWinnerAsync(updatedMatch.StageId); + + DetailedMatchResponse detailedMatch = mapper.Map(updatedMatch); + return Ok(detailedMatch); + } +} diff --git a/Club12-Backend/API/Controllers/MatchSeriesController.cs b/Club12-Backend/API/Controllers/MatchSeriesController.cs new file mode 100644 index 0000000..4fddb8a --- /dev/null +++ b/Club12-Backend/API/Controllers/MatchSeriesController.cs @@ -0,0 +1,93 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.MatchSeries.Request; +using Application.DTOs.MatchSeries.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages best-of-N playoff series between two teams; reads are public but writes require Owner or Admin. +/// +/// Service for series business logic and persistence operations. +/// AutoMapper instance for mapping between entities and DTOs. +[Route("api/match-series/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class MatchSeriesController(IMatchSeriesService matchSeriesService, IMapper mapper) : ControllerBase +{ + /// + /// Creates a new best-of-N series between two teams at a stage. + /// + /// The series creation request DTO. + /// The created series response. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MatchSeriesResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateSeries(CreateMatchSeriesRequest request) + { + MatchSeries createdSeries = await matchSeriesService.CreateSeriesAsync(request.StageId, request.HomeTeamId, request.VisitorTeamId); + MatchSeriesResponse response = mapper.Map(createdSeries); + return CreatedAtAction(nameof(GetSeriesById), new { id = response.Id }, response); + } + + /// + /// Retrieves a series by its id. + /// + /// The id of the series. + /// The series entity, including its games. + [AllowAnonymous] + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MatchSeriesResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSeriesById(Guid id) + { + MatchSeries? series = await matchSeriesService.GetSeriesByIdAsync(id); + + return series is null ? (ActionResult) NotFound(ErrorMessages.MatchSeries.NotFoundById(id)) : (ActionResult) Ok(mapper.Map(series)); + } + + /// + /// Retrieves filtered and paginated series. + /// + /// The filter and pagination parameters. + /// Paginated list of series. + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + public async Task>> GetFilteredSeries([FromQuery] GetMatchSeriesFilteredRequest filterRequest) + { + PaginatedResponse paginatedSeries = await matchSeriesService.GetAllSeriesAsync(filterRequest); + PaginatedResponse response = mapper.Map>(paginatedSeries); + return Ok(response); + } + + /// + /// Schedules the next game of an existing series. + /// + /// The id of the series to add a game to. + /// The game scheduling request. + /// The created game. + [HttpPost("{id:guid}/games")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AddGameToSeries(Guid id, AddGameToSeriesRequest request) + { + Match game = await matchSeriesService.AddGameToSeriesAsync(id, request.MatchDate, request.VenueId); + return CreatedAtAction(nameof(GetSeriesById), new { id }, mapper.Map(game)); + } +} diff --git a/Club12-Backend/API/Controllers/MedicalRecordController.cs b/Club12-Backend/API/Controllers/MedicalRecordController.cs new file mode 100644 index 0000000..0466756 --- /dev/null +++ b/Club12-Backend/API/Controllers/MedicalRecordController.cs @@ -0,0 +1,145 @@ +using API.Utils; + +using Application.DTOs.MedicalRecord.Request; +using Application.DTOs.MedicalRecord.Response; +using Application.Interfaces.Services; +using Application.Interfaces.Storage; +using Application.Utils.Constants; + +using Domain.Constants; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages player medical records and per-season eligibility, scoped to a player, team, and tournament rather than to the player globally. +/// +/// The medical-record service. +/// The medical-record file storage boundary. +[Route("api/medical-records/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class MedicalRecordController( + IMedicalRecordService medicalRecordService, + IMedicalRecordStorage medicalRecordStorage) : ControllerBase +{ + /// + /// Uploads a player's medical-record PDF for a team and tournament, starting the record in Pending status until it is approved. + /// + /// The player, team, tournament, and PDF file. + /// The resulting medical-record state, with status Pending. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(MedicalRecordResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> UploadMedicalRecord([FromForm] UploadMedicalRecordRequest request) + { + if (!request.File.IsValidPdfFile()) + { + return this.BadRequestProblem(ErrorMessages.MedicalRecord.InvalidPdfFile); + } + + // Rejects the upload before touching storage when the ficha is already Approved, since an approved record is view or download only. + MedicalRecordResponse? current = await medicalRecordService.GetAsync( + request.PlayerId, request.TeamId, request.TournamentId); + + if (current?.Status == MedicalRecordStatus.Approved) + { + return Conflict(ErrorMessages.MedicalRecord.AlreadyApproved); + } + + string fileReference = await medicalRecordStorage.StoreAsync( + request.TeamId, + request.PlayerId, + request.File.FileName, + request.File.OpenReadStream()); + + MedicalRecordResponse response = await medicalRecordService.RecordUploadAsync( + request.PlayerId, request.TeamId, request.TournamentId, + fileReference, request.File.FileName, GetActor()); + + return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Approves or rejects a player's medical record for a team and tournament, where approval grants eligibility and rejection can include an optional reason. + /// + /// The player, team, tournament, decision, and reason. + /// The resulting medical-record state. + [HttpPut("review")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MedicalRecordResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> ReviewMedicalRecord(ReviewMedicalRecordRequest request) + { + MedicalRecordResponse response = await medicalRecordService.ReviewAsync( + request.PlayerId, request.TeamId, request.TournamentId, + request.Approve, request.Reason, GetActor()); + + return Ok(response); + } + + /// + /// Returns the current medical-record and eligibility state of a player's season registration. + /// + /// The player. + /// The team the player is registered to. + /// The tournament, i.e. the season. + /// The medical-record state, or 404 when none exists. + [HttpGet()] + [Authorize(Roles = Roles.AdminOrOwner)] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MedicalRecordResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMedicalRecord( + [FromQuery] Guid playerId, [FromQuery] Guid teamId, [FromQuery] Guid tournamentId) + { + MedicalRecordResponse? response = await medicalRecordService.GetAsync(playerId, teamId, tournamentId); + + return response is null + ? this.NotFoundProblem(nameof(MedicalRecordResponse), $"{playerId}/{teamId}/{tournamentId}") + : Ok(response); + } + + /// + /// Downloads a player's medical-record PDF by streaming it through the API rather than via a public URL, since the storage area is private. + /// + /// The player. + /// The team the player is registered to. + /// The tournament, i.e. the season. + /// The stored PDF, or 404 when no record/file exists. + [HttpGet("download")] + [Authorize(Roles = Roles.AdminOrOwner)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadMedicalRecord( + [FromQuery] Guid playerId, [FromQuery] Guid teamId, [FromQuery] Guid tournamentId) + { + MedicalRecordResponse? record = await medicalRecordService.GetAsync(playerId, teamId, tournamentId); + + if (record?.FileUrl is null) + { + return this.NotFoundProblem(nameof(MedicalRecordResponse), $"{playerId}/{teamId}/{tournamentId}"); + } + + byte[] content = await medicalRecordStorage.DownloadAsync(record.FileUrl); + string fileName = string.IsNullOrWhiteSpace(record.FileName) ? "medical-record.pdf" : record.FileName; + + return File(content, "application/pdf", fileName); + } + + private string GetActor() + { + return User?.Identity?.Name + ?? User?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? AuditConstants.SystemUser; + } +} diff --git a/Club12-Backend/API/Controllers/PlayerController.cs b/Club12-Backend/API/Controllers/PlayerController.cs new file mode 100644 index 0000000..990c607 --- /dev/null +++ b/Club12-Backend/API/Controllers/PlayerController.cs @@ -0,0 +1,249 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.Player.Request; +using Application.DTOs.Player.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages players; public reads return minimal data, while full details and every write require staff roles. +/// +/// The Player service. +/// The Team service. +/// The AutoMapper instance. +[Route("api/players/")] +[ApiController] +public class PlayerController( + IPlayerService playerService, + ITeamService teamService, + IMapper mapper + ) : ControllerBase +{ + /// + /// Creates a player under the specified team, returning the full admin-facing player response rather than the trimmed public shape. + /// + /// The player request. + /// The created Player response. + /// Returns 201 Created with the Player response if the creation was successful. + /// Returns 400 Bad Request if the team was not found or is not linked to a tournament. + /// Returns 403 Forbidden if the user lacks the Admin or Owner role. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(AdminPlayerResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreatePlayerAsync(CreatePlayerRequest playerRequest) + { + Guid teamId = playerRequest.TeamId; + Team? existingTeam = await teamService.GetTeamByIdAsync(teamId); + + if (existingTeam is null) + { + return this.BadRequestProblem(ErrorMessages.Team.NotFound(teamId)); + } + + if (existingTeam.TournamentId is null) + { + return this.BadRequestProblem(ErrorMessages.Team.NotInTournament(teamId)); + } + + Player mappedPlayer = mapper.Map(playerRequest); + Player createdPlayer = await playerService.CreatePlayerAsync(mappedPlayer, existingTeam.TournamentId.Value); + AdminPlayerResponse playerResponse = mapper.Map(createdPlayer); + return CreatedAtRoute("GetPlayerById", new { idOrSlug = createdPlayer.Id }, playerResponse); + } + + /// + /// Registers a player onto a team's roster for a season, enforcing one-team-per-tournament, the roster-size cap, and unique dorsal numbers. + /// + /// The player to register. + /// The team, tournament and optional dorsal. + /// + /// Returns 200 OK with the registration outcome. + /// Returns 409 Conflict if a roster invariant is violated. + /// Returns 403 Forbidden if the user is not authorized. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpPost("{playerId:guid}/registration")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerRegistrationResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> RegisterPlayerToTeam( + Guid playerId, [FromBody] RegisterPlayerToTeamRequest request) + { + PlayerTeamRegistration registration = await playerService.RegisterPlayerToTeamAsync( + playerId, request.TeamId, request.TournamentId, request.JerseyNumber); + + return Ok(mapper.Map(registration)); + } + + /// + /// Retrieves a player by its id or its public slug. + /// + /// The GUID id or slug of the player to retrieve. + /// The Player with the specified id or slug. + /// Returns 200 OK with the Player response if it was found. + /// Returns 404 Not Found if the Player with the provided id or slug was not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}", Name = "GetPlayerById")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PublicPlayerResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlayerByIdAsync(string idOrSlug) + { + Player? player = await playerService.GetPlayerByIdOrSlugAsync(idOrSlug); + + if (player is null) + { + return this.NotFoundProblem(nameof(Player), idOrSlug); + } + + PublicPlayerResponse playerResponse = mapper.Map(player); + return Ok(playerResponse); + } + + /// + /// Retrieves a player by its id or its slug, with complete data for the admin view. + /// + /// The GUID id or exact slug of the player to retrieve. + /// The player's complete admin data. + /// Returns 200 OK with the player's admin data if the player was found. + /// Returns 404 Not Found if no player matches the provided id or slug. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpGet("admin/{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AdminPlayerResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlayerByIdCompleteDataAsync(string idOrSlug) + { + Player? player = await playerService.GetPlayerByIdOrSlugAsync(idOrSlug); + + if (player is null) + { + return this.NotFoundProblem(nameof(Player), idOrSlug); + } + + AdminPlayerResponse playerResponse = mapper.Map(player); + return Ok(playerResponse); + } + + /// + /// Updates a player by its id. + /// + /// The id of the player to update. + /// The player request. + /// + /// Returns 200 OK with the updated player if the update was successful. + /// Returns 400 Bad Request if the player's team was not found or is not linked to a tournament. + /// Returns 404 Not Found if no player matches the provided id. + /// Returns 403 Forbidden if the user lacks the Admin or Owner role. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdatePlayerAsync(Guid id, UpdatePlayerRequest playerRequest) + { + Player? existingPlayer = await playerService.GetPlayerByIdAsync(id); + + if (existingPlayer is null) + { + return this.NotFoundProblem(nameof(Player), id); + } + + mapper.Map(playerRequest, existingPlayer); + + Team? currentTeam = await teamService.GetTeamByIdAsync(existingPlayer.TeamId); + + if (currentTeam is null) + { + return this.BadRequestProblem(ErrorMessages.Team.NotFound(existingPlayer.TeamId)); + } + + if (currentTeam.TournamentId is null) + { + return this.BadRequestProblem(ErrorMessages.Team.NotInTournament(existingPlayer.TeamId)); + } + + await playerService.UpdatePlayerAsync(existingPlayer, currentTeam.TournamentId.Value); + + return Ok(existingPlayer); + } + + /// + /// Deletes a player by its id. + /// + /// The id of the Player to delete. + /// + /// Returns 204 No Content if the Player was successfully deleted. + /// Returns 403 Forbidden if the user lacks the Admin or Owner role. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task DeletePlayerByIdAsync(Guid id) + { + await playerService.DeletePlayerAsync(id); + return NoContent(); + } + + /// + /// Retrieves filtered players with pagination. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered players. + [AllowAnonymous] + [HttpGet("public")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredPlayersAsync([FromQuery] PlayerFilterRequestBase filterRequest) + { + PaginatedResponse paginatedPlayers = await playerService.GetAllPlayersAsync(filterRequest); + + PaginatedResponse response = mapper.Map>(paginatedPlayers); + + return Ok(response); + } + + /// + /// Retrieves filtered players with pagination and detailed information, for admin use only. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered players. + /// Returns a paginated list of filtered players + /// Returns 400 if there is an invalid filter parameter or the filter results in no data + /// Returns 403 if the user does not have the required admin permissions + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpGet("")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetFilteredPlayersPrivateAsync([FromQuery] GetPlayersFilteredRequest filterRequest) + { + PaginatedResponse paginatedPlayers = await playerService.GetAllPlayersAsync(filterRequest); + + PaginatedResponse response = mapper.Map>(paginatedPlayers); + + return Ok(response); + } +} diff --git a/Club12-Backend/API/Controllers/PlayerSanctionController.cs b/Club12-Backend/API/Controllers/PlayerSanctionController.cs new file mode 100644 index 0000000..b40ad79 --- /dev/null +++ b/Club12-Backend/API/Controllers/PlayerSanctionController.cs @@ -0,0 +1,235 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.PlayerSanction.Request; +using Application.DTOs.PlayerSanction.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages player sanctions; reads are public but writes require Owner or Admin. +/// +/// The Player Sanction service. +/// The AutoMapper instance. +[Route("api/player-sanctions/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class PlayerSanctionController(IPlayerSanctionService playerSanctionService, IMapper mapper) : ControllerBase +{ + /// + /// Creates a sanction against a player, team, or staff member, enriched with the subject's display name and fechas-based status. + /// + /// The player sanction request DTO. + /// The created player sanction response. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PlayerSanctionResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreatePlayerSanction(CreatePlayerSanctionRequest playerSanctionRequest) + { + PlayerSanction mappedSanction = mapper.Map(playerSanctionRequest); + PlayerSanction createdSanction = await playerSanctionService.CreatePlayerSanctionAsync(mappedSanction); + PlayerSanctionResponse sanctionResponse = await ToResponseAsync(createdSanction); + + return CreatedAtAction(nameof(GetPlayerSanctionById), new { idOrSlug = sanctionResponse.Id }, sanctionResponse); + } + + /// + /// Retrieves a player sanction by its id or its slug. + /// + /// Player sanction identifier as a GUID or slug. + /// The player sanction response DTO. + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlayerSanctionById(string idOrSlug) + { + PlayerSanction? sanction = await playerSanctionService.GetPlayerSanctionByIdOrSlugAsync(idOrSlug); + + if (sanction is null) + { + return this.NotFoundProblem(nameof(PlayerSanction), idOrSlug); + } + + PlayerSanctionResponse sanctionResponse = await ToResponseAsync(sanction); + return Ok(sanctionResponse); + } + + /// + /// Retrieves a paginated list of player sanctions filtered by the specified criteria. + /// + /// The filtering parameters for player sanctions. + /// + /// Returns a PaginatedResponse{PlayerSanctionResponse} containing the filtered sanctions. + /// Possible HTTP responses: + /// + /// 200 OK - The filtered sanctions were retrieved successfully. + /// 400 Bad Request - The request parameters were invalid. + /// + /// + [AllowAnonymous] + [HttpGet("find")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetFilteredPlayersPrivateAsync([FromQuery] GetPlayerSanctionsFilteredRequest filterRequest) + { + PaginatedResponse paginatedPlayerSanctions = await playerSanctionService.GetPlayerSanctionsAsync(filterRequest); + + PaginatedResponse response = mapper.Map>(paginatedPlayerSanctions); + + List enrichedItems = []; + foreach (PlayerSanction sanction in paginatedPlayerSanctions.Items) + { + enrichedItems.Add(await ToResponseAsync(sanction)); + } + response.Items = enrichedItems; + + return Ok(response); + } + /// + /// Updates a player sanction's fields and re-enriches the response with the subject's display name and fechas-based status. + /// + /// The id of the sanction to update. + /// The request with updated sanction data. + /// Returns 200 OK with the updated sanction, or 404 Not Found if no sanction matches the id. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePlayerSanction(Guid id, UpdatePlayerSanctionRequest updateRequest) + { + PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id); + + if (existingSanction is null) + { + return this.NotFoundProblem(nameof(PlayerSanction), id); + } + + mapper.Map(updateRequest, existingSanction); + await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction); + return Ok(await ToResponseAsync(existingSanction)); + } + + /// + /// Submits an appeal against a sanction, moving it into pending review. + /// + /// The id of the sanction being appealed. + /// The appeal reason. + /// The updated sanction response. Returns 400 Bad Request if an appeal is already pending for this sanction. + [HttpPut("{id:guid}/appeal")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AppealPlayerSanction( + Guid id, AppealPlayerSanctionRequest appealRequest) + { + PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id); + + if (existingSanction is null) + { + return this.NotFoundProblem(nameof(PlayerSanction), id); + } + + if (existingSanction.AppealStatus == SanctionAppealStatus.Pending) + { + return this.BadRequestProblem(ErrorMessages.PlayerSanction.AppealAlreadyPending); + } + + existingSanction.AppealStatus = SanctionAppealStatus.Pending; + existingSanction.AppealReason = appealRequest.Reason; + existingSanction.AppealDate = DateTime.UtcNow; + existingSanction.AppealResolution = null; + existingSanction.AppealResolvedDate = null; + + await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction); + return Ok(await ToResponseAsync(existingSanction)); + } + + /// + /// Resolves a sanction's pending appeal as accepted or rejected, where accepting lifts the sanction immediately without deleting the sanction record. + /// + /// The id of the sanction whose appeal is resolved. + /// The accepted or rejected decision and resolution notes. + /// The updated sanction response. Returns 400 Bad Request if the sanction has no pending appeal to resolve. + [HttpPut("{id:guid}/appeal/resolve")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerSanctionResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ResolvePlayerSanctionAppeal( + Guid id, ResolveAppealRequest resolveRequest) + { + PlayerSanction? existingSanction = await playerSanctionService.GetPlayerSanctionByIdAsync(id); + + if (existingSanction is null) + { + return this.NotFoundProblem(nameof(PlayerSanction), id); + } + + if (existingSanction.AppealStatus != SanctionAppealStatus.Pending) + { + return this.BadRequestProblem(ErrorMessages.PlayerSanction.NoPendingAppealToResolve); + } + + existingSanction.AppealStatus = resolveRequest.Accepted + ? SanctionAppealStatus.Accepted + : SanctionAppealStatus.Rejected; + existingSanction.AppealResolution = resolveRequest.Resolution; + existingSanction.AppealResolvedDate = DateTime.UtcNow; + + await playerSanctionService.UpdatePlayerSanctionAsync(existingSanction); + return Ok(await ToResponseAsync(existingSanction)); + } + + /// + /// Deletes a player sanction by its id. + /// + /// The id of the player sanction to delete. + /// Returns 204 No Content on success. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task DeletePlayerSanctionById(Guid id) + { + await playerSanctionService.DeletePlayerSanctionAsync(id); + return NoContent(); + } + + /// + /// Maps a sanction to its response and enriches it with the fechas-based remaining/active status, labelled in fechas rather than calendar days. + /// + private async Task ToResponseAsync(PlayerSanction sanction) + { + PlayerSanctionResponse response = mapper.Map(sanction); + + // Resolves who the sanction targets so the list or detail always shows the subject, regardless of which navigations the query happened to load. + (string? playerFullName, string? teamName, string? staffName) = + await playerSanctionService.ResolveSubjectAsync(sanction); + response.PlayerFullName = playerFullName; + response.TeamName = teamName; + response.StaffName = staffName; + + int? fechasRemaining = await playerSanctionService.GetFechasRemainingAsync(sanction); + response.FechasRemaining = fechasRemaining; + response.IsActive = fechasRemaining.HasValue + ? fechasRemaining.Value > 0 + : sanction.Duration > 0; + + return response; + } +} diff --git a/Club12-Backend/API/Controllers/PlayerStatisticController.cs b/Club12-Backend/API/Controllers/PlayerStatisticController.cs new file mode 100644 index 0000000..62e22da --- /dev/null +++ b/Club12-Backend/API/Controllers/PlayerStatisticController.cs @@ -0,0 +1,151 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.PlayerStatistic.Request; +using Application.DTOs.PlayerStatistic.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages player statistics; reads are public but writes require Owner or Admin. +/// +/// The Player Statistic service. +/// The Auto_mapper instance. +[Route("api/player-statistics/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class PlayerStatisticController(IPlayerStatisticService playerStatisticService, IMapper mapper) : ControllerBase +{ + + /// + /// Records a single player-statistic entry directly, without the roster, eligibility, or score-total validation used by the match-sheet endpoint. + /// + /// The player statistic request DTO. + /// The created player statistic response. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PlayerStatisticResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreatePlayerStatistic(CreatePlayerStatisticRequest playerStatisticRequest) + { + PlayerStatistic mappedStatistic = mapper.Map(playerStatisticRequest); + PlayerStatistic createdStatistic = await playerStatisticService.CreatePlayerStatisticAsync(mappedStatistic); + PlayerStatisticResponse statisticResponse = mapper.Map(createdStatistic); + + return new ObjectResult(statisticResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Loads a whole team's scoring sheet for a match, validating that per-player points sum to the team's final score and every player is rostered and eligible. + /// + /// The match, team, and per-player points. + /// The persisted Points statistics for the team in this match. + [HttpPost("match-sheet")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task LoadMatchSheet(LoadMatchSheetRequest request) + { + List created = await playerStatisticService.LoadTeamMatchSheetAsync(request); + + List response = mapper.Map>(created); + + return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Retrieves a paginated, filtered list of player statistics. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered player statistics. + [AllowAnonymous] + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredPlayerStatistics( + [FromQuery] GetPlayerStatisticsFilteredRequest filterRequest) + { + PaginatedResponse paginatedStatistics = + await playerStatisticService.GetPlayerStatisticsAsync(filterRequest); + + PaginatedResponse response = + mapper.Map>(paginatedStatistics); + + return Ok(response); + } + + /// + /// Retrieves a player statistic by its id. + /// + /// The id of the player statistic. + /// The player statistic response DTO. + [AllowAnonymous] + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerStatisticResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlayerStatisticById(Guid id) + { + PlayerStatistic? statistic = await playerStatisticService.GetPlayerStatisticByIdAsync(id); + + if (statistic is null) + { + return this.NotFoundProblem(nameof(PlayerStatistic), id); + } + + PlayerStatisticResponse statisticResponse = mapper.Map(statistic); + return Ok(statisticResponse); + } + + /// + /// Updates a player statistic by its id. + /// + /// The id of the statistic to update. + /// The request with updated statistics. + /// Returns 204 No Content on success, or 404 Not Found if no statistic matches the id. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePlayerStatistic(Guid id, UpdatePlayerStatisticRequest updateRequest) + { + PlayerStatistic? existingStatistic = await playerStatisticService.GetPlayerStatisticByIdAsync(id); + + if (existingStatistic is null) + { + return this.NotFoundProblem(nameof(PlayerStatistic), id); + } + + mapper.Map(updateRequest, existingStatistic); + await playerStatisticService.UpdatePlayerStatisticAsync(existingStatistic); + + return NoContent(); + } + + /// + /// Deletes a player statistic by its id. + /// + /// The id of the player statistic to delete. + /// Returns 204 No Content on success. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task DeletePlayerStatisticById(Guid id) + { + await playerStatisticService.DeletePlayerStatisticAsync(id); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/PointDeductionController.cs b/Club12-Backend/API/Controllers/PointDeductionController.cs new file mode 100644 index 0000000..82400eb --- /dev/null +++ b/Club12-Backend/API/Controllers/PointDeductionController.cs @@ -0,0 +1,102 @@ +using API.Utils; + +using Application.DTOs.PointDeductions.Request; +using Application.DTOs.PointDeductions.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages disciplinary point deductions applied to teams within a division; creating and deleting require Admin or Owner while listing is public. +/// +/// The point-deduction service. +/// The division service, used to validate the division. +/// The team service, used to validate the team. +/// The AutoMapper instance. +[Route("api/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class PointDeductionController( + ITeamPointDeductionService deductionService, + IDivisionService divisionService, + ITeamService teamService, + IMapper mapper) : ControllerBase +{ + /// + /// Applies a point deduction to a team in a division. + /// + /// The division whose standings the penalty affects. + /// The deduction, including team, points, and reason. + /// + /// 201 Created with the deduction; 404 Not Found when the division or + /// team does not exist; 403 Forbidden for non-staff callers. + /// + [HttpPost("divisions/{divisionId:guid}/point-deductions")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(PointDeductionResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreatePointDeduction( + Guid divisionId, CreatePointDeductionRequest request) + { + Division? division = await divisionService.GetSimpleDivisionByIdAsync(divisionId); + if (division is null) + { + return this.NotFoundProblem(nameof(Division), divisionId); + } + + Team? team = await teamService.GetTeamByIdAsync(request.TeamId); + if (team is null) + { + return this.NotFoundProblem(nameof(Team), request.TeamId); + } + + TeamPointDeduction deduction = mapper.Map(request); + deduction.DivisionId = divisionId; + + TeamPointDeduction created = await deductionService.CreateAsync(deduction); + PointDeductionResponse response = mapper.Map(created); + + return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Lists every point deduction applied in a division, public so standings can show each penalty. + /// + /// The division whose deductions to list. + /// 200 OK with the deductions, newest first. + [AllowAnonymous] + [HttpGet("divisions/{divisionId:guid}/point-deductions")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task>> GetPointDeductions(Guid divisionId) + { + List deductions = await deductionService.GetByDivisionIdAsync(divisionId); + return Ok(mapper.Map>(deductions)); + } + + /// + /// Removes a point deduction by its id. + /// + /// The id of the deduction to remove. + /// 204 No Content; 403 Forbidden for non-staff callers. + [HttpDelete("point-deductions/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeletePointDeduction(Guid id) + { + await deductionService.DeleteAsync(id); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/RosterController.cs b/Club12-Backend/API/Controllers/RosterController.cs new file mode 100644 index 0000000..159da47 --- /dev/null +++ b/Club12-Backend/API/Controllers/RosterController.cs @@ -0,0 +1,47 @@ +using Application.DTOs.Roster.Request; +using Application.DTOs.Roster.Response; +using Application.Interfaces.Services; + +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Controller for team roster operations. Writes require Owner or Admin. +/// +/// The service that clones rosters across seasons. +[Route("api/teams/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class RosterController( + IRosterCopyService rosterCopyService + ) : ControllerBase +{ + /// + /// Clones a roster from a source team's season into this team's target season, creating a registration per player and skipping ones already registered. + /// + /// The target team to copy the roster into. + /// The source team and season and the target season. + /// + /// Returns 200 OK with how many registrations were created or skipped. + /// Returns 403 Forbidden if the user is not authorized. + /// + [Authorize(Roles = Roles.AdminOrOwner)] + [HttpPost("{id:guid}/roster/copy")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RosterCopyResult))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CopyRoster(Guid id, [FromBody] CopyRosterRequest request) + { + RosterCopyResult result = await rosterCopyService.CopyRosterAsync( + request.SourceTeamId, request.SourceTournamentId, id, request.TargetTournamentId); + + return Ok(result); + } +} diff --git a/Club12-Backend/API/Controllers/ScorerController.cs b/Club12-Backend/API/Controllers/ScorerController.cs new file mode 100644 index 0000000..f93f88f --- /dev/null +++ b/Club12-Backend/API/Controllers/ScorerController.cs @@ -0,0 +1,47 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; +using Application.DTOs.Scorer.Request; +using Application.DTOs.Scorer.Response; +using Application.Interfaces.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Read-only scorer ranking aggregations, always public: goals by team and by player. +/// +[ApiController] +[Route("api/[controller]/")] +[AllowAnonymous] +public class ScorerController(IScorerService scorerService) : ControllerBase +{ + /// + /// Ranks teams by goals scored across the filtered matches, the Goleadores-by-team view. + /// + /// The match filtering and pagination parameters. + /// A paginated team scorer ranking. + [HttpGet("by-team")] + public async Task GetFilteredScorerByTeam([FromQuery] GetMatchesFilteredRequest filter) + { + PaginatedResponse response = await scorerService.GetAllScorersByTeamAsync(filter); + + return Ok(response); + } + + /// + /// Ranks individual players by goals scored, the Goleadores-by-player view. + /// + /// The scorer filtering and pagination parameters. + /// A paginated player scorer ranking. + [HttpGet("by-player")] + public async Task GetFilteredScorerByPlayer([FromQuery] GetScorerFilteredRequest filter) + { + PaginatedResponse response = await scorerService.GetAllScorersByPlayerAsync(filter); + + return Ok(response); + } +} diff --git a/Club12-Backend/API/Controllers/SeasonController.cs b/Club12-Backend/API/Controllers/SeasonController.cs new file mode 100644 index 0000000..4203856 --- /dev/null +++ b/Club12-Backend/API/Controllers/SeasonController.cs @@ -0,0 +1,147 @@ +using API.Utils; + +using Application.DTOs.Season.Request; +using Application.DTOs.Season.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages Seasons, the top-level grouping of a period's tournaments. Reads are public; writes require Owner or Admin. +/// +/// The Season service. +/// The AutoMapper instance. +[Route("api/seasons/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class SeasonController(ISeasonService seasonService, IMapper mapper) : ControllerBase +{ + /// + /// Creates a new season asynchronously. + /// + /// The season creation request. + /// The created Season response. + /// Returns 201 Created with the Season response if the creation was successful. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(SeasonResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateSeason(CreateSeasonRequest seasonRequest) + { + Season season = mapper.Map(seasonRequest); + await seasonService.CreateSeasonAsync(season); + SeasonResponse seasonResponse = mapper.Map(season); + return CreatedAtAction(nameof(GetSeasonById), new { idOrSlug = seasonResponse.Id }, seasonResponse); + } + + /// + /// Retrieves a season by its id or its public slug asynchronously. + /// + /// The season's GUID id or slug to retrieve. + /// The Season with the specified id or slug. + /// Returns 200 OK with the Season response if it was found. + /// Returns 404 Not Found if the Season with the provided id or slug was not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SeasonResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSeasonById(string idOrSlug) + { + Season? season = await seasonService.GetSeasonByIdOrSlugAsync(idOrSlug); + + if (season is null) + { + return this.NotFoundProblem(nameof(Season), idOrSlug); + } + + SeasonResponse seasonResponse = mapper.Map(season); + return Ok(seasonResponse); + } + + /// + /// Updates a season by its id asynchronously. + /// + /// The id of the season to update. + /// The season update request. + /// + /// Returns 204 No Content if the update was successful. + /// Returns 404 Not Found if the Season with the provided id was not found. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateSeason(Guid id, UpdateSeasonRequest seasonRequest) + { + Season? existingSeason = await seasonService.GetSeasonByIdAsync(id); + + if (existingSeason is null) + { + return this.NotFoundProblem(nameof(Season), id); + } + + mapper.Map(seasonRequest, existingSeason); + + await seasonService.UpdateSeasonAsync(existingSeason); + + return NoContent(); + } + + /// + /// Deletes a season by its id asynchronously. + /// + /// The id of the Season to delete. + /// + /// Returns 204 No Content if the Season was successfully deleted. + /// Returns 404 Not Found if the Season with the provided id was not found. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteSeasonById(Guid id) + { + Season? season = await seasonService.GetSeasonByIdAsync(id); + + if (season is null) + { + return this.NotFoundProblem(nameof(Season), id); + } + + await seasonService.DeleteSeasonAsync(id); + return NoContent(); + } + + /// + /// Retrieves all seasons asynchronously. + /// + /// A list of all Season responses. + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetAllSeasons() + { + IEnumerable seasons = await seasonService.GetAllSeasonsAsync(); + IEnumerable response = mapper.Map>(seasons); + + return Ok(response); + } +} diff --git a/Club12-Backend/API/Controllers/StageController.cs b/Club12-Backend/API/Controllers/StageController.cs new file mode 100644 index 0000000..ebaff16 --- /dev/null +++ b/Club12-Backend/API/Controllers/StageController.cs @@ -0,0 +1,242 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Response; +using Application.DTOs.Stage.Request; +using Application.DTOs.Stage.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages Stage entities: creation, retrieval, update, deletion, and team assignment. Reads are public; writes require Owner or Admin. +/// +/// Service for Stage business logic and persistence operations. +/// AutoMapper instance for mapping between entities and DTOs. +[Route("api/stages/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class StageController(IStageService stageService, IMapper mapper) : ControllerBase +{ + /// + /// Creates a new Stage. + /// + /// The Stage creation request DTO. + /// The created Stage response. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(StageResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateStage(CreateStageRequest stageRequest) + { + Stage mappedStage = mapper.Map(stageRequest); + Stage createdStage = await stageService.CreateStageAsync(mappedStage); + StageResponse stageResponse = mapper.Map(createdStage); + return CreatedAtAction(nameof(GetStageById), new { idOrSlug = createdStage.Id }, stageResponse); + } + + /// + /// Retrieves a Stage by its id or its public slug. + /// + /// The Stage's GUID id or slug. + /// The Stage entity. + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Stage))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> GetStageById(string idOrSlug) + { + Stage? stage = await stageService.GetStageByIdOrSlugAsync(idOrSlug); + + if (stage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(idOrSlug)); + } + + StageResponse stageResponse = mapper.Map(stage); + return Ok(stageResponse); + } + + /// + /// Retrieves filtered and paginated Stages. + /// + /// The filter and pagination parameters. + /// Paginated list of Stages. + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetFilteredStages([FromQuery] GetStagesFilteredRequest filterRequest) + { + PaginatedResponse paginatedStages = await stageService.GetAllStagesAsync(filterRequest); + PaginatedResponse paginatedResponse = mapper.Map>(paginatedStages); + return Ok(paginatedResponse); + } + + /// + /// Updates an existing Stage. + /// + /// The id of the Stage to update. + /// The updated Stage data. + /// The updated Stage entity. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StageResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateStage(Guid id, UpdateStageRequest stageRequest) + { + Stage? existingStage = await stageService.GetStageByIdAsync(id); + if (existingStage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(id)); + } + + mapper.Map(stageRequest, existingStage); + await stageService.UpdateStageAsync(existingStage); + + StageResponse stageResponse = mapper.Map(existingStage); + return Ok(stageResponse); + } + + /// + /// Deletes a Stage by its id. + /// + /// The id of the Stage to delete. + /// Result of the delete operation. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteStage(Guid id) + { + await stageService.DeleteStageAsync(id); + return NoContent(); + } + + /// + /// Assigns one or more teams to a specific stage. + /// + /// The unique identifier of the stage to which teams will be assigned. + /// The assignment request containing team IDs and assignment mode. + /// + /// Returns HTTP 200 OK if the assignment is successful, or 404 Not Found if the stage does not exist. + /// + [HttpPost("{id:guid}/assign-team")] + public async Task AssignmentTeam(Guid id, AssignmentTeamRequest request) + { + Stage? stage = await stageService.GetStageByIdAsync(id); + + if (stage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(id)); + } + + await stageService.AssignTeamsToStageAsync(stage, request.TeamIds, request.Auto); + + return Ok(); + } + + /// + /// Unassigns one or more teams from a specific stage. + /// + /// The unique identifier of the stage from which teams will be unassigned. + /// The unassignment request containing team IDs. + /// + /// Returns HTTP 200 OK if the unassignment is successful, or 404 Not Found if the stage does not exist. + /// + [HttpDelete("{id:guid}/unassign-team")] + public async Task UnassignmentTeam(Guid id, UnassignmentTeamRequest request) + { + Stage? stage = await stageService.GetStageByIdAsync(id); + + if (stage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(id)); + } + + await stageService.UnassignTeamsFromStageAsync(stage, request.TeamIds); + return Ok(); + } + + /// + /// Seeds an elimination stage's matches from the group-stage standings using the classic 1v8, 4v5, 2v7, 3v6 bracket seed order. + /// + /// The elimination stage to seed. + /// + /// Returns HTTP 200 OK with the now-seeded matches, or 404 Not Found + /// if the stage does not exist. + /// + [HttpPost("{id:guid}/seed")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SeedKnockoutStage(Guid id) + { + Stage? stage = await stageService.GetStageByIdAsync(id); + + if (stage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(id)); + } + + List seededMatches = await stageService.SeedKnockoutStageAsync(id); + + return Ok(mapper.Map>(seededMatches)); + } + + /// + /// Computes a first-round pairing for a groupless bracket without persisting it, returning a signed token that a later commit can replay exactly. + /// + [HttpPost("{id:guid}/preview-draw")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DrawPreviewResult))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> PreviewDraw(Guid id, DrawRequest request) + { + Stage? stage = await stageService.GetStageByIdAsync(id); + + if (stage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(id)); + } + + DrawPreviewResult preview = await stageService.PreviewDrawAsync(id, request.Mode, request.ManualOrder); + return Ok(preview); + } + + /// + /// Seeds a groupless bracket from a previewed token or a manual order, stamping DrawnAt and auditing the draw. + /// + [HttpPost("{id:guid}/draw")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task>> CommitDraw(Guid id, DrawRequest request) + { + Stage? stage = await stageService.GetStageByIdAsync(id); + + if (stage == null) + { + return NotFound(ErrorMessages.Stage.NotFoundById(id)); + } + + List seededMatches = await stageService.CommitDrawAsync(id, request.Mode, request.DrawToken, request.ManualOrder); + return Ok(mapper.Map>(seededMatches)); + } +} diff --git a/Club12-Backend/API/Controllers/StatisticsController.cs b/Club12-Backend/API/Controllers/StatisticsController.cs new file mode 100644 index 0000000..64ffa65 --- /dev/null +++ b/Club12-Backend/API/Controllers/StatisticsController.cs @@ -0,0 +1,62 @@ +using API.Utils; + +using Application.DTOs.Statistics.Response; +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Read-only historical player statistics; always public. +/// +[ApiController] +[Route("api/statistics/")] +[AllowAnonymous] +public class StatisticsController(IStatisticsService statisticsService) : ControllerBase +{ + /// + /// A player's statistic card: total and average points and games played, per season and overall. + /// + /// The player's id. + [HttpGet("players/{playerId:guid}/card")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerStatisticCardResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlayerCard(Guid playerId) + { + PlayerStatisticCardResponse? card = await statisticsService.GetPlayerCardAsync(playerId); + + if (card is null) + { + return this.NotFoundProblem(nameof(Player), playerId); + } + + return Ok(card); + } + + /// + /// A player's trajectory across seasons: team, stats, and sanctions for each season. + /// + /// The player's id. + [HttpGet("players/{playerId:guid}/history")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PlayerHistoryResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlayerHistory(Guid playerId) + { + PlayerHistoryResponse? history = await statisticsService.GetPlayerHistoryAsync(playerId); + + if (history is null) + { + return this.NotFoundProblem(nameof(Player), playerId); + } + + return Ok(history); + } +} diff --git a/Club12-Backend/API/Controllers/TeamController.cs b/Club12-Backend/API/Controllers/TeamController.cs new file mode 100644 index 0000000..64b07f8 --- /dev/null +++ b/Club12-Backend/API/Controllers/TeamController.cs @@ -0,0 +1,294 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.Team.Request; +using Application.DTOs.Team.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Storage; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages teams. Reads are public; writes require Owner or Admin. +/// +/// The team service for handling team-related operations. +/// The Supabase helper for storage operations. +/// The AutoMapper instance for mapping data models. +[Route("api/teams/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class TeamController( + ITeamService teamService, + SupabaseHelper supabaseHelper, + IMapper mapper + ) : ControllerBase +{ + /// + /// Creates a new team. + /// + /// The team creation request object containing the team details. + /// The created team response with details of the new team. + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateTeam([FromForm] CreateTeamRequest teamRequest) + { + if (!teamRequest.LogoFile.IsValidImageFile()) + { + return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile); + } + + string logoUrl = await supabaseHelper.UploadImageAsync(teamRequest.LogoFile.OpenReadStream(), teamRequest.LogoFile.FileName); + + Team team = mapper.Map(teamRequest); + team.LogoUrl = logoUrl; + + Team createdTeam = await teamService.CreateTeamAsync(team); + TeamResponse teamResponse = mapper.Map(createdTeam); + + return CreatedAtAction(nameof(GetTeamById), new { idOrSlug = createdTeam.Id }, teamResponse); + } + + /// + /// Updates a team by its id. + /// + /// The id of the team to update. + /// The team request excluding logo update. + /// + /// Returns 204 No Content if the update was successful. + /// Returns 404 Not Found if the team with the provided id was not found. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateTeam(Guid id, UpdateTeamRequest teamRequest) + { + Team? existingTeam = await teamService.GetTeamByIdAsync(id); + + if (existingTeam is null) + { + return this.NotFoundProblem(nameof(Team), id); + } + + // A team's name and three-letter code are frozen while it participates in an Ongoing tournament, so the check must run before the request is mapped over the entity, while the original identity is still available to compare. + await teamService.EnsureTeamIdentityEditableAsync(existingTeam, teamRequest.Name, teamRequest.ThreeLetterCode); + + mapper.Map(teamRequest, existingTeam); + + await teamService.UpdateTeamAsync(existingTeam); + + return NoContent(); + } + + /// + /// Updates the logo of a team. + /// + /// The id of the team to update the logo. + /// The update team logo request. + /// Returns 200 OK if the logo was successfully updated. + [HttpPut("{id:guid}/logo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpdateTeamLogo(Guid id, [FromForm] UpdateTeamLogoRequest logoRequest) + { + if (!logoRequest.LogoFile.IsValidImageFile()) + { + return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile); + } + + Team? team = await teamService.GetTeamByIdAsync(id); + if (team is null) + { + return this.NotFoundProblem(nameof(Team), id); + } + + team.LogoUrl = await supabaseHelper.UploadImageAsync( + logoRequest.LogoFile.OpenReadStream(), + logoRequest.LogoFile.FileName); + + await teamService.UpdateTeamAsync(team); + return Ok(); + } + + /// + /// Retrieves a team by its id or slug; the embedded roster is scoped to one season. + /// + /// The team's GUID id or slug to retrieve. + /// + /// Optional: the season whose roster to embed. Defaults to the team's + /// own current tournament, meaning today's roster, when omitted — pass this + /// to look up the roster the team had during a past season instead. + /// + /// The team with the specified id or slug. + /// Returns 200 OK with the team response if it was found. + /// Returns 404 Not Found if the team with the provided id or slug was not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TeamResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetTeamById(string idOrSlug, [FromQuery] Guid? tournamentId = null) + { + Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId); + + if (team is null) + { + return this.NotFoundProblem(nameof(Team), idOrSlug); + } + + TeamResponse teamResponse = mapper.Map(team); + return Ok(teamResponse); + } + + /// + /// Retrieves the team's current group-stage standing row for a tournament, powering the profile summary card. + /// + /// The team's GUID id or slug. + /// + /// Optional: the tournament to summarize. Defaults to the team's own current + /// tournament when omitted. + /// + /// + /// Returns 200 OK with the standing row when the team is in a + /// group-stage table for the tournament. + /// Returns 200 OK with a null body when the team is in no group-stage + /// standing: playoff-only, unassigned, or with no finished matches yet. + /// Returns 404 Not Found when the team does not exist. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}/summary")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TeamSummaryResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetTeamSummary(string idOrSlug, [FromQuery] Guid? tournamentId = null) + { + Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId); + + if (team is null) + { + return this.NotFoundProblem(nameof(Team), idOrSlug); + } + + TeamSummaryResponse? summary = await teamService.GetTeamSummaryAsync(team.Id, tournamentId ?? team.TournamentId); + return Ok(summary); + } + + /// + /// Retrieves the team's home or visitor matches in a tournament, oriented from the team's perspective and ordered by date ascending. + /// + /// The team's GUID id or slug. + /// + /// Optional: the tournament to list matches for. Defaults to the team's own + /// current tournament when omitted. + /// + /// + /// Returns 200 OK with the team's matches, empty when there are none. + /// Returns 404 Not Found when the team does not exist. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}/matches")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetTeamMatches(string idOrSlug, [FromQuery] Guid? tournamentId = null) + { + Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug, tournamentId); + + if (team is null) + { + return this.NotFoundProblem(nameof(Team), idOrSlug); + } + + List matches = await teamService.GetTeamMatchesAsync(team.Id, tournamentId ?? team.TournamentId); + return Ok(matches); + } + + /// + /// Retrieves every tournament the team has participated in, newest first, with season info. + /// + /// The team's GUID id or slug. + /// + /// Returns 200 OK with the team's participations, empty when none. + /// Returns 404 Not Found when the team does not exist. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}/participations")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetTeamParticipations(string idOrSlug) + { + Team? team = await teamService.GetTeamByIdOrSlugAsync(idOrSlug); + + if (team is null) + { + return this.NotFoundProblem(nameof(Team), idOrSlug); + } + + List participations = await teamService.GetTeamParticipationsAsync(team.Id, team.TournamentId); + return Ok(participations); + } + + /// + /// Deletes a team by its id. + /// + /// The id of the team to delete. + /// + /// Returns 204 No Content if the team was successfully deleted. + /// Returns 404 Not Found if the team with the provided id was not found. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteTeamById(Guid id) + { + Team? team = await teamService.GetTeamByIdAsync(id); + + if (team is null) + { + return this.NotFoundProblem(nameof(Team), id); + } + + if (Uri.TryCreate(team.LogoUrl, UriKind.Absolute, out Uri? logoUri) + && (logoUri.Scheme == Uri.UriSchemeHttp || logoUri.Scheme == Uri.UriSchemeHttps)) + { + await supabaseHelper.DeleteImageAsync(logoUri.Segments[^1]); + } + await teamService.DeleteTeamAsync(id); + return NoContent(); + } + + /// + /// Retrieves filtered teams with pagination. + /// + /// The filtering and pagination parameters. + /// A paginated response containing the filtered teams. + [AllowAnonymous] + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredTeams([FromQuery] GetTeamsFilteredRequest filterRequest) + { + PaginatedResponse paginatedTeams = await teamService.GetAllTeamsAsync(filterRequest); + + PaginatedResponse response = mapper.Map>(paginatedTeams); + + return Ok(response); + } +} diff --git a/Club12-Backend/API/Controllers/TeamStaffController.cs b/Club12-Backend/API/Controllers/TeamStaffController.cs new file mode 100644 index 0000000..8fee1af --- /dev/null +++ b/Club12-Backend/API/Controllers/TeamStaffController.cs @@ -0,0 +1,105 @@ +using API.Utils; + +using Application.DTOs.TeamStaff.Request; +using Application.DTOs.TeamStaff.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages a team's technical staff, scoped per team and tournament; creating and deleting require Admin or Owner, listing is public. +/// +/// The team-staff service. +/// The team service, used to validate the team. +/// The tournament service, used to validate the tournament. +/// The AutoMapper instance. +[Route("api/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class TeamStaffController( + ITeamStaffService staffService, + ITeamService teamService, + ITournamentService tournamentService, + IMapper mapper) : ControllerBase +{ + /// + /// Adds a member to a team's technical staff for a given tournament. + /// + /// The team the staff member belongs to. + /// The staff member's full name, role, and tournament. + /// + /// Returns 201 Created with the staff member; 404 Not Found when the team or + /// tournament does not exist; 403 Forbidden for non-staff callers. + /// + [HttpPost("teams/{teamId:guid}/staff")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamStaffResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateTeamStaff( + Guid teamId, CreateTeamStaffRequest request) + { + Team? team = await teamService.GetTeamByIdAsync(teamId); + if (team is null) + { + return this.NotFoundProblem(nameof(Team), teamId); + } + + Tournament? tournament = await tournamentService.GetTournamentByIdAsync(request.TournamentId); + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), request.TournamentId); + } + + TeamStaff staff = mapper.Map(request); + staff.TeamId = teamId; + + TeamStaff created = await staffService.CreateAsync(staff); + TeamStaffResponse response = mapper.Map(created); + + return new ObjectResult(response) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Lists a team's technical staff for a given tournament; public so a team's profile can show its staff. + /// + /// The team whose staff to list. + /// The tournament season to scope the staff to. + /// Returns 200 OK with the staff in the order they were added. + [AllowAnonymous] + [HttpGet("teams/{teamId:guid}/staff")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task>> GetTeamStaff( + Guid teamId, [FromQuery] Guid tournamentId) + { + List staff = + await staffService.GetByTeamAndTournamentAsync(teamId, tournamentId); + return Ok(mapper.Map>(staff)); + } + + /// + /// Removes a staff member by their id. + /// + /// The id of the staff member to remove. + /// Returns 204 No Content; 403 Forbidden for non-staff callers. + [HttpDelete("staff/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteTeamStaff(Guid id) + { + await staffService.DeleteAsync(id); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/TournamentController.cs b/Club12-Backend/API/Controllers/TournamentController.cs new file mode 100644 index 0000000..9420947 --- /dev/null +++ b/Club12-Backend/API/Controllers/TournamentController.cs @@ -0,0 +1,364 @@ +using API.Utils; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.Champions.Response; +using Application.DTOs.Divisions.Response; +using Application.DTOs.Team.Response; +using Application.DTOs.Tournament.Request; +using Application.DTOs.Tournament.Response; +using Application.Interfaces.Services; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages tournaments: create, retrieve, update, delete, filter, and register teams. Reads are public; writes require Owner or Admin. +/// +[Route("api/tournaments/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class TournamentController( + ITournamentService tournamentService, + ITeamService teamService, + IChampionService championService, + IMapper mapper) : ControllerBase +{ + /// + /// Creates a new tournament. + /// + /// Tournament creation data. + /// + /// Returns 201 Created with the created tournament details. + /// Returns 400 Bad Request if the request is invalid. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TournamentResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateTournamentAsync(CreateTournamentRequest tournamentRequest) + { + Tournament mappedTournament = mapper.Map(tournamentRequest); + Tournament createdTournament = await tournamentService.CreateTournamentAsync(mappedTournament); + TournamentResponse tournamentResponse = mapper.Map(createdTournament); + + return new ObjectResult(tournamentResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Creates a whole tournament, including its divisions' cups, points, playoff mappings, and stages, in one atomic transaction. + /// + /// The full tournament-wizard payload. + /// + /// Returns 201 Created with the created tournament, including its divisions. + /// Returns 400 Bad Request if the payload is invalid or a rule, for + /// example a division category mismatch, aborts the atomic create. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPost("full")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TournamentResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateFullTournamentAsync(CreateFullTournamentRequest request) + { + Tournament createdTournament = await tournamentService.CreateFullTournamentAsync(request); + TournamentResponse tournamentResponse = mapper.Map(createdTournament); + + return new ObjectResult(tournamentResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Adds one division, with its group stage, cups, and playoff mappings, to an existing tournament in one atomic transaction, only while OpenForRegistration. + /// + /// The parent tournament's id. + /// The division's structure, zone or cross-cup. + /// + /// Returns 201 Created with the created division. + /// Returns 404 Not Found if the tournament does not exist. + /// Returns 409 Conflict if the tournament is not OpenForRegistration or + /// the division's category does not match the tournament's. + /// + [HttpPost("{tournamentId:guid}/divisions/full")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(DivisionResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> AddFullDivision( + Guid tournamentId, CreateFullDivisionRequest request) + { + Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId); + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), tournamentId); + } + + Division createdDivision = await tournamentService.AddFullDivisionAsync(tournament, request); + DivisionResponse divisionResponse = mapper.Map(createdDivision); + + return new ObjectResult(divisionResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Retrieves a tournament by its unique identifier or its public slug. + /// + /// Tournament's GUID identifier or slug. + /// + /// Returns 200 OK with tournament details if found. + /// Returns 404 Not Found if not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetTournamentById(string idOrSlug) + { + Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug); + + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), idOrSlug); + } + + TournamentResponse tournamentResponse = mapper.Map(tournament); + return Ok(tournamentResponse); + } + + /// + /// Retrieves a source tournament's full cloneable structure tree — every + /// division's scoring/cup configuration, Stages, and PlayoffMappings — for + /// the tournament-cloning wizard-prefill flow. Carries no instance data + /// (teams, matches, rosters); additive, never replaces TournamentResponse + /// or DivisionResponse. + /// + /// Tournament's GUID identifier or slug. + /// + /// Returns 200 OK with the tournament's structure tree if found. + /// Returns 404 Not Found if not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}/structure")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentStructureResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetTournamentStructure(string idOrSlug) + { + Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug); + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), idOrSlug); + } + + Tournament? structure = await tournamentService.GetTournamentStructureAsync(tournament.Id); + TournamentStructureResponse response = mapper.Map(structure); + return Ok(response); + } + + /// + /// Retrieves each division's podium: champion, runner-up, and third place, decided by playoff or group standings, with undecided places left null. + /// + /// Tournament's GUID identifier or slug. + /// + /// Returns 200 OK with one podium per division. + /// Returns 404 Not Found if the tournament does not exist. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}/champions")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetTournamentChampions(string idOrSlug) + { + Tournament? tournament = await tournamentService.GetTournamentByIdOrSlugAsync(idOrSlug); + + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), idOrSlug); + } + + List podiums = await championService.GetTournamentChampionsAsync(tournament.Id); + return Ok(podiums); + } + + /// + /// Updates a tournament's descriptive fields; a Status change is routed through a forward-only state machine instead of being written directly. + /// + /// Tournament's GUID identifier. + /// Tournament update data. + /// + /// Returns 200 OK if updated successfully. + /// Returns 404 Not Found if the tournament does not exist. + /// Returns 403 Forbidden if unauthorized. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateTournamentAsync(Guid id, UpdateTournamentRequest tournamentRequest) + { + Tournament? existingTournament = await tournamentService.GetTournamentByIdAsync(id); + + if (existingTournament is null) + { + return this.NotFoundProblem(nameof(Tournament), id); + } + + // Status is intentionally excluded from this mapping since descriptive fields update freely here, but a status change is a guarded state-machine transition handled below. + mapper.Map(tournamentRequest, existingTournament); + + await tournamentService.UpdateTournamentAsync(existingTournament); + + // Routes any requested status change through the forward-only state machine; a no-op transition where the status is unchanged is ignored by the service, so re-sending the current status on a plain edit is harmless. + if (tournamentRequest.Status is TournamentStatus requestedStatus) + { + await tournamentService.ChangeStatusAsync(id, requestedStatus); + } + + return NoContent(); + } + + /// + /// Deletes a tournament by its identifier. + /// + /// Tournament's GUID identifier. + /// + /// Returns 200 OK if deleted successfully. + /// Returns 400 Bad Request if not found. + /// Returns 403 Forbidden if unauthorized. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteTournamentById(Guid id) + { + await tournamentService.DeleteTournamentAsync(id); + return NoContent(); + } + + /// + /// Retrieves tournaments filtered and paginated according to provided parameters. + /// + /// Filtering and pagination parameters. + /// + /// Returns 200 OK with paginated tournament results. + /// Returns 400 Bad Request if parameters are invalid. + /// + [AllowAnonymous] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetFilteredTournaments([FromQuery] GetTournamentsFilteredRequest filterRequest) + { + PaginatedResponse paginatedTournaments = await tournamentService.GetAllTournamentsAsync(filterRequest); + PaginatedResponse response = mapper.Map>(paginatedTournaments); + + return Ok(response); + } + + /// + /// Enrolls a team into the tournament's registration phase, either as a new team or an existing club with an optional roster copy, in one transaction. + /// + /// Tournament's GUID identifier. + /// The enroll payload. + /// + /// Returns 201 Created with the enrolled team, its roster scoped to this tournament. + /// Returns 400 Bad Request when the payload shape is invalid: not exactly one of + /// ExistingTeamId or NewTeamName, or CopyRosterFromTournamentId without ExistingTeamId. + /// Returns 404 Not Found when the tournament or an existing team does not exist. + /// Returns 409 Conflict when the tournament is not OpenForRegistration or the team + /// is already enrolled. + /// + [HttpPost("{tournamentId:guid}/enroll-team")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(TeamResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> EnrollTeam(Guid tournamentId, EnrollTeamRequest request) + { + bool hasExistingTeam = request.ExistingTeamId.HasValue; + bool hasNewTeamName = !string.IsNullOrWhiteSpace(request.NewTeamName); + + // Exactly one of ExistingTeamId / NewTeamName must be provided. + if (hasExistingTeam == hasNewTeamName) + { + return BadRequest("Provide exactly one of ExistingTeamId or NewTeamName."); + } + + // CopyRosterFromTournamentId is only valid together with ExistingTeamId. + if (request.CopyRosterFromTournamentId.HasValue && !hasExistingTeam) + { + return BadRequest("CopyRosterFromTournamentId is only allowed together with ExistingTeamId."); + } + + Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId); + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), tournamentId); + } + + Team enrolledTeam = await teamService.EnrollTeamAsync( + tournament, + request.ExistingTeamId, + request.NewTeamName, + request.CopyRosterFromTournamentId); + + TeamResponse teamResponse = mapper.Map(enrolledTeam); + return new ObjectResult(teamResponse) { StatusCode = StatusCodes.Status201Created }; + } + + /// + /// Reports whether the tournament can be completed once started and the blocking issues when it cannot. + /// + /// Tournament's GUID identifier. + /// + /// Returns 200 OK with the completability report, CanStart plus Issues. + /// Returns 404 Not Found when the tournament does not exist. + /// Returns 403 Forbidden if unauthorized. + /// + [HttpGet("{tournamentId:guid}/completability")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TournamentCompletabilityResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> GetCompletability(Guid tournamentId) + { + TournamentCompletabilityResponse response = await tournamentService.GetCompletabilityAsync(tournamentId); + return Ok(response); + } + + /// + /// Removes a team from the tournament during its registration or pre-start window, clearing only this tournament's footprint for the team. + /// + /// Tournament's GUID identifier. + /// Team's GUID identifier. + /// + /// Returns 204 No Content when the team is removed. + /// Returns 404 Not Found when the tournament does not exist or the team is not enrolled. + /// Returns 409 Conflict when the tournament has already started and is not in a removable phase. + /// Returns 403 Forbidden if unauthorized. + /// + [HttpDelete("{tournamentId:guid}/teams/{teamId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UnenrollTeam(Guid tournamentId, Guid teamId) + { + Tournament? tournament = await tournamentService.GetTournamentByIdAsync(tournamentId); + if (tournament is null) + { + return this.NotFoundProblem(nameof(Tournament), tournamentId); + } + + await teamService.UnenrollTeamAsync(tournament, teamId); + return NoContent(); + } +} diff --git a/Club12-Backend/API/Controllers/UserController.cs b/Club12-Backend/API/Controllers/UserController.cs new file mode 100644 index 0000000..da6b90a --- /dev/null +++ b/Club12-Backend/API/Controllers/UserController.cs @@ -0,0 +1,139 @@ +using API.Utils.Helpers; + +using Application.DTOs.Abstract.Response; +using Application.DTOs.User.Request; +using Application.DTOs.User.Response; +using Application.Interfaces.Services; + +using Domain.Enums; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// User management endpoints for CRUD and logout; access rules are enforced in IUserManagementService. +/// +[ApiController] +[Route("api/users")] +[Authorize] +public class UserController( + IUserManagementService userManagementService, + IAuditService auditService) : ControllerBase +{ + /// + /// Lists users paginated and filtered; Admins see every user, Owners only their subordinates, other roles get 403. + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PaginatedResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAll( + [FromQuery] UserFilteredRequest filter, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + return Ok(await userManagementService.GetAllAsync(role, id, filter, ct)); + } + + /// + /// Retrieves a single user by id; Admins may view anyone, Owners themselves or their subordinates, other roles only themselves. + /// + [HttpGet("{userId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(Guid userId, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + return Ok(await userManagementService.GetByIdAsync(role, id, userId, ct)); + } + + /// + /// Updates a user's profile fields and, if Role is set, reassigns the target's role; only Admins and Owners may reassign, and nobody may change their own. + /// + [HttpPut("{userId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Update( + Guid userId, [FromBody] UpdateUserRequest request, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + return Ok(await userManagementService.UpdateAsync(role, id, userId, request, ct)); + } + + /// + /// Changes a user's password; CurrentPassword is required for a self-service change, optional when an Admin or Owner changes another's. + /// + [HttpPut("{userId:guid}/password")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ChangePassword( + Guid userId, [FromBody] ChangePasswordRequest request, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + await userManagementService.ChangePasswordAsync(role, id, userId, request, ct); + return NoContent(); + } + + /// + /// Forces a password reset, generating a new temporary password server-side and recording an audit log entry. + /// + [HttpPost("{userId:guid}/password/reset")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ResetPasswordResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ResetPassword( + Guid userId, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + ResetPasswordResponse response = await userManagementService.ResetPasswordAsync(role, id, userId, ct); + + // The target's email is looked up separately, since ResetPasswordResponse only carries the id and the audit trail needs to show who, not just an opaque guid. + UserResponse resetUser = await userManagementService.GetByIdAsync(role, id, userId, ct); + await auditService.LogAsync( + AuditAction.PasswordReset, + targetType: "User", + targetId: userId.ToString(), + targetName: resetUser.Email, + ct: ct); + + return Ok(response); + } + + /// + /// Deletes a user. Admins may delete any user; Owners only their own subordinates. + /// + [HttpDelete("{userId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Guid userId, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + await userManagementService.DeleteAsync(role, id, userId, ct); + return NoContent(); + } + + /// + /// Activates or deactivates a user account via Identity lockout; a deactivated account cannot log in. + /// + [HttpPut("{userId:guid}/active")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SetActive( + Guid userId, [FromBody] SetUserActiveRequest request, CancellationToken ct) + { + (string? role, Guid id) = User.GetCallerClaims(); + return Ok(await userManagementService.SetActiveAsync(role, id, userId, request.IsActive, ct)); + } +} diff --git a/Club12-Backend/API/Controllers/VenueController.cs b/Club12-Backend/API/Controllers/VenueController.cs new file mode 100644 index 0000000..596b83b --- /dev/null +++ b/Club12-Backend/API/Controllers/VenueController.cs @@ -0,0 +1,201 @@ +using API.Utils; + +using Application.DTOs.Venue.Request; +using Application.DTOs.Venue.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using AutoMapper; + +using Domain.Entities.Models; +using Domain.Enums; + +using Infrastructure.Storage; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace API.Controllers; + +/// +/// Manages Venues. Reads are public; writes require Owner or Admin. +/// +/// The Venue service. +/// The Supabase helper for storage operations. +/// The AutoMapper instance. +[Route("api/venues/")] +[ApiController] +[Authorize(Roles = Roles.AdminOrOwner)] +public class VenueController(IVenueService venueService, SupabaseHelper supabaseHelper, IMapper mapper) : ControllerBase +{ + + /// + /// Creates a new venue asynchronously. + /// + /// The venue creation request. + /// The created Venue response. + /// Returns 201 Created with the Venue response if the creation was successful. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPost()] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(VenueResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateVenue([FromForm] CreateVenueRequest venueRequest) + { + Venue venue = mapper.Map(venueRequest); + + if (venueRequest.ImageFile is not null) + { + venue.PhotoUrl = await supabaseHelper.UploadImageAsync( + venueRequest.ImageFile.OpenReadStream(), + venueRequest.ImageFile.FileName); + } + + await venueService.CreateVenueAsync(venue); + VenueResponse venueResponse = mapper.Map(venue); + return CreatedAtAction(nameof(GetVenueById), new { idOrSlug = venueResponse.Id }, venueResponse); + } + + /// + /// Retrieves a venue by its id or its public slug asynchronously. + /// + /// The venue's GUID id or slug to retrieve. + /// The Venue with the specified id or slug. + /// Returns 200 OK with the Venue response if it was found. + /// Returns 404 Not Found if the Venue with the provided id or slug was not found. + /// + [AllowAnonymous] + [HttpGet("{idOrSlug}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(VenueResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetVenueById(string idOrSlug) + { + Venue? venue = await venueService.GetVenueByIdOrSlugAsync(idOrSlug); + + if (venue is null) + { + return this.NotFoundProblem(nameof(Venue), idOrSlug); + } + + VenueResponse venueResponse = mapper.Map(venue); + return Ok(venueResponse); + } + + /// + /// Updates a venue by its id asynchronously. + /// + /// The id of the venue to update. + /// The venue update request. + /// + /// Returns 200 OK if the update was successful. + /// Returns 404 Not Found if the Venue with the provided id was not found. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateVenue(Guid id, UpdateVenueRequest venueRequest) + { + Venue? existingVenue = await venueService.GetVenueByIdAsync(id); + + if (existingVenue is null) + { + return this.NotFoundProblem(nameof(Venue), id); + } + + mapper.Map(venueRequest, existingVenue); + + await venueService.UpdateVenueAsync(existingVenue); + + return NoContent(); + } + + /// + /// Updates the photo of a venue. + /// + /// The id of the venue to update the photo. + /// The update venue photo request. + /// Returns 200 OK if the photo was successfully updated. + [HttpPut("{id:guid}/photo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateVenuePhoto(Guid id, [FromForm] UpdateVenuePhotoRequest photoRequest) + { + if (!photoRequest.ImageFile.IsValidImageFile()) + { + return this.BadRequestProblem(ErrorMessages.Media.InvalidImageFile); + } + + Venue? venue = await venueService.GetVenueByIdAsync(id); + if (venue is null) + { + return this.NotFoundProblem(nameof(Venue), id); + } + + venue.PhotoUrl = await supabaseHelper.UploadImageAsync( + photoRequest.ImageFile.OpenReadStream(), + photoRequest.ImageFile.FileName); + + await venueService.UpdateVenueAsync(venue); + return Ok(); + } + + /// + /// Deletes a venue by its id asynchronously. + /// + /// The id of the Venue to delete. + /// + /// Returns 204 No Content if the Venue was successfully deleted. + /// Returns 404 Not Found if the Venue with the provided id was not found. + /// Returns 409 Conflict if the Venue is still referenced by one or more matches. + /// Returns 403 Forbidden if the user is not authorized. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task DeleteVenueById(Guid id) + { + Venue? venue = await venueService.GetVenueByIdAsync(id); + + if (venue is null) + { + return this.NotFoundProblem(nameof(Venue), id); + } + + // Runs the integrity guard first, which throws 409 when the venue is still referenced by matches, so the stored photo is only removed once the venue row is actually gone. + await venueService.DeleteVenueAsync(id); + + if (Uri.TryCreate(venue.PhotoUrl, UriKind.Absolute, out Uri? photoUri) + && (photoUri.Scheme == Uri.UriSchemeHttp || photoUri.Scheme == Uri.UriSchemeHttps)) + { + await supabaseHelper.DeleteImageAsync(photoUri.Segments[^1]); + } + + return NoContent(); + } + + /// + /// Retrieves all venues asynchronously. + /// + /// A list of all Venue responses. + [AllowAnonymous] + [HttpGet()] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetAllVenues() + { + IEnumerable venues = await venueService.GetAllVenuesAsync(); + IEnumerable response = mapper.Map>(venues); + + return Ok(response); + } +} diff --git a/Club12-Backend/API/Program.cs b/Club12-Backend/API/Program.cs new file mode 100644 index 0000000..44b85a9 --- /dev/null +++ b/Club12-Backend/API/Program.cs @@ -0,0 +1,102 @@ +using API.BackgroundServices; +using API.Utils; +using API.Utils.Middlewares; + +using Application.Utils.Options; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +using Serilog; +using Serilog.Events; + +using System; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Host.AddSerilogConfig(builder.Configuration); + +builder.Services + .AddAutoMapper(cfg => { }, typeof(Program).Assembly) + .AddDbContextConfig(builder.Configuration) + .AddCorsConfig(builder.Configuration) + .RegisterScoped() + .RegisterSingletons() + .AddCustomAuthorization() + .AddCustomAuthentication(builder.Configuration) + .AddCustomSwagger(builder.Configuration) + .AddEmailConfig(builder.Configuration) + .AddIdentityConfig(builder.Configuration) + .AddBackupConfig(builder.Configuration) + .AddHealthChecksConfig() + .AddExceptionHandler() + .AddProblemDetails(); + +// Configurable roster limits; an absent section falls back to defaults. +builder.Services.Configure( + builder.Configuration.GetSection(RosterOptions.SectionName)); + +builder.Services.AddControllers().AddCustomJsonOptions(); + +if (builder.Configuration.GetValue(ConfigurationKeys.Backup.Enabled)) +{ + builder.Services.AddHostedService(sp => sp.GetRequiredService()); +} + +WebApplication app = builder.Build(); + +await app.ExecuteMigrationsAndSeedAsync(); + +app.UseSwaggerConfig(builder.Environment) + .UseSerilogRequestLogging(options => options.GetLevel = GetRequestLoggingLevel) + .UseCors() + .UseMiddleware() + .UseAuthentication() + .UseAuthorization() + .UseMiddleware() + .UseExceptionHandlerConfig() + .UseLoggingToRequestContextMiddleware(builder.Configuration); + +app.MapControllers(); +app.MapHealthCheckEndpoints(); + +app.LogStartupBanner(); + +try +{ + await app.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, LogMessages.TerminatedUnexpectedly); +} +finally +{ + await Log.CloseAndFlushAsync(); +} + +// The docker-compose healthcheck polls /health/ready every 30s, so logging every successful poll at Information would drown out real request logs; failures still surface at Error regardless of path, so a flapping health check remains visible. +static LogEventLevel GetRequestLoggingLevel(HttpContext httpContext, double elapsedMs, Exception? ex) +{ + if (ex is not null || httpContext.Response.StatusCode > 499) + { + return LogEventLevel.Error; + } + + if (httpContext.Request.Path.StartsWithSegments("/health")) + { + return LogEventLevel.Verbose; + } + + return LogEventLevel.Information; +} + +/// +/// Visibility-only shim making Program a public partial type since WebApplicationFactory needs it for integration tests. +/// +public partial class Program +{ + protected Program() { } +} diff --git a/Club12-Backend/API/Properties/launchSettings.json b/Club12-Backend/API/Properties/launchSettings.json new file mode 100644 index 0000000..1c3ebac --- /dev/null +++ b/Club12-Backend/API/Properties/launchSettings.json @@ -0,0 +1,67 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5194", + "dotnetRunMessages": true + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:7122;http://localhost:5194", + "dotnetRunMessages": true + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Facundo": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Facundo" + }, + "applicationUrl": "https://localhost:5001/" + }, + "Franco": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Franco" + }, + "applicationUrl": "https://localhost:5001/" + }, + "Tomas": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Tomas" + }, + "applicationUrl": "https://localhost:5001/" + } + }, + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:59691/", + "sslPort": 44349 + } + } +} \ No newline at end of file diff --git a/Club12-Backend/API/Utils/ConfigurationKeys.cs b/Club12-Backend/API/Utils/ConfigurationKeys.cs new file mode 100644 index 0000000..d4ea166 --- /dev/null +++ b/Club12-Backend/API/Utils/ConfigurationKeys.cs @@ -0,0 +1,71 @@ +namespace API.Utils; + +/// +/// Centralized configuration key paths read at startup, so a typo in a key name is caught by the compiler instead of silently returning null or a default value at runtime. +/// +public static class ConfigurationKeys +{ + public const string DbConnection = "DbConnection"; + public const string AllowedOrigins = "AllowedOrigins"; + public const string UseLoggingMiddleware = "UseLoggingMiddleware"; + + public static class Jwt + { + public const string Key = "JWT:Key"; + public const string Issuer = "JWT:Issuer"; + public const string Audience = "JWT:Audience"; + } + + public static class Swagger + { + public const string Title = "Swagger:Title"; + public const string Version = "Swagger:Version"; + } + + public static class Smtp + { + public const string Host = "Smtp:Host"; + public const string Port = "Smtp:Port"; + public const string Username = "Smtp:Username"; + public const string Password = "Smtp:Password"; + public const string UseSsl = "Smtp:UseSsl"; + public const string FromEmail = "Smtp:FromEmail"; + public const string FromName = "Smtp:FromName"; + } + + public static class Backup + { + public const string Section = "Backup"; + public const string Enabled = "Backup:Enabled"; + } + + public static class Seed + { + public const string Enabled = "Seed:Enabled"; + + /// + /// When true, the startup DataSeeder deletes existing sample domain data before seeding, forcing a clean, FK-safe reseed; defaults to false. + /// + public const string Reset = "Seed:Reset"; + + /// + /// Filesystem folder the startup DataSeeder reads team crest PNGs from to upload as real team logos, falling back to placeholders when absent. + /// + public const string LogosPath = "Seed:LogosPath"; + + /// + /// Filesystem path to the medical PDF the startup DataSeeder uploads for every should-be-habilitado seeded registration, skipping the backfill step without failing the seed when absent. + /// + public const string MedicalRecordPath = "Seed:MedicalRecordPath"; + + /// + /// How many consecutive seasons of history the startup DataSeeder builds, counting backwards from the most recent one and clamped to a safe range by the seeder. + /// + public const string Seasons = "Seed:Seasons"; + + /// + /// Roster size for every seeded team, defaulting to SampleTournamentBuilder.DefaultPlayersPerTeam and clamped to a safe range by the seeder. + /// + public const string PlayersPerTeam = "Seed:PlayersPerTeam"; + } +} diff --git a/Club12-Backend/API/Utils/ControllerBaseExtensions.cs b/Club12-Backend/API/Utils/ControllerBaseExtensions.cs new file mode 100644 index 0000000..f6f0f99 --- /dev/null +++ b/Club12-Backend/API/Utils/ControllerBaseExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace API.Utils; + +/// +/// Shared ControllerBase extensions for consistent HTTP responses across controllers. +/// +public static class ControllerBaseExtensions +{ + /// + /// Builds a 404 response whose ProblemDetails body matches the shape emitted by GlobalExceptionHandler for unhandled exceptions. + /// + /// The controller issuing the response. + /// The entity type that could not be found. + /// The identifier that was looked up. + /// An ObjectResult with a 404 status and ProblemDetails body. + public static ObjectResult NotFoundProblem(this ControllerBase controller, string entity, object id) + { + return controller.Problem( + detail: $"{entity} with id {id} not found.", + statusCode: StatusCodes.Status404NotFound, + title: "Not Found: The specified resource could not be found."); + } + + /// + /// Builds a 400 response whose ProblemDetails body matches the shape emitted by GlobalExceptionHandler for unhandled exceptions, so the frontend's error handler (which reads the detail field) surfaces this message instead of a generic fallback. + /// + /// The controller issuing the response. + /// The specific, user-facing reason the request was rejected. + /// An ObjectResult with a 400 status and ProblemDetails body. + public static ObjectResult BadRequestProblem(this ControllerBase controller, string detail) + { + return controller.Problem( + detail: detail, + statusCode: StatusCodes.Status400BadRequest, + title: "Bad Request: The request could not be completed."); + } +} diff --git a/Club12-Backend/API/Utils/Converters/DateOnlyJsonConverter.cs b/Club12-Backend/API/Utils/Converters/DateOnlyJsonConverter.cs new file mode 100644 index 0000000..12f498f --- /dev/null +++ b/Club12-Backend/API/Utils/Converters/DateOnlyJsonConverter.cs @@ -0,0 +1,69 @@ +using Application.Utils.Constants; + +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace API.Utils.Converters; + +/// +/// A custom JSON converter for DateTime that accepts many common input formats when reading but always writes a single canonical format. +/// +public class DateOnlyJsonConverter : JsonConverter +{ + + /// + /// The formats accepted when parsing a date from JSON, tried in order; only used for reading, since Write always emits a single canonical format. + /// + private static readonly string[] _formats = + [ + "dd/MM/yyyy", + "MM/dd/yyyy", + "yyyy-MM-dd", + "yyyy/MM/dd", + "yyyyMMdd", + "dd-MM-yyyy", + "MM-dd-yyyy", + "dd.MM.yyyy", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fffZ", + "o", + "s", + "yyyy-MM-ddTHH:mm" + ]; + + /// + /// Reads and converts the JSON string to a DateTime, trying each of the accepted formats in order until one matches. + /// + /// The Utf8JsonReader to read from. + /// The type to convert, expected to be DateTime. + /// The JsonSerializerOptions used during deserialization. + /// A DateTime parsed from the JSON string. + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? dateString = reader.GetString(); + + return DateTime.TryParseExact( + dateString, + _formats, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime parsedDate) + ? DateTime.SpecifyKind(parsedDate, DateTimeKind.Unspecified) + : throw new JsonException(ErrorMessages.Serialization.InvalidDate(dateString)); + } + + /// + /// Writes a DateTime value as a JSON string using the canonical yyyy-MM-ddTHH:mm:ss.fffZ format, regardless of which accepted format it was originally read from. + /// + /// The Utf8JsonWriter to write to. + /// The DateTime value to serialize. + /// The JsonSerializerOptions used during serialization. + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)); + } +} diff --git a/Club12-Backend/API/Utils/DisplayFilter.cs b/Club12-Backend/API/Utils/DisplayFilter.cs new file mode 100644 index 0000000..5f2260d --- /dev/null +++ b/Club12-Backend/API/Utils/DisplayFilter.cs @@ -0,0 +1,39 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.SwaggerGen; + +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; + +namespace API.Utils; + +/// +/// Displays the display name of an enum value in the Swagger UI. +/// +public class DisplayEnumSchemaFilter : ISchemaFilter +{ + /// + /// Replaces an enum's raw member names in the generated schema with each value's DisplayAttribute.Name, falling back to the member name when absent, so Swagger shows the same friendly text as the UI. + /// + /// The OpenAPI schema being generated for context's type. + /// The schema generation context; used to check Type for enum-ness. + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (context.Type.IsEnum) + { + Type enumType = context.Type; + schema.Enum = [.. Enum.GetNames(enumType) + .Select(name => + { + MemberInfo[] memberInfo = enumType.GetMember(name); + DisplayAttribute? displayAttribute = memberInfo[0].GetCustomAttribute(); + + string displayName = displayAttribute?.Name ?? name; + return new OpenApiString(displayName) as IOpenApiAny; + })]; + } + } +} \ No newline at end of file diff --git a/Club12-Backend/API/Utils/FileExtensionMappings.cs b/Club12-Backend/API/Utils/FileExtensionMappings.cs new file mode 100644 index 0000000..07e30ad --- /dev/null +++ b/Club12-Backend/API/Utils/FileExtensionMappings.cs @@ -0,0 +1,17 @@ +namespace API.Utils; + +/// +/// Maps file-extension enums to the actual dotted extension string, so ImageFileExtension.Jpg maps to ".jpg". +/// +public static class FileExtensionMappings +{ + public static string ToExtensionString(this ImageFileExtension extension) + { + return $".{extension.ToString().ToLowerInvariant()}"; + } + + public static string ToExtensionString(this SpreadsheetFileExtension extension) + { + return $".{extension.ToString().ToLowerInvariant()}"; + } +} diff --git a/Club12-Backend/API/Utils/FileExtensions.cs b/Club12-Backend/API/Utils/FileExtensions.cs new file mode 100644 index 0000000..6fa16bd --- /dev/null +++ b/Club12-Backend/API/Utils/FileExtensions.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Http; + +using System; +using System.IO; + +namespace API.Utils; + +/// +/// Provides extension methods for file validation. +/// +public static class FileExtensions +{ + private static readonly ImageFileExtension[] _validImageExtensions = + [ + ImageFileExtension.Jpg, + ImageFileExtension.Jpeg, + ImageFileExtension.Png, + ImageFileExtension.Webp, + ]; + + private static readonly SpreadsheetFileExtension[] _validSpreadsheetExtensions = + [ + SpreadsheetFileExtension.Xls, + SpreadsheetFileExtension.Xlsx, + ]; + + /// + /// Checks if the provided file is a valid JPEG, PNG, or WEBP image. + /// + /// The uploaded file to check. + /// True if the file is a valid image, otherwise false. + public static bool IsValidImageFile(this IFormFile file) + { + if (file is null || file.Length is 0) + { + return false; + } + + string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + + return Array.Exists(_validImageExtensions, extension => extension.ToExtensionString() == fileExtension); + } + + private const string PdfExtension = ".pdf"; + private const string PdfContentType = "application/pdf"; + + /// + /// Checks whether the uploaded file is a valid PDF by extension and, when present, content type. Used to gate medical-record uploads. + /// + /// The uploaded file to check. + /// True if the file is a non-empty PDF, otherwise false. + public static bool IsValidPdfFile(this IFormFile file) + { + if (file is null || file.Length is 0) + { + return false; + } + + string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + bool extensionOk = fileExtension == PdfExtension; + bool contentTypeOk = string.IsNullOrEmpty(file.ContentType) + || string.Equals(file.ContentType, PdfContentType, StringComparison.OrdinalIgnoreCase); + + return extensionOk && contentTypeOk; + } + + /// + /// Checks if the provided file is a valid XLSX or XLS Excel file. + /// + /// The uploaded file to check. + /// True if the file is a valid Excel file, otherwise false. + public static bool IsValidExcelFile(this IFormFile file) + { + if (file is null || file.Length is 0) + { + return false; + } + + string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + + return Array.Exists(_validSpreadsheetExtensions, extension => extension.ToExtensionString() == fileExtension); + } +} diff --git a/Club12-Backend/API/Utils/GlobalExceptionHandler.cs b/Club12-Backend/API/Utils/GlobalExceptionHandler.cs new file mode 100644 index 0000000..58a9de1 --- /dev/null +++ b/Club12-Backend/API/Utils/GlobalExceptionHandler.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace API.Utils; + +/// +/// Handles global exceptions and returns standardized ProblemDetails responses. +/// +public class GlobalExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _logger.LogInformation("GlobalHandlerException initialized."); + } + + /// + /// Tries to handle exceptions globally and returns standardized ProblemDetails. + /// + /// The current HTTP context. + /// The exception to handle. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation of handling the exception. + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + IExceptionHandlerFeature? exceptionHandlerFeature = httpContext.Features.Get(); + if (exceptionHandlerFeature is not null) + { + string traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; + Exception exceptionDetails = exceptionHandlerFeature.Error; + + _logger.LogError(exceptionDetails, + "An unhandled exception occurred on the {MachineName}. TraceId: {TraceId}.", + Environment.MachineName, + traceId); + + (int statusCode, string title, bool exposeMessage) = MapException(exceptionDetails); + + IResult result = Results.Problem( + title: title, + detail: exposeMessage + ? exceptionDetails.Message + : "Ocurrió un error inesperado. Contactá a soporte con el ID de rastreo a continuación.", + statusCode: statusCode, + extensions: new Dictionary { ["traceId"] = traceId } + ); + + await result.ExecuteAsync(httpContext); + + return true; + } + + return false; + } + + /// + /// Maps known exceptions to HTTP status codes and response titles, and decides whether the exception's own message is safe to expose to the client. + /// + /// The exception to map. + /// + /// A tuple containing the HTTP status code, a corresponding title for the response, + /// and whether the exception's message is safe to expose to the client. + /// + private static (int StatusCode, string Title, bool ExposeMessage) MapException(Exception exception) + { + return exception switch + { + ArgumentNullException => (StatusCodes.Status400BadRequest, "Bad Request: Required argument is missing.", true), + UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Forbidden: You do not have permission to access this resource.", true), + KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found: The specified resource could not be found.", true), + InvalidOperationException => (StatusCodes.Status409Conflict, "Conflict: The operation is invalid in the current state.", true), + TimeoutException => (StatusCodes.Status408RequestTimeout, "Request Timeout: The operation took too long to complete.", false), + FormatException => (StatusCodes.Status400BadRequest, "Bad Request: Invalid format encountered.", true), + NotImplementedException => (StatusCodes.Status501NotImplemented, "Not Implemented: The requested functionality is not available.", true), + StackOverflowException => (StatusCodes.Status500InternalServerError, "Internal Server Error: Stack overflow occurred.", false), + OperationCanceledException => (StatusCodes.Status499ClientClosedRequest, "Client Closed Request: Operation was cancelled by the client.", true), + _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.", false) + }; + } +} diff --git a/Club12-Backend/API/Utils/Helpers/AuthHelper.cs b/Club12-Backend/API/Utils/Helpers/AuthHelper.cs new file mode 100644 index 0000000..7eb54aa --- /dev/null +++ b/Club12-Backend/API/Utils/Helpers/AuthHelper.cs @@ -0,0 +1,17 @@ +using System; +using System.Security.Claims; + +namespace API.Utils.Helpers; + +public static class AuthHelper +{ + /// + /// Extracts the caller's role and id from the Role and NameIdentifier claims, letting Guid.Parse throw a FormatException on an unauthenticated or malformed principal rather than returning a default value. + /// + public static (string role, Guid id) GetCallerClaims(this ClaimsPrincipal principal) + { + string role = principal.FindFirstValue(ClaimTypes.Role) ?? string.Empty; + string sub = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + return (role, Guid.Parse(sub)); + } +} diff --git a/Club12-Backend/API/Utils/Helpers/HttpCurrentUserAccessor.cs b/Club12-Backend/API/Utils/Helpers/HttpCurrentUserAccessor.cs new file mode 100644 index 0000000..da07cea --- /dev/null +++ b/Club12-Backend/API/Utils/Helpers/HttpCurrentUserAccessor.cs @@ -0,0 +1,28 @@ +using Application.Interfaces.Services; + +using Domain.Constants; + +using Microsoft.AspNetCore.Http; + +using System.Security.Claims; + +namespace API.Utils.Helpers; + +/// +/// Resolves the current caller's identity from the HTTP context for the audit trail, falling back to AuditConstants.SystemUser when there is no authenticated request. +/// +public sealed class HttpCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor +{ + public string Actor + { + get + { + ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User; + + string? identifier = user?.FindFirstValue(ClaimTypes.Email) + ?? user?.FindFirstValue(ClaimTypes.NameIdentifier); + + return string.IsNullOrWhiteSpace(identifier) ? AuditConstants.SystemUser : identifier; + } + } +} diff --git a/Club12-Backend/API/Utils/ImageFileExtension.cs b/Club12-Backend/API/Utils/ImageFileExtension.cs new file mode 100644 index 0000000..1b39af2 --- /dev/null +++ b/Club12-Backend/API/Utils/ImageFileExtension.cs @@ -0,0 +1,12 @@ +namespace API.Utils; + +/// +/// The image formats accepted for photo/logo uploads. +/// +public enum ImageFileExtension +{ + Jpg, + Jpeg, + Png, + Webp, +} diff --git a/Club12-Backend/API/Utils/LogMessages.cs b/Club12-Backend/API/Utils/LogMessages.cs new file mode 100644 index 0000000..f677605 --- /dev/null +++ b/Club12-Backend/API/Utils/LogMessages.cs @@ -0,0 +1,22 @@ +namespace API.Utils; + +/// +/// Centralized log message text used at application startup and shutdown. +/// +public static class LogMessages +{ + public const string StartingUp = "----- Starting up -----"; + public const string Started = "----- Started -----"; + public const string TerminatedUnexpectedly = "Application terminated unexpectedly"; + + public const string Banner = @" + + #### ## ## ## ##### ## #### + ## ## ## ## ## ## ## ### ## ## + ## ## ## ## ##### ## ## + ## ## ## ## ## ## ## ## + ## ## ## ## ## ## ## ## ## + #### ###### ###### ##### ###### ###### + +"; +} diff --git a/Club12-Backend/API/Utils/Middlewares/MaintenanceModeMiddleware.cs b/Club12-Backend/API/Utils/Middlewares/MaintenanceModeMiddleware.cs new file mode 100644 index 0000000..53239b8 --- /dev/null +++ b/Club12-Backend/API/Utils/Middlewares/MaintenanceModeMiddleware.cs @@ -0,0 +1,37 @@ +using Application.Interfaces.Backup; + +using Microsoft.AspNetCore.Http; + +using System; +using System.Threading.Tasks; + +namespace API.Utils.Middlewares; + +/// +/// Returns 503 for every request outside the allow-listed paths while maintenance mode is active. +/// +public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeState maintenanceModeState) +{ + private static readonly string[] AllowedPaths = + [ + "/health", + "/api/maintenance", + ]; + + public async Task InvokeAsync(HttpContext context) + { + if (maintenanceModeState.IsActive && !Array.Exists(AllowedPaths, p => context.Request.Path.StartsWithSegments(p))) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = "30"; + await context.Response.WriteAsJsonAsync(new + { + error = "The application is temporarily in maintenance mode.", + reason = maintenanceModeState.Reason, + }); + return; + } + + await next(context); + } +} diff --git a/Club12-Backend/API/Utils/Middlewares/MustChangePasswordMiddleware.cs b/Club12-Backend/API/Utils/Middlewares/MustChangePasswordMiddleware.cs new file mode 100644 index 0000000..d5b0663 --- /dev/null +++ b/Club12-Backend/API/Utils/Middlewares/MustChangePasswordMiddleware.cs @@ -0,0 +1,39 @@ +using Application.Utils.Constants.Auth; + +using Microsoft.AspNetCore.Http; + +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace API.Utils.Middlewares; + +/// +/// Rejects every request except the user-update and logout endpoints with 403 once CustomClaimTypes.MustChangePassword is set on the caller's token, until the password is changed. +/// +public class MustChangePasswordMiddleware(RequestDelegate next) +{ + private static readonly string[] AllowedPaths = + [ + "/api/users/", + "/api/auth/logout", + ]; + + public async Task InvokeAsync(HttpContext context) + { + bool mustChange = context.User.FindFirstValue(CustomClaimTypes.MustChangePassword) == "true"; + + if (mustChange && !Array.Exists(AllowedPaths, p => context.Request.Path.StartsWithSegments(p))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new + { + error = "Password change required.", + action = "PUT /api/users/{your-id}/password" + }); + return; + } + + await next(context); + } +} diff --git a/Club12-Backend/API/Utils/Middlewares/RequestLoggingMiddleware.cs b/Club12-Backend/API/Utils/Middlewares/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..97fdae5 --- /dev/null +++ b/Club12-Backend/API/Utils/Middlewares/RequestLoggingMiddleware.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using System.Diagnostics; +using System.Threading.Tasks; + +namespace API.Utils.Middlewares; + +/// +/// Logs one compact, structured line per HTTP request: method, path, status code, elapsed time, and correlation id. +/// +public class RequestLoggingMiddleware(RequestDelegate next, ILogger logger) +{ + private const string LogTemplate = + "HTTP {Method} {PathAndQuery} responded {StatusCode} in {ElapsedMs} ms [{CorrelationId}]"; + private const string HttpMethodOptions = "OPTIONS"; + + /// + /// Times the request, invokes the pipeline, and logs the outcome at a level matched to the response status code. + /// + /// The current HTTP context. + /// A task representing the asynchronous operation. + public async Task InvokeAsync(HttpContext context) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + + await next(context); + + stopwatch.Stop(); + + bool isPreflight = context.Request.Method == HttpMethodOptions; + int statusCode = context.Response.StatusCode; + LogLevel level = statusCode switch + { + _ when isPreflight => LogLevel.Debug, + >= 500 => LogLevel.Error, + >= 400 => LogLevel.Warning, + _ => LogLevel.Information, + }; + + logger.Log( + level, + LogTemplate, + context.Request.Method, + context.Request.Path + context.Request.QueryString, + statusCode, + stopwatch.ElapsedMilliseconds, + context.TraceIdentifier); + } +} diff --git a/Club12-Backend/API/Utils/SpreadsheetFileExtension.cs b/Club12-Backend/API/Utils/SpreadsheetFileExtension.cs new file mode 100644 index 0000000..b9deeb7 --- /dev/null +++ b/Club12-Backend/API/Utils/SpreadsheetFileExtension.cs @@ -0,0 +1,10 @@ +namespace API.Utils; + +/// +/// The spreadsheet formats accepted for bulk-import uploads. +/// +public enum SpreadsheetFileExtension +{ + Xls, + Xlsx, +} diff --git a/Club12-Backend/API/Utils/StartupExtensions.cs b/Club12-Backend/API/Utils/StartupExtensions.cs new file mode 100644 index 0000000..2367abc --- /dev/null +++ b/Club12-Backend/API/Utils/StartupExtensions.cs @@ -0,0 +1,533 @@ +using API.BackgroundServices; +using API.Utils.Converters; +using API.Utils.Middlewares; + +using Application.Backup; +using Application.Interfaces.Backup; +using Application.Interfaces.Mappers; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Interfaces.Storage; +using Application.Services; +using Application.Utils.Constants; +using Application.Utils.Helper.SupabaseHelper; +using Application.Utils.Mappers; + +using Domain.Enums; + +using FluentEmail.MailKitSmtp; + +using Infrastructure.Backup; +using Infrastructure.Email; +using Infrastructure.Identity; +using Infrastructure.Persistance; +using Infrastructure.Repositories; +using Infrastructure.Storage; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; + +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; + +using Serilog; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace API.Utils; + +/// +/// Provides extension methods for configuring application startup, including logging, database context, CORS, authentication, authorization, Swagger, JSON options, and dependency injection. +/// +public static class StartupExtensions +{ + /// + /// Configures Serilog logging for the application host. + /// + public static void AddSerilogConfig(this IHostBuilder hostBuilder, IConfiguration configuration) + { + hostBuilder.UseSerilog((context, config) => config.ReadFrom.Configuration(configuration)); + } + + /// + /// Adds and configures the application's database context and dependency injection for the database. + /// + public static IServiceCollection AddDbContextConfig(this IServiceCollection services, IConfiguration configuration) + { + string? connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection); + if (connectionString is null) + { + Log.Fatal("Connection string is missing. Using default or fallback connection string."); + throw new ArgumentException(ErrorMessages.Configuration.ConnectionStringMissing); + } + services.AddDbContext(options => options.UseNpgsql(connectionString, ConfigureNpgsql)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// Shared Npgsql options for every DbContext so both stay in sync, with a bounded command timeout that turns a pathological query into a fast failure instead of a hung request. + /// + private static void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder npgsql) + { + npgsql.CommandTimeout(30); + } + + /// + /// Adds and configures CORS policies for the application. + /// + public static IServiceCollection AddCorsConfig(this IServiceCollection services, IConfiguration configuration) + { + services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(configuration.GetSection(ConfigurationKeys.AllowedOrigins).Get()!) + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + + return services; + } + + /// + /// Registers the self health check tagged live, which always reports Healthy, and the db health check tagged ready, which probes ApplicationDBContext connectivity so a misconfigured or unreachable connection string surfaces as not-ready instead of silently healthy. + /// + public static IServiceCollection AddHealthChecksConfig(this IServiceCollection services) + { + services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]) + .AddDbContextCheck("db", tags: ["ready"]); + return services; + } + + /// + /// Maps /health as the liveness endpoint using only the live-tagged self check, and /health/ready as the readiness endpoint using only the ready-tagged database check; both are anonymous since no fallback authorization policy exists. + /// + public static WebApplication MapHealthCheckEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live"), + }).AllowAnonymous(); + + app.MapHealthChecks("/health/ready", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("ready"), + }).AllowAnonymous(); + + return app; + } + + /// + /// Runs EF migrations for both ApplicationDBContext and IdentityAppDbContext, then seeds the initial admin user. + /// + public static async Task ExecuteMigrationsAndSeedAsync(this WebApplication app) + { + await using AsyncServiceScope scope = app.Services.CreateAsyncScope(); + + ApplicationDBContext db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + // The migration only adds the schema, so the data backfill runs here as an idempotent step to give every team a stable cross-season club identity regardless of which migration or seed path created it; a re-run is a cheap no-op once every team is already linked. + IClubService clubService = scope.ServiceProvider.GetRequiredService(); + await clubService.BackfillClubsAsync(); + + IdentityAppDbContext identityDb = scope.ServiceProvider.GetRequiredService(); + await identityDb.Database.MigrateAsync(); + + IdentitySeeder seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(); + + IConfiguration configuration = scope.ServiceProvider.GetRequiredService(); + if (configuration.GetValue(ConfigurationKeys.Seed.Enabled)) + { + bool reset = configuration.GetValue(ConfigurationKeys.Seed.Reset); + string? logosPath = configuration[ConfigurationKeys.Seed.LogosPath]; + string? medicalRecordPath = configuration[ConfigurationKeys.Seed.MedicalRecordPath]; + int seasons = configuration.GetValue(ConfigurationKeys.Seed.Seasons, 1); + int playersPerTeam = configuration.GetValue( + ConfigurationKeys.Seed.PlayersPerTeam, SampleTournamentBuilder.DefaultPlayersPerTeam); + DataSeeder dataSeeder = scope.ServiceProvider.GetRequiredService(); + await dataSeeder.SeedAsync(reset, logosPath, medicalRecordPath, seasons, playersPerTeam); + } + } + + /// + /// Logs the startup banner once the app is fully configured and about to start listening for requests. + /// + public static void LogStartupBanner(this WebApplication app) + { + Log.Information(LogMessages.StartingUp); + Log.Information(LogMessages.Banner); + Log.Information(LogMessages.Started); + } + + /// + /// Configures Swagger middleware for API documentation in non-production environments. + /// + public static IApplicationBuilder UseSwaggerConfig(this IApplicationBuilder app, IHostEnvironment env) + { + if (!env.IsProduction()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + return app; + } + + /// + /// Configures exception handling and status code pages middleware. + /// + public static IApplicationBuilder UseExceptionHandlerConfig(this IApplicationBuilder app) + { + app.UseStatusCodePages(); + app.UseExceptionHandler(); + + return app; + } + + /// + /// One policy per domain role — single source of truth via UserRoleType enum. + /// + private static readonly string[] _roleNames = + [ + UserRoleType.ADMIN.ToRoleName(), + UserRoleType.OWNER.ToRoleName(), + UserRoleType.GUEST.ToRoleName(), + ]; + + /// + /// Adds custom authorization policies based on UserRoleType domain roles. + /// + public static IServiceCollection AddCustomAuthorization(this IServiceCollection services) + { + services.AddAuthorization(options => + { + foreach (string role in _roleNames) + { + options.AddPolicy(role, policy => policy.RequireRole(role)); + } + }); + + return services; + } + + /// + /// Adds and configures JWT authentication for the application. + /// + public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) + { + string? jwtSecret = configuration.GetSection(ConfigurationKeys.Jwt.Key)?.Value; + if (string.IsNullOrEmpty(jwtSecret)) + { + throw new ArgumentException(ErrorMessages.Configuration.JwtMissing); + } + + services.AddAuthentication(options => + { + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration[ConfigurationKeys.Jwt.Issuer], + ValidAudience = configuration[ConfigurationKeys.Jwt.Audience], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)) + }; + }); + + return services; + } + + /// + /// Adds middleware for logging requests to the request context if enabled in configuration. + /// + public static IApplicationBuilder UseLoggingToRequestContextMiddleware(this IApplicationBuilder app, IConfiguration configuration) + { + bool useLoggingMiddleware = configuration.GetValue(ConfigurationKeys.UseLoggingMiddleware, false); + if (useLoggingMiddleware) + { + app.UseMiddleware(); + Log.Information("Request logging middleware enabled."); + } + else + { + Log.Information("Request logging middleware disabled."); + } + return app; + } + + /// + /// Adds custom JSON serialization options for MVC, including enum and date converters. + /// + public static void AddCustomJsonOptions(this IMvcBuilder builder) + { + builder.AddJsonOptions(options => + { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter()); + }); + } + + /// + /// Adds and configures Swagger for API documentation, including security definitions and schema filters. + /// + public static IServiceCollection AddCustomSwagger(this IServiceCollection services, IConfiguration configuration) + { + const string bearerScheme = "Bearer"; + const string swaggerDocVersion = "v1"; + + services.AddSwaggerGen(context => + { + context.SwaggerDoc(swaggerDocVersion, new OpenApiInfo + { + Title = configuration[ConfigurationKeys.Swagger.Title], + Version = configuration[ConfigurationKeys.Swagger.Version], + }); + + // Only API's own xml file was ever wired in here, so DTO/enum summaries defined outside the API project never reached Swagger's schema view even though the compiled xml exists for each project. + string[] xmlDocAssemblyNames = ["API", "Application", "Domain", "Infrastructure"]; + foreach (string assemblyName in xmlDocAssemblyNames) + { + string xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.xml"); + if (File.Exists(xmlPath)) + { + context.IncludeXmlComments(xmlPath); + } + } + + context.ResolveConflictingActions(apiDescriptions => apiDescriptions.First()); + + context.AddSecurityDefinition(bearerScheme, new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below.", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = bearerScheme + }); + + context.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = bearerScheme }, + Scheme = "oauth2", + Name = bearerScheme, + In = ParameterLocation.Header + }, + new List() + } + }); + + context.SchemaFilter(); + }); + + return services; + } + + /// + /// Registers singleton services for dependency injection. + /// + public static IServiceCollection RegisterSingletons(this IServiceCollection services) + { + services.AddSingleton(); + + // Medical-record file storage reuses the shared Supabase client via ISupabaseRawStorage and confines files to their own medical-records area, separate from backups, registered here unconditionally because medical-record uploads always target Supabase. + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + return services; + } + + /// + /// Registers Identity DbContext, ASP.NET Core Identity services, IAuthenticationService, IUserManagementService, and IdentitySeeder. + /// + public static IServiceCollection AddIdentityConfig( + this IServiceCollection services, IConfiguration configuration) + { + string? connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException(ErrorMessages.Configuration.ConnectionStringMissing); + } + + services.AddDbContext(options => options.UseNpgsql(connectionString, ConfigureNpgsql)); + + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// Registers scoped services and repositories for dependency injection using reflection. + /// + public static IServiceCollection RegisterScoped(this IServiceCollection services) + { + string? serviceInterfaceNamespace = typeof(IDivisionService).Namespace; + string? serviceImplNamespace = typeof(DivisionService).Namespace; + string? repositoryInterfaceNamespace = typeof(IBlogPostRepository).Namespace; + string? repositoryImplNamespace = typeof(BlogPostRepository).Namespace; + string? mapperInterfaceNamespace = typeof(IScorerMapper).Namespace; + string? mapperImplNamespace = typeof(ScorerMapper).Namespace; + string serviceSuffix = "Service"; + string repositorySuffix = "Repository"; + string mapperSuffix = "Mapper"; + + ArgumentNullException.ThrowIfNull(serviceInterfaceNamespace); + ArgumentNullException.ThrowIfNull(serviceImplNamespace); + ArgumentNullException.ThrowIfNull(repositoryInterfaceNamespace); + ArgumentNullException.ThrowIfNull(repositoryImplNamespace); + ArgumentNullException.ThrowIfNull(mapperInterfaceNamespace); + ArgumentNullException.ThrowIfNull(mapperImplNamespace); + + Assembly serviceAssembly = typeof(AuthService).Assembly; + Assembly IServiceAssembly = typeof(IAuthService).Assembly; + Assembly repoAssembly = typeof(BlogPostRepository).Assembly; + Assembly IRepoAssembly = typeof(IBlogPostRepository).Assembly; + Assembly mapperAssembly = typeof(ScorerMapper).Assembly; + Assembly IMapperAssembly = typeof(IScorerMapper).Assembly; + + services.HelperRegisterScoped(serviceAssembly, IServiceAssembly, serviceSuffix, serviceInterfaceNamespace, serviceImplNamespace); + services.HelperRegisterScoped(repoAssembly, IRepoAssembly, repositorySuffix, repositoryInterfaceNamespace, repositoryImplNamespace); + services.HelperRegisterScoped(mapperAssembly, IMapperAssembly, mapperSuffix, mapperInterfaceNamespace, mapperImplNamespace); + + + services.AddScoped(); + + // Resolves the current caller's identity from the HTTP context so application and infrastructure services can record who without depending on the web layer. + services.AddHttpContextAccessor(); + services.AddScoped(); + + return services; + } + + private static void HelperRegisterScoped(this IServiceCollection services, Assembly implementationAssembly, Assembly interfaceAssembly, string suffix, string iNamespace, string implNamespace) + { + string interfacePrefix = "I"; + + IEnumerable interfaces = interfaceAssembly.GetTypes() + .Where(t => t.IsInterface && t.Namespace == iNamespace + && t.Name.StartsWith(interfacePrefix) && t.Name.EndsWith(suffix)); + + foreach (Type iface in interfaces) + { + Type? implementation = Array.Find(implementationAssembly.GetTypes(), + t => t.IsClass && !t.IsAbstract + && t.Namespace == implNamespace + && t.Name == iface.Name[1..]); + + if (implementation is not null) + { + services.AddScoped(iface, implementation); + } + } + } + /// + /// Registers FluentEmail with Mailgun and binds IEmailService to FluentEmailHelper. + /// + public static IServiceCollection AddEmailConfig( + this IServiceCollection services, IConfiguration configuration) + { + string smtpHost = configuration[ConfigurationKeys.Smtp.Host] + ?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Host)); + int smtpPort = int.Parse(configuration[ConfigurationKeys.Smtp.Port] + ?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Port))); + string username = configuration[ConfigurationKeys.Smtp.Username] + ?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Username)); + string password = configuration[ConfigurationKeys.Smtp.Password] + ?? throw new ArgumentException(ErrorMessages.Configuration.SmtpKeyMissing(ConfigurationKeys.Smtp.Password)); + bool useSsl = configuration.GetValue(ConfigurationKeys.Smtp.UseSsl, true); + string fromEmail = configuration[ConfigurationKeys.Smtp.FromEmail] ?? "noreply@club12.com"; + string fromName = configuration[ConfigurationKeys.Smtp.FromName] ?? "Club12"; + + services + .AddFluentEmail(fromEmail, fromName) + .AddMailKitSender(new SmtpClientOptions + { + Server = smtpHost, + Port = smtpPort, + UseSsl = useSsl, + User = username, + Password = password, + RequiresAuthentication = true + }); + + services.AddScoped(); + + return services; + } + + /// + /// Registers the database backup feature by binding the Backup config section into a BackupOptions singleton and registering its ports and adapters. + /// + public static IServiceCollection AddBackupConfig(this IServiceCollection services, IConfiguration configuration) + { + BackupOptions options = configuration.GetSection(ConfigurationKeys.Backup.Section).Get() ?? new BackupOptions(); + services.AddSingleton(options); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + if (string.Equals(options.StorageTarget, BackupStorageTargets.Supabase, StringComparison.OrdinalIgnoreCase)) + { + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + } + else + { + services.AddSingleton(sp => new LocalDirectoryBackupStorage( + options.LocalStoragePath, sp.GetRequiredService>())); + } + + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + + return services; + } +} diff --git a/Club12-Backend/API/appsettings.json b/Club12-Backend/API/appsettings.json new file mode 100644 index 0000000..8270549 --- /dev/null +++ b/Club12-Backend/API/appsettings.json @@ -0,0 +1,51 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Enrichers.Environment", "Serilog.Enrichers.Process", "Serilog.Enrichers.Thread" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId", + "WithProcessId" + ], + "Filter": [ + { + "Name": "ByExcluding", + "Args": { + "expression": "@mt = 'An unhandled exception has occurred while executing the request.'" + } + } + ], + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}", + "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console" + } + } + ] + }, + "Backup": { + "Enabled": false, + "IntervalHours": 24, + "RetentionCount": 7, + "StorageTarget": "Local", + "LocalStoragePath": "backups", + "PgDumpPath": "pg_dump" + }, + "Seed": { + "Enabled": true, + "Reset": true, + "Seasons": 4, + "PlayersPerTeam": 12 + } +} diff --git a/Club12-Backend/Application/Application.csproj b/Club12-Backend/Application/Application.csproj new file mode 100644 index 0000000..07fbac9 --- /dev/null +++ b/Club12-Backend/Application/Application.csproj @@ -0,0 +1,20 @@ + + + + NU1903 + + + + + + + + + + + + net8.0 + enable + true + + \ No newline at end of file diff --git a/Club12-Backend/Application/Backup/BackupOperationLock.cs b/Club12-Backend/Application/Backup/BackupOperationLock.cs new file mode 100644 index 0000000..5f8dd6d --- /dev/null +++ b/Club12-Backend/Application/Backup/BackupOperationLock.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Backup; + +/// +/// Process-wide single-flight guard for backup and restore write operations. +/// +public sealed class BackupOperationLock +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + /// Attempts to acquire the lock, waiting up to timeout; callers pass TimeSpan.Zero to fail fast. + /// + public Task WaitAsync(TimeSpan timeout, CancellationToken ct = default) + { + return _semaphore.WaitAsync(timeout, ct); + } + + /// + /// Releases the lock; must be called exactly once for every successful WaitAsync that returned true. + /// + public void Release() + { + _semaphore.Release(); + } +} diff --git a/Club12-Backend/Application/Backup/BackupOperationsService.cs b/Club12-Backend/Application/Backup/BackupOperationsService.cs new file mode 100644 index 0000000..6bb6ba6 --- /dev/null +++ b/Club12-Backend/Application/Backup/BackupOperationsService.cs @@ -0,0 +1,244 @@ +using Application.DTOs.Backup.Response; +using Application.Interfaces.Backup; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using Microsoft.Extensions.Logging; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Backup; + +/// +/// The one shared write path for backup operations. +/// +public sealed class BackupOperationsService( + IBackupCatalog catalog, + IBackupStorage storage, + IDatabaseBackupService backupService, + IDatabaseRestoreService restoreService, + IBackupRetentionPolicy retentionPolicy, + BackupOptions options, + BackupOperationLock operationLock, + IMaintenanceModeState maintenanceModeState, + IAuditService auditService, + ILogger logger) : IBackupOperationsService +{ + public Task> ListNewestFirstAsync(CancellationToken ct = default) => + catalog.ListNewestFirstAsync(ct); + + public async Task CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default) + { + if (!await operationLock.WaitAsync(TimeSpan.Zero, ct)) + { + return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress); + } + + try + { + return await CreateBackupCoreAsync(origin, applyRetention: true, ct); + } + finally + { + operationLock.Release(); + } + } + + public async Task DeleteBackupAsync(Guid id, CancellationToken ct = default) + { + if (!await operationLock.WaitAsync(TimeSpan.Zero, ct)) + { + return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress); + } + + try + { + BackupRecord? record = await catalog.GetByIdAsync(id, ct); + if (record is null) + { + return new BackupOperationResult(BackupOperationOutcome.NotFound, null, null); + } + + try + { + await storage.DeleteAsync(record.StoragePath, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // An already-missing file deleted out-of-band must not block removing the catalog row, so log and continue. + logger.LogWarning( + ex, + "Backup file could not be deleted from storage; removing catalog record anyway. StoragePath: {StoragePath}", + record.StoragePath); + } + + await catalog.RemoveAsync(record.Id, ct); + + return new BackupOperationResult(BackupOperationOutcome.Completed, BackupRecordResponse.FromEntity(record), null); + } + finally + { + operationLock.Release(); + } + } + + /// + /// Enters maintenance mode, takes an automatic safety backup, restores the selected one, then exits. + /// + public async Task RestoreBackupAsync(Guid id, CancellationToken ct = default) + { + if (!await operationLock.WaitAsync(TimeSpan.Zero, ct)) + { + return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress); + } + + try + { + BackupRecord? record = await catalog.GetByIdAsync(id, ct); + if (record is null) + { + return new BackupOperationResult(BackupOperationOutcome.NotFound, null, null); + } + + maintenanceModeState.Enter($"Restoring backup {record.Id}."); + string? tempFilePath = null; + try + { + BackupOperationResult safetyBackup = await CreateBackupCoreAsync(BackupOrigin.Job, applyRetention: false, ct); + if (safetyBackup.Outcome != BackupOperationOutcome.Completed) + { + return new BackupOperationResult(BackupOperationOutcome.Failed, null, safetyBackup.Message); + } + + tempFilePath = Path.Combine(Path.GetTempPath(), $"restore-{Guid.NewGuid():N}.sql"); + await using (Stream source = await storage.OpenReadAsync(record.StoragePath, ct)) + await using (FileStream destination = File.Create(tempFilePath)) + { + await source.CopyToAsync(destination, ct); + } + + await restoreService.RestoreAsync(tempFilePath, ct); + + // Logging the restore is non-throwing by contract via IAuditService.LogAsync, so a logging hiccup never turns a successful restore into a reported failure. + await auditService.LogAsync( + AuditAction.BackupRestore, + targetType: nameof(BackupRecord), + targetId: record.Id.ToString(), + targetName: record.StoragePath, + detail: $"Origen: {record.Origin}", + ct: ct); + + return new BackupOperationResult(BackupOperationOutcome.Completed, safetyBackup.Record, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Restore failed; maintenance mode will be cleared and the host keeps running."); + return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message); + } + finally + { + if (tempFilePath is not null && File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + + maintenanceModeState.Exit(); + } + } + finally + { + operationLock.Release(); + } + } + + /// + /// Assumes the lock is already held by the caller, so a restore's safety backup avoids self-deadlock. + /// + private async Task CreateBackupCoreAsync(BackupOrigin origin, bool applyRetention, CancellationToken ct) + { + Stream dump; + try + { + dump = await backupService.CreateDumpAsync(ct); + } + catch (BackupExecutionException ex) + { + logger.LogError(ex, "Backup dump failed; no catalog record written."); + return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message); + } + + await using (dump) + { + string name = $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.sql"; + long sizeBytes = dump.Length; + + try + { + await storage.StoreAsync(name, dump, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to store backup dump; no catalog record written."); + return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message); + } + + BackupRecord record = new() + { + CreatedBy = AuditConstants.SystemUser, + StoragePath = name, + SizeBytes = sizeBytes, + Origin = origin, + }; + BackupRecord added = await catalog.AddAsync(record, ct); + + if (applyRetention) + { + await ApplyRetentionAsync(ct); + } + + return new BackupOperationResult(BackupOperationOutcome.Completed, BackupRecordResponse.FromEntity(added), null); + } + } + + private async Task ApplyRetentionAsync(CancellationToken ct) + { + IReadOnlyList all = await catalog.ListNewestFirstAsync(ct); + IReadOnlyList files = all + .Select(r => new BackupFile(r.StoragePath, new DateTimeOffset(DateTime.SpecifyKind(r.DateCreated, DateTimeKind.Utc)))) + .ToList(); + + IReadOnlyList toDelete = retentionPolicy.SelectForDeletion(files, options.RetentionCount); + if (toDelete.Count == 0) + { + return; + } + + Dictionary byStoragePath = all.ToDictionary(r => r.StoragePath, StringComparer.Ordinal); + IEnumerable recordsToDelete = toDelete + .Select(stale => byStoragePath.GetValueOrDefault(stale.Name)) + .OfType(); + + foreach (BackupRecord record in recordsToDelete) + { + try + { + await storage.DeleteAsync(record.StoragePath, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to delete stale backup file {StoragePath} during retention pruning.", record.StoragePath); + } + + await catalog.RemoveAsync(record.Id, ct); + } + } +} diff --git a/Club12-Backend/Application/Backup/KeepLastNRetentionPolicy.cs b/Club12-Backend/Application/Backup/KeepLastNRetentionPolicy.cs new file mode 100644 index 0000000..18d82e7 --- /dev/null +++ b/Club12-Backend/Application/Backup/KeepLastNRetentionPolicy.cs @@ -0,0 +1,35 @@ +using Application.Interfaces.Backup; +using Application.Utils.Constants; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Backup; + +/// +/// Pure keep-last-N retention policy that retains the newest entries by timestamp with no I/O. +/// +public sealed class KeepLastNRetentionPolicy : IBackupRetentionPolicy +{ + public IReadOnlyList SelectForDeletion(IReadOnlyList existing, int retainCount) + { + ArgumentNullException.ThrowIfNull(existing); + + if (retainCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(retainCount), ErrorMessages.Backup.RetentionCountNegative); + } + + if (existing.Count <= retainCount) + { + return []; + } + + return existing + .OrderByDescending(f => f.Timestamp) + .ThenBy(f => f.Name, StringComparer.Ordinal) + .Skip(retainCount) + .ToList(); + } +} diff --git a/Club12-Backend/Application/Backup/MaintenanceModeState.cs b/Club12-Backend/Application/Backup/MaintenanceModeState.cs new file mode 100644 index 0000000..c739a00 --- /dev/null +++ b/Club12-Backend/Application/Backup/MaintenanceModeState.cs @@ -0,0 +1,31 @@ +using Application.Interfaces.Backup; + +using System; + +namespace Application.Backup; + +/// +/// In-memory IMaintenanceModeState singleton, pure process state with no OS or network I/O. +/// +public sealed class MaintenanceModeState : IMaintenanceModeState +{ + public bool IsActive { get; private set; } + + public string? Reason { get; private set; } + + public DateTimeOffset? EnteredAtUtc { get; private set; } + + public void Enter(string reason) + { + Reason = reason; + EnteredAtUtc = DateTimeOffset.UtcNow; + IsActive = true; + } + + public void Exit() + { + IsActive = false; + Reason = null; + EnteredAtUtc = null; + } +} diff --git a/Club12-Backend/Application/DTOs/Abstract/Request/IOrderRequest.cs b/Club12-Backend/Application/DTOs/Abstract/Request/IOrderRequest.cs new file mode 100644 index 0000000..67a63b5 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Abstract/Request/IOrderRequest.cs @@ -0,0 +1,17 @@ +namespace Application.DTOs.Abstract.Request; + +/// +/// Defines the ordering request properties. +/// +public interface IOrderRequest +{ + /// + /// The property name to sort by. + /// + string? OrderBy { get; set; } + + /// + /// The sort order. Default is Ascending. + /// + SortOrder? Order { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Abstract/Request/IPaginationRequest.cs b/Club12-Backend/Application/DTOs/Abstract/Request/IPaginationRequest.cs new file mode 100644 index 0000000..4dce802 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Abstract/Request/IPaginationRequest.cs @@ -0,0 +1,17 @@ +namespace Application.DTOs.Abstract.Request; + +/// +/// Defines the pagination request properties. +/// +public interface IPaginationRequest +{ + /// + /// The page number for pagination. Default is 1. + /// + int PageNumber { get; set; } + + /// + /// The page size for pagination. Defaults to 100. + /// + int PageSize { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Abstract/Request/PaginatedFilterRequest.cs b/Club12-Backend/Application/DTOs/Abstract/Request/PaginatedFilterRequest.cs new file mode 100644 index 0000000..34ba0de --- /dev/null +++ b/Club12-Backend/Application/DTOs/Abstract/Request/PaginatedFilterRequest.cs @@ -0,0 +1,47 @@ +using Application.Utils.Constants.Pagination; + +namespace Application.DTOs.Abstract.Request; + +/// +/// Represents a request for paginated and filtered data. +/// +public class PaginatedFilterRequest : IPaginationRequest, IOrderRequest +{ + /// + /// The page number for pagination. Default is 1. + /// + public int PageNumber { get; set; } = 1; + + private int _pageSize = PaginationDefaults.DefaultPageSize; + + /// + /// The page size for pagination; values outside 1-100 are silently clamped rather than rejected. + /// + public int PageSize + { + get => _pageSize; + set => _pageSize = ClampPageSize(value); + } + + private static int ClampPageSize(int requestedPageSize) + { + if (requestedPageSize < 1) + { + return PaginationDefaults.DefaultPageSize; + } + + return requestedPageSize > PaginationDefaults.MaxPageSize + ? PaginationDefaults.MaxPageSize + : requestedPageSize; + } + + /// + /// The property name to sort by. + /// + public string? OrderBy { get; set; } = "DateCreated"; + + /// + /// The sort order. Default is Ascending. + /// + public SortOrder? Order { get; set; } = SortOrder.Ascending; +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Abstract/Request/SortOrder.cs b/Club12-Backend/Application/DTOs/Abstract/Request/SortOrder.cs new file mode 100644 index 0000000..a8b45cd --- /dev/null +++ b/Club12-Backend/Application/DTOs/Abstract/Request/SortOrder.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Abstract.Request; + +/// +/// Enum to specify the sort order. +/// +public enum SortOrder +{ + /// + /// Ascending order. + /// + [Display(Name = "asc")] + Ascending, + + /// + /// Descending order. + /// + [Display(Name = "desc")] + Descending +} diff --git a/Club12-Backend/Application/DTOs/Abstract/Response/BaseEntityResponse.cs b/Club12-Backend/Application/DTOs/Abstract/Response/BaseEntityResponse.cs new file mode 100644 index 0000000..d81dc3d --- /dev/null +++ b/Club12-Backend/Application/DTOs/Abstract/Response/BaseEntityResponse.cs @@ -0,0 +1,14 @@ +using System; + +namespace Application.DTOs.Abstract.Response; + +/// +/// Base response class containing common properties for responses. +/// +public class BaseEntityResponse +{ + /// + /// The unique identifier of the entity. + /// + public required Guid Id { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Abstract/Response/PaginatedResponse.cs b/Club12-Backend/Application/DTOs/Abstract/Response/PaginatedResponse.cs new file mode 100644 index 0000000..7acdae8 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Abstract/Response/PaginatedResponse.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Application.DTOs.Abstract.Response; + +/// +/// A page of results together with the metadata needed to fetch adjacent pages. +/// +/// The type of item contained in the page. +public class PaginatedResponse +{ + /// + /// The items in the collection. + /// + public IEnumerable Items { get; set; } = []; + + /// + /// The current page number. + /// + public required int Page { get; set; } + + /// + /// The number of items in a page. + /// + public required int PageSize { get; set; } + + /// + /// The total number of items in the collection. + /// + public required int TotalCount { get; set; } + +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/AuditLogs/Request/AuditLogFilteredRequest.cs b/Club12-Backend/Application/DTOs/AuditLogs/Request/AuditLogFilteredRequest.cs new file mode 100644 index 0000000..10586de --- /dev/null +++ b/Club12-Backend/Application/DTOs/AuditLogs/Request/AuditLogFilteredRequest.cs @@ -0,0 +1,27 @@ +using Application.DTOs.Abstract.Request; +using Application.Utils.Constants.Pagination; + +using Domain.Enums; + +namespace Application.DTOs.AuditLogs.Request; + +/// +/// Filtering and pagination for the audit-trail listing, defaulting to newest-first. +/// +public class AuditLogFilteredRequest : PaginatedFilterRequest +{ + public AuditLogFilteredRequest() + { + Order = SortOrder.Descending; + } + + /// + /// Optional filter by the actor who performed the action. + /// + public string? Actor { get; set; } + + /// + /// Optional filter by the action type. + /// + public AuditAction? Action { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/AuditLogs/Response/AuditLogResponse.cs b/Club12-Backend/Application/DTOs/AuditLogs/Response/AuditLogResponse.cs new file mode 100644 index 0000000..e2507ce --- /dev/null +++ b/Club12-Backend/Application/DTOs/AuditLogs/Response/AuditLogResponse.cs @@ -0,0 +1,46 @@ +using Application.DTOs.Abstract.Response; + +using System; + +namespace Application.DTOs.AuditLogs.Response; + +/// +/// Audit-trail entry as returned by the listing endpoint. +/// +public class AuditLogResponse : BaseEntityResponse +{ + /// + /// The sensitive action that was performed, as the enum name. + /// + public required string Action { get; set; } + + /// + /// Who performed the action, as an email address or "System". + /// + public required string Actor { get; set; } + + /// + /// The kind of entity targeted, when applicable. + /// + public string? TargetType { get; set; } + + /// + /// Identifier of the targeted entity, when applicable. + /// + public string? TargetId { get; set; } + + /// + /// Null when there is no single named target or the entry predates this field. + /// + public string? TargetName { get; set; } + + /// + /// Free-form human-readable context. + /// + public string? Detail { get; set; } + + /// + /// When the action happened, in UTC. + /// + public required DateTime Timestamp { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/ActivateAccountRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/ActivateAccountRequest.cs new file mode 100644 index 0000000..73dd326 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/ActivateAccountRequest.cs @@ -0,0 +1,23 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Payload the invited user submits from the activation email link to set their first password. +/// +public sealed class ActivateAccountRequest +{ + [Required, EmailAddress] + public string Email { get; set; } = string.Empty; + + /// + /// Activation token received via email, URL-decoded automatically by the framework. + /// + [Required] + public string Token { get; set; } = string.Empty; + + [Required, MinLength(UserFieldLengths.PasswordMinLength)] + public string NewPassword { get; set; } = string.Empty; +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/InviteUserRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/InviteUserRequest.cs new file mode 100644 index 0000000..475136f --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/InviteUserRequest.cs @@ -0,0 +1,28 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Request to create a user by email only; the system emails a magic activation link to set the password. +/// +public sealed class InviteUserRequest +{ + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Invalid email format.")] + public required string Email { get; set; } + + /// + /// Optional contact phone number. + /// + [ArgentinePhoneNumber] + [MaxLength(UserFieldLengths.PhoneMaxLength, ErrorMessage = "Phone number must not exceed 15 characters.")] + public string? Phone { get; set; } + + /// + /// Target role. Accepted values: ADMIN, OWNER. + /// + [Required(ErrorMessage = "Role is required.")] + public required string Role { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/LogInUserRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/LogInUserRequest.cs new file mode 100644 index 0000000..db31dbe --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/LogInUserRequest.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Auth.Request; + +/// +/// Represents a request object for user login. +/// +public class LogInUserRequest +{ + [Required(ErrorMessage = "The Email field is required.")] + public required string Email { get; set; } + + [Required(ErrorMessage = "The Password field is required.")] + public required string Password { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/MagicLinkLoginRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/MagicLinkLoginRequest.cs new file mode 100644 index 0000000..9aca287 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/MagicLinkLoginRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Request to login using a magic-link token. +/// +public sealed class MagicLinkLoginRequest +{ + [Required(ErrorMessage = "The Email field is required.")] + [EmailAddress(ErrorMessage = "Invalid email format.")] + public required string Email { get; set; } + + [Required(ErrorMessage = "The Token field is required.")] + public required string Token { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/MagicLinkRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/MagicLinkRequest.cs new file mode 100644 index 0000000..7c69c42 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/MagicLinkRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Request to generate a magic-link login token. +/// +public sealed class MagicLinkRequest +{ + [Required(ErrorMessage = "The Email field is required.")] + [EmailAddress(ErrorMessage = "Invalid email format.")] + public required string Email { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/PasswordResetConfirmRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/PasswordResetConfirmRequest.cs new file mode 100644 index 0000000..078d2d4 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/PasswordResetConfirmRequest.cs @@ -0,0 +1,23 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Payload for confirming a password reset from the email link. +/// +public sealed class PasswordResetConfirmRequest +{ + [Required, EmailAddress] + public string Email { get; set; } = string.Empty; + + /// + /// Token received via email, URL-decoded automatically by the framework. + /// + [Required] + public string Token { get; set; } = string.Empty; + + [Required, MinLength(UserFieldLengths.PasswordMinLength)] + public string NewPassword { get; set; } = string.Empty; +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/RefreshTokenRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/RefreshTokenRequest.cs new file mode 100644 index 0000000..72b3fd2 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/RefreshTokenRequest.cs @@ -0,0 +1,12 @@ +namespace Application.DTOs.Auth.Request; + +/// +/// Represents a request object for refreshing a JWT token. +/// +public class RefreshTokenRequest +{ + /// + /// The refresh token to use for generating a new JWT token. + /// + public required string RefreshToken { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Request/RegisterUserRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/RegisterUserRequest.cs new file mode 100644 index 0000000..e2af36b --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/RegisterUserRequest.cs @@ -0,0 +1,36 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Request to register a new user. Password is generated automatically by the system. +/// +public sealed class RegisterUserRequest +{ + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Invalid email format.")] + public required string Email { get; set; } + + /// + /// Display name for the user; must be unique across all accounts. + /// + [Required(ErrorMessage = "Username is required.")] + [StringLength(UserFieldLengths.UsernameMaxLength, MinimumLength = UserFieldLengths.UsernameMinLength, + ErrorMessage = "Username must be between 3 and 50 characters.")] + public required string Username { get; set; } + + /// + /// Optional contact phone number. + /// + [ArgentinePhoneNumber] + [MaxLength(UserFieldLengths.PhoneMaxLength, ErrorMessage = "Phone number must not exceed 15 characters.")] + public string? Phone { get; set; } + + /// + /// Target role. Accepted values: ADMIN, OWNER. + /// + [Required(ErrorMessage = "Role is required.")] + public required string Role { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Auth/Request/RequestPasswordResetRequest.cs b/Club12-Backend/Application/DTOs/Auth/Request/RequestPasswordResetRequest.cs new file mode 100644 index 0000000..51076d7 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Request/RequestPasswordResetRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Auth.Request; + +/// +/// Self-service request to receive a password-reset magic link by email. +/// +public sealed class RequestPasswordResetRequest +{ + [Required(ErrorMessage = "The Email field is required.")] + [EmailAddress(ErrorMessage = "Invalid email format.")] + public required string Email { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Auth/Response/InviteUserResponse.cs b/Club12-Backend/Application/DTOs/Auth/Response/InviteUserResponse.cs new file mode 100644 index 0000000..7badfdb --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Response/InviteUserResponse.cs @@ -0,0 +1,13 @@ +using System; + +namespace Application.DTOs.Auth.Response; + +/// +/// Confirmation payload returned after inviting a user by email; the account has no password until activation. +/// +public sealed class InviteUserResponse(Guid userId, string email, string role) +{ + public Guid UserId { get; init; } = userId; + public string Email { get; init; } = email; + public string Role { get; init; } = role; +} diff --git a/Club12-Backend/Application/DTOs/Auth/Response/MagicLinkResponse.cs b/Club12-Backend/Application/DTOs/Auth/Response/MagicLinkResponse.cs new file mode 100644 index 0000000..cd0ad49 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Response/MagicLinkResponse.cs @@ -0,0 +1,10 @@ +namespace Application.DTOs.Auth.Response; + +/// +/// Response for magic-link request flow. +/// +public sealed class MagicLinkResponse(string message, string? magicLink = null) +{ + public string Message { get; init; } = message; + public string? MagicLink { get; init; } = magicLink; +} diff --git a/Club12-Backend/Application/DTOs/Auth/Response/RegisterUserResponse.cs b/Club12-Backend/Application/DTOs/Auth/Response/RegisterUserResponse.cs new file mode 100644 index 0000000..27751ea --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Response/RegisterUserResponse.cs @@ -0,0 +1,15 @@ +using System; + +namespace Application.DTOs.Auth.Response; + +/// +/// Confirmation payload returned after successfully registering a new user. +/// +public sealed class RegisterUserResponse(Guid userId, string email, string username, string role, string? phone) +{ + public Guid UserId { get; init; } = userId; + public string Email { get; init; } = email; + public string Username { get; init; } = username; + public string Role { get; init; } = role; + public string? PhoneNumber { get; init; } = phone; +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Auth/Response/TokenResponse.cs b/Club12-Backend/Application/DTOs/Auth/Response/TokenResponse.cs new file mode 100644 index 0000000..5948faa --- /dev/null +++ b/Club12-Backend/Application/DTOs/Auth/Response/TokenResponse.cs @@ -0,0 +1,21 @@ +using System; + +namespace Application.DTOs.Auth.Response; + +/// +/// Represents the response containing access token, expiration time, and refresh token. +/// +public sealed class TokenResponse(string accessToken, TimeSpan expiresIn, string? refreshToken) +{ + /// + /// The access token used for authorization. + /// + public string AccessToken { get; init; } = accessToken; + + public TimeSpan ExpiresIn { get; init; } = expiresIn; + + /// + /// The refresh token used to obtain a new access token. + /// + public string? RefreshToken { get; init; } = refreshToken; +} diff --git a/Club12-Backend/Application/DTOs/Backup/Response/BackupRecordResponse.cs b/Club12-Backend/Application/DTOs/Backup/Response/BackupRecordResponse.cs new file mode 100644 index 0000000..252f60f --- /dev/null +++ b/Club12-Backend/Application/DTOs/Backup/Response/BackupRecordResponse.cs @@ -0,0 +1,18 @@ +using Domain.Entities.Models; + +using System; + +namespace Application.DTOs.Backup.Response; + +/// +/// Response projection of a catalogued BackupRecord, with Origin serialized as its enum name. +/// +public sealed record BackupRecordResponse( + Guid Id, DateTime CreatedAt, long SizeBytes, string Origin, string StoragePath) +{ + public static BackupRecordResponse FromEntity(BackupRecord record) + { + return new BackupRecordResponse( + record.Id, record.DateCreated, record.SizeBytes, record.Origin.ToString(), record.StoragePath); + } +} diff --git a/Club12-Backend/Application/DTOs/Backup/Response/MaintenanceStatusResponse.cs b/Club12-Backend/Application/DTOs/Backup/Response/MaintenanceStatusResponse.cs new file mode 100644 index 0000000..b9c066c --- /dev/null +++ b/Club12-Backend/Application/DTOs/Backup/Response/MaintenanceStatusResponse.cs @@ -0,0 +1,8 @@ +using System; + +namespace Application.DTOs.Backup.Response; + +/// +/// The current maintenance window; Reason and EnteredAtUtc are null when IsActive is false. +/// +public sealed record MaintenanceStatusResponse(bool IsActive, string? Reason, DateTimeOffset? EnteredAtUtc); diff --git a/Club12-Backend/Application/DTOs/BlogPosts/Request/CreateBlogPostRequest.cs b/Club12-Backend/Application/DTOs/BlogPosts/Request/CreateBlogPostRequest.cs new file mode 100644 index 0000000..4e6456d --- /dev/null +++ b/Club12-Backend/Application/DTOs/BlogPosts/Request/CreateBlogPostRequest.cs @@ -0,0 +1,29 @@ + +using Microsoft.AspNetCore.Http; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.BlogPosts.Request; + +/// +/// Represents a request to create a new blog post, including file upload for the photo. +/// +public class CreateBlogPostRequest +{ + [Required] + [MaxLength(50)] + public required string Author { get; set; } + + [Required] + public required string Title { get; set; } + + public IFormFile? PhotoFile { get; set; } + + [Required] + public required string MarkdownText { get; set; } + + /// + /// Whether the post is published or a draft; defaults to true so existing create flows are unaffected. + /// + public bool IsPublished { get; set; } = true; +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/BlogPosts/Request/GetBlogPostsFilteredRequest.cs b/Club12-Backend/Application/DTOs/BlogPosts/Request/GetBlogPostsFilteredRequest.cs new file mode 100644 index 0000000..117539e --- /dev/null +++ b/Club12-Backend/Application/DTOs/BlogPosts/Request/GetBlogPostsFilteredRequest.cs @@ -0,0 +1,23 @@ +using Application.DTOs.Abstract.Request; + +namespace Application.DTOs.BlogPosts.Request; + +/// +/// Represents a request to get filtered blog posts. +/// +public class GetBlogPostsFilteredRequest : PaginatedFilterRequest +{ + /// + /// Overrides the base PaginatedFilterRequest default to sort newest-created-first. + /// + public GetBlogPostsFilteredRequest() + { + // String literal matches EntityBase.DateCreated; QueryableExtensions.SortBy resolves this name against the entity by reflection. + OrderBy = "DateCreated"; + Order = SortOrder.Descending; + } + + public string? Author { get; set; } + + public string? Title { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/BlogPosts/Request/UpdateBlogPostPhotoRequest.cs b/Club12-Backend/Application/DTOs/BlogPosts/Request/UpdateBlogPostPhotoRequest.cs new file mode 100644 index 0000000..09c853b --- /dev/null +++ b/Club12-Backend/Application/DTOs/BlogPosts/Request/UpdateBlogPostPhotoRequest.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.BlogPosts.Request; + +/// +/// Represents a request to update the photo for a blog post. +/// +public class UpdateBlogPostPhotoRequest +{ + [Required] + public required IFormFile PhotoFile { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/BlogPosts/Request/UpdateBlogPostRequest.cs b/Club12-Backend/Application/DTOs/BlogPosts/Request/UpdateBlogPostRequest.cs new file mode 100644 index 0000000..6be83a7 --- /dev/null +++ b/Club12-Backend/Application/DTOs/BlogPosts/Request/UpdateBlogPostRequest.cs @@ -0,0 +1,28 @@ +namespace Application.DTOs.BlogPosts.Request; + + +/// +/// Represents a request to update a blog post. +/// +public class UpdateBlogPostRequest +{ + /// + /// Null leaves the current title unchanged. + /// + public string? Title { get; set; } + + /// + /// Null leaves the current Markdown content unchanged. + /// + public string? MarkdownText { get; set; } + + /// + /// Null leaves the current author unchanged. + /// + public string? Author { get; set; } + + /// + /// The updated publication state; null leaves it unchanged, true publishes, false reverts to a draft. + /// + public bool? IsPublished { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/BlogPosts/Response/BlogPostResponse.cs b/Club12-Backend/Application/DTOs/BlogPosts/Response/BlogPostResponse.cs new file mode 100644 index 0000000..feca51d --- /dev/null +++ b/Club12-Backend/Application/DTOs/BlogPosts/Response/BlogPostResponse.cs @@ -0,0 +1,33 @@ +using Application.DTOs.Abstract.Response; + +using System; + +namespace Application.DTOs.BlogPosts.Response; + +/// +/// Represents the response data for a blog post. +/// +public class BlogPostResponse : BaseEntityResponse +{ + public required string Author { get; set; } + + public required string Title { get; set; } + + /// + /// The unique, URL-friendly identifier used in public blog post links. + /// + public required string Slug { get; set; } + + public int Views { get; set; } + + public string? PhotoUrl { get; set; } + + public required string MarkdownText { get; set; } + + public required DateTime CreatedAt { get; set; } + + /// + /// Whether the post is published and visible publicly, or is a draft. + /// + public bool IsPublished { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Champions/Response/ChampionHistoryResponse.cs b/Club12-Backend/Application/DTOs/Champions/Response/ChampionHistoryResponse.cs new file mode 100644 index 0000000..9f5d2b3 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Champions/Response/ChampionHistoryResponse.cs @@ -0,0 +1,49 @@ +using System; + +namespace Application.DTOs.Champions.Response; + +/// +/// One row of the champions history for a finished division, with context to render a table across seasons. +/// +public class ChampionHistoryResponse +{ + /// + /// The id of the tournament the champion was crowned in. + /// + public required Guid TournamentId { get; set; } + + /// + /// The name of the tournament. + /// + public required string TournamentName { get; set; } + + /// + /// The season name, or null when the tournament is not grouped under any season. + /// + public string? SeasonName { get; set; } + + /// + /// The season's calendar year, or null when there is no season or year set. + /// + public int? SeasonYear { get; set; } + + /// + /// The competitive category of the division, Masculine or Feminine. + /// + public required string Category { get; set; } + + /// + /// The name of the division that was won. + /// + public required string DivisionName { get; set; } + + /// + /// The name of the sub-cup that was won, or null when the division crowns a single champion. + /// + public string? CupName { get; set; } + + /// + /// The team that won this cup, in first place. + /// + public required PodiumTeamResponse ChampionTeam { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Champions/Response/PodiumResponse.cs b/Club12-Backend/Application/DTOs/Champions/Response/PodiumResponse.cs new file mode 100644 index 0000000..f4689a1 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Champions/Response/PodiumResponse.cs @@ -0,0 +1,39 @@ +using System; + +namespace Application.DTOs.Champions.Response; + +/// +/// The final podium of a single competition; any place not yet decided is null rather than guessed. +/// +public class PodiumResponse +{ + /// + /// The id of the division this podium belongs to. + /// + public required Guid DivisionId { get; set; } + + /// + /// The name of the division this podium belongs to. + /// + public required string DivisionName { get; set; } + + /// + /// True when the division's champion is decided by a playoff Final rather than by group standings. + /// + public bool HasPlayoff { get; set; } + + /// + /// The champion in first place, or null when not yet decided. + /// + public PodiumTeamResponse? First { get; set; } + + /// + /// The runner-up in second place, or null when not yet decided. + /// + public PodiumTeamResponse? Second { get; set; } + + /// + /// The team in third place, or null when not yet decided. + /// + public PodiumTeamResponse? Third { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Champions/Response/PodiumTeamResponse.cs b/Club12-Backend/Application/DTOs/Champions/Response/PodiumTeamResponse.cs new file mode 100644 index 0000000..345c5ec --- /dev/null +++ b/Club12-Backend/Application/DTOs/Champions/Response/PodiumTeamResponse.cs @@ -0,0 +1,24 @@ +using System; + +namespace Application.DTOs.Champions.Response; + +/// +/// A lightweight reference to a team occupying a podium place, carrying only what a team badge needs. +/// +public class PodiumTeamResponse +{ + /// + /// The unique identifier of the team. + /// + public required Guid TeamId { get; set; } + + /// + /// The display name of the team. + /// + public required string TeamName { get; set; } + + /// + /// The URL of the team's logo. + /// + public required string LogoUrl { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Club/Request/LinkClubParentRequest.cs b/Club12-Backend/Application/DTOs/Club/Request/LinkClubParentRequest.cs new file mode 100644 index 0000000..c4f1d53 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Request/LinkClubParentRequest.cs @@ -0,0 +1,11 @@ +using System; + +namespace Application.DTOs.Club.Request; + +/// +/// Links a club as a squad of the given parent institution club. +/// +public class LinkClubParentRequest +{ + public required Guid ParentClubId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Club/Request/RenameClubRequest.cs b/Club12-Backend/Application/DTOs/Club/Request/RenameClubRequest.cs new file mode 100644 index 0000000..a2d6ec9 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Request/RenameClubRequest.cs @@ -0,0 +1,9 @@ +namespace Application.DTOs.Club.Request; + +/// +/// Renames a club. The club's slug (used in its public URL) never changes, only its display name. +/// +public class RenameClubRequest +{ + public required string Name { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Club/Response/ClubBackfillResult.cs b/Club12-Backend/Application/DTOs/Club/Response/ClubBackfillResult.cs new file mode 100644 index 0000000..5a3d3c4 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Response/ClubBackfillResult.cs @@ -0,0 +1,10 @@ +namespace Application.DTOs.Club.Response; + +/// +/// Outcome of the idempotent club backfill; a second run reports zeros. +/// +public class ClubBackfillResult +{ + public required int ClubsCreated { get; set; } + public required int TeamsLinked { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Club/Response/ClubHistoryResponse.cs b/Club12-Backend/Application/DTOs/Club/Response/ClubHistoryResponse.cs new file mode 100644 index 0000000..64d5bae --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Response/ClubHistoryResponse.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Club.Response; + +/// +/// A club's stable identity plus every per-season team that belongs to it. +/// +public class ClubHistoryResponse +{ + public required Guid Id { get; set; } + public required string Name { get; set; } + public required string Slug { get; set; } + public string? LogoUrl { get; set; } + + /// + /// The per-season teams that make up this club's history. + /// + public required List Teams { get; set; } = []; + + /// + /// The parent institution this club is a squad of, or null when this club has no parent linked. + /// + public ClubSummaryResponse? ParentClub { get; set; } + + /// + /// Other squads linked to this club as their parent institution, empty when this club has none. + /// + public required List ChildClubs { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Club/Response/ClubSeasonResponse.cs b/Club12-Backend/Application/DTOs/Club/Response/ClubSeasonResponse.cs new file mode 100644 index 0000000..32c4e74 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Response/ClubSeasonResponse.cs @@ -0,0 +1,17 @@ +using System; + +namespace Application.DTOs.Club.Response; + +/// +/// One season a club's team participated in. +/// +public class ClubSeasonResponse +{ + public required Guid TournamentId { get; set; } + public string? TournamentName { get; set; } + + /// + /// Sort key only, not displayed; defaults to the minimum date so unresolved tournaments sort last. + /// + public DateTime StartDate { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Club/Response/ClubSummaryResponse.cs b/Club12-Backend/Application/DTOs/Club/Response/ClubSummaryResponse.cs new file mode 100644 index 0000000..8e56c8c --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Response/ClubSummaryResponse.cs @@ -0,0 +1,14 @@ +using System; + +namespace Application.DTOs.Club.Response; + +/// +/// A minimal club identity, used for pickers and for referencing a related club (parent/child) without pulling in its full season history. +/// +public class ClubSummaryResponse +{ + public required Guid Id { get; set; } + public required string Name { get; set; } + public required string Slug { get; set; } + public string? LogoUrl { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Club/Response/ClubTeamSeasonResponse.cs b/Club12-Backend/Application/DTOs/Club/Response/ClubTeamSeasonResponse.cs new file mode 100644 index 0000000..389f095 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Club/Response/ClubTeamSeasonResponse.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Club.Response; + +/// +/// One per-season team row belonging to a club, with the seasons it participated in. +/// +public class ClubTeamSeasonResponse +{ + public required Guid TeamId { get; set; } + public required string Name { get; set; } + public required string Slug { get; set; } + public required string ThreeLetterCode { get; set; } + + /// + /// The tournaments this team was registered in, sourced from its registration records. + /// + public required List Seasons { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/DataMaintenance/Response/DataSeedResult.cs b/Club12-Backend/Application/DTOs/DataMaintenance/Response/DataSeedResult.cs new file mode 100644 index 0000000..ab7a158 --- /dev/null +++ b/Club12-Backend/Application/DTOs/DataMaintenance/Response/DataSeedResult.cs @@ -0,0 +1,14 @@ +namespace Application.DTOs.DataMaintenance.Response; + +/// +/// Row counts created by the sample-data seed, for the admin UI's success summary. +/// +public sealed record DataSeedResult( + int Tournaments, + int Divisions, + int Teams, + int Players, + int Matches, + int PlayerSanctions, + int BlogPosts +); diff --git a/Club12-Backend/Application/DTOs/DataMaintenance/Response/DataWipeResult.cs b/Club12-Backend/Application/DTOs/DataMaintenance/Response/DataWipeResult.cs new file mode 100644 index 0000000..5aee490 --- /dev/null +++ b/Club12-Backend/Application/DTOs/DataMaintenance/Response/DataWipeResult.cs @@ -0,0 +1,21 @@ +namespace Application.DTOs.DataMaintenance.Response; + +/// +/// Row counts removed by the sample-data wipe, for the admin UI's success summary. +/// +public sealed record DataWipeResult( + int Tournaments, + int Divisions, + int Teams, + int Players, + int Matches, + int MatchSeries, + int PlayerSanctions, + int PlayerStatistics, + int Scorers, + int StageTeamMatches, + int PlayerTeamRegistrations, + int Stages, + int Venues, + int BlogPosts +); diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/CreateDivisionRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/CreateDivisionRequest.cs new file mode 100644 index 0000000..b351ca1 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/CreateDivisionRequest.cs @@ -0,0 +1,54 @@ +using Application.Utils.Constants.Validation; + +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Divisions.Request; + +/// +/// Represents a request to create a division. +/// +public class CreateDivisionRequest +{ + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(DivisionFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + [Required(ErrorMessage = "The TournamentId field is required.")] + public required Guid TournamentId { get; set; } + + /// + /// Marks this division as a cross-division cup that intentionally draws teams from every division. + /// + public bool IsCrossDivisionCup { get; set; } = false; + + /// + /// Points awarded for a win in this division's standings; defaults to 2. + /// + [Range(0, int.MaxValue, ErrorMessage = "PointsForWin cannot be negative.")] + public int PointsForWin { get; set; } = 2; + + /// + /// Points awarded for a loss in this division's standings; defaults to 1. + /// + [Range(0, int.MaxValue, ErrorMessage = "PointsForLoss cannot be negative.")] + public int PointsForLoss { get; set; } = 1; + + /// + /// How many teams qualify from each internal group of a multi-group cross-division cup; defaults to 1. + /// + [Range(1, int.MaxValue, ErrorMessage = "QualifiersPerGroup must be at least 1.")] + public int QualifiersPerGroup { get; set; } = 1; + + /// + /// Competitive category of the division, which must match the parent tournament's category. + /// + public TournamentCategory Category { get; set; } = TournamentCategory.Masculine; + + /// + /// Optional position-range to playoff-destination mapping the wizard sends to seed multiple cups. + /// + public List? PlayoffMappings { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/EnrollTeamsRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/EnrollTeamsRequest.cs new file mode 100644 index 0000000..fcceb61 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/EnrollTeamsRequest.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Divisions.Request; + +public class EnrollTeamsRequest +{ + public List TeamIds { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/GetDivisionsFilteredRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/GetDivisionsFilteredRequest.cs new file mode 100644 index 0000000..86a4677 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/GetDivisionsFilteredRequest.cs @@ -0,0 +1,16 @@ +using Application.DTOs.Abstract.Request; + +using System; +namespace Application.DTOs.Divisions.Request; + +/// +/// Represents a request to get filtered divisions. +/// +public class GetDivisionsFilteredRequest : PaginatedFilterRequest +{ + public string? Name { get; set; } + + public bool? IsFinished { get; set; } + + public Guid? TournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/PlayoffMappingRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/PlayoffMappingRequest.cs new file mode 100644 index 0000000..b5599ad --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/PlayoffMappingRequest.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Divisions.Request; + +/// +/// One position-range to playoff-destination entry the wizard sends for a division; ranges must not overlap. +/// +public class PlayoffMappingRequest +{ + /// + /// First standings position in the range, 1-based and inclusive. + /// + [Range(1, int.MaxValue, ErrorMessage = "FromPosition must be 1 or greater.")] + public required int FromPosition { get; set; } + + /// + /// Last standings position in the range, 1-based and inclusive. + /// + [Range(1, int.MaxValue, ErrorMessage = "ToPosition must be 1 or greater.")] + public required int ToPosition { get; set; } + + /// + /// The playoff cup the teams in this range qualify for, matching an elimination stage's BracketName. + /// + [Required(ErrorMessage = "The Destination field is required.")] + public required string Destination { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/ReassignTeamToSubGroupRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/ReassignTeamToSubGroupRequest.cs new file mode 100644 index 0000000..5576e97 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/ReassignTeamToSubGroupRequest.cs @@ -0,0 +1,12 @@ +using System; + +namespace Application.DTOs.Divisions.Request; + +public class ReassignTeamToSubGroupRequest +{ + public required Guid TeamId { get; set; } + + public required Guid FromStageId { get; set; } + + public required Guid ToStageId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/RebuildSubGroupsRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/RebuildSubGroupsRequest.cs new file mode 100644 index 0000000..027e970 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/RebuildSubGroupsRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Divisions.Request; + +public class RebuildSubGroupsRequest +{ + [Range(1, int.MaxValue, ErrorMessage = "SubGroupCount must be at least 1.")] + public required int SubGroupCount { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/UnenrollTeamsRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/UnenrollTeamsRequest.cs new file mode 100644 index 0000000..0a9e79a --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/UnenrollTeamsRequest.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Divisions.Request; + +public class UnenrollTeamsRequest +{ + public List TeamIds { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Request/UpdateDivisionRequest.cs b/Club12-Backend/Application/DTOs/Divisions/Request/UpdateDivisionRequest.cs new file mode 100644 index 0000000..56a0d8a --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Request/UpdateDivisionRequest.cs @@ -0,0 +1,22 @@ +using Application.Utils.Constants.Validation; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Divisions.Request; + +/// +/// Represents a request to create a division. +/// +public class UpdateDivisionRequest +{ + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(DivisionFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + public required bool IsFinished { get; set; } + + /// + /// When provided, moves the division and everything under it to this tournament; null keeps it in place. + /// + public Guid? TournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/DetailedDivisionResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/DetailedDivisionResponse.cs new file mode 100644 index 0000000..7c69b82 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/DetailedDivisionResponse.cs @@ -0,0 +1,25 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Response; + +using System; +using System.Collections.Generic; +namespace Application.DTOs.Divisions.Response; + +/// +/// Represents a response for a division, inheriting from the base response. +/// +public class DetailedDivisionResponse : BaseEntityResponse +{ + public required string Name { get; set; } + + public required bool IsFinished { get; set; } + + public required IEnumerable Positions { get; set; } + + public required Guid TournamentId { get; set; } + + /// + /// The matches grouped by week in the division. + /// + public required IDictionary> MatchesByWeek { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/DivisionResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/DivisionResponse.cs new file mode 100644 index 0000000..f0be3e3 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/DivisionResponse.cs @@ -0,0 +1,74 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; +namespace Application.DTOs.Divisions.Response; + +/// +/// The response structure for a division, including its matches and positions. +/// +public class DivisionResponse +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// + /// The unique, URL-friendly identifier used in public division links. + /// + public string Slug { get; set; } = string.Empty; + + public bool IsFinished { get; set; } + + /// + /// For a multi-group cup this is the pooled union across every group; use GroupStandings for per-group tables. + /// + public List? Positions { get; set; } + + /// + /// One standings table per Group stage, null or empty when the division has no Group stage yet. + /// + public List? GroupStandings { get; set; } + + public Guid TournamentId { get; set; } + + /// + /// The parent tournament's slug, when loaded; null otherwise, for building a clean tournament link. + /// + public string? TournamentSlug { get; set; } + + /// + /// Whether this division is a cross-division cup that intentionally draws teams from every division. + /// + public bool IsCrossDivisionCup { get; set; } + + /// + /// Competitive category of the division, always matching the parent tournament's category. + /// + public TournamentCategory Category { get; set; } + + /// + /// Points awarded for a win in this division's standings. + /// + public int PointsForWin { get; set; } + + /// + /// Points awarded for a loss in this division's standings. + /// + public int PointsForLoss { get; set; } + + /// + /// How many teams qualify from each internal group of a multi-group cup; defaults to 1. + /// + public int QualifiersPerGroup { get; set; } + + /// + /// The division's position-range to playoff-destination mapping. + /// + public List? PlayoffMappings { get; set; } + + /// + /// The standings-position ranges that qualify to a playoff cup, ordered top-down; empty when none exist. + /// + public List? QualificationRanges { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/GroupStandingsResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/GroupStandingsResponse.cs new file mode 100644 index 0000000..3698ff3 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/GroupStandingsResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Divisions.Response; + +/// +/// The standings of a single Group stage; a multi-group division sends one entry per internal group. +/// +public class GroupStandingsResponse +{ + /// + /// The id of the Group stage these standings belong to. + /// + public Guid StageId { get; set; } + + /// + /// The Group stage's name, used as the table label. + /// + public string StageName { get; set; } = string.Empty; + + /// + /// The ordered standings for the teams in this group. + /// + public List Positions { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/MinimalDivisionResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/MinimalDivisionResponse.cs new file mode 100644 index 0000000..870189f --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/MinimalDivisionResponse.cs @@ -0,0 +1,12 @@ +using Application.DTOs.Abstract.Response; +namespace Application.DTOs.Divisions.Response; + +/// +/// Represents a minimal response for a tournament. +/// +public class MinimalDivisionResponse : BaseEntityResponse +{ + public required string Name { get; set; } + + public required bool IsFinished { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/PlayoffMappingResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/PlayoffMappingResponse.cs new file mode 100644 index 0000000..8cb844e --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/PlayoffMappingResponse.cs @@ -0,0 +1,14 @@ +using System; + +namespace Application.DTOs.Divisions.Response; + +/// +/// One position-range to playoff-destination entry of a division. +/// +public class PlayoffMappingResponse +{ + public Guid Id { get; set; } + public int FromPosition { get; set; } + public int ToPosition { get; set; } + public string Destination { get; set; } = string.Empty; +} diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/PositionsResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/PositionsResponse.cs new file mode 100644 index 0000000..05363f2 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/PositionsResponse.cs @@ -0,0 +1,40 @@ +using System; + +using Application.DTOs.PointDeductions.Response; + +namespace Application.DTOs.Divisions.Response; + + +/// +/// Represents the position of a team in a division, including various match statistics. +/// +public class PositionResponse +{ + public required Guid TeamId { get; set; } + + public required string TeamName { get; set; } + + public required string LogoUrl { get; set; } + + public required int MatchesPlayed { get; set; } + + public required int Wins { get; set; } + + public required int Losses { get; set; } + + public required int PointsFor { get; set; } + + public required int PointsAgainst { get; set; } + + public required int PointsDifference { get; set; } + + /// + /// The total points accumulated by the team, with any disciplinary deduction already subtracted. + /// + public required int Points { get; set; } + + /// + /// The disciplinary point deduction applied to this team, or null when there is none. + /// + public AppliedPointDeductionResponse? PointDeduction { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Divisions/Response/QualificationRangeResponse.cs b/Club12-Backend/Application/DTOs/Divisions/Response/QualificationRangeResponse.cs new file mode 100644 index 0000000..148ef99 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Divisions/Response/QualificationRangeResponse.cs @@ -0,0 +1,27 @@ +namespace Application.DTOs.Divisions.Response; + +/// +/// One standings-position range that qualifies to a playoff cup, derived from the division's mapping set. +/// +public class QualificationRangeResponse +{ + /// + /// First standings position in the range, 1-based and inclusive. + /// + public int FromPosition { get; set; } + + /// + /// Last standings position in the range, 1-based and inclusive. + /// + public int ToPosition { get; set; } + + /// + /// The cup the teams in this range qualify for. + /// + public string CupName { get; set; } = string.Empty; + + /// + /// The cup's rank, top-down, where 0 is the top cup; drives the color the frontend paints each range. + /// + public int Order { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Request/CreateMatchRequest.cs b/Club12-Backend/Application/DTOs/Match/Request/CreateMatchRequest.cs new file mode 100644 index 0000000..fac0bfa --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Request/CreateMatchRequest.cs @@ -0,0 +1,30 @@ +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations; + + +namespace Application.DTOs.Match.Request; + +/// +/// Represents a request to create a match. +/// +public class CreateMatchRequest +{ + [Required(ErrorMessage = "The MatchDate field is required.")] + public required DateTime MatchDate { get; set; } + + [AllowedValues(MatchType.Regular, MatchType.Playoff)] + public MatchType? Type { get; set; } = MatchType.Regular; + + [Required(ErrorMessage = "The HomeTeamId field is required.")] + public required Guid HomeTeamId { get; set; } + + [Required(ErrorMessage = "The VisitorTeamId field is required.")] + public required Guid VisitorTeamId { get; set; } + + [Required(ErrorMessage = "The DivisionId field is required.")] + public required Guid StageId { get; set; } + + public Guid? VenueId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Request/GetMatchesFilteredRequest.cs b/Club12-Backend/Application/DTOs/Match/Request/GetMatchesFilteredRequest.cs new file mode 100644 index 0000000..f5949a9 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Request/GetMatchesFilteredRequest.cs @@ -0,0 +1,27 @@ +using Application.DTOs.Abstract.Request; + +using Domain.Enums; + +using System; + +namespace Application.DTOs.Match.Request; + +/// +/// Represents a request to filter and paginate matches. +/// +public class GetMatchesFilteredRequest : PaginatedFilterRequest +{ + public string? HomeTeamName { get; set; } + + public string? VisitorTeamName { get; set; } + + public Guid? StageId { get; set; } + + public Guid? DivisionId { get; set; } + + public Guid? TournamentId { get; set; } + + public MatchType? Type { get; set; } + + public bool? IsFinished { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Request/LoadWalkOverRequest.cs b/Club12-Backend/Application/DTOs/Match/Request/LoadWalkOverRequest.cs new file mode 100644 index 0000000..68ada99 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Request/LoadWalkOverRequest.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Match.Request; + +/// +/// Request to mark a match as a walkover, awarding the present team the regulation default result. +/// +public class LoadWalkOverRequest +{ + /// + /// The walkover winner; must be one of the match's two teams. + /// + [Required] + public required Guid PresentTeamId { get; set; } + + /// + /// Optional override for the present team's awarded score; the regulation default is used when omitted. + /// + [Range(0, int.MaxValue, ErrorMessage = "The PresentTeamScore must be a non-negative number.")] + public int? PresentTeamScore { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Request/SuspendMatchRequest.cs b/Club12-Backend/Application/DTOs/Match/Request/SuspendMatchRequest.cs new file mode 100644 index 0000000..5885897 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Request/SuspendMatchRequest.cs @@ -0,0 +1,14 @@ +using System; + +namespace Application.DTOs.Match.Request; + +/// +/// Request to suspend a match and optionally reschedule it, never changing its round. +/// +public class SuspendMatchRequest +{ + /// + /// Optional new date and time for the match; null suspends it in place without rescheduling. + /// + public DateTime? MatchDate { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Request/UpdateMatchRequest.cs b/Club12-Backend/Application/DTOs/Match/Request/UpdateMatchRequest.cs new file mode 100644 index 0000000..4f325d2 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Request/UpdateMatchRequest.cs @@ -0,0 +1,13 @@ +using System; + +namespace Application.DTOs.Match.Request; + +/// +/// Request to reschedule a match; edits only the calendar date and venue. +/// +public class UpdateMatchRequest +{ + public DateTime? MatchDate { get; set; } + + public Guid? VenueId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Request/UpdateMatchScoreRequest.cs b/Club12-Backend/Application/DTOs/Match/Request/UpdateMatchScoreRequest.cs new file mode 100644 index 0000000..063f1f7 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Request/UpdateMatchScoreRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Match.Request; + +/// +/// Represents a request to update the score of a match. +/// +public class UpdateMatchScoreRequest +{ + [Required] + [Range(0, int.MaxValue, ErrorMessage = "The HomeScore must be a non-negative number.")] + public required int HomeScore { get; set; } + + [Required] + [Range(0, int.MaxValue, ErrorMessage = "The VisitorScore must be a non-negative number.")] + public required int VisitorScore { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Response/DetailedMatchResponse.cs b/Club12-Backend/Application/DTOs/Match/Response/DetailedMatchResponse.cs new file mode 100644 index 0000000..ecd2b69 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Response/DetailedMatchResponse.cs @@ -0,0 +1,56 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Team.Response; +using Application.DTOs.Venue.Response; + +using System; + +namespace Application.DTOs.Match.Response; + +/// +/// Represents the response data for a match. +/// +public class DetailedMatchResponse : BaseEntityResponse +{ + public required DateTime MatchDate { get; set; } + + /// + /// The 1-based matchday this match belongs to; null for stages with no round-robin matchday. + /// + public int? Round { get; set; } + + /// + /// The type of the match, regular or playoff. + /// + public required string MatchType { get; set; } + + /// + /// The unique, URL-friendly identifier used in public match links. + /// + public required string Slug { get; set; } + + public TeamDetailedMatchResponse? HomeTeam { get; set; } + + public TeamDetailedMatchResponse? VisitorTeam { get; set; } + + public VenueResponse? Venue { get; set; } + + public required bool IsFinished { get; set; } + + /// + /// The result lifecycle state: Scheduled, Played, Suspended, or WalkOver. + /// + public string? Status { get; set; } + + public bool WentToOvertime { get; set; } + + public string? WinningTeamName { get; set; } + + public Guid? WinningTeamId { get; set; } + + public Guid? StageId { get; set; } + + /// + /// The id of the tournament this match belongs to, letting the public page link back to it directly. + /// + public Guid? TournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Response/MinimalMatchResponse.cs b/Club12-Backend/Application/DTOs/Match/Response/MinimalMatchResponse.cs new file mode 100644 index 0000000..b5e7807 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Response/MinimalMatchResponse.cs @@ -0,0 +1,40 @@ +using Application.DTOs.Abstract.Response; + +using System; + +namespace Application.DTOs.Match.Response; + +/// +/// Represents the minimal response data for a match, tailored for divisions. +/// +public class MinimalMatchResponse : BaseEntityResponse +{ + public required DateTime MatchDate { get; set; } + + /// + /// The 1-based matchday this match belongs to; null for stages with no round-robin matchday. + /// + public int? Round { get; set; } + + public required string HomeTeamName { get; set; } + + public required string VisitorTeamName { get; set; } + + public int? HomeScore { get; set; } + + public int? VisitorScore { get; set; } + + public string? WinningTeamName { get; set; } + + public required bool IsFinished { get; set; } + + /// + /// The result lifecycle state: Scheduled, Played, Suspended, or WalkOver. + /// + public string? Status { get; set; } + + /// + /// The type of the match, regular or playoff. + /// + public required string MatchType { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Match/Response/RoundMatchesResponse.cs b/Club12-Backend/Application/DTOs/Match/Response/RoundMatchesResponse.cs new file mode 100644 index 0000000..2d60207 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Match/Response/RoundMatchesResponse.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Application.DTOs.Match.Response; + +/// +/// A single matchday and its matches, letting the frontend group the fixture by round instead of date. +/// +public class RoundMatchesResponse +{ + /// + /// The 1-based round number; null groups matches with no round-robin matchday. + /// + public int? Round { get; set; } + + /// + /// The matches played in this round, in a stable order. + /// + public List Matches { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/MatchSeries/Request/AddGameToSeriesRequest.cs b/Club12-Backend/Application/DTOs/MatchSeries/Request/AddGameToSeriesRequest.cs new file mode 100644 index 0000000..1834966 --- /dev/null +++ b/Club12-Backend/Application/DTOs/MatchSeries/Request/AddGameToSeriesRequest.cs @@ -0,0 +1,15 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.MatchSeries.Request; + +/// +/// Payload for scheduling the next game of an existing series; GameNumber and teams are derived server-side. +/// +public class AddGameToSeriesRequest +{ + [Required(ErrorMessage = "Match date field is required.")] + public required DateTime MatchDate { get; set; } + + public Guid? VenueId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/MatchSeries/Request/CreateMatchSeriesRequest.cs b/Club12-Backend/Application/DTOs/MatchSeries/Request/CreateMatchSeriesRequest.cs new file mode 100644 index 0000000..26e45f6 --- /dev/null +++ b/Club12-Backend/Application/DTOs/MatchSeries/Request/CreateMatchSeriesRequest.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.MatchSeries.Request; + +/// +/// Payload for creating a new best-of-N playoff series between two teams at one bracket round. +/// +public class CreateMatchSeriesRequest +{ + /// + /// The stage this series belongs to; its BestOf value is copied onto the series at creation time. + /// + [Required(ErrorMessage = "Stage ID field is required.")] + public required Guid StageId { get; set; } + + [Required(ErrorMessage = "Home team ID field is required.")] + public required Guid HomeTeamId { get; set; } + + [Required(ErrorMessage = "Visitor team ID field is required.")] + public required Guid VisitorTeamId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/MatchSeries/Request/GetMatchSeriesFilteredRequest.cs b/Club12-Backend/Application/DTOs/MatchSeries/Request/GetMatchSeriesFilteredRequest.cs new file mode 100644 index 0000000..8a7fcc9 --- /dev/null +++ b/Club12-Backend/Application/DTOs/MatchSeries/Request/GetMatchSeriesFilteredRequest.cs @@ -0,0 +1,13 @@ +using Application.DTOs.Abstract.Request; + +using System; + +namespace Application.DTOs.MatchSeries.Request; + +/// +/// Represents a request to filter and paginate playoff series. +/// +public class GetMatchSeriesFilteredRequest : PaginatedFilterRequest +{ + public Guid? StageId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/MatchSeries/Response/MatchSeriesResponse.cs b/Club12-Backend/Application/DTOs/MatchSeries/Response/MatchSeriesResponse.cs new file mode 100644 index 0000000..ed56fda --- /dev/null +++ b/Club12-Backend/Application/DTOs/MatchSeries/Response/MatchSeriesResponse.cs @@ -0,0 +1,42 @@ +using Application.DTOs.Abstract.Response; + +using System; +using System.Collections.Generic; + +namespace Application.DTOs.MatchSeries.Response; + +/// +/// A best-of-N playoff series between two teams at one bracket round, including its individual games. +/// +public class MatchSeriesResponse : BaseEntityResponse +{ + public required Guid StageId { get; set; } + + public required Guid HomeTeamId { get; set; } + + public required string HomeTeamName { get; set; } + + public required Guid VisitorTeamId { get; set; } + + public required string VisitorTeamName { get; set; } + + /// + /// Number of games in this series, one of 1, 3, 5, or 7. + /// + public required int BestOf { get; set; } + + /// + /// The id of the winning team, set once one team has won the majority of the series' games. + /// + public Guid? WinningTeamId { get; set; } + + /// + /// The name of the winning team, if the series has been decided. + /// + public string? WinningTeamName { get; set; } + + /// + /// The individual games played so far in this series. + /// + public List Games { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/MatchSeries/Response/SeriesGameResponse.cs b/Club12-Backend/Application/DTOs/MatchSeries/Response/SeriesGameResponse.cs new file mode 100644 index 0000000..870e9fe --- /dev/null +++ b/Club12-Backend/Application/DTOs/MatchSeries/Response/SeriesGameResponse.cs @@ -0,0 +1,14 @@ +using Application.DTOs.Match.Response; + +namespace Application.DTOs.MatchSeries.Response; + +/// +/// A single game within a best-of-N series, with its position in the series alongside the match details. +/// +public class SeriesGameResponse : MinimalMatchResponse +{ + /// + /// The game's 1-based position within the series. + /// + public required int GameNumber { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/MedicalRecord/Request/ReviewMedicalRecordRequest.cs b/Club12-Backend/Application/DTOs/MedicalRecord/Request/ReviewMedicalRecordRequest.cs new file mode 100644 index 0000000..b20a024 --- /dev/null +++ b/Club12-Backend/Application/DTOs/MedicalRecord/Request/ReviewMedicalRecordRequest.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.MedicalRecord.Request; + +/// +/// Owner or admin request to approve or reject a player's medical record for a team and tournament. +/// +public class ReviewMedicalRecordRequest +{ + /// + /// The player whose medical record is being reviewed. + /// + [Required] + public required Guid PlayerId { get; set; } + + /// + /// The team the player is registered to for the season. + /// + [Required] + public required Guid TeamId { get; set; } + + /// + /// The tournament the record applies to. + /// + [Required] + public required Guid TournamentId { get; set; } + + /// + /// True to approve and make the player habilitado; false to reject. + /// + [Required] + public required bool Approve { get; set; } + + /// + /// Optional reason, typically recorded when rejecting. + /// + [MaxLength(500)] + public string? Reason { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/MedicalRecord/Request/UploadMedicalRecordRequest.cs b/Club12-Backend/Application/DTOs/MedicalRecord/Request/UploadMedicalRecordRequest.cs new file mode 100644 index 0000000..d263a70 --- /dev/null +++ b/Club12-Backend/Application/DTOs/MedicalRecord/Request/UploadMedicalRecordRequest.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.MedicalRecord.Request; + +/// +/// Multipart request to upload a player's medical-record PDF for a specific team and tournament. +/// +public class UploadMedicalRecordRequest +{ + /// + /// The player the medical record belongs to. + /// + [Required] + public required Guid PlayerId { get; set; } + + /// + /// The team the player is registered to for the season. + /// + [Required] + public required Guid TeamId { get; set; } + + /// + /// The tournament the record applies to. + /// + [Required] + public required Guid TournamentId { get; set; } + + /// + /// The medical-record PDF to upload. + /// + [Required] + public required IFormFile File { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/MedicalRecord/Response/MedicalRecordResponse.cs b/Club12-Backend/Application/DTOs/MedicalRecord/Response/MedicalRecordResponse.cs new file mode 100644 index 0000000..d8ac870 --- /dev/null +++ b/Club12-Backend/Application/DTOs/MedicalRecord/Response/MedicalRecordResponse.cs @@ -0,0 +1,65 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using System; + +namespace Application.DTOs.MedicalRecord.Response; + +/// +/// The medical-record eligibility state of a player's season registration, flagging not-habilitado players. +/// +public class MedicalRecordResponse +{ + public required Guid PlayerId { get; set; } + public required Guid TeamId { get; set; } + public required Guid TournamentId { get; set; } + + /// + /// The medical-record status: Pending, Approved, or Rejected. + /// + public required MedicalRecordStatus Status { get; set; } + + /// + /// True only when the record is Approved. + /// + public required bool IsHabilitado { get; set; } + + /// + /// Storage reference of the uploaded file, or null if none yet. + /// + public string? FileUrl { get; set; } + + /// + /// Original uploaded file name, or null if none yet. + /// + public string? FileName { get; set; } + + /// + /// Reason recorded on rejection, if any. + /// + public string? ReviewReason { get; set; } + + /// + /// When the record was last approved or rejected, if ever. + /// + public DateTime? ReviewedAt { get; set; } + + /// + /// Projects a PlayerTeamRegistration into its medical-record view. + /// + public static MedicalRecordResponse FromRegistration(PlayerTeamRegistration registration) + { + return new MedicalRecordResponse + { + PlayerId = registration.PlayerId, + TeamId = registration.TeamId, + TournamentId = registration.TournamentId, + Status = registration.MedicalRecordStatus, + IsHabilitado = registration.IsHabilitado, + FileUrl = registration.MedicalRecordFileUrl, + FileName = registration.MedicalRecordFileName, + ReviewReason = registration.MedicalRecordReviewReason, + ReviewedAt = registration.MedicalRecordReviewedAt, + }; + } +} diff --git a/Club12-Backend/Application/DTOs/Player/Request/CreatePlayerRequest.cs b/Club12-Backend/Application/DTOs/Player/Request/CreatePlayerRequest.cs new file mode 100644 index 0000000..f2e3d22 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Request/CreatePlayerRequest.cs @@ -0,0 +1,50 @@ +using Application.Utils.Constants.Validation; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Player.Request; + +/// +/// Represents a request to create a Player. +/// +public class CreatePlayerRequest +{ + [Required(ErrorMessage = "The First Name field is required.")] + [MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Name field must not exceed 70 characters.")] + public required string FirstName { get; set; } + + [MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Second Name field must not exceed 70 characters.")] + public string? SecondName { get; set; } = null; + + [Required(ErrorMessage = "The LastName field is required.")] + [MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The LastName field must not exceed 70 characters.")] + public required string LastName { get; set; } + + [Required(ErrorMessage = "The DocumentNumber field is required.")] + [MaxLength(PlayerFieldLengths.DocumentNumberMaxLength, ErrorMessage = "The DocumentNumber field must not exceed 15 characters.")] + [RegularExpression(ValidationPatterns.DocumentNumber, ErrorMessage = ValidationPatterns.DocumentNumberError)] + public required string DocumentNumber { get; set; } + + /// + /// The birthdate of the player. Must be at least 15 years ago. + /// + [Required(ErrorMessage = "The BirthDate field is required.")] + [MinimumAge(15)] + public required DateTime BirthDate { get; set; } + + [Required(ErrorMessage = "The PhoneNumber field is required.")] + [MaxLength(PlayerFieldLengths.PhoneNumberMaxLength, ErrorMessage = "The PhoneNumber field must not exceed 15 characters.")] + [ArgentinePhoneNumber] + public required string PhoneNumber { get; set; } + + /// + /// The player's health insurance provider or social work plan. + /// + [Required(ErrorMessage = "The SocialSecurity field is required.")] + [MaxLength(PlayerFieldLengths.SocialSecurityMaxLength, ErrorMessage = "The SocialSecurity field must not exceed 100 characters.")] + public required string SocialSecurity { get; set; } + + [Required(ErrorMessage = "The TeamId field is required.")] + public required Guid TeamId { get; set; } + +} diff --git a/Club12-Backend/Application/DTOs/Player/Request/GetPlayersFilteredRequest.cs b/Club12-Backend/Application/DTOs/Player/Request/GetPlayersFilteredRequest.cs new file mode 100644 index 0000000..3e27008 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Request/GetPlayersFilteredRequest.cs @@ -0,0 +1,22 @@ +using System; +namespace Application.DTOs.Player.Request; + +/// +/// Represents a request to get filtered players for admin access. +/// +public class GetPlayersFilteredRequest : PlayerFilterRequestBase +{ + public string? DocumentNumber { get; set; } + + public DateTime? BirthDate { get; set; } + + public string? PhoneNumber { get; set; } + + public string? SocialSecurity { get; set; } + + public bool? IsFederated { get; set; } + + public string? Club { get; set; } + + public string? Category { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Player/Request/PlayerFilterRequestBase.cs b/Club12-Backend/Application/DTOs/Player/Request/PlayerFilterRequestBase.cs new file mode 100644 index 0000000..155ab9a --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Request/PlayerFilterRequestBase.cs @@ -0,0 +1,17 @@ +using Application.DTOs.Abstract.Request; + +using System; +namespace Application.DTOs.Player.Request; + +/// +/// Base class for filtering players with common properties. +/// +public class PlayerFilterRequestBase : PaginatedFilterRequest +{ + public string? Names { get; set; } + + public string? LastName { get; set; } + + public Guid? TeamId { get; set; } +} + diff --git a/Club12-Backend/Application/DTOs/Player/Request/RegisterPlayerToTeamRequest.cs b/Club12-Backend/Application/DTOs/Player/Request/RegisterPlayerToTeamRequest.cs new file mode 100644 index 0000000..83304ea --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Request/RegisterPlayerToTeamRequest.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Player.Request; + +/// +/// Request to register a player onto a team's roster for a tournament season, optionally with a dorsal. +/// +public class RegisterPlayerToTeamRequest +{ + /// + /// The team to register the player onto. + /// + [Required] + public required Guid TeamId { get; set; } + + /// + /// The tournament the registration belongs to. + /// + [Required] + public required Guid TournamentId { get; set; } + + /// + /// The player's jersey number for this team and season, from 0 to 99 inclusive, or null. + /// + [Range(0, 99, ErrorMessage = "El dorsal debe ser un número entre 0 y 99.")] + public int? JerseyNumber { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Player/Request/UpdatePlayerRequest.cs b/Club12-Backend/Application/DTOs/Player/Request/UpdatePlayerRequest.cs new file mode 100644 index 0000000..6072626 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Request/UpdatePlayerRequest.cs @@ -0,0 +1,51 @@ +using Application.Utils.Constants.Validation; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Player.Request; + +/// +/// Represents a request to update a Player. +/// +public class UpdatePlayerRequest +{ + [Required(ErrorMessage = "The First Name field is required.")] + [MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Name field must not exceed 70 characters.")] + public required string FirstName { get; set; } + + [MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The Second Name field must not exceed 70 characters.")] + public string? SecondName { get; set; } = null; + + [MaxLength(PlayerFieldLengths.NameMaxLength, ErrorMessage = "The LastName field must not exceed 70 characters.")] + public string? LastName { get; set; } + + [MaxLength(PlayerFieldLengths.DocumentNumberMaxLength, ErrorMessage = "The DocumentNumber field must not exceed 15 characters.")] + [RegularExpression(ValidationPatterns.DocumentNumber, ErrorMessage = ValidationPatterns.DocumentNumberError)] + public string? DocumentNumber { get; set; } + + /// + /// The birthdate of the player. Must be at least 15 years ago. + /// + [MinimumAge(15)] + public DateTime? BirthDate { get; set; } + + [MaxLength(PlayerFieldLengths.PhoneNumberMaxLength, ErrorMessage = "The PhoneNumber field must not exceed 15 characters.")] + [ArgentinePhoneNumber] + public string? PhoneNumber { get; set; } + + /// + /// The player's health insurance provider or social work plan. + /// + [MaxLength(PlayerFieldLengths.SocialSecurityMaxLength, ErrorMessage = "The SocialSecurity field must not exceed 100 characters.")] + public string? SocialSecurity { get; set; } + + public Guid? TeamId { get; set; } + + public bool? IsFederated { get; set; } + + [MaxLength(100)] + public string? Club { get; set; } + + [MaxLength(50)] + public string? Category { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Player/Response/AdminPlayerResponse.cs b/Club12-Backend/Application/DTOs/Player/Response/AdminPlayerResponse.cs new file mode 100644 index 0000000..e69e6cd --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Response/AdminPlayerResponse.cs @@ -0,0 +1,31 @@ +using System; +namespace Application.DTOs.Player.Response; + +/// +/// Detailed response for an admin to view player information, extending PublicPlayerResponse with sensitive fields. +/// +public class AdminPlayerResponse : PublicPlayerResponse +{ + public required string DocumentNumber { get; set; } + + public required DateTime BirthDate { get; set; } + + public required string PhoneNumber { get; set; } + + /// + /// The player's health insurance provider or social work plan. + /// + public required string SocialSecurity { get; set; } + + public required bool IsFederated { get; set; } + + /// + /// The club the player is affiliated with; only meaningful when IsFederated is true. + /// + public required string Club { get; set; } + + /// + /// The player's federation category; only meaningful when IsFederated is true. + /// + public required string Category { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Player/Response/PlayerRegistrationResponse.cs b/Club12-Backend/Application/DTOs/Player/Response/PlayerRegistrationResponse.cs new file mode 100644 index 0000000..1945dba --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Response/PlayerRegistrationResponse.cs @@ -0,0 +1,14 @@ +using System; + +namespace Application.DTOs.Player.Response; + +/// +/// The outcome of registering a player onto a team's roster for a season. +/// +public class PlayerRegistrationResponse +{ + public required Guid PlayerId { get; set; } + public required Guid TeamId { get; set; } + public required Guid TournamentId { get; set; } + public int? JerseyNumber { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Player/Response/PublicPlayerResponse.cs b/Club12-Backend/Application/DTOs/Player/Response/PublicPlayerResponse.cs new file mode 100644 index 0000000..5682e26 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Player/Response/PublicPlayerResponse.cs @@ -0,0 +1,42 @@ +using Application.DTOs.Abstract.Response; + +using Domain.Enums; + +using System; +namespace Application.DTOs.Player.Response; + +/// +/// Represents a response for a Player, inheriting from the base response. +/// +public class PublicPlayerResponse : BaseEntityResponse +{ + public required string FirstName { get; set; } + + public required string SecondName { get; set; } + + public required string LastName { get; set; } + + public required string FullName { get; set; } + + /// + /// The unique, URL-friendly identifier used in public player links. + /// + public required string Slug { get; set; } + + public required Guid TeamId { get; set; } + + /// + /// The player's medical-record status for this season roster; null when not loaded for a specific season. + /// + public MedicalRecordStatus? MedicalRecordStatus { get; set; } + + /// + /// Whether the player's medical record is Approved for this season roster, letting the frontend flag it. + /// + public bool IsHabilitado { get; set; } + + /// + /// The player's jersey number for this season roster; null when not loaded or not assigned. + /// + public int? JerseyNumber { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerSanction/Request/AppealPlayerSanctionRequest.cs b/Club12-Backend/Application/DTOs/PlayerSanction/Request/AppealPlayerSanctionRequest.cs new file mode 100644 index 0000000..cef5a6c --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerSanction/Request/AppealPlayerSanctionRequest.cs @@ -0,0 +1,14 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.PlayerSanction.Request; + +/// +/// Request DTO for submitting an appeal against a player sanction. +/// +public class AppealPlayerSanctionRequest +{ + [Required] + [StringLength(SanctionFieldLengths.LongTextMaxLength, MinimumLength = SanctionFieldLengths.LongTextMinLength)] + public required string Reason { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerSanction/Request/CreatePlayerSanctionRequest.cs b/Club12-Backend/Application/DTOs/PlayerSanction/Request/CreatePlayerSanctionRequest.cs new file mode 100644 index 0000000..9852b9b --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerSanction/Request/CreatePlayerSanctionRequest.cs @@ -0,0 +1,76 @@ +using Application.Utils.Constants.Validation; + +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.PlayerSanction.Request; + +/// +/// Represents a request to create a Player Sanction. +/// +public class CreatePlayerSanctionRequest : IValidatableObject +{ + /// + /// The duration in fixtures, or fechas, of the sanction. + /// + [Required(ErrorMessage = "The Duration field is required.")] + public required int Duration { get; set; } + + [Required(ErrorMessage = "The IssuedDate field is required.")] + public required DateTime IssuedDate { get; set; } + + [Required(ErrorMessage = "The Description field is required.")] + [MaxLength(SanctionFieldLengths.DescriptionMaxLength)] + public required string Description { get; set; } + + /// + /// The kind of subject the sanction targets; defaults to Player so existing clients keep working. + /// + public SanctionSubjectType SubjectType { get; set; } = SanctionSubjectType.Player; + + /// + /// The sanctioned player's identifier; required when SubjectType is Player. + /// + public Guid? PlayerId { get; set; } + + /// + /// The sanctioned team's identifier; required when SubjectType is Team. + /// + public Guid? TeamId { get; set; } + + /// + /// The sanctioned staff member's name; required when SubjectType is Staff. + /// + [MaxLength(SanctionFieldLengths.DescriptionMaxLength)] + public string? StaffName { get; set; } + + public required Guid MatchId { get; set; } + + /// + /// Ensures the identity that matches the chosen subject type is supplied. + /// + public IEnumerable Validate(ValidationContext validationContext) + { + switch (SubjectType) + { + case SanctionSubjectType.Player when !PlayerId.HasValue: + yield return new ValidationResult( + "The PlayerId field is required for a player sanction.", + [nameof(PlayerId)]); + break; + case SanctionSubjectType.Team when !TeamId.HasValue: + yield return new ValidationResult( + "The TeamId field is required for a team sanction.", + [nameof(TeamId)]); + break; + case SanctionSubjectType.Staff when string.IsNullOrWhiteSpace(StaffName): + yield return new ValidationResult( + "The StaffName field is required for a staff sanction.", + [nameof(StaffName)]); + break; + } + } +} diff --git a/Club12-Backend/Application/DTOs/PlayerSanction/Request/GetPlayerSanctionsFilteredRequest.cs b/Club12-Backend/Application/DTOs/PlayerSanction/Request/GetPlayerSanctionsFilteredRequest.cs new file mode 100644 index 0000000..d6c561f --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerSanction/Request/GetPlayerSanctionsFilteredRequest.cs @@ -0,0 +1,35 @@ +using Application.DTOs.Abstract.Request; + +using System; +namespace Application.DTOs.PlayerSanction.Request; + +/// +/// Represents a request to get filtered player sanctions. +/// +public class GetPlayerSanctionsFilteredRequest : PaginatedFilterRequest +{ + /// + /// Overrides the base PaginatedFilterRequest default to sort newest-issued-first. + /// + public GetPlayerSanctionsFilteredRequest() + { + // String literal, not nameof, because the enclosing namespace Application.DTOs.PlayerSanction shadows the entity type; QueryableExtensions.SortBy resolves this name against the entity by reflection and matches PlayerSanction.IssuedDate. + OrderBy = "IssuedDate"; + Order = SortOrder.Descending; + } + + public Guid? PlayerId { get; set; } + + public Guid? MatchId { get; set; } + + public Guid? TournamentId { get; set; } + public Guid? DivisionId { get; set; } + public Guid? StageId { get; set; } + public Guid? TeamId { get; set; } + + public string? Description { get; set; } + + public DateTime? IssuedDate { get; set; } + + public int? Duration { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerSanction/Request/ResolveAppealRequest.cs b/Club12-Backend/Application/DTOs/PlayerSanction/Request/ResolveAppealRequest.cs new file mode 100644 index 0000000..690cc95 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerSanction/Request/ResolveAppealRequest.cs @@ -0,0 +1,17 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.PlayerSanction.Request; + +/// +/// Request DTO for resolving a player sanction appeal. +/// +public class ResolveAppealRequest +{ + [Required] + public required bool Accepted { get; set; } + + [Required] + [StringLength(SanctionFieldLengths.LongTextMaxLength, MinimumLength = SanctionFieldLengths.LongTextMinLength)] + public required string Resolution { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerSanction/Request/UpdatePlayerSanctionRequest.cs b/Club12-Backend/Application/DTOs/PlayerSanction/Request/UpdatePlayerSanctionRequest.cs new file mode 100644 index 0000000..11acae2 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerSanction/Request/UpdatePlayerSanctionRequest.cs @@ -0,0 +1,18 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.PlayerSanction.Request; + +/// +/// Represents a request to update a Player Sanction. +/// +public class UpdatePlayerSanctionRequest +{ + /// + /// The duration in fixtures of the sanction. + /// + public int? Duration { get; set; } + + [MaxLength(SanctionFieldLengths.DescriptionMaxLength)] + public string? Description { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerSanction/Response/PlayerSanctionResponse.cs b/Club12-Backend/Application/DTOs/PlayerSanction/Response/PlayerSanctionResponse.cs new file mode 100644 index 0000000..3570793 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerSanction/Response/PlayerSanctionResponse.cs @@ -0,0 +1,78 @@ +using Application.DTOs.Abstract.Response; + +using Domain.Enums; + +using System; +namespace Application.DTOs.PlayerSanction.Response; + +/// +/// Represents a response for a Player Sanction. +/// +public class PlayerSanctionResponse : BaseEntityResponse +{ + /// + /// The duration in fixtures, or fechas, of the sanction. + /// + public required int Duration { get; set; } + + /// + /// The number of fechas still to be served; zero means fully served, null means it cannot be computed. + /// + public int? FechasRemaining { get; set; } + + /// + /// Whether the sanction is still active, meaning fechas remain to be served. + /// + public bool IsActive { get; set; } + + public required DateTime IssuedDate { get; set; } + + public required string Description { get; set; } + + /// + /// The unique, URL-friendly identifier used in sanction links. + /// + public required string Slug { get; set; } + + /// + /// The kind of subject the sanction targets: Player, Team, or Staff. + /// + public SanctionSubjectType SubjectType { get; set; } + + /// + /// The sanctioned player's identifier; null for team or staff sanctions. + /// + public Guid? PlayerId { get; set; } + + /// + /// The sanctioned player's full name. Null for team or staff sanctions. + /// + public string? PlayerFullName { get; set; } + + /// + /// The sanctioned team's identifier; null unless this is a team sanction. + /// + public Guid? TeamId { get; set; } + + /// + /// The sanctioned team's name. Null unless this is a team sanction. + /// + public string? TeamName { get; set; } + + /// + /// The sanctioned staff member's name. Null unless this is a staff sanction. + /// + public string? StaffName { get; set; } + + public required Guid MatchId { get; set; } + + public SanctionAppealStatus AppealStatus { get; set; } + + public string? AppealReason { get; set; } + + public DateTime? AppealDate { get; set; } + + public string? AppealResolution { get; set; } + + public DateTime? AppealResolvedDate { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Request/CreatePlayerStatisticRequest.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/CreatePlayerStatisticRequest.cs new file mode 100644 index 0000000..812fc95 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/CreatePlayerStatisticRequest.cs @@ -0,0 +1,26 @@ +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.PlayerStatistic.Request; + +/// +/// Request DTO for creating a player statistic. +/// +public class CreatePlayerStatisticRequest +{ + [Required] + [Range(1, int.MaxValue, ErrorMessage = "Value must be a non-negative number.")] + public int Value { get; set; } + + /// + /// The type of statistic, Points or Assists, defaulting to Points. + /// + public StatisticType Type { get; set; } = StatisticType.Points; + + [Required] + public Guid MatchId { get; set; } + + [Required] + public Guid PlayerId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Request/GetPlayerStatisticsFilteredRequest.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/GetPlayerStatisticsFilteredRequest.cs new file mode 100644 index 0000000..c37b8ba --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/GetPlayerStatisticsFilteredRequest.cs @@ -0,0 +1,26 @@ +using Application.DTOs.Abstract.Request; + +using Domain.Enums; + +using System; +namespace Application.DTOs.PlayerStatistic.Request; + +/// +/// Represents a request to get filtered player statistics. +/// +public class GetPlayerStatisticsFilteredRequest : PaginatedFilterRequest +{ + public Guid? MatchId { get; set; } + + public Guid? PlayerId { get; set; } + + /// + /// The team to filter statistics by, matching the statistic's player's team. + /// + public Guid? TeamId { get; set; } + + /// + /// The type of statistic to filter by, Points or Assists. + /// + public StatisticType? Type { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Request/LoadMatchResultFromSheetsRequest.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/LoadMatchResultFromSheetsRequest.cs new file mode 100644 index 0000000..bbee35e --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/LoadMatchResultFromSheetsRequest.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.PlayerStatistic.Request; + +/// +/// Request to finish a match by loading both teams' scoring sheets in one coherent operation. +/// +public class LoadMatchResultFromSheetsRequest +{ + /// + /// The match being finished, assigned server-side from the route rather than sent by the client. + /// + public Guid MatchId { get; set; } + + /// + /// The home team's per-player points; a player may score 0 and not every roster player must be listed. + /// + [Required] + public required List HomeScores { get; set; } = []; + + /// + /// The visitor team's per-player points. + /// + [Required] + public required List VisitorScores { get; set; } = []; + + /// + /// Whether the match was decided in overtime; purely informational and defaults to false. + /// + public bool WentToOvertime { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Request/LoadMatchSheetRequest.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/LoadMatchSheetRequest.cs new file mode 100644 index 0000000..b8341b0 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/LoadMatchSheetRequest.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.PlayerStatistic.Request; + +/// +/// Request to load a whole team's scoring sheet for a match in one coherent operation. +/// +public class LoadMatchSheetRequest +{ + /// + /// The match whose sheet is being loaded. + /// + [Required] + public required Guid MatchId { get; set; } + + /// + /// The home or visitor team whose players are being loaded. + /// + [Required] + public required Guid TeamId { get; set; } + + /// + /// The per-player points, which must add up to the team's score; a player may score 0 and need not be listed. + /// + [Required] + public required List Scores { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Request/PlayerScoreEntry.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/PlayerScoreEntry.cs new file mode 100644 index 0000000..41df233 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/PlayerScoreEntry.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.PlayerStatistic.Request; + +/// +/// A single player's points within a team's match sheet. +/// +public class PlayerScoreEntry +{ + /// + /// The player who scored. + /// + [Required] + public required Guid PlayerId { get; set; } + + /// + /// The points the player scored in the match; may be zero. + /// + [Range(0, int.MaxValue, ErrorMessage = "Points must be a non-negative number.")] + public required int Points { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Request/UpdatePlayerStatisticRequest.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/UpdatePlayerStatisticRequest.cs new file mode 100644 index 0000000..57bc3fb --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Request/UpdatePlayerStatisticRequest.cs @@ -0,0 +1,13 @@ +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.PlayerStatistic.Request; + +/// +/// Represents a request to update a player statistic. +/// +public class UpdatePlayerStatisticRequest +{ + [Required] + [Range(0, int.MaxValue, ErrorMessage = "Value must be a non-negative integer.")] + public required int Value { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PlayerStatistic/Response/PlayerStatisticResponse.cs b/Club12-Backend/Application/DTOs/PlayerStatistic/Response/PlayerStatisticResponse.cs new file mode 100644 index 0000000..85bcc6c --- /dev/null +++ b/Club12-Backend/Application/DTOs/PlayerStatistic/Response/PlayerStatisticResponse.cs @@ -0,0 +1,28 @@ +using Application.DTOs.Abstract.Response; + +using Domain.Enums; + +using System; +namespace Application.DTOs.PlayerStatistic.Response; + +/// +/// Response DTO for player statistic. +/// +public class PlayerStatisticResponse : BaseEntityResponse +{ + public Guid PlayerId { get; set; } + + public int Value { get; set; } + + /// + /// The type of statistic, Points or Assists. + /// + public StatisticType Type { get; set; } + + public Guid MatchId { get; set; } + + /// + /// The date of the associated match, for display without a separate lookup. + /// + public DateTime? MatchDate { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PointDeductions/Request/CreatePointDeductionRequest.cs b/Club12-Backend/Application/DTOs/PointDeductions/Request/CreatePointDeductionRequest.cs new file mode 100644 index 0000000..0d853d5 --- /dev/null +++ b/Club12-Backend/Application/DTOs/PointDeductions/Request/CreatePointDeductionRequest.cs @@ -0,0 +1,26 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.PointDeductions.Request; + +/// +/// Request to apply a disciplinary point deduction to a team within a division taken from the route. +/// +public class CreatePointDeductionRequest +{ + [Required(ErrorMessage = "The TeamId field is required.")] + public required Guid TeamId { get; set; } + + /// + /// The positive amount of table points to subtract from the team's total. + /// + [Range(1, int.MaxValue, ErrorMessage = "Points must be at least 1.")] + public required int Points { get; set; } + + /// + /// The disciplinary reason for the deduction. + /// + [Required(ErrorMessage = "The Reason field is required.")] + [MaxLength(300, ErrorMessage = "Reason cannot exceed 300 characters.")] + public required string Reason { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/PointDeductions/Response/AppliedPointDeductionResponse.cs b/Club12-Backend/Application/DTOs/PointDeductions/Response/AppliedPointDeductionResponse.cs new file mode 100644 index 0000000..472ccbc --- /dev/null +++ b/Club12-Backend/Application/DTOs/PointDeductions/Response/AppliedPointDeductionResponse.cs @@ -0,0 +1,17 @@ +namespace Application.DTOs.PointDeductions.Response; + +/// +/// The point-deduction summary carried on a standings position row for a penalised team. +/// +public class AppliedPointDeductionResponse +{ + /// + /// The total table points subtracted from the team. + /// + public int Points { get; set; } + + /// + /// The combined disciplinary reasons. + /// + public string Reason { get; set; } = string.Empty; +} diff --git a/Club12-Backend/Application/DTOs/PointDeductions/Response/PointDeductionResponse.cs b/Club12-Backend/Application/DTOs/PointDeductions/Response/PointDeductionResponse.cs new file mode 100644 index 0000000..8225d0c --- /dev/null +++ b/Club12-Backend/Application/DTOs/PointDeductions/Response/PointDeductionResponse.cs @@ -0,0 +1,44 @@ +using System; + +namespace Application.DTOs.PointDeductions.Response; + +/// +/// A disciplinary point deduction record, as returned by the create and list endpoints. +/// +public class PointDeductionResponse +{ + /// + /// The unique identifier of the deduction. + /// + public Guid Id { get; set; } + + /// + /// The division whose standings the deduction affects. + /// + public Guid DivisionId { get; set; } + + /// + /// The penalised team's id. + /// + public Guid TeamId { get; set; } + + /// + /// The penalised team's name, when the team was loaded. + /// + public string? TeamName { get; set; } + + /// + /// The table points subtracted from the team's total. + /// + public int Points { get; set; } + + /// + /// The disciplinary reason for the deduction. + /// + public string Reason { get; set; } = string.Empty; + + /// + /// When the deduction was applied. + /// + public DateTime DateCreated { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Roster/Request/CopyRosterRequest.cs b/Club12-Backend/Application/DTOs/Roster/Request/CopyRosterRequest.cs new file mode 100644 index 0000000..553096e --- /dev/null +++ b/Club12-Backend/Application/DTOs/Roster/Request/CopyRosterRequest.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Roster.Request; + +/// +/// Request to clone a roster from a previous season's team into a new season's team taken from the route. +/// +public class CopyRosterRequest +{ + /// + /// The past-season team whose roster is the source. + /// + [Required] + public required Guid SourceTeamId { get; set; } + + /// + /// The season the source roster belongs to. + /// + [Required] + public required Guid SourceTournamentId { get; set; } + + /// + /// The new season the roster is cloned into. + /// + [Required] + public required Guid TargetTournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Roster/Response/RosterCopyResult.cs b/Club12-Backend/Application/DTOs/Roster/Response/RosterCopyResult.cs new file mode 100644 index 0000000..a0dfd2d --- /dev/null +++ b/Club12-Backend/Application/DTOs/Roster/Response/RosterCopyResult.cs @@ -0,0 +1,17 @@ +namespace Application.DTOs.Roster.Response; + +/// +/// Outcome of copying a roster from a previous season into a new season. +/// +public class RosterCopyResult +{ + /// + /// New season registrations created on the target team. + /// + public required int CopiedCount { get; set; } + + /// + /// Source players skipped because they were already registered to the target season. + /// + public required int SkippedCount { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Scorer/Request/GetScorerFilteredRequest.cs b/Club12-Backend/Application/DTOs/Scorer/Request/GetScorerFilteredRequest.cs new file mode 100644 index 0000000..d047cc9 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Scorer/Request/GetScorerFilteredRequest.cs @@ -0,0 +1,30 @@ + +using Application.DTOs.Abstract.Request; + +using System; + +namespace Application.DTOs.Scorer.Request; + +public class GetScorerFilteredRequest : PaginatedFilterRequest +{ + public Guid? TournamentId { get; set; } + + /// + /// Scopes the ranking to one division, including every stage in it, group and playoff alike. + /// + public Guid? DivisionId { get; set; } + + /// + /// Scopes the ranking to a single stage, the group phase or one playoff bracket's round. + /// + public Guid? StageId { get; set; } + + public Guid? MatchId { get; set; } + public Guid? TeamId { get; set; } + public Guid? PlayerId { get; set; } + + /// + /// Scopes the ranking to a whole calendar-year season instead of a single tournament. + /// + public int? Season { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Scorer/Response/ScorerBaseResponse.cs b/Club12-Backend/Application/DTOs/Scorer/Response/ScorerBaseResponse.cs new file mode 100644 index 0000000..b6fb829 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Scorer/Response/ScorerBaseResponse.cs @@ -0,0 +1,10 @@ +namespace Application.DTOs.Scorer.Response; + +public class ScorerBaseResponse +{ + + /// + /// Total points scored, aggregated over the grouping this response represents, not just one match. + /// + public required int Points { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Scorer/Response/ScorerByPlayerResponse.cs b/Club12-Backend/Application/DTOs/Scorer/Response/ScorerByPlayerResponse.cs new file mode 100644 index 0000000..567b916 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Scorer/Response/ScorerByPlayerResponse.cs @@ -0,0 +1,52 @@ +using System; +namespace Application.DTOs.Scorer.Response; + +/// +/// Response model representing a scorer's performance in a match. +/// +public class ScorerByPlayerResponse : ScorerBaseResponse +{ + public required Guid PlayerId { get; set; } + + public required string FullName { get; set; } + + /// + /// The player's jersey number for rendering the kit on the scoreboard, null when there is no roster number. + /// + public int? JerseyNumber { get; set; } + + /// + /// The player's current team. + /// + public Guid TeamId { get; set; } + + /// + /// The player's current team's name. + /// + public string TeamName { get; set; } = string.Empty; + + /// + /// The player's current team's crest, for the ranking row. + /// + public string TeamLogoUrl { get; set; } = string.Empty; + + /// + /// The player's current team's primary shirt color, for rendering the kit alongside the dorsal. + /// + public string TeamShirtColor { get; set; } = string.Empty; + + /// + /// The player's current team's kit pattern; see Team.JerseyStyle. + /// + public string TeamJerseyStyle { get; set; } = "solid"; + + /// + /// The player's current team's secondary shirt color, when set. + /// + public string? TeamShirtSecondaryColor { get; set; } + + /// + /// The player's current team's tertiary shirt color, when set. + /// + public string? TeamShirtTertiaryColor { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Scorer/Response/ScorerByTeamResponse.cs b/Club12-Backend/Application/DTOs/Scorer/Response/ScorerByTeamResponse.cs new file mode 100644 index 0000000..384acd0 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Scorer/Response/ScorerByTeamResponse.cs @@ -0,0 +1,13 @@ +using System; +namespace Application.DTOs.Scorer.Response; + +/// +/// Response model representing a scorer's performance in a match. +/// +public class ScorerByTeamResponse : ScorerBaseResponse +{ + public required Guid TeamId { get; set; } + + public required string Name { get; set; } + +} diff --git a/Club12-Backend/Application/DTOs/Season/Request/CreateSeasonRequest.cs b/Club12-Backend/Application/DTOs/Season/Request/CreateSeasonRequest.cs new file mode 100644 index 0000000..6974ec9 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Season/Request/CreateSeasonRequest.cs @@ -0,0 +1,23 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Season.Request; + +/// +/// Request model for creating a new season. +/// +public class CreateSeasonRequest +{ + /// + /// The display name of the season. + /// + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(SeasonFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + /// + /// The calendar year the season is played in; optional. + /// + public int? Year { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Season/Request/UpdateSeasonRequest.cs b/Club12-Backend/Application/DTOs/Season/Request/UpdateSeasonRequest.cs new file mode 100644 index 0000000..a840f6c --- /dev/null +++ b/Club12-Backend/Application/DTOs/Season/Request/UpdateSeasonRequest.cs @@ -0,0 +1,23 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Season.Request; + +/// +/// Request model for updating an existing season. +/// +public class UpdateSeasonRequest +{ + /// + /// The display name of the season. + /// + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(SeasonFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + /// + /// The calendar year the season is played in; optional. + /// + public int? Year { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Season/Response/SeasonResponse.cs b/Club12-Backend/Application/DTOs/Season/Response/SeasonResponse.cs new file mode 100644 index 0000000..e76bb6d --- /dev/null +++ b/Club12-Backend/Application/DTOs/Season/Response/SeasonResponse.cs @@ -0,0 +1,31 @@ +using Application.DTOs.Abstract.Response; + +using System.Collections.Generic; + +namespace Application.DTOs.Season.Response; + +/// +/// Season details, including the lightweight list of tournaments grouped under it. +/// +public class SeasonResponse : BaseEntityResponse +{ + /// + /// The display name of the season. + /// + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public season links. + /// + public required string Slug { get; set; } + + /// + /// The calendar year the season is played in; optional. + /// + public int? Year { get; set; } + + /// + /// The tournaments grouped under this season. + /// + public IEnumerable Tournaments { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Season/Response/SeasonTournamentResponse.cs b/Club12-Backend/Application/DTOs/Season/Response/SeasonTournamentResponse.cs new file mode 100644 index 0000000..2ea13a1 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Season/Response/SeasonTournamentResponse.cs @@ -0,0 +1,31 @@ +using Application.DTOs.Abstract.Response; + +using Domain.Enums; + +namespace Application.DTOs.Season.Response; + +/// +/// Lightweight view of a tournament inside a SeasonResponse, without its full division graph. +/// +public class SeasonTournamentResponse : BaseEntityResponse +{ + /// + /// The name of the tournament. + /// + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public tournament links. + /// + public required string Slug { get; set; } + + /// + /// Competitive category of the tournament, kept per tournament even when grouped under a season. + /// + public TournamentCategory Category { get; set; } + + /// + /// The tournament's lifecycle status, matching every other tournament list view's status chip. + /// + public TournamentStatus Status { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Stage/Request/AssignmentTeamRequest.cs b/Club12-Backend/Application/DTOs/Stage/Request/AssignmentTeamRequest.cs new file mode 100644 index 0000000..5287766 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Request/AssignmentTeamRequest.cs @@ -0,0 +1,6 @@ +namespace Application.DTOs.Stage.Request; + +public class AssignmentTeamRequest : UnassignmentTeamRequest +{ + public bool Auto { get; set; } = false; +} diff --git a/Club12-Backend/Application/DTOs/Stage/Request/CreateStageRequest.cs b/Club12-Backend/Application/DTOs/Stage/Request/CreateStageRequest.cs new file mode 100644 index 0000000..1bf4551 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Request/CreateStageRequest.cs @@ -0,0 +1,71 @@ + +using Application.Utils.Constants.Stage; + +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Stage.Request; + +/// +/// Represents the payload for creating a new stage in a tournament. +/// +public class CreateStageRequest +{ + /// + /// The name of the stage. + /// + [Required(ErrorMessage = "Stage name field is required.")] + public required string Name { get; set; } + + public string? Description { get; set; } + + [AllowedValues(StageType.Group, StageType.RoundOf16, StageType.QuarterFinal, StageType.SemiFinal, StageType.ThirdPlace, StageType.Final)] + public required StageType StageType { get; set; } = StageType.Group; + + /// + /// Whether the stage is currently active; null defaults to true. + /// + public bool? IsActive { get; set; } + + /// + /// Whether this is an elimination stage; null defaults to false. + /// + public bool? IsElimination { get; set; } + + /// + /// The starting date of the stage. + /// + [Required(ErrorMessage = "Start date field is required.")] + public required DateTime StartDate { get; set; } + + /// + /// The ending date of the stage. + /// + [Required(ErrorMessage = "End date field is required.")] + public required DateTime EndDate { get; set; } + + /// + /// The ID of the division to which this stage belongs. + /// + [Required(ErrorMessage = "Division ID field is required.")] + public required Guid DivisionId { get; set; } + + /// + /// Optional label grouping this stage with parallel elimination brackets; null uses the default bracket. + /// + public string? BracketName { get; set; } + + /// + /// Number of games in a series between two teams at this round, one of 1, 3, 5, or 7; defaults to 1. + /// + [AllowedValues(1, 3, 5, 7)] + public int BestOf { get; set; } = 1; + + /// + /// How many times each pair of teams plays within this group stage; only meaningful for a Group stage. + /// + [Range(RoundRobinFormat.MinLegs, RoundRobinFormat.MaxLegs)] + public int RoundRobinLegs { get; set; } = RoundRobinFormat.MinLegs; +} diff --git a/Club12-Backend/Application/DTOs/Stage/Request/DrawRequest.cs b/Club12-Backend/Application/DTOs/Stage/Request/DrawRequest.cs new file mode 100644 index 0000000..d4bd80d --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Request/DrawRequest.cs @@ -0,0 +1,18 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Stage.Request; + +/// +/// A preview-draw or commit-draw request for a playoffs-only bracket, carrying either a random-draw token or an explicit manual order. +/// +public class DrawRequest +{ + public required DrawMode Mode { get; set; } + + public string? DrawToken { get; set; } + + public List? ManualOrder { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Stage/Request/GetStagesFilteredRequest.cs b/Club12-Backend/Application/DTOs/Stage/Request/GetStagesFilteredRequest.cs new file mode 100644 index 0000000..6a7ff3d --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Request/GetStagesFilteredRequest.cs @@ -0,0 +1,38 @@ +using Application.DTOs.Abstract.Request; + +using Domain.Enums; + +using System; + +namespace Application.DTOs.Stage.Request; + +/// +/// Represents the filter and pagination parameters to retrieve a filtered list of stages. +/// +public class GetStagesFilteredRequest : PaginatedFilterRequest +{ + public Guid? DivisionId { get; set; } + + public Guid? TournamentId { get; set; } + + public StageType? StageType { get; set; } + + public bool? IsActive { get; set; } + + public bool? IsElimination { get; set; } + + /// + /// Optional filter by stage name, supporting partial matching. + /// + public string? Name { get; set; } + + /// + /// Optional filter to get stages starting on or after this date. + /// + public DateTime? StartDate { get; set; } + + /// + /// Optional filter to get stages ending on or before this date. + /// + public DateTime? EndDate { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Stage/Request/UnassignmentTeamRequest.cs b/Club12-Backend/Application/DTOs/Stage/Request/UnassignmentTeamRequest.cs new file mode 100644 index 0000000..1bbbcc4 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Request/UnassignmentTeamRequest.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Stage.Request; + +public class UnassignmentTeamRequest +{ + public List TeamIds { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Stage/Request/UpdateStageRequest.cs b/Club12-Backend/Application/DTOs/Stage/Request/UpdateStageRequest.cs new file mode 100644 index 0000000..d42401c --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Request/UpdateStageRequest.cs @@ -0,0 +1,33 @@ +using Application.Utils.Constants.Stage; + +namespace Application.DTOs.Stage.Request; + +/// +/// Represents the payload for updating an existing stage. +/// +public class UpdateStageRequest +{ + public string? Description { get; set; } + + /// + /// Whether the stage is currently active; null defaults to true. + /// + public bool? IsActive { get; set; } + + /// + /// Optional label grouping this stage with parallel elimination brackets; null clears the grouping. + /// + public string? BracketName { get; set; } + + /// + /// Number of games in a series between two teams at this round, one of 1, 3, 5, or 7; null leaves it unchanged. + /// + [System.ComponentModel.DataAnnotations.AllowedValues(1, 3, 5, 7)] + public int? BestOf { get; set; } + + /// + /// How many times each pair of teams plays within this group stage; null leaves it unchanged. + /// + [System.ComponentModel.DataAnnotations.Range(RoundRobinFormat.MinLegs, RoundRobinFormat.MaxLegs)] + public int? RoundRobinLegs { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Stage/Response/DrawPairPreview.cs b/Club12-Backend/Application/DTOs/Stage/Response/DrawPairPreview.cs new file mode 100644 index 0000000..e88e6d4 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Response/DrawPairPreview.cs @@ -0,0 +1,13 @@ +using System; + +namespace Application.DTOs.Stage.Response; + +/// +/// One first-round bracket pairing in a draw preview, with a null visitor meaning the home team gets a bye. +/// +public class DrawPairPreview +{ + public required Guid HomeTeamId { get; set; } + + public Guid? VisitorTeamId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Stage/Response/DrawPreviewResult.cs b/Club12-Backend/Application/DTOs/Stage/Response/DrawPreviewResult.cs new file mode 100644 index 0000000..7e5c9a4 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Response/DrawPreviewResult.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Application.DTOs.Stage.Response; + +/// +/// The pairing a draw preview computed together with the signed token that replays it exactly on commit. +/// +public class DrawPreviewResult +{ + public required List Pairs { get; set; } + + public required string DrawToken { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Stage/Response/StageResponse.cs b/Club12-Backend/Application/DTOs/Stage/Response/StageResponse.cs new file mode 100644 index 0000000..9748489 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Stage/Response/StageResponse.cs @@ -0,0 +1,62 @@ +using Application.DTOs.Abstract.Response; + +using Domain.Enums; + +using System; + +namespace Application.DTOs.Stage.Response; + +/// +/// Represents the data returned when querying a stage entity. +/// +public class StageResponse : BaseEntityResponse +{ + public required DateTime StartDate { get; set; } + + public required DateTime EndDate { get; set; } + + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public stage links. + /// + public required string Slug { get; set; } + + public string? Description { get; set; } + + public required StageType StageType { get; set; } + + /// + /// Indicates whether the stage is currently active. + /// + public required bool IsActive { get; set; } + + /// + /// Indicates whether the stage is an elimination stage. + /// + public bool IsElimination { get; set; } + + public required Guid DivisionId { get; set; } + + public int Order { get; set; } + + /// + /// Optional label grouping this stage with parallel elimination brackets; null uses the default bracket. + /// + public string? BracketName { get; set; } + + /// + /// Number of games in a series between two teams at this round, one of 1, 3, 5, or 7. + /// + public int BestOf { get; set; } + + /// + /// How many times each pair of teams plays within this group stage. + /// + public int RoundRobinLegs { get; set; } + + /// + /// When this bracket's seeding draw was committed, null until a draw runs. + /// + public DateTime? DrawnAt { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistoryResponse.cs b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistoryResponse.cs new file mode 100644 index 0000000..c36de38 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistoryResponse.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Statistics.Response; + +/// +/// A player's full cross-season trajectory, one entry per registered season, most recent first. +/// +public class PlayerHistoryResponse +{ + public required Guid PlayerId { get; set; } + + public required string FullName { get; set; } + + /// + /// Per-season trajectory rows, most recent season first. + /// + public IEnumerable Seasons { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistorySanctionResponse.cs b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistorySanctionResponse.cs new file mode 100644 index 0000000..4d8515d --- /dev/null +++ b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistorySanctionResponse.cs @@ -0,0 +1,25 @@ +using System; + +namespace Application.DTOs.Statistics.Response; + +/// +/// A single sanction the player received during a given season. +/// +public class PlayerHistorySanctionResponse +{ + public required Guid SanctionId { get; set; } + + public required string Description { get; set; } + + /// + /// Length in fechas, or matchdays. + /// + public required int Duration { get; set; } + + public required DateTime IssuedDate { get; set; } + + /// + /// The match the sanction was issued in. + /// + public required Guid MatchId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistorySeasonResponse.cs b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistorySeasonResponse.cs new file mode 100644 index 0000000..5e82242 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerHistorySeasonResponse.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Statistics.Response; + +/// +/// One row of a player's trajectory, sourced from the season-scoped registration to preserve past history. +/// +public class PlayerHistorySeasonResponse +{ + /// + /// The season, as the calendar year of the tournament's start date. + /// + public required int Season { get; set; } + + public required Guid TournamentId { get; set; } + + public required string TournamentName { get; set; } + + /// + /// The team the player was registered to for that season. + /// + public required Guid TeamId { get; set; } + + public required string TeamName { get; set; } + + /// + /// Total points scored for that season's tournament. + /// + public required int TotalPoints { get; set; } + + /// + /// Distinct games played in that season's tournament. + /// + public required int GamesPlayed { get; set; } + + /// + /// Sanctions received during that season's tournament. + /// + public IEnumerable Sanctions { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Statistics/Response/PlayerStatisticCardResponse.cs b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerStatisticCardResponse.cs new file mode 100644 index 0000000..46a5709 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Statistics/Response/PlayerStatisticCardResponse.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Statistics.Response; + +/// +/// A player's individual statistic card: total and average points and games played, per season and overall. +/// +public class PlayerStatisticCardResponse +{ + /// + /// The player's stable identity, the same person across every season. + /// + public required Guid PlayerId { get; set; } + + /// + /// The player's full name, formatted as LAST First Second. + /// + public required string FullName { get; set; } + + /// + /// Overall total points across every season. + /// + public required int TotalPoints { get; set; } + + /// + /// Overall distinct games played across every season. + /// + public required int GamesPlayed { get; set; } + + /// + /// Overall points per game played, rounded to two decimals; zero when no games were played. + /// + public required double AveragePoints { get; set; } + + /// + /// Per-season breakdown, most recent season first. + /// + public IEnumerable Seasons { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Statistics/Response/SeasonStatLineResponse.cs b/Club12-Backend/Application/DTOs/Statistics/Response/SeasonStatLineResponse.cs new file mode 100644 index 0000000..346b7b2 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Statistics/Response/SeasonStatLineResponse.cs @@ -0,0 +1,27 @@ +namespace Application.DTOs.Statistics.Response; + +/// +/// One season's scoring line in a player's statistic card, where a season is a tournament start-date year. +/// +public class SeasonStatLineResponse +{ + /// + /// The season, as the calendar year of the tournament's start date. + /// + public required int Season { get; set; } + + /// + /// Total points the player scored in that season. + /// + public required int TotalPoints { get; set; } + + /// + /// Distinct matches the player had a scoring-sheet entry for in that season. + /// + public required int GamesPlayed { get; set; } + + /// + /// Points per game played in that season, rounded to two decimals; zero when no games were played. + /// + public required double AveragePoints { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Request/CreateTeamRequest.cs b/Club12-Backend/Application/DTOs/Team/Request/CreateTeamRequest.cs new file mode 100644 index 0000000..f54644b --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Request/CreateTeamRequest.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http; + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Team.Request; + +/// +/// Represents a request to create a team. +/// +public class CreateTeamRequest +{ + [Required(ErrorMessage = "The Name field is required.")] + public required string Name { get; init; } + + [Required(ErrorMessage = "The Three-letter code field is required.")] + [MaxLength(3)] + public required string ThreeLetterCode { get; init; } + + [Required(ErrorMessage = "The ShirtColor field is required")] + public required string ShirtColor { get; set; } + + /// + /// The jersey kit pattern applied over the primary shirt color; defaults to "solid" when not supplied. + /// + [MaxLength(20)] + public string JerseyStyle { get; set; } = "solid"; + + /// + /// Optional secondary #rrggbb hex color used for the jersey pattern or trim. + /// + [MaxLength(9)] + public string? ShirtSecondaryColor { get; set; } + + /// + /// Optional third #rrggbb hex color, used only by tri-color kit templates as a second accent. + /// + [MaxLength(9)] + public string? ShirtTertiaryColor { get; set; } + + /// + /// The logo image file to upload, which must be JPEG or PNG. + /// + [Required(ErrorMessage = "The Logo image is required.")] + [DataType(DataType.Upload)] + public required IFormFile LogoFile { get; init; } + + public Guid? TournamentId { get; init; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Request/GetTeamsFilteredRequest.cs b/Club12-Backend/Application/DTOs/Team/Request/GetTeamsFilteredRequest.cs new file mode 100644 index 0000000..69c35e3 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Request/GetTeamsFilteredRequest.cs @@ -0,0 +1,20 @@ +using Application.DTOs.Abstract.Request; + +using System; + +namespace Application.DTOs.Team.Request; + +/// +/// Represents a request to get filtered teams. +/// +public class GetTeamsFilteredRequest : PaginatedFilterRequest +{ + public string? Name { get; set; } + + public string? ThreeLetterCode { get; set; } + + public string? ShirtColor { get; set; } + + public Guid? StageId { get; set; } + public Guid? TournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Request/UpdateTeamLogoRequest.cs b/Club12-Backend/Application/DTOs/Team/Request/UpdateTeamLogoRequest.cs new file mode 100644 index 0000000..c67f4f5 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Request/UpdateTeamLogoRequest.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Team.Request; + +/// +/// Represents a request to update a team's logo. +/// +public class UpdateTeamLogoRequest +{ + [Required(ErrorMessage = "The TeamId field is required.")] + public required Guid TeamId { get; init; } + + /// + /// The logo image file to upload, which must be JPEG or PNG. + /// + [Required(ErrorMessage = "The Logo image is required.")] + [DataType(DataType.Upload)] + public required IFormFile LogoFile { get; init; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Request/UpdateTeamRequest.cs b/Club12-Backend/Application/DTOs/Team/Request/UpdateTeamRequest.cs new file mode 100644 index 0000000..8334fad --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Request/UpdateTeamRequest.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Team.Request; + +/// +/// Represents a request to update a team's details. +/// +public class UpdateTeamRequest +{ + public string? Name { get; init; } + + [MaxLength(3)] + public string? ThreeLetterCode { get; init; } + + public string? ShirtColor { get; set; } + + /// + /// The jersey kit pattern applied over the primary shirt color; left unchanged when not supplied. + /// + [MaxLength(20)] + public string? JerseyStyle { get; set; } + + /// + /// Optional secondary #rrggbb hex color used for the jersey pattern or trim. + /// + [MaxLength(9)] + public string? ShirtSecondaryColor { get; set; } + + /// + /// Optional third #rrggbb hex color, used only by tri-color kit templates as a second accent. + /// + [MaxLength(9)] + public string? ShirtTertiaryColor { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Response/TeamDetailedMatchResponse.cs b/Club12-Backend/Application/DTOs/Team/Response/TeamDetailedMatchResponse.cs new file mode 100644 index 0000000..ce205c4 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Response/TeamDetailedMatchResponse.cs @@ -0,0 +1,45 @@ +using Application.DTOs.Player.Response; +using Application.DTOs.Scorer.Response; + +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Team.Response; + +/// +/// Represents the response data for a team in a match. +/// +public class TeamDetailedMatchResponse +{ + public required Guid Id { get; set; } + + public required string Name { get; set; } + + public required string LogoUrl { get; set; } + + /// + /// Primary shirt color as #rrggbb, for rendering the kit. + /// + public string? ShirtColor { get; set; } + + /// + /// Jersey kit pattern. + /// + public string? JerseyStyle { get; set; } + + /// + /// Secondary shirt color as #rrggbb, for the kit trim or pattern. + /// + public string? ShirtSecondaryColor { get; set; } + + /// + /// Third shirt color as #rrggbb, used only by tri-color kit templates. + /// + public string? ShirtTertiaryColor { get; set; } + + public int Score { get; set; } + + public List Scorers { get; set; } = []; + + public List Players { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Team/Response/TeamMatchResponse.cs b/Club12-Backend/Application/DTOs/Team/Response/TeamMatchResponse.cs new file mode 100644 index 0000000..a51c53c --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Response/TeamMatchResponse.cs @@ -0,0 +1,69 @@ +using System; + +namespace Application.DTOs.Team.Response; + +/// +/// A single match projected from a team's point of view, with scores and result oriented relative to the team. +/// +public class TeamMatchResponse +{ + /// + /// The id of the match. + /// + public required Guid MatchId { get; set; } + + /// + /// The scheduled or played calendar date of the match. + /// + public DateTime? MatchDate { get; set; } + + /// + /// Whether the match has finished, meaning a decisive result exists. + /// + public required bool IsFinished { get; set; } + + /// + /// The match lifecycle status name: Scheduled, Played, Suspended, or WalkOver. + /// + public required string Status { get; set; } + + /// + /// True when the team played this match at home. + /// + public required bool IsHome { get; set; } + + /// + /// The opponent team's id; Guid.Empty when the opponent slot is unassigned. + /// + public required Guid OpponentTeamId { get; set; } + + /// + /// The opponent team's name; empty when the opponent slot is unassigned. + /// + public required string OpponentName { get; set; } + + /// + /// The opponent team's logo URL, when available. + /// + public string? OpponentLogoUrl { get; set; } + + /// + /// The team's own score, when the match has a result. + /// + public int? TeamScore { get; set; } + + /// + /// The opponent's score, when the match has a result. + /// + public int? OpponentScore { get; set; } + + /// + /// "W" or "L" from the team's perspective when finished; null otherwise. + /// + public string? Result { get; set; } + + /// + /// The venue's name, when the match has a venue assigned. + /// + public string? VenueName { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Response/TeamParticipationResponse.cs b/Club12-Backend/Application/DTOs/Team/Response/TeamParticipationResponse.cs new file mode 100644 index 0000000..066b434 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Response/TeamParticipationResponse.cs @@ -0,0 +1,49 @@ +using System; + +namespace Application.DTOs.Team.Response; + +/// +/// One tournament a team participated in, enriched with season info for the public trajectory list. +/// +public class TeamParticipationResponse +{ + /// + /// The id of the tournament the team participated in. + /// + public required Guid TournamentId { get; set; } + + /// + /// The tournament's display name. + /// + public required string TournamentName { get; set; } + + /// + /// The tournament's public slug. + /// + public string? TournamentSlug { get; set; } + + /// + /// The tournament's competitive category name, Masculine or Feminine. + /// + public required string Category { get; set; } + + /// + /// The id of the season the tournament belongs to, when grouped under one. + /// + public Guid? SeasonId { get; set; } + + /// + /// The season's display name, when the tournament belongs to a season. + /// + public string? SeasonName { get; set; } + + /// + /// The season's calendar year, when known. + /// + public int? Year { get; set; } + + /// + /// True when this is the team's current tournament. + /// + public required bool IsCurrent { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Team/Response/TeamResponse.cs b/Club12-Backend/Application/DTOs/Team/Response/TeamResponse.cs new file mode 100644 index 0000000..18f3435 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Response/TeamResponse.cs @@ -0,0 +1,55 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Player.Response; + +using System; +using System.Collections.Generic; + +namespace Application.DTOs.Team.Response; + +/// +/// Represents a response for a team, inheriting from the base response. +/// +public class TeamResponse : BaseEntityResponse +{ + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public team links. + /// + public required string Slug { get; set; } + + public required string ThreeLetterCode { get; set; } + + public required string ShirtColor { get; set; } + + /// + /// The jersey kit pattern applied over the primary shirt color; see the frontend's JERSEY_STYLES list. + /// + public string JerseyStyle { get; set; } = "solid"; + + /// + /// Optional secondary #rrggbb hex color used for the jersey pattern or trim; null means no accent color. + /// + public string? ShirtSecondaryColor { get; set; } + + /// + /// Optional third #rrggbb hex color, used only by tri-color kit templates; null when unused. + /// + public string? ShirtTertiaryColor { get; set; } + + public required string LogoUrl { get; set; } + + public Guid? TournamentId { get; set; } + + /// + /// The team's current tournament name, disambiguating same-named teams across seasons; null when TournamentId is null. + /// + public string? TournamentName { get; set; } + + /// + /// The club this team belongs to, letting the frontend link to it; null when not associated with a club. + /// + public Guid? ClubId { get; set; } + + public required List Players { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Team/Response/TeamSummaryResponse.cs b/Club12-Backend/Application/DTOs/Team/Response/TeamSummaryResponse.cs new file mode 100644 index 0000000..ff5c373 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Team/Response/TeamSummaryResponse.cs @@ -0,0 +1,64 @@ +using System; + +namespace Application.DTOs.Team.Response; + +/// +/// A team's current standing row inside its group-stage table for a single tournament. +/// +public class TeamSummaryResponse +{ + /// + /// The id of the division whose group standings contain the team. + /// + public required Guid DivisionId { get; set; } + + /// + /// The division's display name. + /// + public required string DivisionName { get; set; } + + /// + /// The team's 1-based rank within its group table. + /// + public required int Position { get; set; } + + /// + /// The number of teams in the same group table. + /// + public required int TotalTeams { get; set; } + + /// + /// Matches played by the team in the group stage. + /// + public required int Played { get; set; } + + /// + /// Matches won. + /// + public required int Wins { get; set; } + + /// + /// Matches lost. + /// + public required int Losses { get; set; } + + /// + /// Total points scored by the team. + /// + public required int PointsFor { get; set; } + + /// + /// Total points scored against the team. + /// + public required int PointsAgainst { get; set; } + + /// + /// PointsFor minus PointsAgainst. + /// + public required int PointsDifference { get; set; } + + /// + /// Table points accumulated, per the division's configured win and loss values. + /// + public required int Points { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/TeamStaff/Request/CreateTeamStaffRequest.cs b/Club12-Backend/Application/DTOs/TeamStaff/Request/CreateTeamStaffRequest.cs new file mode 100644 index 0000000..47a145d --- /dev/null +++ b/Club12-Backend/Application/DTOs/TeamStaff/Request/CreateTeamStaffRequest.cs @@ -0,0 +1,24 @@ +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.TeamStaff.Request; + +/// +/// Request to add a member to a team's technical staff; the team is taken from the route. +/// +public class CreateTeamStaffRequest +{ + [Required(ErrorMessage = "The FullName field is required.")] + [MaxLength(150, ErrorMessage = "FullName cannot exceed 150 characters.")] + public required string FullName { get; set; } + + public required TeamStaffRole Role { get; set; } + + /// + /// The tournament this staff membership belongs to. + /// + [Required(ErrorMessage = "The TournamentId field is required.")] + public required Guid TournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/TeamStaff/Response/TeamStaffResponse.cs b/Club12-Backend/Application/DTOs/TeamStaff/Response/TeamStaffResponse.cs new file mode 100644 index 0000000..259bf56 --- /dev/null +++ b/Club12-Backend/Application/DTOs/TeamStaff/Response/TeamStaffResponse.cs @@ -0,0 +1,41 @@ +using System; + +namespace Application.DTOs.TeamStaff.Response; + +/// +/// A team's technical staff member record, as returned by the create and list endpoints. +/// +public class TeamStaffResponse +{ + /// + /// The unique identifier of the staff member. + /// + public Guid Id { get; set; } + + /// + /// The id of the team this staff member belongs to. + /// + public Guid TeamId { get; set; } + + /// + /// The team's name, when the team was loaded. + /// + public string? TeamName { get; set; } + + /// + /// The tournament this staff membership belongs to. + /// + public Guid TournamentId { get; set; } + + public string FullName { get; set; } = string.Empty; + + /// + /// The role this person holds on the team's technical staff. + /// + public required string Role { get; set; } + + /// + /// When the staff member was added. + /// + public DateTime DateCreated { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullDivisionRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullDivisionRequest.cs new file mode 100644 index 0000000..6b7ccfd --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullDivisionRequest.cs @@ -0,0 +1,51 @@ +using Application.DTOs.Divisions.Request; +using Application.Utils.Constants.Validation; + +using Domain.Enums; + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Tournament.Request; + +/// +/// One division within a CreateFullTournamentRequest; the TournamentId is implied by nesting. +/// +public class CreateFullDivisionRequest +{ + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(DivisionFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + /// + /// Marks a cross-division cup that draws teams from every zone. + /// + public bool IsCrossDivisionCup { get; set; } = false; + + [Range(0, int.MaxValue, ErrorMessage = "PointsForWin cannot be negative.")] + public int PointsForWin { get; set; } = 2; + + [Range(0, int.MaxValue, ErrorMessage = "PointsForLoss cannot be negative.")] + public int PointsForLoss { get; set; } = 1; + + /// + /// How many teams qualify from each internal group of a multi-group cross-division cup; defaults to 1. + /// + [Range(1, int.MaxValue, ErrorMessage = "QualifiersPerGroup must be at least 1.")] + public int QualifiersPerGroup { get; set; } = 1; + + /// + /// Competitive category of the division; a mismatch with the parent tournament aborts the whole create. + /// + public TournamentCategory Category { get; set; } = TournamentCategory.Masculine; + + /// + /// Optional position-range to playoff-destination mappings. + /// + public List? PlayoffMappings { get; set; } + + /// + /// The stages, group and cup elimination rounds alike, to create in this division. + /// + public List Stages { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullStageRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullStageRequest.cs new file mode 100644 index 0000000..7cd1a06 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullStageRequest.cs @@ -0,0 +1,49 @@ +using Application.Utils.Constants.Stage; + +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Tournament.Request; + +/// +/// One stage within a CreateFullDivisionRequest, mirroring CreateStageRequest minus the DivisionId. +/// +public class CreateFullStageRequest +{ + [Required(ErrorMessage = "Stage name field is required.")] + public required string Name { get; set; } + + public string? Description { get; set; } + + [AllowedValues(StageType.Group, StageType.RoundOf16, StageType.QuarterFinal, StageType.SemiFinal, StageType.ThirdPlace, StageType.Final)] + public required StageType StageType { get; set; } = StageType.Group; + + /// + /// Defaults to true when null. + /// + public bool? IsActive { get; set; } + + /// + /// Defaults to true for non-Group stage types when null. + /// + public bool? IsElimination { get; set; } + + [Required(ErrorMessage = "Start date field is required.")] + public required DateTime StartDate { get; set; } + + [Required(ErrorMessage = "End date field is required.")] + public required DateTime EndDate { get; set; } + + /// + /// Groups parallel elimination brackets under a cup name. + /// + public string? BracketName { get; set; } + + [AllowedValues(1, 3, 5, 7)] + public int BestOf { get; set; } = 1; + + [Range(RoundRobinFormat.MinLegs, RoundRobinFormat.MaxLegs)] + public int RoundRobinLegs { get; set; } = RoundRobinFormat.MinLegs; +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullTournamentRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullTournamentRequest.cs new file mode 100644 index 0000000..a4a2ce3 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/CreateFullTournamentRequest.cs @@ -0,0 +1,42 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.Tournament.Request; + +/// +/// The whole tournament-wizard payload, persisted atomically so a failure leaves no partial tournament behind. +/// +public class CreateFullTournamentRequest +{ + /// + /// Optional; neither the create wizard nor the edit form require it. + /// + public string Description { get; set; } = string.Empty; + + [Required(ErrorMessage = "Name is required.")] + public required string Name { get; set; } + + [Required(ErrorMessage = "Team registration deadline is required.")] + public required DateTime TeamRegistrationDeadline { get; set; } + + [Required(ErrorMessage = "Start date is required.")] + public required DateTime StartDate { get; set; } + + /// + /// Competitive category of the tournament; every division below must share this category. + /// + public TournamentCategory Category { get; set; } = TournamentCategory.Masculine; + + /// + /// Optional id of the season this tournament belongs to; omitting it leaves the tournament ungrouped. + /// + public Guid? SeasonId { get; set; } + + /// + /// The divisions to create under this tournament, each with its own stages and playoff mappings. + /// + public List Divisions { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/CreateTournamentRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/CreateTournamentRequest.cs new file mode 100644 index 0000000..ff98aaa --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/CreateTournamentRequest.cs @@ -0,0 +1,38 @@ +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Tournament.Request; + +/// +/// Represents a request to create a new tournament. +/// +public class CreateTournamentRequest +{ + /// + /// The description of the tournament. Optional. + /// + public string Description { get; set; } = string.Empty; + + [Required(ErrorMessage = "Name is required.")] + public required string Name { get; set; } + + /// + /// The deadline for team registrations; must be earlier than the tournament start date. + /// + [Required(ErrorMessage = "Team registration deadline is required.")] + public required DateTime TeamRegistrationDeadline { get; set; } + + [Required(ErrorMessage = "Start date is required.")] + public required DateTime StartDate { get; set; } + + /// + /// Competitive category of the tournament; feminine and masculine competitions are separate tournaments. + /// + public TournamentCategory Category { get; set; } = TournamentCategory.Masculine; + + /// + /// Optional id of the season this tournament belongs to; omitting it leaves the tournament ungrouped. + /// + public Guid? SeasonId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/EnrollTeamRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/EnrollTeamRequest.cs new file mode 100644 index 0000000..0d1ff35 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/EnrollTeamRequest.cs @@ -0,0 +1,24 @@ +using System; + +namespace Application.DTOs.Tournament.Request; + +/// +/// Payload for enrolling a single team into a tournament's registration phase. +/// +public class EnrollTeamRequest +{ + /// + /// The existing team to enroll; mutually exclusive with NewTeamName. + /// + public Guid? ExistingTeamId { get; set; } + + /// + /// The name of a brand-new team to create and enroll; mutually exclusive with ExistingTeamId. + /// + public string? NewTeamName { get; set; } + + /// + /// Optional source season whose roster is copied in; only allowed together with ExistingTeamId. + /// + public Guid? CopyRosterFromTournamentId { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/GetTournamentsFilteredRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/GetTournamentsFilteredRequest.cs new file mode 100644 index 0000000..d08dc16 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/GetTournamentsFilteredRequest.cs @@ -0,0 +1,16 @@ +using Application.DTOs.Abstract.Request; + +using Domain.Enums; +namespace Application.DTOs.Tournament.Request; + +/// +/// Represents a request to get filtered tournaments. +/// +public class GetTournamentsFilteredRequest : PaginatedFilterRequest +{ + public string? Name { get; set; } + + public string? Description { get; set; } + + public TournamentStatus? Status { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Request/UpdateTournamentRequest.cs b/Club12-Backend/Application/DTOs/Tournament/Request/UpdateTournamentRequest.cs new file mode 100644 index 0000000..f8cea30 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Request/UpdateTournamentRequest.cs @@ -0,0 +1,8 @@ +using Domain.Enums; + +namespace Application.DTOs.Tournament.Request; + +public class UpdateTournamentRequest : CreateTournamentRequest +{ + public TournamentStatus? Status { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Response/CompletabilityIssue.cs b/Club12-Backend/Application/DTOs/Tournament/Response/CompletabilityIssue.cs new file mode 100644 index 0000000..cd938a8 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Response/CompletabilityIssue.cs @@ -0,0 +1,37 @@ +namespace Application.DTOs.Tournament.Response; + +/// +/// One completability violation, with every field except Code optional and populated only where relevant. +/// +public class CompletabilityIssue +{ + /// + /// The violated rule's stable code, one of the constants in CompletabilityIssueCodes. + /// + public required string Code { get; set; } + + /// + /// The offending division or zone name, when the rule is about a division. + /// + public string? DivisionName { get; set; } + + /// + /// The offending team name, when the rule is about a team. + /// + public string? TeamName { get; set; } + + /// + /// The playoff range's first position, for PlayoffRangeExceedsTeams. + /// + public int? FromPosition { get; set; } + + /// + /// The number of teams assigned to the offending division or group, for the count-based rules. + /// + public int? AssignedTeams { get; set; } + + /// + /// The offending team's habilitado player count, not its raw roster size, for TeamTooFewPlayers. + /// + public int? PlayerCount { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Response/CompletabilityIssueCodes.cs b/Club12-Backend/Application/DTOs/Tournament/Response/CompletabilityIssueCodes.cs new file mode 100644 index 0000000..5fe0c35 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Response/CompletabilityIssueCodes.cs @@ -0,0 +1,42 @@ +namespace Application.DTOs.Tournament.Response; + +/// +/// The stable, English and neutral codes for every completability violation, localized by the frontend. +/// +public static class CompletabilityIssueCodes +{ + /// + /// A regular zone has fewer than the required assigned teams. + /// + public const string ZoneTooFewTeams = nameof(ZoneTooFewTeams); + + /// + /// An enrolled team is not assigned to any regular zone. + /// + public const string TeamNotAssigned = nameof(TeamNotAssigned); + + /// + /// A team is assigned to more than one regular zone. + /// + public const string TeamInMultipleZones = nameof(TeamInMultipleZones); + + /// + /// A playoff range starts beyond the zone's assigned team count. + /// + public const string PlayoffRangeExceedsTeams = nameof(PlayoffRangeExceedsTeams); + + /// + /// A cross-division-cup group has fewer than the required assigned teams. + /// + public const string CrossCupGroupTooFewTeams = nameof(CrossCupGroupTooFewTeams); + + /// + /// An enrolled team has fewer than the required registered players. + /// + public const string TeamTooFewPlayers = nameof(TeamTooFewPlayers); + + /// + /// A regular division's sub-groups have a group below the minimum size or an imbalance across groups. + /// + public const string SubGroupTooFewTeams = nameof(SubGroupTooFewTeams); +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Response/TournamentCompletabilityResponse.cs b/Club12-Backend/Application/DTOs/Tournament/Response/TournamentCompletabilityResponse.cs new file mode 100644 index 0000000..0d160ff --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Response/TournamentCompletabilityResponse.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Application.DTOs.Tournament.Response; + +/// +/// The completability report for a tournament: whether it can be started, and the blocking issues if not. +/// +public class TournamentCompletabilityResponse +{ + /// + /// True when the tournament has no completability issues and may be started. + /// + public bool CanStart { get; set; } + + /// + /// Every completability violation found; empty when CanStart is true. + /// + public List Issues { get; set; } = []; +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Response/TournamentResponse.cs b/Club12-Backend/Application/DTOs/Tournament/Response/TournamentResponse.cs new file mode 100644 index 0000000..41f33de --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Response/TournamentResponse.cs @@ -0,0 +1,54 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Divisions.Response; + +using Domain.Enums; + +using System; +using System.Collections.Generic; +namespace Application.DTOs.Tournament.Response; + +/// +/// Represents the response containing details of a tournament. +/// +public class TournamentResponse : BaseEntityResponse +{ + public required string Description { get; set; } + + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public tournament links. + /// + public required string Slug { get; set; } + + public required IEnumerable Divisions { get; set; } + + /// + /// The deadline for team registrations; must be earlier than the tournament start date. + /// + public required DateTime TeamRegistrationDeadline { get; set; } + + public required DateTime StartDate { get; set; } + + public TournamentStatus Status { get; set; } + + /// + /// Competitive category of the tournament; the feminine competition is played as its own tournament. + /// + public TournamentCategory Category { get; set; } + + /// + /// Id of the season this tournament belongs to, or null when not grouped under any season. + /// + public Guid? SeasonId { get; set; } + + /// + /// Name of the season this tournament belongs to, when loaded; null otherwise. + /// + public string? SeasonName { get; set; } + + /// + /// Slug of the season this tournament belongs to, when loaded, for building a clean season link. + /// + public string? SeasonSlug { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/Tournament/Response/TournamentStructureResponse.cs b/Club12-Backend/Application/DTOs/Tournament/Response/TournamentStructureResponse.cs new file mode 100644 index 0000000..572705a --- /dev/null +++ b/Club12-Backend/Application/DTOs/Tournament/Response/TournamentStructureResponse.cs @@ -0,0 +1,78 @@ +using Application.DTOs.Divisions.Response; + +using Domain.Enums; + +using System.Collections.Generic; + +namespace Application.DTOs.Tournament.Response; + +/// +/// A source tournament's pure structure tree (divisions, their stages and +/// playoff mappings) for the tournament-cloning wizard-prefill flow (HU-cloning). +/// Carries STRUCTURE ONLY — no rosters, matches, standings, sanctions, audit +/// logs, or DrawnAt timestamps — so it can never leak instance data into a +/// clone. Additive: never replaces TournamentResponse/DivisionResponse, which +/// stay unchanged for their existing unrelated pages. +/// +public class TournamentStructureResponse +{ + public required string Name { get; set; } + + public string? Description { get; set; } + + /// + /// The source tournament's category, shown for reference only — the clone + /// action always requires the organizer to choose the new category + /// explicitly, never inheriting this value silently. + /// + public required TournamentCategory Category { get; set; } + + public required List Divisions { get; set; } +} + +/// +/// One division's cloneable structure: its scoring/cup configuration, playoff +/// mappings, and full Stage list — everything the reverse-mapper needs to +/// reconstruct a `ZoneConfig` or `CrossCupConfig` (D1), and nothing else. +/// +public class DivisionStructureResponse +{ + public required string Name { get; set; } + + public bool IsCrossDivisionCup { get; set; } + + public int PointsForWin { get; set; } + + public int PointsForLoss { get; set; } + + public int QualifiersPerGroup { get; set; } + + public List PlayoffMappings { get; set; } = []; + + public required List Stages { get; set; } +} + +/// +/// One stage's cloneable shape: its type, ordering, and bracket/series +/// configuration. Carries no dates, no DrawnAt, and no match data. +/// +public class StageStructureResponse +{ + public required string Name { get; set; } + + /// + /// Groups parallel elimination brackets under a cup name; null for a + /// division's default/group stage. + /// + public string? BracketName { get; set; } + + public required StageType StageType { get; set; } + + public bool IsElimination { get; set; } + + public int Order { get; set; } + + public int BestOf { get; set; } + + public int RoundRobinLegs { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/User/Request/ChangePasswordRequest.cs b/Club12-Backend/Application/DTOs/User/Request/ChangePasswordRequest.cs new file mode 100644 index 0000000..17ffef3 --- /dev/null +++ b/Club12-Backend/Application/DTOs/User/Request/ChangePasswordRequest.cs @@ -0,0 +1,20 @@ +using Application.Utils.Constants.Validation; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.User.Request; + +/// +/// Request to change a user's password; self-service changes require CurrentPassword. +/// +public sealed class ChangePasswordRequest +{ + [Required] + [MinLength(UserFieldLengths.PasswordMinLength)] + public string NewPassword { get; set; } = string.Empty; + + /// + /// Required only for self-service password changes. + /// + public string? CurrentPassword { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/User/Request/SetUserActiveRequest.cs b/Club12-Backend/Application/DTOs/User/Request/SetUserActiveRequest.cs new file mode 100644 index 0000000..947e614 --- /dev/null +++ b/Club12-Backend/Application/DTOs/User/Request/SetUserActiveRequest.cs @@ -0,0 +1,6 @@ +namespace Application.DTOs.User.Request; + +public sealed class SetUserActiveRequest +{ + public bool IsActive { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/User/Request/UpdateUserRequest.cs b/Club12-Backend/Application/DTOs/User/Request/UpdateUserRequest.cs new file mode 100644 index 0000000..308f0c5 --- /dev/null +++ b/Club12-Backend/Application/DTOs/User/Request/UpdateUserRequest.cs @@ -0,0 +1,25 @@ +using Application.Utils.Constants.Validation; + +using Domain.Enums; + +using System.ComponentModel.DataAnnotations; + +namespace Application.DTOs.User.Request; + +public sealed class UpdateUserRequest +{ + [StringLength(UserFieldLengths.UsernameMaxLength, MinimumLength = UserFieldLengths.UsernameMinLength)] + public string? Username { get; set; } + + [EmailAddress] + public string? Email { get; set; } + + [ArgentinePhoneNumber] + [MaxLength(UserFieldLengths.PhoneMaxLength, ErrorMessage = "Phone number must not exceed 15 characters.")] + public string? Phone { get; set; } + + /// + /// When provided, replaces the target user's role; null updates profile fields without touching it. + /// + public UserRoleType? Role { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/User/Request/UserFilteredRequest.cs b/Club12-Backend/Application/DTOs/User/Request/UserFilteredRequest.cs new file mode 100644 index 0000000..cdd15be --- /dev/null +++ b/Club12-Backend/Application/DTOs/User/Request/UserFilteredRequest.cs @@ -0,0 +1,13 @@ +using Application.DTOs.Abstract.Request; + +using Domain.Enums; + +namespace Application.DTOs.User.Request; + +public class UserFilteredRequest : PaginatedFilterRequest +{ + public string? UserName { get; set; } + public string? Email { get; set; } + public UserRoleType? Role { get; set; } + public string? PhoneNumber { get; set; } +} diff --git a/Club12-Backend/Application/DTOs/User/Response/ResetPasswordResponse.cs b/Club12-Backend/Application/DTOs/User/Response/ResetPasswordResponse.cs new file mode 100644 index 0000000..7e84d85 --- /dev/null +++ b/Club12-Backend/Application/DTOs/User/Response/ResetPasswordResponse.cs @@ -0,0 +1,10 @@ +using System; + +namespace Application.DTOs.User.Response; + +/// +/// Confirms a forced password reset was performed; the generated temporary password is never included here. +/// +public sealed record ResetPasswordResponse( + Guid UserId +); \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/User/Response/UserResponse.cs b/Club12-Backend/Application/DTOs/User/Response/UserResponse.cs new file mode 100644 index 0000000..c4a4812 --- /dev/null +++ b/Club12-Backend/Application/DTOs/User/Response/UserResponse.cs @@ -0,0 +1,21 @@ +using System; + +namespace Application.DTOs.User.Response; + +public sealed class UserResponse( + Guid userId, + string email, + string username, + string role, + string? phone, + Guid? createdByOwnerId, + bool isActive) +{ + public Guid UserId { get; init; } = userId; + public string Email { get; init; } = email; + public string Username { get; init; } = username; + public string Role { get; init; } = role; + public string? PhoneNumber { get; init; } = phone; + public Guid? CreatedByOwnerId { get; init; } = createdByOwnerId; + public bool IsActive { get; init; } = isActive; +} diff --git a/Club12-Backend/Application/DTOs/Venue/Request/CreateVenueRequest.cs b/Club12-Backend/Application/DTOs/Venue/Request/CreateVenueRequest.cs new file mode 100644 index 0000000..163d263 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Venue/Request/CreateVenueRequest.cs @@ -0,0 +1,36 @@ +using Application.Utils.Constants.Validation; + +using Microsoft.AspNetCore.Http; + +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Venue.Request; + +/// +/// Request model for creating a new venue. +/// +public class CreateVenueRequest +{ + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(VenueFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + [Required(ErrorMessage = "The Address field is required.")] + [MaxLength(VenueFieldLengths.AddressMaxLength)] + public required string Address { get; set; } + + /// + /// Optional photo of the venue, which must be JPEG or PNG; may be omitted. + /// + [DataType(DataType.Upload)] + public IFormFile? ImageFile { get; init; } + + /// + /// Optional geographic latitude of the venue. + /// + public double? Latitude { get; set; } + + /// + /// Optional geographic longitude of the venue. + /// + public double? Longitude { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Venue/Request/UpdateVenuePhotoRequest.cs b/Club12-Backend/Application/DTOs/Venue/Request/UpdateVenuePhotoRequest.cs new file mode 100644 index 0000000..f338972 --- /dev/null +++ b/Club12-Backend/Application/DTOs/Venue/Request/UpdateVenuePhotoRequest.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +using System; +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Venue.Request; + +/// +/// Represents a request to update a venue's photo. +/// +public class UpdateVenuePhotoRequest +{ + [Required(ErrorMessage = "The VenueId field is required.")] + public required Guid VenueId { get; init; } + + /// + /// The photo image file to upload, which must be JPEG or PNG. + /// + [Required(ErrorMessage = "The image file is required.")] + [DataType(DataType.Upload)] + public required IFormFile ImageFile { get; init; } +} diff --git a/Club12-Backend/Application/DTOs/Venue/Request/UpdateVenueRequest.cs b/Club12-Backend/Application/DTOs/Venue/Request/UpdateVenueRequest.cs new file mode 100644 index 0000000..bcd813c --- /dev/null +++ b/Club12-Backend/Application/DTOs/Venue/Request/UpdateVenueRequest.cs @@ -0,0 +1,32 @@ +using Application.Utils.Constants.Validation; +using Application.Utils.Extensions; + +using System.ComponentModel.DataAnnotations; +namespace Application.DTOs.Venue.Request; + +/// +/// Request model for updating an existing venue. +/// +public class UpdateVenueRequest +{ + [Required(ErrorMessage = "The Name field is required.")] + [MaxLength(VenueFieldLengths.NameMaxLength)] + public required string Name { get; set; } + + [Required(ErrorMessage = "The Address field is required.")] + [MaxLength(VenueFieldLengths.AddressMaxLength)] + public required string Address { get; set; } + + [ImageReference] + public string? PhotoUrl { get; set; } + + /// + /// Optional geographic latitude of the venue. + /// + public double? Latitude { get; set; } + + /// + /// Optional geographic longitude of the venue. + /// + public double? Longitude { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Application/DTOs/Venue/Response/VenueResponse.cs b/Club12-Backend/Application/DTOs/Venue/Response/VenueResponse.cs new file mode 100644 index 0000000..cc2acaf --- /dev/null +++ b/Club12-Backend/Application/DTOs/Venue/Response/VenueResponse.cs @@ -0,0 +1,30 @@ +using Application.DTOs.Abstract.Response; +namespace Application.DTOs.Venue.Response; + +/// +/// Response model for returning venue details. +/// +public class VenueResponse : BaseEntityResponse +{ + + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public venue links. + /// + public required string Slug { get; set; } + + public required string Address { get; set; } + + public string? PhotoUrl { get; set; } + + /// + /// Optional geographic latitude of the venue, for the public map link. + /// + public double? Latitude { get; set; } + + /// + /// Optional geographic longitude of the venue, for the public map link. + /// + public double? Longitude { get; set; } +} diff --git a/Club12-Backend/Application/Interfaces/Backup/BackupExecutionException.cs b/Club12-Backend/Application/Interfaces/Backup/BackupExecutionException.cs new file mode 100644 index 0000000..77cd944 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/BackupExecutionException.cs @@ -0,0 +1,17 @@ +using System; + +namespace Application.Interfaces.Backup; + +/// +/// Raised when a backup step fails in an expected, handled way that callers must catch and log instead of letting it crash the host. +/// +public sealed class BackupExecutionException : Exception +{ + public BackupExecutionException(string message) : base(message) + { + } + + public BackupExecutionException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/Club12-Backend/Application/Interfaces/Backup/BackupFile.cs b/Club12-Backend/Application/Interfaces/Backup/BackupFile.cs new file mode 100644 index 0000000..b244cf5 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/BackupFile.cs @@ -0,0 +1,8 @@ +using System; + +namespace Application.Interfaces.Backup; + +/// +/// Metadata describing a single stored backup dump: its stored name and creation timestamp. +/// +public sealed record BackupFile(string Name, DateTimeOffset Timestamp); diff --git a/Club12-Backend/Application/Interfaces/Backup/BackupOperationOutcome.cs b/Club12-Backend/Application/Interfaces/Backup/BackupOperationOutcome.cs new file mode 100644 index 0000000..f3df739 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/BackupOperationOutcome.cs @@ -0,0 +1,12 @@ +namespace Application.Interfaces.Backup; + +/// +/// Outcome of a single IBackupOperationsService call, mapped explicitly by controllers to an HTTP status code. +/// +public enum BackupOperationOutcome +{ + Completed, + Busy, + NotFound, + Failed, +} diff --git a/Club12-Backend/Application/Interfaces/Backup/BackupOperationResult.cs b/Club12-Backend/Application/Interfaces/Backup/BackupOperationResult.cs new file mode 100644 index 0000000..f284b90 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/BackupOperationResult.cs @@ -0,0 +1,9 @@ +using Application.DTOs.Backup.Response; + +namespace Application.Interfaces.Backup; + +/// +/// Result of a create, delete, or restore attempt, with Record populated only when Outcome is Completed. +/// +public sealed record BackupOperationResult( + BackupOperationOutcome Outcome, BackupRecordResponse? Record, string? Message); diff --git a/Club12-Backend/Application/Interfaces/Backup/BackupOptions.cs b/Club12-Backend/Application/Interfaces/Backup/BackupOptions.cs new file mode 100644 index 0000000..a4bb966 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/BackupOptions.cs @@ -0,0 +1,15 @@ +namespace Application.Interfaces.Backup; + +/// +/// Non-secret backup configuration bound from the Backup configuration section. +/// +public sealed class BackupOptions +{ + public bool Enabled { get; set; } + public int IntervalHours { get; set; } = 24; + public int RetentionCount { get; set; } = 7; + public string StorageTarget { get; set; } = BackupStorageTargets.Local; + public string LocalStoragePath { get; set; } = "backups"; + public string PgDumpPath { get; set; } = "pg_dump"; + public string PsqlPath { get; set; } = "psql"; +} diff --git a/Club12-Backend/Application/Interfaces/Backup/BackupStorageTargets.cs b/Club12-Backend/Application/Interfaces/Backup/BackupStorageTargets.cs new file mode 100644 index 0000000..7590f66 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/BackupStorageTargets.cs @@ -0,0 +1,10 @@ +namespace Application.Interfaces.Backup; + +/// +/// Named BackupOptions.StorageTarget values, where any value other than Supabase resolves to the local storage backend so a configuration typo fails safe. +/// +public static class BackupStorageTargets +{ + public const string Local = "Local"; + public const string Supabase = "Supabase"; +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IBackupCatalog.cs b/Club12-Backend/Application/Interfaces/Backup/IBackupCatalog.cs new file mode 100644 index 0000000..4c93d68 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IBackupCatalog.cs @@ -0,0 +1,22 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Backup; + +/// +/// Durable, queryable per-backup record store that is the source of truth for the admin backup listing. +/// +public interface IBackupCatalog +{ + Task AddAsync(BackupRecord record, CancellationToken ct = default); + + Task GetByIdAsync(Guid id, CancellationToken ct = default); + + Task> ListNewestFirstAsync(CancellationToken ct = default); + + Task RemoveAsync(Guid id, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IBackupOperationsService.cs b/Club12-Backend/Application/Interfaces/Backup/IBackupOperationsService.cs new file mode 100644 index 0000000..808bcc8 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IBackupOperationsService.cs @@ -0,0 +1,23 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Backup; + +/// +/// The single shared write path for backup operations, serialized by BackupOperationLock so a concurrent attempt returns Busy instead of running alongside another. +/// +public interface IBackupOperationsService +{ + Task> ListNewestFirstAsync(CancellationToken ct = default); + + Task CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default); + + Task DeleteBackupAsync(Guid id, CancellationToken ct = default); + + Task RestoreBackupAsync(Guid id, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IBackupRetentionPolicy.cs b/Club12-Backend/Application/Interfaces/Backup/IBackupRetentionPolicy.cs new file mode 100644 index 0000000..e0f71a5 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IBackupRetentionPolicy.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Application.Interfaces.Backup; + +/// +/// Pure keep-last-N pruning decision over an existing list of backup files; implementations must not perform I/O. +/// +public interface IBackupRetentionPolicy +{ + IReadOnlyList SelectForDeletion(IReadOnlyList existing, int retainCount); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IBackupStorage.cs b/Club12-Backend/Application/Interfaces/Backup/IBackupStorage.cs new file mode 100644 index 0000000..450cc5a --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IBackupStorage.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Backup; + +/// +/// Persists, lists, and deletes backup dumps in durable off-host storage, whether cloud object storage or a local or mounted directory. +/// +public interface IBackupStorage +{ + Task StoreAsync(string name, Stream content, CancellationToken ct = default); + + Task> ListAsync(CancellationToken ct = default); + + Task DeleteAsync(string name, CancellationToken ct = default); + + /// + /// Opens a readable stream over the stored backup named name, for the restore flow to copy into a local temp file before invoking IDatabaseRestoreService. + /// + Task OpenReadAsync(string name, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IDatabaseBackupService.cs b/Club12-Backend/Application/Interfaces/Backup/IDatabaseBackupService.cs new file mode 100644 index 0000000..db7ff19 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IDatabaseBackupService.cs @@ -0,0 +1,13 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Backup; + +/// +/// Produces a single database dump without ever letting an underlying failure crash the caller. +/// +public interface IDatabaseBackupService +{ + Task CreateDumpAsync(CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IDatabaseRestoreService.cs b/Club12-Backend/Application/Interfaces/Backup/IDatabaseRestoreService.cs new file mode 100644 index 0000000..c83c223 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IDatabaseRestoreService.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Backup; + +/// +/// Restores the database from a local plain-SQL dump file. +/// +public interface IDatabaseRestoreService +{ + /// + /// Restores from the plain-SQL dump at dumpFilePath. + /// + Task RestoreAsync(string dumpFilePath, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IMaintenanceModeState.cs b/Club12-Backend/Application/Interfaces/Backup/IMaintenanceModeState.cs new file mode 100644 index 0000000..157c67c --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IMaintenanceModeState.cs @@ -0,0 +1,25 @@ +using System; + +namespace Application.Interfaces.Backup; + +/// +/// Process-wide maintenance-mode flag registered as a singleton, in-memory only. +/// +public interface IMaintenanceModeState +{ + bool IsActive { get; } + + string? Reason { get; } + + DateTimeOffset? EnteredAtUtc { get; } + + /// + /// Enters maintenance mode with reason. + /// + void Enter(string reason); + + /// + /// Exits maintenance mode and is safe to call even when not currently active. + /// + void Exit(); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/IProcessRunner.cs b/Club12-Backend/Application/Interfaces/Backup/IProcessRunner.cs new file mode 100644 index 0000000..0acd486 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/IProcessRunner.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Backup; + +/// +/// Thin abstraction over external process execution. +/// +public interface IProcessRunner +{ + /// + /// Runs fileName with the given args, passed as a vector so no argument can be reinterpreted as shell syntax by the child process. + /// + Task RunAsync( + string fileName, + IReadOnlyList args, + IReadOnlyDictionary? environmentVariables = null, + CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Backup/ProcessResult.cs b/Club12-Backend/Application/Interfaces/Backup/ProcessResult.cs new file mode 100644 index 0000000..8f9d424 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Backup/ProcessResult.cs @@ -0,0 +1,6 @@ +namespace Application.Interfaces.Backup; + +/// +/// Result of running an external process via IProcessRunner, where callers must not treat StdOut as valid output unless ExitCode is 0. +/// +public sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); diff --git a/Club12-Backend/Application/Interfaces/Mappers/IScorerMapper.cs b/Club12-Backend/Application/Interfaces/Mappers/IScorerMapper.cs new file mode 100644 index 0000000..00927f1 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Mappers/IScorerMapper.cs @@ -0,0 +1,17 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Scorer.Response; + +using Domain.Entities.Models; + +namespace Application.Interfaces.Mappers; + +public interface IScorerMapper +{ + ScorerByPlayerResponse FromScorerToScorerByPlayerResponse(Scorer scorer); + + ScorerByTeamResponse FromScorerToScorerByTeamResponse(Scorer scorer); + + PaginatedResponse FromPaginatedScorerToPaginatedScorerByPlayerResponse(PaginatedResponse paginatedScorers); + + PaginatedResponse FromPaginatedMatchToPaginatedScorerByTeamResponse(PaginatedResponse paginatedMatches); +} diff --git a/Club12-Backend/Application/Interfaces/Mappers/ITeamMapper.cs b/Club12-Backend/Application/Interfaces/Mappers/ITeamMapper.cs new file mode 100644 index 0000000..948f72f --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Mappers/ITeamMapper.cs @@ -0,0 +1,5 @@ +namespace Application.Interfaces.Mappers; + +internal interface ITeamMapper +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IAuditLogRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IAuditLogRepository.cs new file mode 100644 index 0000000..eba1e33 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IAuditLogRepository.cs @@ -0,0 +1,10 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +/// +/// Repository interface for managing AuditLog entries. +/// +public interface IAuditLogRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IBlogPostRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IBlogPostRepository.cs new file mode 100644 index 0000000..ca800ad --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IBlogPostRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IBlogPostRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IClubRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IClubRepository.cs new file mode 100644 index 0000000..7d84fce --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IClubRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IClubRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IDivisionRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IDivisionRepository.cs new file mode 100644 index 0000000..49145ea --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IDivisionRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IDivisionRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IDivisionTeamRegistrationRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IDivisionTeamRegistrationRepository.cs new file mode 100644 index 0000000..011bf12 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IDivisionTeamRegistrationRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IDivisionTeamRegistrationRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IGenericRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IGenericRepository.cs new file mode 100644 index 0000000..7cce95c --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IGenericRepository.cs @@ -0,0 +1,128 @@ +using Application.DTOs.Abstract.Request; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Interfaces.Repositories; + + +/// +/// Generic repository interface for common CRUD operations on entities. +/// +/// The entity type, which must inherit from EntityBase. +public interface IGenericRepository where TEntity : EntityBase +{ + /// + /// Gets an entity by its unique identifier. + /// + /// The unique identifier of the entity. + /// Optional navigation property expressions to include related data. + /// + /// A task representing the asynchronous operation. The task result contains the entity if found, otherwise null. + /// + Task GetByIdAsync(Guid id, IEnumerable>>? includes = null); + + /// + /// Returns a queryable collection of entities for advanced querying scenarios. + /// + IQueryable GetQueryable(); + + /// + /// Gets all entities of type TEntity. + /// + /// + /// A task representing the asynchronous operation. The task result contains a collection of all entities. + /// + Task> GetAllAsync(); + + /// + /// Finds entities matching the given predicate. + /// + /// The filter expression to match entities. + /// Optional navigation property expressions to include related data. + /// Optional pagination and filtering request. + /// + /// Set true when including 2+ collection navigations in the same query — + /// otherwise EF Core joins them into one cartesian-product result set, + /// which is both slow and, combined with pagination, can return + /// incorrect rows. + /// + /// + /// A task representing the asynchronous operation. The task result contains a collection of matching entities. + /// + Task> FindAsync( + Expression> predicate, + IEnumerable>>? includes = null, + PaginatedFilterRequest? filter = null, + bool asSplitQuery = false); + + /// + /// Adds a new entity to the repository. + /// + /// The entity to add. + /// + /// A task representing the asynchronous operation. The task result contains the added entity. + /// + Task AddAsync(TEntity entity); + + /// + /// Adds a range of entities to the repository. + /// + /// The entities to add. + /// + /// A task representing the asynchronous operation. The task result contains the added entities. + /// + Task> AddRangeAsync(IEnumerable entities); + + /// + /// Marks a single entity for deletion without calling SaveChanges. + /// + /// The entity to remove. + void Remove(TEntity entity); + + /// + /// Deletes all entities matching expression directly via EF Core's ExecuteDeleteAsync, a single DB-side statement that bypasses the change tracker entirely. + /// + /// The filter expression to match entities for removal. + /// + /// A task representing the asynchronous operation. The task result contains the number of entities deleted. + /// + Task RemoveAsync(Expression> expression); + + /// + /// Counts the number of entities matching the given predicate, or all if predicate is null. + /// + /// The filter expression, optional. + /// + /// A task representing the asynchronous operation. The task result contains the count of matching entities. + /// + Task CountAsync(Expression>? predicate = null); + + /// + /// Checks if any entity matches the given predicate. + /// + /// The filter expression. + /// + /// A task representing the asynchronous operation. The task result is true if any entity matches, otherwise false. + /// + Task ExistsAsync(Expression> predicate); + + /// + /// Updates a range of entities in the repository. + /// + /// The entities to update. + /// A task representing the asynchronous update operation. + Task UpdateRangeAsync(IEnumerable entities); + + /// + /// Updates a single entity in the repository. + /// + /// The entity to update. + /// A task representing the asynchronous update operation. + Task UpdateAsync(TEntity entity); +} \ No newline at end of file diff --git a/Club12-Backend/Application/Interfaces/Repositories/IMatchRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IMatchRepository.cs new file mode 100644 index 0000000..a52c5ea --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IMatchRepository.cs @@ -0,0 +1,19 @@ +using Domain.Entities.Models; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Repositories; + +/// +/// Repository interface for managing Match entities. +/// +public interface IMatchRepository : IGenericRepository +{ + /// + /// Loads a single match by its id or slug with everything the public match detail needs: both teams, the venue, and the match's scorers with their players. + /// + /// The match's GUID id or its slug. + /// The match with its detail graph loaded, or null if not found. + Task GetDetailByIdOrSlugAsync(string idOrSlug); +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IMatchSeriesRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IMatchSeriesRepository.cs new file mode 100644 index 0000000..5e3134b --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IMatchSeriesRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IMatchSeriesRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IPlayerRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IPlayerRepository.cs new file mode 100644 index 0000000..2e74392 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IPlayerRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IPlayerRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IPlayerSanctionRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IPlayerSanctionRepository.cs new file mode 100644 index 0000000..51126a9 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IPlayerSanctionRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IPlayerSanctionRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IPlayerStatisticRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IPlayerStatisticRepository.cs new file mode 100644 index 0000000..6cdafbb --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IPlayerStatisticRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IPlayerStatisticRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IPlayerTeamRegistrationRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IPlayerTeamRegistrationRepository.cs new file mode 100644 index 0000000..cbfe69f --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IPlayerTeamRegistrationRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IPlayerTeamRegistrationRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IScorerRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IScorerRepository.cs new file mode 100644 index 0000000..a984bc8 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IScorerRepository.cs @@ -0,0 +1,13 @@ +using Application.DTOs.Scorer.Request; +using Application.DTOs.Scorer.Response; + +using Domain.Entities.Models; + +using System.Threading.Tasks; + +namespace Application.Interfaces.Repositories; + +public interface IScorerRepository : IGenericRepository +{ + Task<(System.Collections.Generic.IEnumerable Items, int TotalCount)> GetPlayerScoresAsync(GetScorerFilteredRequest filter); +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/ISeasonRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/ISeasonRepository.cs new file mode 100644 index 0000000..979057b --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/ISeasonRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface ISeasonRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IStageRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IStageRepository.cs new file mode 100644 index 0000000..e1643b9 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IStageRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IStageRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IStageTeamMatchRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IStageTeamMatchRepository.cs new file mode 100644 index 0000000..530d871 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IStageTeamMatchRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IStageTeamMatchRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IStatisticsRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IStatisticsRepository.cs new file mode 100644 index 0000000..89ab34d --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IStatisticsRepository.cs @@ -0,0 +1,22 @@ +using Application.DTOs.Statistics.Response; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Repositories; + +/// +/// Read-only historical-statistics aggregations that group a person's data across every season by their stable PlayerId. +/// +public interface IStatisticsRepository +{ + /// + /// Returns the player's statistic card with total and average points and games played per season and overall, or null when the player does not exist. + /// + Task GetPlayerCardAsync(Guid playerId); + + /// + /// Returns the player's per-season trajectory with team, stats, and sanctions for each season they were registered, or null when the player does not exist. + /// + Task GetPlayerHistoryAsync(Guid playerId); +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/ITeamPointDeductionRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/ITeamPointDeductionRepository.cs new file mode 100644 index 0000000..832ce2a --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/ITeamPointDeductionRepository.cs @@ -0,0 +1,10 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +/// +/// Repository interface for managing TeamPointDeduction entities, the disciplinary point deductions applied to teams in a division. +/// +public interface ITeamPointDeductionRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/ITeamRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/ITeamRepository.cs new file mode 100644 index 0000000..4cb1de1 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/ITeamRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface ITeamRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/ITeamStaffRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/ITeamStaffRepository.cs new file mode 100644 index 0000000..d7359f4 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/ITeamStaffRepository.cs @@ -0,0 +1,10 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +/// +/// Repository interface for managing TeamStaff entities, a team's technical staff for a given tournament and season. +/// +public interface ITeamStaffRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/ITeamTournamentRegistrationRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/ITeamTournamentRegistrationRepository.cs new file mode 100644 index 0000000..5993a43 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/ITeamTournamentRegistrationRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface ITeamTournamentRegistrationRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/ITournamentRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/ITournamentRepository.cs new file mode 100644 index 0000000..e7bb395 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/ITournamentRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface ITournamentRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IUnitOfWork.cs b/Club12-Backend/Application/Interfaces/Repositories/IUnitOfWork.cs new file mode 100644 index 0000000..4263457 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IUnitOfWork.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Repositories; + +/// +/// Unit of Work interface for coordinating multiple repositories and saving changes atomically. +/// +public interface IUnitOfWork +{ + IBlogPostRepository BlogPostRepository { get; } + IClubRepository ClubRepository { get; } + ITeamRepository TeamRepository { get; } + IVenueRepository VenueRepository { get; } + ITournamentRepository TournamentRepository { get; } + IStageRepository StageRepository { get; } + IPlayerStatisticRepository PlayerStatisticRepository { get; } + IPlayerSanctionRepository PlayerSanctionRepository { get; } + IPlayerRepository PlayerRepository { get; } + IMatchRepository MatchRepository { get; } + IMatchSeriesRepository MatchSeriesRepository { get; } + IDivisionRepository DivisionRepository { get; } + IStageTeamMatchRepository StageTeamMatchRepository { get; } + IPlayerTeamRegistrationRepository PlayerTeamRegistrationRepository { get; } + ITeamTournamentRegistrationRepository TeamTournamentRegistrationRepository { get; } + IDivisionTeamRegistrationRepository DivisionTeamRegistrationRepository { get; } + Task SaveChangesAsync(); + + /// + /// Runs operation inside a single database transaction, rolling back entirely with no partial writes if the operation throws. + /// + Task ExecuteInTransactionAsync(Func operation); +} diff --git a/Club12-Backend/Application/Interfaces/Repositories/IVenueRepository.cs b/Club12-Backend/Application/Interfaces/Repositories/IVenueRepository.cs new file mode 100644 index 0000000..9ed716c --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Repositories/IVenueRepository.cs @@ -0,0 +1,7 @@ +using Domain.Entities.Models; + +namespace Application.Interfaces.Repositories; + +public interface IVenueRepository : IGenericRepository +{ +} diff --git a/Club12-Backend/Application/Interfaces/Services/IAuditService.cs b/Club12-Backend/Application/Interfaces/Services/IAuditService.cs new file mode 100644 index 0000000..259089a --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IAuditService.cs @@ -0,0 +1,32 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.AuditLogs.Request; + +using Domain.Entities.Models; +using Domain.Enums; + +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Records and reads the sensitive-action audit trail. +/// +public interface IAuditService +{ + /// + /// Writes an audit entry for action without ever throwing at the call site. + /// + Task LogAsync( + AuditAction action, + string? targetType = null, + string? targetId = null, + string? targetName = null, + string? detail = null, + CancellationToken ct = default); + + /// + /// Returns audit entries, newest first by default, with pagination. + /// + Task> GetAuditLogsAsync(AuditLogFilteredRequest filter); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IAuthService.cs b/Club12-Backend/Application/Interfaces/Services/IAuthService.cs new file mode 100644 index 0000000..fa0a9e2 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IAuthService.cs @@ -0,0 +1,16 @@ +using Application.DTOs.Auth.Response; + +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Token generator service for JWT and refresh tokens. +/// +public interface IAuthService +{ + Task GenerateJwtTokenAsync(IEnumerable claims, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IAuthenticationService.cs b/Club12-Backend/Application/Interfaces/Services/IAuthenticationService.cs new file mode 100644 index 0000000..a7eff27 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IAuthenticationService.cs @@ -0,0 +1,72 @@ +using Application.DTOs.Auth.Request; +using Application.DTOs.Auth.Response; + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Application boundary for authentication and user-registration flows. +/// +public interface IAuthenticationService +{ + /// + /// Password login for the operator accounts, ADMIN and OWNER. + /// + Task LoginAsync(LogInUserRequest request, CancellationToken ct = default); + + /// + /// Generates a magic-link token, deferred to Phase 2 D6. + /// + Task RequestMagicLinkAsync(MagicLinkRequest request, CancellationToken ct = default); + Task MagicLinkLoginAsync(MagicLinkLoginRequest request, CancellationToken ct = default); + + /// + /// Issues a guest JWT without any database interaction. + /// + Task GuestAsync(CancellationToken ct = default); + Task RefreshAsync(RefreshTokenRequest request, CancellationToken ct = default); + + /// + /// Verifies the password-reset token from the email link, sets the new password, clears MustChangePassword, and returns a ready-to-use JWT. + /// + Task ConfirmPasswordResetAsync( + PasswordResetConfirmRequest request, CancellationToken ct = default); + + /// + /// Registers a new user, where callerRole determines permitted target roles. + /// + Task RegisterAsync( + RegisterUserRequest request, + string callerRole, + Guid callerId, + CancellationToken ct = default); + + /// + /// Creates a user by email only, with no password, and emails a magic activation link so the user sets their own password. + /// + Task InviteUserAsync( + InviteUserRequest request, + string callerRole, + Guid callerId, + CancellationToken ct = default); + + /// + /// Consumes the activation token from the invitation email, sets the user's first password, enables login, and returns a ready-to-use JWT. + /// + Task ActivateAccountAsync( + ActivateAccountRequest request, CancellationToken ct = default); + + /// + /// Emails a password-reset magic link for the given email, completing silently with no user enumeration when no account matches. + /// + Task RequestPasswordResetAsync( + RequestPasswordResetRequest request, CancellationToken ct = default); + + /// + /// Clears the caller's stored RefreshToken and RefreshTokenExpiryTime. + /// + Task LogoutAsync(Guid userId, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IBlogPostService.cs b/Club12-Backend/Application/Interfaces/Services/IBlogPostService.cs new file mode 100644 index 0000000..d6c63b9 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IBlogPostService.cs @@ -0,0 +1,49 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.BlogPosts.Request; + +using Domain.Entities.Models; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IBlogPostService +{ + /// + /// Creates a blog post, deriving a unique slug from its title. + /// + /// The blog post entity to create. + /// The created blog post. + Task CreateBlogPostAsync(BlogPost blogPostEntity); + + Task GetBlogPostByIdAsync(Guid blogPostId); + + /// + /// Retrieves a blog post by its id or its public slug, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The blog post's GUID id or its slug. + /// + /// When false, the default for public callers, a draft post is treated as not found and null is returned; when true, for Admin or Owner, drafts are returned the same as any other post. + /// + /// The blog post with the specified id or slug, or null if not found. + Task GetBlogPostByIdOrSlugAsync(string idOrSlug, bool includeUnpublished = false); + + /// + /// Deletes a blog post as a no-op with no exception when id does not match any post. + /// + /// The id of the blog post to delete. + Task DeleteBlogPostAsync(Guid id); + + Task UpdateBlogPostAsync(BlogPost blogPostEntity); + + /// + /// Retrieves blog posts with pagination and filtering. + /// + /// The filtering and pagination request. + /// + /// When false, the default for public callers, only published posts are returned; when true, for Admin or Owner, drafts are included too. + /// + /// A paginated response containing the blog posts. + Task> GetAllBlogPostsAsync(GetBlogPostsFilteredRequest filter, bool includeUnpublished = false); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IChampionService.cs b/Club12-Backend/Application/Interfaces/Services/IChampionService.cs new file mode 100644 index 0000000..a6fe434 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IChampionService.cs @@ -0,0 +1,34 @@ +using Application.DTOs.Champions.Response; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Computes the champion and podium of each competition, a zone division or the cross-division cup. +/// +public interface IChampionService +{ + /// + /// Computes a division's podium from the playoff final and third-place match when the division has a playoff, or from the group-phase standings otherwise. + /// + /// The id of the division. + /// The division's podium, or null when the division does not exist. + Task GetDivisionPodiumAsync(Guid divisionId); + + /// + /// Computes the podium of every division of a tournament so the caller sees the whole tournament at a glance. + /// + /// The id of the tournament. + /// One podium per division; empty when the tournament has no divisions. + Task> GetTournamentChampionsAsync(Guid tournamentId); + + /// + /// Returns the champion, 1st place, of every division of every FINISHED tournament, optionally scoped to a single season. + /// + /// Optional season filter; when null, spans all seasons. + /// One row per crowned division champion. + Task> GetChampionsHistoryAsync(Guid? seasonId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IClubService.cs b/Club12-Backend/Application/Interfaces/Services/IClubService.cs new file mode 100644 index 0000000..deda218 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IClubService.cs @@ -0,0 +1,65 @@ +using Application.DTOs.Club.Response; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Manages the stable, cross-season club identity that per-season Team rows hang off of. +/// +public interface IClubService +{ + /// + /// Idempotently ensures every team is linked to a stable Club, creating one club per distinct team Name and linking each currently-unlinked team to it. + /// + /// How many clubs were created and teams linked, zeros on a re-run. + Task BackfillClubsAsync(); + + /// + /// Idempotently links a single team to its stable club, creating the club if this is the first team with that name. + /// + /// A persisted team. Left untouched if already linked. + Task EnsureTeamLinkedToClubAsync(Team team); + + /// + /// Returns a club and its trajectory across seasons: every per-season team that belongs to it and the tournaments each was registered in. + /// + /// The club's GUID id or its slug. + /// The club history, or null when no club matches. + Task GetClubHistoryAsync(string idOrSlug); + + /// + /// Every club's stable identity summary, ordered by name — used to populate the "link to parent club" picker. + /// + Task> GetAllClubsAsync(); + + /// + /// Links a club as a squad of a parent institution club. Flat and one level deep: rejects linking a club to itself, linking to a parent that is itself already a squad, and linking a club that already has its own squads. + /// + /// The squad club to link. + /// The institution club it becomes a squad of. + Task LinkClubToParentAsync(Guid childClubId, Guid parentClubId); + + /// + /// Clears a club's parent link, if any. Idempotent. + /// + /// The club to unlink. + Task UnlinkClubParentAsync(Guid childClubId); + + /// + /// Renames a club. The club's slug never changes, so its public URL stays stable. + /// + /// The club to rename. + /// The new display name. + Task RenameClubAsync(Guid clubId, string name); + + /// + /// Deletes a club, blocking the delete while it still has teams or squad clubs linked to it. + /// + /// The club to delete. + Task DeleteClubAsync(Guid clubId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/ICurrentUserAccessor.cs b/Club12-Backend/Application/Interfaces/Services/ICurrentUserAccessor.cs new file mode 100644 index 0000000..4c303c5 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/ICurrentUserAccessor.cs @@ -0,0 +1,12 @@ +namespace Application.Interfaces.Services; + +/// +/// Abstracts access to the current caller's identity so application services can record who in the audit trail without depending on the web layer. +/// +public interface ICurrentUserAccessor +{ + /// + /// The authenticated caller's identifier, their email, or AuditConstants.SystemUser when no user is bound to the current execution context. + /// + string Actor { get; } +} diff --git a/Club12-Backend/Application/Interfaces/Services/IDataMaintenanceService.cs b/Club12-Backend/Application/Interfaces/Services/IDataMaintenanceService.cs new file mode 100644 index 0000000..4724fc4 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IDataMaintenanceService.cs @@ -0,0 +1,22 @@ +using Application.DTOs.DataMaintenance.Response; + +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Admin-only tools for resetting the tournament-domain data to a clean, realistic sample state. +/// +public interface IDataMaintenanceService +{ + /// + /// Deletes every tournament-domain row inside one transaction, leaving Identity untouched. + /// + Task WipeSampleDataAsync(CancellationToken ct = default); + + /// + /// Seeds 2 complete, distinct sample tournaments. + /// + Task SeedSampleDataAsync(CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IDivisionRosterService.cs b/Club12-Backend/Application/Interfaces/Services/IDivisionRosterService.cs new file mode 100644 index 0000000..0b0a841 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IDivisionRosterService.cs @@ -0,0 +1,43 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IDivisionRosterService +{ + /// + /// Returns every team currently enrolled in the division, independent of any stage placement. + /// + Task> GetRosterAsync(Guid divisionId); + + /// + /// Enrolls teams in a division, skipping already-registered teams and rejecting a conflicting cross-division registration. + /// + /// + /// Thrown as a 409 when a team already holds a conflicting registration, or the tournament has already started. + /// + Task> EnrollTeamsAsync(Guid divisionId, List teamIds); + + /// + /// Removes teams from a division's roster, cascading to delete their stage placements in that division first. + /// + Task UnenrollTeamsAsync(Guid divisionId, List teamIds); + + /// + /// Replaces a division's sub-group stages with a new count, re-balancing the untouched roster across them. + /// + Task> RebuildSubGroupsAsync(Guid divisionId, int subGroupCount); + + /// + /// Clears every current sub-group placement and re-deals the whole roster in a fresh balanced distribution. + /// + Task AutoDistributeRosterAsync(Guid divisionId); + + /// + /// Manually moves one enrolled team from one sub-group to another, re-validating only the minimum sub-group size. + /// + Task ReassignTeamToSubGroupAsync(Guid teamId, Guid fromStageId, Guid toStageId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IDivisionService.cs b/Club12-Backend/Application/Interfaces/Services/IDivisionService.cs new file mode 100644 index 0000000..6d056cf --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IDivisionService.cs @@ -0,0 +1,89 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Divisions.Request; +using Application.Utils.Helper.Standings; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IDivisionService +{ + /// + /// Creates a division and generates its unique slug from the name. + /// + /// The division entity to create. + /// The created division. + /// + /// Thrown when the tournament is not TournamentStatus.OpenForRegistration, since structure is frozen once registration closes, when the division's Division.Category does not match its tournament's category, or when its playoff mappings are invalid. + /// + Task CreateDivisionAsync(Division divisionEntity); + + Task GetFullDivisionByIdAsync(Guid divisionId); + + Task GetSimpleDivisionByIdAsync(Guid divisionId); + + /// + /// Retrieves a division by its id or its slug asynchronously, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The division's GUID id or its slug. + /// The matching division, or null if not found. + Task GetSimpleDivisionByIdOrSlugAsync(string idOrSlug); + + /// + /// Deletes a division, blocked whenever its match, statistics, or point-deduction history exists or would be destroyed silently. + /// + /// The unique identifier of the division to delete. + /// + /// Thrown when the division has any finished match or point deduction, or its tournament has already started, Ongoing or Finished, or was Canceled. + /// + Task DeleteDivisionAsync(Guid id); + + /// + /// Updates a division, re-validating it against the same tournament-status and category rules enforced on create. See CreateDivisionAsync. + /// + /// The division entity with updated values. + /// + /// Thrown when the tournament no longer allows structural edits, the + /// category no longer matches, or the playoff mappings are invalid. + /// + Task UpdateDivisionAsync(Division divisionEntity); + + /// + /// Retrieves divisions with pagination and filtering asynchronously. + /// + /// The filtering and pagination request. + /// A paginated response containing the divisions. + Task> GetAllDivisionsAsync(GetDivisionsFilteredRequest filter); + + /// + /// Computes standings for a division from its Group stage's finished matches; elimination-stage matches do not feed a standings table. + /// + /// The id of the division. + /// One Position per team with at least one finished Group-stage match; empty if the division has no Group stage or no finished matches yet. + Task> GetPositionsByDivisionIdAsync(Guid divisionId); + + /// + /// Computes standings for a division split by Group stage, with a regular zone returning a single entry and a multi-group cross-division cup returning one entry per internal group. + /// + /// The id of the division. + /// One GroupStandings per Group stage. + Task> GetGroupStandingsByDivisionIdAsync(Guid divisionId); + + /// + /// Returns every team registered to the tournament that does not yet belong to any division, regular or cross-division cup. + /// + /// The id of the tournament. + Task> GetUnassignedTeamsAsync(Guid tournamentId); + + /// + /// Reassigns a division to a different tournament, moving everything under it along with it. + /// + /// The division to reassign. Its Tournament navigation and TournamentId are mutated in place. + /// The id of the tournament the division should belong to. + /// True if the target tournament exists and the division was reassigned in memory; false if no tournament with that id exists. + Task TryAssignTournamentAsync(Division division, Guid tournamentId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IEmailService.cs b/Club12-Backend/Application/Interfaces/Services/IEmailService.cs new file mode 100644 index 0000000..a9fc11c --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IEmailService.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IEmailService +{ + Task SendPasswordResetAsync( + string toEmail, string toUsername, string resetLink, + CancellationToken ct = default); + + Task SendMagicLinkAsync( + string toEmail, string toUsername, string magicLink, + CancellationToken ct = default); + + Task SendWelcomeSetPasswordAsync( + string toEmail, string toUsername, string setPasswordLink, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Club12-Backend/Application/Interfaces/Services/IMatchSeriesService.cs b/Club12-Backend/Application/Interfaces/Services/IMatchSeriesService.cs new file mode 100644 index 0000000..b4a2fc6 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IMatchSeriesService.cs @@ -0,0 +1,62 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.MatchSeries.Request; + +using Domain.Entities.Models; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Service for managing best-of-N playoff series between two teams at a single bracket round. +/// +public interface IMatchSeriesService +{ + /// + /// Retrieves a series by its identifier, including its games. + /// + /// Unique identifier of the series. + /// The found series or null if it does not exist. + Task GetSeriesByIdAsync(Guid seriesId); + + /// + /// Retrieves a paginated and filtered list of series. + /// + /// Object containing parameters to filter, sort, and paginate the results. + /// A paginated response with the list of series. + Task> GetAllSeriesAsync(GetMatchSeriesFilteredRequest filter); + + /// + /// Creates a new best-of-N series between two teams at a stage, copying the stage's BestOf value onto the series. + /// + /// The stage, round, the series belongs to. + /// The home team. + /// The visitor team. + /// The created series entity. + /// + /// Thrown when the stage does not exist, the two teams are the same, + /// either team is not assigned to the stage's division, or a series + /// between these two teams already exists for this stage. + /// + Task CreateSeriesAsync(Guid stageId, Guid homeTeamId, Guid visitorTeamId); + + /// + /// Schedules the next game of an existing series. + /// + /// The series to add a game to. + /// The date of the game. + /// Optional venue for the game. + /// The created game, a Match entity. + /// + /// Thrown when the series does not exist, is already decided, or + /// already has BestOf games scheduled. + /// + Task AddGameToSeriesAsync(Guid seriesId, DateTime matchDate, Guid? venueId); + + /// + /// Recomputes and persists the series' winner based on its finished games. + /// + /// The series to recompute. + Task RecalculateSeriesWinnerAsync(Guid seriesId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IMatchService.cs b/Club12-Backend/Application/Interfaces/Services/IMatchService.cs new file mode 100644 index 0000000..ec7ad12 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IMatchService.cs @@ -0,0 +1,62 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IMatchService +{ + /// + /// Creates a match and generates its unique slug from the home and visitor team names and match date. + /// + Task CreateMatchAsync(Match matchEntity); + + Task GetMatchByIdAsync(Guid matchId); + + /// + /// Retrieves a match by its id or its slug, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The match's GUID id or its slug. + /// The matching match, or null if not found. + Task GetMatchByIdOrSlugAsync(string idOrSlug); + + Task GetMatchByIdWithScorersAsync(Guid matchId); + + Task UpdateMatchAsync(Match matchEntity); + + /// + /// True when another match, not excludeMatchId, is scheduled at the same venue less than 2 hours from matchDate, since two matches on one court must be at least 2 hours apart. + /// + Task HasVenueScheduleConflictAsync(Guid venueId, DateTime matchDate, Guid excludeMatchId); + + /// + /// Loads a decisive final result for a match, rejecting a tied score. + /// + Task LoadMatchResultAsync(Guid matchId, int homeScore, int visitorScore); + + /// + /// Applies a walkover result to a match, awarding the regulation default to the present team. + /// + Task LoadWalkOverAsync(Guid matchId, Guid presentTeamId, int? presentTeamScore); + + /// + /// Reprograms or suspends a match, marking it MatchStatus.Suspended and optionally moving it to a new calendar date. + /// + Task SuspendMatchAsync(Guid matchId, DateTime? newMatchDate); + + Task DeleteMatchAsync(Guid id); + + Task> GetAllMatchesAsync(GetMatchesFilteredRequest filter); + + /// + /// Retrieves every match of a stage ordered by matchday, round 1 first then round 2 and so on. + /// + Task> GetStageMatchesByRoundAsync(Guid stageId); + + Task> CreateAutomatedMatchesAsync(Guid stageId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IMedicalRecordService.cs b/Club12-Backend/Application/Interfaces/Services/IMedicalRecordService.cs new file mode 100644 index 0000000..044f7c7 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IMedicalRecordService.cs @@ -0,0 +1,29 @@ +using Application.DTOs.MedicalRecord.Response; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Manages the medical-record and eligibility state of a player's season registration. +/// +public interface IMedicalRecordService +{ + /// + /// Records a just-uploaded medical-record file reference on the player's season registration. + /// + Task RecordUploadAsync( + Guid playerId, Guid teamId, Guid tournamentId, string fileReference, string fileName, string actor); + + /// + /// Approves or rejects the medical record. + /// + Task ReviewAsync( + Guid playerId, Guid teamId, Guid tournamentId, bool approve, string? reason, string actor); + + /// + /// Returns the current medical-record and eligibility state for a player's season registration, or null when no such registration exists. + /// + Task GetAsync(Guid playerId, Guid teamId, Guid tournamentId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IPlayerSanctionService.cs b/Club12-Backend/Application/Interfaces/Services/IPlayerSanctionService.cs new file mode 100644 index 0000000..ef143e2 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IPlayerSanctionService.cs @@ -0,0 +1,71 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.PlayerSanction.Request; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IPlayerSanctionService +{ + /// + /// Creates a sanction and assigns it a unique slug derived from its subject's resolved name and issue date, so a player, team, or staff sanction all get a readable slug regardless of subject type. + /// + /// The player sanction entity to create. + /// The created player sanction. + Task CreatePlayerSanctionAsync(PlayerSanction playerSanctionEntity); + + Task GetPlayerSanctionByIdAsync(Guid playerSanctionId); + + /// + /// Retrieves a player sanction by its id or its slug, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The sanction's GUID id or its slug. + /// The matching player sanction, or null if not found. + Task GetPlayerSanctionByIdOrSlugAsync(string idOrSlug); + + Task DeletePlayerSanctionAsync(Guid id); + + Task UpdatePlayerSanctionAsync(PlayerSanction playerSanctionEntity); + + /// + /// Retrieves expired player sanctions as of a specific date asynchronously. + /// + /// The date to check for expired sanctions. + /// A collection of expired player sanctions. + Task> GetExpiredSanctionsAsync(DateTime date); + + /// + /// Computes how many FECHAS, jornadas, of a sanction are still to be served, based on the subject team's finished rounds since the sanction was issued. + /// + /// The sanction to evaluate. + /// The fechas remaining, or null when not computable by rounds. + Task GetFechasRemainingAsync(PlayerSanction sanction); + + /// + /// Determines whether a player has any active sanction, one with fechas still to be served, so eligibility stays consistent with the fechas-based rule. + /// + /// The player to check. + /// True when the player has at least one active sanction. + Task HasActiveSanctionAsync(Guid playerId); + + /// + /// Retrieves player sanctions with pagination and filtering asynchronously. + /// + /// The filtering and pagination request. + /// A paginated response containing the player sanctions. + Task> GetPlayerSanctionsAsync(GetPlayerSanctionsFilteredRequest filter); + + /// + /// Resolves the human-readable subject of a sanction into the three mutually-exclusive display fields the response exposes. + /// + /// The sanction whose subject to resolve. + /// + /// The player, team and staff display names; exactly one is non-null for a + /// well-formed sanction, the others are null. + /// + Task<(string? PlayerFullName, string? TeamName, string? StaffName)> ResolveSubjectAsync(PlayerSanction sanction); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IPlayerService.cs b/Club12-Backend/Application/Interfaces/Services/IPlayerService.cs new file mode 100644 index 0000000..7dbb36c --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IPlayerService.cs @@ -0,0 +1,61 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Player.Request; + +using Domain.Entities.Models; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IPlayerService +{ + /// + /// Creates a new Player and registers them to tournamentId on the player's team. + /// + /// The Player entity to create. + /// + /// The season, Tournament, to register the player's team assignment to, normally the player's team's current TournamentId. + /// + /// The created Player. + Task CreatePlayerAsync(Player playerEntity, Guid tournamentId); + + Task GetPlayerByIdAsync(Guid playerId); + + /// + /// Retrieves a Player by its id or its slug, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The player's GUID id or its slug. + /// The matching player, or null if not found. + Task GetPlayerByIdOrSlugAsync(string idOrSlug); + + /// + /// Updates a Player and keeps their season-scoped roster registration in sync. + /// + /// The Player entity, with updated fields already applied. + /// The season, Tournament, the player's current team belongs to. + Task UpdatePlayerAsync(Player playerEntity, Guid tournamentId); + + Task DeletePlayerAsync(Guid id); + + /// + /// Registers a player onto a team's roster for a specific tournament, enforcing the roster invariants. + /// + /// The player to register. + /// The team to register the player onto. + /// The tournament, season, the registration belongs to. + /// The player's dorsal for this team/season, or null. + /// The created or updated registration. + /// + /// Thrown when any of the roster invariants is violated. + /// + Task RegisterPlayerToTeamAsync( + Guid playerId, Guid teamId, Guid tournamentId, int? jerseyNumber = null); + + /// + /// Retrieves players with pagination and filtering. + /// + /// The filtering and pagination request. + /// A paginated response containing the players. + Task> GetAllPlayersAsync(PlayerFilterRequestBase filter); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IPlayerStatisticService.cs b/Club12-Backend/Application/Interfaces/Services/IPlayerStatisticService.cs new file mode 100644 index 0000000..9421a91 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IPlayerStatisticService.cs @@ -0,0 +1,37 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.PlayerStatistic.Request; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IPlayerStatisticService +{ + Task CreatePlayerStatisticAsync(PlayerStatistic playerStatisticEntity); + + Task GetPlayerStatisticByIdAsync(Guid playerStatisticId); + + Task DeletePlayerStatisticAsync(Guid id); + + Task UpdatePlayerStatisticAsync(PlayerStatistic playerStatisticEntity); + + /// + /// Retrieves a paginated, filtered list of player statistics. + /// + Task> GetPlayerStatisticsAsync(GetPlayerStatisticsFilteredRequest filter); + + /// + /// Loads a whole team's coherent scoring sheet for a match. + /// + Task> LoadTeamMatchSheetAsync(LoadMatchSheetRequest request); + + /// + /// Finishes a match by loading both teams' scoring sheets in one operation. + /// + /// The finalized match, or null if no match with that id exists. + Task LoadMatchResultFromSheetsAsync(LoadMatchResultFromSheetsRequest request); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IRosterCopyService.cs b/Club12-Backend/Application/Interfaces/Services/IRosterCopyService.cs new file mode 100644 index 0000000..3aa2d22 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IRosterCopyService.cs @@ -0,0 +1,23 @@ +using Application.DTOs.Roster.Response; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Clones a roster from a previous season's team into a new season's team so admins don't re-enter every player each year. +/// +public interface IRosterCopyService +{ + /// + /// Creates a new season registration on targetTeamId for targetTournamentId for every player registered to the source team in the source season. + /// + /// The past-season team to copy from. + /// The season the source roster belongs to. + /// The new-season team to copy into. + /// The new season the roster is cloned into. + /// How many registrations were created and how many were skipped. + Task CopyRosterAsync( + Guid sourceTeamId, Guid sourceTournamentId, Guid targetTeamId, Guid targetTournamentId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IScorerService.cs b/Club12-Backend/Application/Interfaces/Services/IScorerService.cs new file mode 100644 index 0000000..16a4756 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IScorerService.cs @@ -0,0 +1,15 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; +using Application.DTOs.Scorer.Request; +using Application.DTOs.Scorer.Response; + +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IScorerService +{ + Task> GetAllScorersByPlayerAsync(GetScorerFilteredRequest filter); + + Task> GetAllScorersByTeamAsync(GetMatchesFilteredRequest filter); +} diff --git a/Club12-Backend/Application/Interfaces/Services/ISeasonService.cs b/Club12-Backend/Application/Interfaces/Services/ISeasonService.cs new file mode 100644 index 0000000..20e88e4 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/ISeasonService.cs @@ -0,0 +1,53 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Represents a service for managing Seasons, Temporadas. +/// +public interface ISeasonService +{ + /// + /// Creates a new Season asynchronously, generating its unique slug from the name. + /// + /// The Season entity to create. + /// The created Season. + Task CreateSeasonAsync(Season seasonEntity); + + /// + /// Retrieves a Season by its id asynchronously, including its tournaments. + /// + /// The id of the Season to retrieve. + /// The Season with the specified id, or null if not found. + Task GetSeasonByIdAsync(Guid seasonId); + + /// + /// Retrieves a Season by its id or its slug asynchronously, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The season's GUID id or its slug. + /// The matching season, or null if not found. + Task GetSeasonByIdOrSlugAsync(string idOrSlug); + + Task UpdateSeasonAsync(Season seasonEntity); + + /// + /// Deletes a season and every tournament it groups, routing each tournament through ITournamentService.DeleteTournamentAsync rather than a bare row delete. + /// + /// The id of the Season to delete. + /// + /// Propagated from ITournamentService.DeleteTournamentAsync + /// when any grouped tournament has started or has played history and + /// cannot be deleted. + /// + Task DeleteSeasonAsync(Guid id); + + /// + /// Retrieves all seasons asynchronously, including their tournaments. + /// + /// All seasons. + Task> GetAllSeasonsAsync(); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IStageService.cs b/Club12-Backend/Application/Interfaces/Services/IStageService.cs new file mode 100644 index 0000000..802aa0e --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IStageService.cs @@ -0,0 +1,141 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Stage.Request; +using Application.DTOs.Stage.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +namespace Application.Interfaces.Services; + +public interface IStageService +{ + Task GetStageByIdAsync(Guid stageId); + + /// + /// Retrieves a stage by its id or its slug asynchronously, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The stage's GUID id or its slug. + /// The matching stage, or null if not found. + Task GetStageByIdOrSlugAsync(string idOrSlug); + + /// + /// Retrieves a paginated and filtered list of Stages. + /// + /// Object containing parameters to filter, sort, and paginate the results. + /// A paginated response with the list of Stages. + Task> GetAllStagesAsync(GetStagesFilteredRequest filter); + + /// + /// Deletes a stage, blocked once its tournament has started. + /// + /// The unique identifier of the stage to delete. + /// + /// Thrown when the stage's tournament has already started or was canceled. + /// + Task DeleteStageAsync(Guid id); + + /// + /// Updates a stage, blocked once its tournament has started. + /// + /// Stage entity with updated data. + /// + /// Thrown, mapped to 409, when the tournament has already started or was canceled. + /// + Task UpdateStageAsync(Stage stageEntity); + + /// + /// Creates a stage, blocked once its tournament has started. + /// + /// Stage entity to create. + /// The created Stage entity. + /// + /// Thrown when a stage with the same name already exists in the division. + /// + Task CreateStageAsync(Stage stageEntity); + + Task AssignTeamsToStageAsync(Stage stage, List? teamIds = null, bool auto = false); + Task UnassignTeamsFromStageAsync(Stage stage, List teamIds); + + /// + /// Seeds the first-round matches of an elimination stage using the division's group-stage standings and the classic bracket seed order, so the top seeds only meet in the final. + /// + /// The elimination stage to seed. + /// The stage's matches, now seeded with home/visitor teams. + Task> SeedKnockoutStageAsync(Guid stageId); + + /// + /// Seeds every playoff cup of a division from its final group-stage standings using the division's position-range mapping. + /// + /// The division whose group stage has finished. + /// The seeded matches per destination cup, keyed by BracketName. + Task>> SeedPlayoffCupsAsync(Guid divisionId); + + /// + /// Automatically seeds a division's playoff cups once every group-stage match is finished. + /// + /// The stage of the match that just finished. + Task TryAutoSeedPlayoffPhaseAsync(Guid finishedMatchStageId); + + /// + /// Pushes each newly-decided bracket slot's winner into its immediate next round within the same cup. + /// + /// The stage whose slots just got decided. + Task TryAdvanceStageWinnerAsync(Guid decidedStageId); + + /// + /// Computes a first-round pairing for a groupless bracket without persisting it, returning a signed token that replays the exact same order on commit. + /// + /// The bracket's first-round stage. + /// Whether the order is shuffled server-side or supplied manually. + /// The explicit team order, required and validated as a roster permutation when mode is Manual. + /// The previewed pairing together with a draw token that commit can replay. + Task PreviewDrawAsync(Guid stageId, DrawMode mode, List? manualOrder = null); + + /// + /// Seeds a groupless bracket from a previewed token or a manual order, stamping DrawnAt and auditing the draw. + /// + /// The bracket's first-round stage. + /// Whether the order comes from a previewed random draw or a manual order. + /// The token returned by a prior preview, required and verified when mode is Random. + /// The explicit team order, required and validated as a roster permutation when mode is Manual. + /// + /// Thrown as a 409 when a real match of this bracket has already been played, or the draw token is missing, tampered, or mismatched. + /// + /// The bracket's first-round matches, now seeded with home/visitor teams. + Task> CommitDrawAsync(Guid stageId, DrawMode mode, string? drawToken = null, List? manualOrder = null); + + /// + /// Replaces a regular division's sub-group stage layer with a new count, re-balancing the untouched roster across it. + /// + /// The division whose sub-groups are rebuilt. + /// The new number of sub-groups, at least 1. + /// + /// Thrown as a 409 when the tournament has already started, the roster is too small for the + /// requested count, or the division already carries a position-range playoff cup. + /// + /// The newly created sub-group stages. + Task> RebuildSubGroupsAsync(Guid divisionId, int subGroupCount); + + /// + /// Clears every current sub-group placement and re-runs balanced distribution over the division's whole roster. + /// + /// The division whose sub-groups are re-balanced. + Task AutoDistributeRosterAsync(Guid divisionId); + + /// + /// Manually moves one enrolled team from one sub-group to another of the same division, re-validating only the minimum sub-group size. + /// + /// The team to move. + /// The sub-group the team currently belongs to. + /// The sub-group the team moves into. + /// + /// Thrown as a 409 when the tournament has already started, the team is not placed in + /// fromStageId, the two stages belong to different divisions, or the move would drop the + /// source sub-group below the minimum size. + /// + Task ReassignTeamToSubGroupAsync(Guid teamId, Guid fromStageId, Guid toStageId); +} + diff --git a/Club12-Backend/Application/Interfaces/Services/IStageTeamMatchService.cs b/Club12-Backend/Application/Interfaces/Services/IStageTeamMatchService.cs new file mode 100644 index 0000000..8b45e16 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IStageTeamMatchService.cs @@ -0,0 +1,20 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IStageTeamMatchService +{ + Task> GetAllStageTeamMatchByStageId(Guid stageId); + Task InsertRangeAsync(List stageTeamMatches); + Task RemoveWhereAsync(Expression> predicate); + + /// + /// True only when every id in TeamIds has a matching assignment row for the stage. + /// + Task AllTeamsAssignedToStage(Guid stageId, List TeamIds); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IStatisticsService.cs b/Club12-Backend/Application/Interfaces/Services/IStatisticsService.cs new file mode 100644 index 0000000..476d541 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IStatisticsService.cs @@ -0,0 +1,22 @@ +using Application.DTOs.Statistics.Response; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Historical player statistics: per-season and overall aggregations for a single person across every season. +/// +public interface IStatisticsService +{ + /// + /// Returns the player's statistic card, or null if the player is unknown. + /// + Task GetPlayerCardAsync(Guid playerId); + + /// + /// Returns the player's cross-season history, or null if the player is unknown. + /// + Task GetPlayerHistoryAsync(Guid playerId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/ITeamPointDeductionService.cs b/Club12-Backend/Application/Interfaces/Services/ITeamPointDeductionService.cs new file mode 100644 index 0000000..64aeccf --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/ITeamPointDeductionService.cs @@ -0,0 +1,32 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Manages disciplinary point deductions, deducción de puntos, applied to teams within a division. +/// +public interface ITeamPointDeductionService +{ + /// + /// Creates a new point deduction. + /// + /// The deduction to create. + /// The created deduction, with the team navigation loaded. + Task CreateAsync(TeamPointDeduction deduction); + + /// + /// Returns every point deduction for a division, newest first, with each deduction's team loaded so the caller can show the team's name. + /// + /// The division whose deductions to list. + Task> GetByDivisionIdAsync(Guid divisionId); + + /// + /// Deletes a point deduction by its id. No-op when it does not exist. + /// + /// The id of the deduction to remove. + Task DeleteAsync(Guid id); +} diff --git a/Club12-Backend/Application/Interfaces/Services/ITeamService.cs b/Club12-Backend/Application/Interfaces/Services/ITeamService.cs new file mode 100644 index 0000000..6dbecf4 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/ITeamService.cs @@ -0,0 +1,132 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Team.Request; +using Application.DTOs.Team.Response; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface ITeamService +{ + /// + /// Creates a team, generates its unique slug from the name, and links it to its stable cross-season Club. + /// + /// The team entity to create. + /// The created team. + Task CreateTeamAsync(Team teamEntity); + + /// + /// Retrieves a team by its id, with its Players roster scoped to one season. + /// + /// The id of the team to retrieve. + /// + /// The season whose roster to attach. Defaults to the team's own current TournamentId when omitted, so callers get today's roster by default. A team with no current tournament, TournamentId null, and no explicit tournamentId gets an empty roster. + /// + /// The team with the specified id, or null if not found. + Task GetTeamByIdAsync(Guid teamId, Guid? tournamentId = null); + + /// + /// Retrieves a team by its id or its slug, with its Players roster scoped to one season, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The team's GUID id or its slug. + /// + /// The season whose roster to attach. Defaults to the team's own current + /// TournamentId when omitted. + /// + /// The matching team, or null if not found. + Task GetTeamByIdOrSlugAsync(string idOrSlug, Guid? tournamentId = null); + + Task UpdateTeamAsync(Team teamEntity); + + /// + /// Guards a team's identity edit, freezing Team.Name and Team.ThreeLetterCode while the team is participating in an Ongoing tournament. + /// + /// The team as currently persisted, original identity and current TournamentId, read BEFORE the request is mapped over it. + /// The requested new name, or null when the request does not change the name. + /// The requested new three-letter code, or null when the request does not change it. + /// Thrown, mapped to 409, when an identity change is attempted while the team's current tournament is Ongoing. + Task EnsureTeamIdentityEditableAsync(Team existingTeam, string? requestedName, string? requestedThreeLetterCode); + + Task UpdateTeamsAsync(IEnumerable teams); + + /// + /// Deletes a team, guarding its competitive history. + /// + /// The unique identifier of the team to delete. + /// + /// Thrown, mapped to 409, when the team has match history, sanctions, point deductions, tournament registrations, or roster players with their own history. + /// + Task DeleteTeamAsync(Guid id); + + /// + /// Retrieves a paginated list of teams based on filtering criteria. + /// + /// The filtering criteria. + /// A paginated response containing the filtered teams. + Task> GetAllTeamsAsync(GetTeamsFilteredRequest filter); + + Task RegisterTeamsToTournamentAsync(Tournament tournament, List teamIds); + + /// + /// Enrolls a single team into tournament's registration phase, atomically. + /// + /// The tournament to enroll the team into. Must be OpenForRegistration. + /// The existing team to enroll, or null. + /// The name of a brand-new team to create and enroll, or null. + /// Optional source season whose roster to copy. + /// The enrolled team, with its roster scoped to this tournament. + Task EnrollTeamAsync( + Tournament tournament, + Guid? existingTeamId, + string? newTeamName, + Guid? copyRosterFromTournamentId); + + /// + /// Removes a team from a tournament, atomically. + /// + /// The tournament to remove the team from. + /// The id of the team to remove. + /// The tournament is not in a phase that allows removing teams. + /// The team is not enrolled in the tournament. + Task UnenrollTeamAsync(Tournament tournament, Guid teamId); + + /// + /// Builds the team's current group-stage standing row for a tournament, powering the public team-profile summary card. + /// + /// The team whose standing to locate. + /// + /// The tournament to look in; when null the team has no season context and + /// null is returned. + /// + /// + /// The team's standing row, or null when the team is in no group-stage + /// standing for the tournament, whether playoff-only or with no finished matches. + /// + Task GetTeamSummaryAsync(Guid teamId, Guid? tournamentId); + + /// + /// Returns the team's matches, as home or visitor, in a tournament, oriented from the team's perspective and ordered by match date ascending. + /// + /// The team whose matches to list. + /// + /// The tournament to scope the matches to; when null an empty list is + /// returned. + /// + /// The team's matches, oldest first; empty when there are none. + Task> GetTeamMatchesAsync(Guid teamId, Guid? tournamentId); + + /// + /// Returns every tournament the team has participated in, enriched with season info, newest first. + /// + /// The team whose participation history to list. + /// + /// The team's current tournament pointer, used to flag the current + /// participation; when null no entry is flagged current. + /// + /// The team's tournament participations. + Task> GetTeamParticipationsAsync(Guid teamId, Guid? currentTournamentId); +} diff --git a/Club12-Backend/Application/Interfaces/Services/ITeamStaffService.cs b/Club12-Backend/Application/Interfaces/Services/ITeamStaffService.cs new file mode 100644 index 0000000..1812278 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/ITeamStaffService.cs @@ -0,0 +1,33 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// Manages a team's technical staff, cuerpo técnico, DT and Asistente, scoped per team and tournament. +/// +public interface ITeamStaffService +{ + /// + /// Creates a new staff member. + /// + /// The staff member to create. + /// The created staff member, with the team navigation loaded. + Task CreateAsync(TeamStaff staff); + + /// + /// Returns every staff member for a team within a tournament, ordered by DateCreated ascending. + /// + /// The team whose staff to list. + /// The tournament, season, to scope the staff to. + Task> GetByTeamAndTournamentAsync(Guid teamId, Guid tournamentId); + + /// + /// Deletes a staff member by their id. No-op when it does not exist. + /// + /// The id of the staff member to remove. + Task DeleteAsync(Guid id); +} diff --git a/Club12-Backend/Application/Interfaces/Services/ITournamentService.cs b/Club12-Backend/Application/Interfaces/Services/ITournamentService.cs new file mode 100644 index 0000000..0cd8b2d --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/ITournamentService.cs @@ -0,0 +1,93 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Tournament.Request; +using Application.DTOs.Tournament.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface ITournamentService +{ + /// + /// Creates a tournament and generates its unique slug from the name. + /// + /// The Tournament entity to create. + /// The created Tournament. + Task CreateTournamentAsync(Tournament tournamentEntity); + + /// + /// Creates a whole tournament in a single transaction, reusing the granular create logic. + /// + /// The full wizard payload. + /// The created Tournament, including its divisions. + Task CreateFullTournamentAsync(CreateFullTournamentRequest request); + + /// + /// Adds one division to an already-existing tournament, in a single transaction. + /// + /// The already-loaded parent tournament. + /// The division's structure, zone or cross-cup. + /// The created Division. + Task AddFullDivisionAsync(Tournament tournament, CreateFullDivisionRequest divisionRequest); + + Task GetTournamentByIdAsync(Guid tournamentId); + + /// + /// Loads a tournament's full cloneable structure — every division with its + /// Stages and PlayoffMappings — for the tournament-cloning wizard-prefill + /// flow (HU-cloning). Carries no instance data (teams, matches, rosters). + /// + /// The source tournament's GUID id. + /// The tournament with its Divisions graph loaded, or null if not found. + Task GetTournamentStructureAsync(Guid tournamentId); + + /// + /// Retrieves a Tournament by its id or its slug asynchronously, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The Tournament's GUID id or its slug. + /// The matching Tournament, or null if not found. + Task GetTournamentByIdOrSlugAsync(string idOrSlug); + + /// + /// Updates a Tournament without changing the lifecycle status; use ChangeStatusAsync for that. + /// + /// The Tournament to update. + Task UpdateTournamentAsync(Tournament tournamentEntity); + + /// + /// Moves a tournament to a new lifecycle status, enforcing the forward-only state machine. + /// + /// The id of the tournament to transition. + /// The target lifecycle status. + /// No tournament exists with the given id. + /// The requested transition is not allowed by the state machine. + Task ChangeStatusAsync(Guid tournamentId, TournamentStatus newStatus); + + /// + /// Reports whether a tournament can be COMPLETED once started, and lists the blocking issues when it cannot. + /// + /// The id of the tournament to evaluate. + /// The completability report, with CanStart and Issues. + /// No tournament exists with the given id. + Task GetCompletabilityAsync(Guid tournamentId); + + /// + /// Deletes a tournament, blocked once it has started or has any finished match, so competitive history is never silently destroyed. + /// + /// The id of the Tournament to delete. + /// + /// Thrown when the tournament has already started or has any finished match. + /// + Task DeleteTournamentAsync(Guid id); + + /// + /// Retrieves tournaments with pagination and filtering asynchronously. + /// + /// The filtering and pagination request. + /// A paginated response containing the tournaments. + Task> GetAllTournamentsAsync(GetTournamentsFilteredRequest filter); +} diff --git a/Club12-Backend/Application/Interfaces/Services/IUserManagementService.cs b/Club12-Backend/Application/Interfaces/Services/IUserManagementService.cs new file mode 100644 index 0000000..1972307 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IUserManagementService.cs @@ -0,0 +1,62 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.User.Request; +using Application.DTOs.User.Response; + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +/// +/// CRUD operations on Identity users, with role-based access enforcement. +/// +public interface IUserManagementService +{ + /// + /// ADMIN sees all users; OWNER sees only their own subordinates; others get 403. + /// + Task> GetAllAsync( + string callerRole, Guid callerId, + UserFilteredRequest filter, + CancellationToken ct = default); + + /// + /// ADMIN → any user. OWNER → self or their subordinates. Others → self only. + /// + Task GetByIdAsync( + string callerRole, Guid callerId, Guid userId, CancellationToken ct = default); + + /// + /// Updates profile fields including username, email, and phone, where ADMIN can update any user, OWNER can update self or their subordinates, and others only themselves. + /// + Task UpdateAsync( + string callerRole, Guid callerId, Guid userId, + UpdateUserRequest request, CancellationToken ct = default); + + /// + /// Changes a user's password, self-service or privileged, where ADMIN can change any user, OWNER can change self or their subordinates, and others only themselves. + /// + Task ChangePasswordAsync( + string callerRole, Guid callerId, Guid userId, + ChangePasswordRequest request, CancellationToken ct = default); + + /// + /// Forces a password reset by generating a temporary password, where ADMIN can reset any user and OWNER only their own subordinates; others get 403. + /// + Task ResetPasswordAsync( + string callerRole, Guid callerId, Guid userId, CancellationToken ct = default); + + /// + /// ADMIN → any user. OWNER → their subordinates only. Others → 403. + /// + Task DeleteAsync( + string callerRole, Guid callerId, Guid userId, CancellationToken ct = default); + + /// + /// Activates or deactivates a user account via Identity lockout, where ADMIN can act on any user and OWNER only their subordinates; others get 403. + /// + Task SetActiveAsync( + string callerRole, Guid callerId, Guid userId, bool isActive, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Club12-Backend/Application/Interfaces/Services/IVenueService.cs b/Club12-Backend/Application/Interfaces/Services/IVenueService.cs new file mode 100644 index 0000000..a833ed1 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Services/IVenueService.cs @@ -0,0 +1,39 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Interfaces.Services; + +public interface IVenueService +{ + /// + /// Creates a venue and generates its unique slug from the name. + /// + /// The Venue entity to create. + /// The created Venue. + Task CreateVenueAsync(Venue venueEntity); + + Task GetVenueByIdAsync(Guid venueId); + + /// + /// Retrieves a Venue by its id or its slug asynchronously, treating the value as an id when it parses as a GUID and otherwise looking it up as a slug. + /// + /// The venue's GUID id or its slug. + /// The matching venue, or null if not found. + Task GetVenueByIdOrSlugAsync(string idOrSlug); + + Task UpdateVenueAsync(Venue venueEntity); + + /// + /// Deletes a venue, blocked while any match still references it, so a match is never left without a venue. + /// + /// The unique identifier of the venue to delete. + /// + /// Thrown when the venue is referenced by one or more matches. + /// + Task DeleteVenueAsync(Guid id); + + Task> GetAllVenuesAsync(); +} diff --git a/Club12-Backend/Application/Interfaces/Storage/IMedicalRecordStorage.cs b/Club12-Backend/Application/Interfaces/Storage/IMedicalRecordStorage.cs new file mode 100644 index 0000000..58464e9 --- /dev/null +++ b/Club12-Backend/Application/Interfaces/Storage/IMedicalRecordStorage.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Interfaces.Storage; + +/// +/// Storage boundary for player medical-record files, PDF. +/// +public interface IMedicalRecordStorage +{ + /// + /// Stores a medical-record file for a player's team registration and returns the storage reference to persist on the registration. + /// + /// The team the registration belongs to. + /// The player the record belongs to. + /// The original file name, its extension is preserved. + /// The file content stream. + /// Cancellation token. + /// The stored object path used as the persisted file reference. + Task StoreAsync(Guid teamId, Guid playerId, string fileName, Stream content, CancellationToken ct = default); + + /// + /// Downloads the raw bytes of a previously stored medical-record object. + /// + /// + /// The stored object path returned by StoreAsync and persisted + /// on the season registration as its file reference. + /// + /// Cancellation token. + /// The raw file content. + Task DownloadAsync(string objectPath, CancellationToken ct = default); +} diff --git a/Club12-Backend/Application/Services/AuditService.cs b/Club12-Backend/Application/Services/AuditService.cs new file mode 100644 index 0000000..6cbde67 --- /dev/null +++ b/Club12-Backend/Application/Services/AuditService.cs @@ -0,0 +1,63 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.AuditLogs.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Extensions; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +public class AuditService(IAuditLogRepository auditLogRepository, ICurrentUserAccessor currentUserAccessor) + : IAuditService +{ + public async Task LogAsync( + AuditAction action, + string? targetType = null, + string? targetId = null, + string? targetName = null, + string? detail = null, + CancellationToken ct = default) + { + string actor = currentUserAccessor.Actor; + + await auditLogRepository.AddAsync(new AuditLog + { + Id = Guid.Empty, + Action = action, + Actor = actor, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + Detail = detail, + DateCreated = DateTime.UtcNow, + // EntityBase.CreatedBy is required; mirror the actor so the base audit columns and the semantic Actor field agree. + CreatedBy = actor, + }); + } + + public async Task> GetAuditLogsAsync(AuditLogFilteredRequest filter) + { + Expression> expression = + QueryableExtensions.ConstructFilterExpression(filter); + + IEnumerable entries = await auditLogRepository.FindAsync(expression, filter: filter); + int totalCount = await auditLogRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = entries, + }; + } +} diff --git a/Club12-Backend/Application/Services/AuthService.cs b/Club12-Backend/Application/Services/AuthService.cs new file mode 100644 index 0000000..912759f --- /dev/null +++ b/Club12-Backend/Application/Services/AuthService.cs @@ -0,0 +1,61 @@ +using Application.DTOs.Auth.Response; +using Application.Interfaces.Services; +using Application.Utils.Constants.Auth; +using Application.Utils.Constants.Configuration; + +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Generates JWT access tokens and refresh tokens. +/// +public class AuthService(IConfiguration configuration) : IAuthService +{ + private readonly string _jwtSecret = configuration.GetSection(ConfigurationKeys.Jwt.Key).Value + ?? throw new ArgumentNullException(nameof(configuration), "The JWT Key should be initialized"); + private readonly string _issuer = configuration.GetSection(ConfigurationKeys.Jwt.Issuer).Value + ?? throw new ArgumentNullException(nameof(configuration), "The JWT Issuer should be initialized"); + private readonly string _audience = configuration.GetSection(ConfigurationKeys.Jwt.Audience).Value + ?? throw new ArgumentNullException(nameof(configuration), "The JWT Audience should be initialized"); + + public Task GenerateJwtTokenAsync(IEnumerable claims, CancellationToken ct = default) + { + JwtSecurityTokenHandler tokenHandler = new(); + byte[] key = Encoding.UTF8.GetBytes(_jwtSecret); + + SecurityTokenDescriptor tokenDescriptor = new() + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddHours(TokenLifetime.AccessTokenExpiryHours), + Issuer = _issuer, + Audience = _audience, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + + SecurityToken token = tokenHandler.CreateToken(tokenDescriptor); + string accessToken = tokenHandler.WriteToken(token); + + string refreshToken = GenerateRefreshToken(); + return Task.FromResult(new TokenResponse( + accessToken, TimeSpan.FromHours(TokenLifetime.AccessTokenExpiryHours), refreshToken)); + } + + private static string GenerateRefreshToken() + { + byte[] randomBytes = new byte[TokenLifetime.RefreshTokenByteLength]; + using RandomNumberGenerator rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } +} \ No newline at end of file diff --git a/Club12-Backend/Application/Services/BlogPostService.cs b/Club12-Backend/Application/Services/BlogPostService.cs new file mode 100644 index 0000000..2c937ea --- /dev/null +++ b/Club12-Backend/Application/Services/BlogPostService.cs @@ -0,0 +1,94 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.BlogPosts.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Extensions; +using Application.Utils.Helper.Slug; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages blog posts and keeps draft visibility opt-in so a public endpoint can never leak a draft. +/// +public class BlogPostService(IBlogPostRepository blogpostRepository) : IBlogPostService +{ + public async Task CreateBlogPostAsync(BlogPost blogPostEntity) + { + blogPostEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + blogPostEntity.Title, + candidate => blogpostRepository.ExistsAsync(post => post.Slug == candidate)); + + return await blogpostRepository.AddAsync(blogPostEntity); + } + + public async Task DeleteBlogPostAsync(Guid id) + { + await blogpostRepository.RemoveAsync(bp => bp.Id == id); + } + + public async Task UpdateBlogPostAsync(BlogPost blogPostEntity) + { + await blogpostRepository.UpdateAsync(blogPostEntity); + } + + public async Task GetBlogPostByIdAsync(Guid blogPostId) + { + return await blogpostRepository.GetByIdAsync(blogPostId); + } + + /// + /// Retrieves a blog post by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The blog post's GUID id or its slug. + /// + /// Defaults to false, which treats a draft as not found so it never leaks through public + /// endpoints; true also resolves drafts. + /// + /// The blog post entity if found; otherwise, null. + public async Task GetBlogPostByIdOrSlugAsync(string idOrSlug, bool includeUnpublished = false) + { + BlogPost? post = Guid.TryParse(idOrSlug, out Guid blogPostId) + ? await GetBlogPostByIdAsync(blogPostId) + : (await blogpostRepository.FindAsync(candidate => candidate.Slug == idOrSlug)).FirstOrDefault(); + + // Public callers must not resolve drafts: a draft is treated as not found so it never leaks through the public detail endpoint. + if (post is not null && !includeUnpublished && !post.IsPublished) + { + return null; + } + + return post; + } + + public async Task> GetAllBlogPostsAsync(GetBlogPostsFilteredRequest filter, bool includeUnpublished = false) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + + // Public listing only ever exposes published posts; Admin/Owner callers pass includeUnpublished: true to also see drafts. + if (!includeUnpublished) + { + Expression> publishedOnly = post => post.IsPublished; + expression = expression.AndAlso(publishedOnly); + } + + IEnumerable filteredBlogPosts = await blogpostRepository.FindAsync(expression, filter: filter); + + int totalCount = await blogpostRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredBlogPosts + }; + } +} diff --git a/Club12-Backend/Application/Services/ChampionService.cs b/Club12-Backend/Application/Services/ChampionService.cs new file mode 100644 index 0000000..bd40c4a --- /dev/null +++ b/Club12-Backend/Application/Services/ChampionService.cs @@ -0,0 +1,247 @@ +using Application.DTOs.Champions.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Helper.Champions; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Computes the champion and podium of each competition and the champions history over finished tournaments. +/// +public class ChampionService( + IDivisionRepository divisionRepository, + IMatchRepository matchRepository, + IMatchSeriesRepository matchSeriesRepository, + ITournamentRepository tournamentRepository, + IDivisionService divisionService) : IChampionService +{ + /// + /// Computes a division's podium; see IChampionService.GetDivisionPodiumAsync. + /// + public async Task GetDivisionPodiumAsync(Guid divisionId) + { + Division? division = await divisionRepository.GetByIdAsync( + divisionId, + includes: [d => d.Stages, d => d.PlayoffMappings]); + + if (division is null) + { + return null; + } + + return await BuildDivisionPodiumAsync(division); + } + + /// + /// Computes the podium of every division of a tournament; see IChampionService.GetTournamentChampionsAsync. + /// + public async Task> GetTournamentChampionsAsync(Guid tournamentId) + { + List divisions = [.. await divisionRepository.FindAsync( + division => division.TournamentId == tournamentId, + includes: [d => d.Stages, d => d.PlayoffMappings])]; + + List podiums = []; + + foreach (Division division in divisions.OrderBy(d => d.Name, StringComparer.Ordinal)) + { + podiums.Add(await BuildDivisionPodiumAsync(division)); + } + + return podiums; + } + + /// + /// Returns the champion of every division of every finished tournament; see IChampionService.GetChampionsHistoryAsync. + /// + public async Task> GetChampionsHistoryAsync(Guid? seasonId) + { + IEnumerable finishedTournaments = seasonId.HasValue + ? await tournamentRepository.FindAsync( + tournament => tournament.Status == TournamentStatus.Finished && tournament.SeasonId == seasonId, + includes: [t => t.Season!, t => t.Divisions]) + : await tournamentRepository.FindAsync( + tournament => tournament.Status == TournamentStatus.Finished, + includes: [t => t.Season!, t => t.Divisions]); + + List history = []; + + foreach (Tournament tournament in finishedTournaments) + { + foreach (Division division in tournament.Divisions) + { + // One row per sub-cup; a single-bracket or group-only division yields exactly one row with a null CupName. + List<(string? CupName, PodiumTeamResponse Champion)> champions = + await GetDivisionChampionsAsync(division.Id); + + foreach ((string? cupName, PodiumTeamResponse champion) in champions) + { + history.Add(new ChampionHistoryResponse + { + TournamentId = tournament.Id, + TournamentName = tournament.Name, + SeasonName = tournament.Season?.Name, + SeasonYear = tournament.Season?.Year, + Category = division.Category.ToString(), + DivisionName = division.Name, + CupName = cupName, + ChampionTeam = champion, + }); + } + } + } + + return history; + } + + /// + /// Resolves the champion of every sub-cup of a division, or the standings leader for a group-only division. + /// + private async Task> GetDivisionChampionsAsync(Guid divisionId) + { + Division? division = await divisionRepository.GetByIdAsync( + divisionId, + includes: [d => d.Stages, d => d.PlayoffMappings]); + + if (division is null) + { + return []; + } + + List eliminationStages = [.. division.Stages.Where(stage => stage.StageType != StageType.Group)]; + + if (eliminationStages.Count > 0) + { + (List matches, List series) = await LoadEliminationDataAsync(eliminationStages); + + IReadOnlyList cupChampions = ChampionResolver.ResolveCupChampions( + eliminationStages, + [.. division.PlayoffMappings], + matches, + series); + + return [.. cupChampions.Select(cup => (cup.CupName, ToTeamResponse(cup.Champion)!))]; + } + + List standings = await divisionService.GetPositionsByDivisionIdAsync(division.Id); + PodiumTeamResponse? leader = ToTeamResponse(standings.ElementAtOrDefault(0)); + + return leader is null ? [] : [((string?)null, leader)]; + } + + /// + /// Builds one division's podium, routing to the bracket resolver or the standings resolver. + /// + private async Task BuildDivisionPodiumAsync(Division division) + { + List eliminationStages = [.. division.Stages.Where(stage => stage.StageType != StageType.Group)]; + + if (eliminationStages.Count > 0) + { + return await BuildPlayoffPodiumAsync(division, eliminationStages); + } + + return await BuildStandingsPodiumAsync(division); + } + + /// + /// Resolves a playoff division's podium from its top cup's Final and third-place match. + /// + private async Task BuildPlayoffPodiumAsync(Division division, List eliminationStages) + { + (List eliminationMatches, List series) = await LoadEliminationDataAsync(eliminationStages); + + ChampionResolver.Podium podium = ChampionResolver.ResolvePlayoffPodium( + eliminationStages, + [.. division.PlayoffMappings], + eliminationMatches, + series); + + // The top cup never had real bracket rounds to draw a third place from, so position 3 in the standings is the implicit bronze there, while a real bracket that opted out of a third-place match keeps podium.Third null instead. + PodiumTeamResponse? third = ToTeamResponse(podium.Third); + if (third is null && podium.ImplicitThirdFromStandings) + { + List standings = await divisionService.GetPositionsByDivisionIdAsync(division.Id); + third = ToTeamResponse(standings.ElementAtOrDefault(2)); + } + + return new PodiumResponse + { + DivisionId = division.Id, + DivisionName = division.Name, + HasPlayoff = true, + First = ToTeamResponse(podium.First), + Second = ToTeamResponse(podium.Second), + Third = third, + }; + } + + /// + /// Resolves a group-only division's podium from the top three of its group standings. + /// + private async Task BuildStandingsPodiumAsync(Division division) + { + List standings = await divisionService.GetPositionsByDivisionIdAsync(division.Id); + + return new PodiumResponse + { + DivisionId = division.Id, + DivisionName = division.Name, + HasPlayoff = false, + First = ToTeamResponse(standings.ElementAtOrDefault(0)), + Second = ToTeamResponse(standings.ElementAtOrDefault(1)), + Third = ToTeamResponse(standings.ElementAtOrDefault(2)), + }; + } + + /// + /// Loads the matches and best-of-N series of a set of elimination stages with their team navigations. + /// + private async Task<(List Matches, List Series)> LoadEliminationDataAsync( + List eliminationStages) + { + List eliminationStageIds = [.. eliminationStages.Select(stage => stage.Id)]; + + List eliminationMatches = [.. await matchRepository.FindAsync( + match => eliminationStageIds.Contains(match.StageId), + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.WinningTeam!])]; + + List series = [.. await matchSeriesRepository.FindAsync( + matchSeries => eliminationStageIds.Contains(matchSeries.StageId), + includes: [s => s.HomeTeam!, s => s.VisitorTeam!, s => s.WinningTeam!])]; + + return (eliminationMatches, series); + } + + private static PodiumTeamResponse? ToTeamResponse(ChampionResolver.TeamRef? team) + { + return team is null + ? null + : new PodiumTeamResponse + { + TeamId = team.TeamId, + TeamName = team.TeamName, + LogoUrl = team.LogoUrl, + }; + } + + private static PodiumTeamResponse? ToTeamResponse(Position? position) + { + return position is null + ? null + : new PodiumTeamResponse + { + TeamId = position.TeamId, + TeamName = position.TeamName, + LogoUrl = position.LogoUrl, + }; + } +} diff --git a/Club12-Backend/Application/Services/ClubService.cs b/Club12-Backend/Application/Services/ClubService.cs new file mode 100644 index 0000000..8da41af --- /dev/null +++ b/Club12-Backend/Application/Services/ClubService.cs @@ -0,0 +1,302 @@ +using Application.DTOs.Club.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Helper.Slug; + +using Domain.Constants; +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages the stable cross-season club identity. +/// +public class ClubService(IUnitOfWork unitOfWork) : IClubService +{ + private readonly IClubRepository _clubRepository = unitOfWork.ClubRepository; + private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository; + private readonly ITeamTournamentRegistrationRepository _tournamentRegistrationRepository = unitOfWork.TeamTournamentRegistrationRepository; + private readonly ITournamentRepository _tournamentRepository = unitOfWork.TournamentRepository; + + /// + public async Task BackfillClubsAsync() + { + List unlinkedTeams = [.. (await _teamRepository.GetAllAsync()).Where(team => team.ClubId is null)]; + + if (unlinkedTeams.Count == 0) + { + return new ClubBackfillResult { ClubsCreated = 0, TeamsLinked = 0 }; + } + + // Slug is the stable identity key: same-named teams generate the same slug and therefore collapse onto a single club. Existing clubs are reused so a partial or previous backfill is never duplicated. + Dictionary clubsBySlug = (await _clubRepository.GetAllAsync()) + .GroupBy(club => club.Slug) + .ToDictionary(group => group.Key, group => group.First()); + + Dictionary slugByTeam = []; + List clubsToCreate = []; + + foreach (Team team in unlinkedTeams) + { + string slug = ClubSlugForTeam(team); + slugByTeam[team.Id] = slug; + + if (!clubsBySlug.ContainsKey(slug)) + { + Club club = new() + { + Id = Guid.Empty, + Name = !string.IsNullOrWhiteSpace(team.Name) ? team.Name.Trim() : team.ThreeLetterCode, + Slug = slug, + LogoUrl = string.IsNullOrWhiteSpace(team.LogoUrl) ? null : team.LogoUrl, + DateCreated = DateTime.UtcNow, + CreatedBy = AuditConstants.SystemUser, + }; + + clubsBySlug[slug] = club; + clubsToCreate.Add(club); + } + } + + if (clubsToCreate.Count > 0) + { + // Persist first so EF assigns each new club an Id before it is referenced by a team's ClubId FK. + await _clubRepository.AddRangeAsync(clubsToCreate); + } + + foreach (Team team in unlinkedTeams) + { + team.ClubId = clubsBySlug[slugByTeam[team.Id]].Id; + } + + await _teamRepository.UpdateRangeAsync(unlinkedTeams); + + return new ClubBackfillResult + { + ClubsCreated = clubsToCreate.Count, + TeamsLinked = unlinkedTeams.Count, + }; + } + + /// + public async Task EnsureTeamLinkedToClubAsync(Team team) + { + if (team.ClubId is not null) + { + return; + } + + string slug = ClubSlugForTeam(team); + + Club? club = (await _clubRepository.FindAsync(candidate => candidate.Slug == slug)).FirstOrDefault(); + + if (club is null) + { + club = new Club + { + Id = Guid.Empty, + Name = !string.IsNullOrWhiteSpace(team.Name) ? team.Name.Trim() : team.ThreeLetterCode, + Slug = slug, + LogoUrl = string.IsNullOrWhiteSpace(team.LogoUrl) ? null : team.LogoUrl, + DateCreated = DateTime.UtcNow, + CreatedBy = AuditConstants.SystemUser, + }; + await _clubRepository.AddAsync(club); + } + + team.ClubId = club.Id; + await _teamRepository.UpdateAsync(team); + } + + /// + /// The stable identity key shared by BackfillClubsAsync and EnsureTeamLinkedToClubAsync. + /// + private static string ClubSlugForTeam(Team team) + { + string key = !string.IsNullOrWhiteSpace(team.Name) ? team.Name.Trim() : team.ThreeLetterCode; + string slug = SlugGenerator.GenerateSlug(key); + + // No alphanumeric characters to slug — fall back to the team id so the club still gets a unique, non-null slug. + return string.IsNullOrEmpty(slug) ? team.Id.ToString() : slug; + } + + /// + /// Resolves a club by id or slug and assembles its cross-season team history. + /// + public async Task GetClubHistoryAsync(string idOrSlug) + { + Club? club = Guid.TryParse(idOrSlug, out Guid clubId) + ? await _clubRepository.GetByIdAsync(clubId) + : (await _clubRepository.FindAsync(candidate => candidate.Slug == idOrSlug)).FirstOrDefault(); + + if (club is null) + { + return null; + } + + List teams = [.. await _teamRepository.FindAsync(team => team.ClubId == club.Id)]; + List teamIds = [.. teams.Select(team => team.Id)]; + + // Season lookups are batched to avoid N+1 queries: all registrations for these teams, then the tournaments they point at, resolved to names in memory. + List registrations = teamIds.Count == 0 + ? [] + : [.. await _tournamentRegistrationRepository.FindAsync( + registration => teamIds.Contains(registration.TeamId))]; + + List tournamentIds = [.. registrations.Select(registration => registration.TournamentId).Distinct()]; + + Dictionary tournamentsById = tournamentIds.Count == 0 + ? [] + : (await _tournamentRepository.FindAsync(tournament => tournamentIds.Contains(tournament.Id))) + .ToDictionary(tournament => tournament.Id); + + ILookup registrationsByTeam = registrations.ToLookup(r => r.TeamId); + + ClubSummaryResponse? parentClub = club.ParentClubId is null + ? null + : ToSummary(await _clubRepository.GetByIdAsync(club.ParentClubId.Value)); + + List childClubs = [.. (await _clubRepository.FindAsync(candidate => candidate.ParentClubId == club.Id)) + .Select(candidate => ToSummary(candidate)!)]; + + return new ClubHistoryResponse + { + Id = club.Id, + Name = club.Name, + Slug = club.Slug, + LogoUrl = club.LogoUrl, + ParentClub = parentClub, + ChildClubs = childClubs, + Teams = [.. teams.Select(team => new ClubTeamSeasonResponse + { + TeamId = team.Id, + Name = team.Name, + Slug = team.Slug, + ThreeLetterCode = team.ThreeLetterCode, + Seasons = [.. registrationsByTeam[team.Id] + .Select(registration => + { + tournamentsById.TryGetValue(registration.TournamentId, out Tournament? tournament); + return new ClubSeasonResponse + { + TournamentId = registration.TournamentId, + TournamentName = tournament?.Name, + StartDate = tournament?.StartDate ?? DateTime.MinValue, + }; + }) + // Newest season first; the history page flattens these across all teams and re-sorts, but ordering here keeps any single-team consumer correct too. + .OrderByDescending(season => season.StartDate)], + })], + }; + } + + /// + public async Task> GetAllClubsAsync() + { + IEnumerable clubs = await _clubRepository.GetAllAsync(); + + return [.. clubs + .OrderBy(club => club.Name) + .Select(club => ToSummary(club)!)]; + } + + /// + public async Task LinkClubToParentAsync(Guid childClubId, Guid parentClubId) + { + if (childClubId == parentClubId) + { + throw new InvalidOperationException(ErrorMessages.Club.CannotLinkToItself); + } + + Club child = await _clubRepository.GetByIdAsync(childClubId) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId)); + + Club parent = await _clubRepository.GetByIdAsync(parentClubId) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(parentClubId)); + + // Flat, one level deep: the parent can't itself be someone else's squad, + // and the child can't already have squads of its own — either would + // create a chain longer than institution -> squads. + if (parent.ParentClubId is not null) + { + throw new InvalidOperationException(ErrorMessages.Club.ParentAlreadyHasParent); + } + + bool childAlreadyHasSquads = await _clubRepository.ExistsAsync(candidate => candidate.ParentClubId == childClubId); + if (childAlreadyHasSquads) + { + throw new InvalidOperationException(ErrorMessages.Club.CannotBecomeChildWithExistingSquads); + } + + child.ParentClubId = parentClubId; + await _clubRepository.UpdateAsync(child); + + return await GetClubHistoryAsync(childClubId.ToString()) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId)); + } + + /// + public async Task UnlinkClubParentAsync(Guid childClubId) + { + Club child = await _clubRepository.GetByIdAsync(childClubId) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId)); + + child.ParentClubId = null; + await _clubRepository.UpdateAsync(child); + + return await GetClubHistoryAsync(childClubId.ToString()) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(childClubId)); + } + + /// + public async Task RenameClubAsync(Guid clubId, string name) + { + string trimmedName = name?.Trim() ?? string.Empty; + if (trimmedName.Length == 0) + { + throw new InvalidOperationException(ErrorMessages.Club.NameRequired); + } + + Club club = await _clubRepository.GetByIdAsync(clubId) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(clubId)); + + club.Name = trimmedName; + await _clubRepository.UpdateAsync(club); + + return await GetClubHistoryAsync(clubId.ToString()) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(clubId)); + } + + /// + public async Task DeleteClubAsync(Guid clubId) + { + _ = await _clubRepository.GetByIdAsync(clubId) + ?? throw new KeyNotFoundException(ErrorMessages.Club.NotFound(clubId)); + + bool hasTeams = await _teamRepository.ExistsAsync(team => team.ClubId == clubId); + bool hasChildClubs = await _clubRepository.ExistsAsync(candidate => candidate.ParentClubId == clubId); + + if (hasTeams || hasChildClubs) + { + throw new InvalidOperationException(ErrorMessages.Club.HasTeamsOrSquadsCannotDelete); + } + + await _clubRepository.RemoveAsync(candidate => candidate.Id == clubId); + } + + private static ClubSummaryResponse? ToSummary(Club? club) => club is null + ? null + : new ClubSummaryResponse + { + Id = club.Id, + Name = club.Name, + Slug = club.Slug, + LogoUrl = club.LogoUrl, + }; +} diff --git a/Club12-Backend/Application/Services/DivisionRosterService.cs b/Club12-Backend/Application/Services/DivisionRosterService.cs new file mode 100644 index 0000000..4101296 --- /dev/null +++ b/Club12-Backend/Application/Services/DivisionRosterService.cs @@ -0,0 +1,143 @@ +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages a division's team roster, independent of any stage placement. +/// +public class DivisionRosterService(IUnitOfWork unitOfWork, IStageService stageService) : IDivisionRosterService +{ + private readonly IDivisionTeamRegistrationRepository _registrationRepository = unitOfWork.DivisionTeamRegistrationRepository; + private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository; + private readonly IDivisionRepository _divisionRepository = unitOfWork.DivisionRepository; + + /// + public async Task> GetRosterAsync(Guid divisionId) + { + IEnumerable registrations = await _registrationRepository.FindAsync( + r => r.DivisionId == divisionId, + includes: [r => r.Team!]); + + return [.. registrations.Select(r => r.Team!)]; + } + + /// + public async Task> EnrollTeamsAsync(Guid divisionId, List teamIds) + { + await EnsureDivisionStructureEditableAsync(divisionId); + + Division targetDivision = await _divisionRepository.GetByIdAsync(divisionId) + ?? throw new InvalidOperationException(ErrorMessages.Stage.DivisionNotFound); + + List distinctIds = [.. teamIds.Distinct()]; + + IEnumerable existingForDivision = await _registrationRepository.FindAsync( + r => r.DivisionId == divisionId && distinctIds.Contains(r.TeamId)); + + HashSet alreadyRegistered = [.. existingForDivision.Select(r => r.TeamId)]; + + List newIds = [.. distinctIds.Where(id => !alreadyRegistered.Contains(id))]; + + if (newIds.Count == 0) + { + return []; + } + + await EnsureNoConflictingRegistrationAsync(targetDivision, newIds); + + List newRegistrations = [.. newIds.Select(teamId => new DivisionTeamRegistration + { + TeamId = teamId, + DivisionId = divisionId, + CreatedBy = AuditConstants.SystemUser, + })]; + + await _registrationRepository.AddRangeAsync(newRegistrations); + + return newRegistrations; + } + + /// + public async Task UnenrollTeamsAsync(Guid divisionId, List teamIds) + { + if (teamIds == null || teamIds.Count == 0) + { + return; + } + + await EnsureDivisionStructureEditableAsync(divisionId); + + await _stageTeamMatchRepository.RemoveAsync(stm => + teamIds.Contains(stm.TeamId) && stm.Stage!.DivisionId == divisionId); + + await _registrationRepository.RemoveAsync(r => + r.DivisionId == divisionId && teamIds.Contains(r.TeamId)); + } + + /// + public Task> RebuildSubGroupsAsync(Guid divisionId, int subGroupCount) => + stageService.RebuildSubGroupsAsync(divisionId, subGroupCount); + + /// + public Task AutoDistributeRosterAsync(Guid divisionId) => + stageService.AutoDistributeRosterAsync(divisionId); + + /// + public Task ReassignTeamToSubGroupAsync(Guid teamId, Guid fromStageId, Guid toStageId) => + stageService.ReassignTeamToSubGroupAsync(teamId, fromStageId, toStageId); + + /// + /// Guards roster edits against the state of the division's tournament, mirroring StageService's structure lock. + /// + private async Task EnsureDivisionStructureEditableAsync(Guid divisionId) + { + Division? division = await _divisionRepository.GetByIdAsync( + divisionId, includes: [d => d.Tournament]); + + if (division?.Tournament is null) + { + return; + } + + bool structureLocked = division.Tournament.Status + is TournamentStatus.Ongoing or TournamentStatus.Finished or TournamentStatus.Canceled; + + if (structureLocked) + { + throw new InvalidOperationException(ErrorMessages.Stage.StructureLockedTournamentStarted); + } + } + + /// + /// Throws when a team already holds a same-kind registration, regular or cross-cup, in a different division of the same tournament. + /// + private async Task EnsureNoConflictingRegistrationAsync(Division targetDivision, List teamIds) + { + IEnumerable candidates = await _registrationRepository.FindAsync( + r => teamIds.Contains(r.TeamId) && r.DivisionId != targetDivision.Id, + includes: [r => r.Division!]); + + List conflictingTeamIds = [.. candidates + .Where(r => r.Division!.TournamentId == targetDivision.TournamentId + && r.Division!.IsCrossDivisionCup == targetDivision.IsCrossDivisionCup) + .Select(r => r.TeamId) + .Distinct()]; + + if (conflictingTeamIds.Count > 0) + { + throw new InvalidOperationException( + ErrorMessages.Division.ConflictingRosterEnrollment(string.Join(", ", conflictingTeamIds))); + } + } +} diff --git a/Club12-Backend/Application/Services/DivisionService.cs b/Club12-Backend/Application/Services/DivisionService.cs new file mode 100644 index 0000000..978ebe2 --- /dev/null +++ b/Club12-Backend/Application/Services/DivisionService.cs @@ -0,0 +1,354 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Divisions.Request; +using Application.DTOs.Match.Request; +using Application.DTOs.Stage.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Constants.Pagination; +using Application.Utils.Extensions; +using Application.Utils.Helper.Playoff; +using Application.Utils.Helper.Slug; +using Application.Utils.Helper.Standings; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages divisions within a tournament, binding their lifecycle to their tournament's. +/// +public class DivisionService( + IDivisionRepository divisionRepository, + IStageService stageService, + IMatchService matchService, + ITeamRepository teamRepository, + IStageTeamMatchRepository stageTeamMatchRepository, + ITournamentRepository tournamentRepository, + ITeamPointDeductionRepository pointDeductionRepository, + IMatchRepository matchRepository) : IDivisionService +{ + /// + /// Creates a division after checking its tournament allows structural edits and its category matches. + /// + /// The division entity to create. + /// The created Division entity. + public async Task CreateDivisionAsync(Division divisionEntity) + { + await EnsureTournamentAllowsDivisionAsync(divisionEntity); + + PlayoffMappingValidator.Validate(divisionEntity.PlayoffMappings); + + divisionEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + divisionEntity.Name, + candidate => divisionRepository.ExistsAsync(division => division.Slug == candidate)); + + await divisionRepository.AddAsync(divisionEntity); + return divisionEntity; + } + + /// + /// Deletes a division, blocking the delete once it has competitive history or its tournament is locked. + /// + /// The unique identifier of the division to delete. + /// + /// Thrown as a 409 when the division has played matches or point deductions, or its tournament has + /// already started or was canceled. + /// + public async Task DeleteDivisionAsync(Guid id) + { + Division? division = await divisionRepository.GetByIdAsync(id, includes: [d => d.Tournament]); + + bool structureLocked = division?.Tournament?.Status + is TournamentStatus.Ongoing or TournamentStatus.Finished or TournamentStatus.Canceled; + + if (structureLocked) + { + throw new InvalidOperationException(ErrorMessages.Division.StructureLockedTournamentStarted); + } + + bool hasPlayedMatches = await matchRepository.ExistsAsync( + match => match.IsFinished && match.Stage.DivisionId == id); + bool hasPointDeductions = await pointDeductionRepository.ExistsAsync( + deduction => deduction.DivisionId == id); + + if (hasPlayedMatches || hasPointDeductions) + { + throw new InvalidOperationException(ErrorMessages.Division.HasHistoryCannotDelete); + } + + await divisionRepository.RemoveAsync(division => division.Id == id); + } + + /// + /// Updates a division after re-checking its tournament still allows structural edits and its category still matches. + /// + /// The division entity with updated values. + public async Task UpdateDivisionAsync(Division divisionEntity) + { + await EnsureTournamentAllowsDivisionAsync(divisionEntity); + + PlayoffMappingValidator.Validate(divisionEntity.PlayoffMappings); + + await divisionRepository.UpdateAsync(divisionEntity); + } + + /// + /// Guards a division create or update against its tournament's status and category. + /// + private async Task EnsureTournamentAllowsDivisionAsync(Division division) + { + Tournament? tournament = await tournamentRepository.GetByIdAsync(division.TournamentId); + + if (tournament is null) + { + return; + } + + if (tournament.Status != TournamentStatus.OpenForRegistration) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.StructuralEditNotAllowed(tournament.Status)); + } + + if (division.Category != tournament.Category) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.CategoryMismatch(division.Category, tournament.Category)); + } + } + + /// + /// Loads a division with just its playoff mappings, the lightweight counterpart to GetFullDivisionByIdAsync. + /// + /// The unique identifier of the division. + /// The Division entity if found; otherwise, null. + public async Task GetSimpleDivisionByIdAsync(Guid divisionId) + { + // Eager-load the playoff mappings so the public division detail can expose its qualification ranges without a second round-trip. + return await divisionRepository.GetByIdAsync( + divisionId, + includes: [division => division.PlayoffMappings]); + } + + /// + /// Retrieves a division's basic data by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The division's GUID id or its slug. + /// The matching Division, or null if not found. + public async Task GetSimpleDivisionByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid divisionId)) + { + return await GetSimpleDivisionByIdAsync(divisionId); + } + + IEnumerable matches = await divisionRepository.FindAsync( + division => division.Slug == idOrSlug, + includes: [division => division.PlayoffMappings]); + + return matches.FirstOrDefault(); + } + + /// + /// Loads a division with its tournament and stages, the heavier counterpart to GetSimpleDivisionByIdAsync. + /// + /// The unique identifier of the division. + /// The Division entity with related data if found; otherwise, null. + public async Task GetFullDivisionByIdAsync(Guid divisionId) + { + return await divisionRepository.GetByIdAsync(divisionId, includes: [division => division.Tournament, division => division.Stages]); + } + + /// + /// Retrieves a paginated and filtered list of division entities asynchronously. + /// + /// The filter and pagination parameters. + /// A PaginatedResponse{Division} containing the filtered divisions. + public async Task> GetAllDivisionsAsync(GetDivisionsFilteredRequest filter) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + IEnumerable filteredDivisions = await divisionRepository.FindAsync(expression, filter: filter); + + int totalCount = await divisionRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredDivisions + }; + } + + /// + /// Computes standings for a division from its Group stage's finished matches only. + /// + /// The id of the division. + /// One Position per team with at least one finished Group-stage match; empty if the division has no Group stage or no finished matches yet. + public async Task> GetPositionsByDivisionIdAsync(Guid divisionId) + { + PaginatedResponse stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest + { + DivisionId = divisionId, + StageType = StageType.Group, + PageSize = PaginationDefaults.MaxPageSize, + }); + + Stage? groupStage = stages.Items.FirstOrDefault(); + if (groupStage is null) + { + return []; + } + + Division? division = await divisionRepository.GetByIdAsync(divisionId); + int pointsForWin = division?.PointsForWin ?? PositionCalculator.DefaultPointsForWin; + int pointsForLoss = division?.PointsForLoss ?? PositionCalculator.DefaultPointsForLoss; + + List deductions = await GetDeductionsAsync(divisionId); + + PaginatedResponse matches = await matchService.GetAllMatchesAsync(new GetMatchesFilteredRequest + { + StageId = groupStage.Id, + IsFinished = true, + PageSize = PaginationDefaults.MaxPageSize, + }); + + // Seed the table with every team assigned to the zone so the standings list all of them at 0-0 from the start, not only once they have a finished match. + List rosterTeams = await GetAssignedTeamsAsync(groupStage.Id); + + return PositionCalculator.CalculatePositions(matches.Items, pointsForWin, pointsForLoss, deductions, rosterTeams); + } + + /// + /// The teams currently assigned to a group stage, resolved through their StageTeamMatch membership. + /// + private async Task> GetAssignedTeamsAsync(Guid stageId) + { + IEnumerable assignments = await stageTeamMatchRepository.FindAsync( + stm => stm.StageId == stageId, + includes: [stm => stm.Team!]); + + return [.. assignments + .Select(stm => stm.Team) + .Where(team => team is not null) + .Cast()]; + } + + /// + /// Loads every disciplinary point deduction applied in a division, threaded into the standings calculation. + /// + private async Task> GetDeductionsAsync(Guid divisionId) + { + return [.. await pointDeductionRepository.FindAsync( + deduction => deduction.DivisionId == divisionId)]; + } + + /// + /// Computes standings for a division split by Group stage, one entry per internal group. + /// + /// The id of the division. + /// One GroupStandings per Group stage; empty when the division has no Group stage. + public async Task> GetGroupStandingsByDivisionIdAsync(Guid divisionId) + { + PaginatedResponse stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest + { + DivisionId = divisionId, + StageType = StageType.Group, + PageSize = PaginationDefaults.MaxPageSize, + }); + + List groupStages = [.. stages.Items + .OrderBy(stage => stage.Order) + .ThenBy(stage => stage.Name, StringComparer.Ordinal)]; + + if (groupStages.Count == 0) + { + return []; + } + + Division? division = await divisionRepository.GetByIdAsync(divisionId); + int pointsForWin = division?.PointsForWin ?? PositionCalculator.DefaultPointsForWin; + int pointsForLoss = division?.PointsForLoss ?? PositionCalculator.DefaultPointsForLoss; + + List deductions = await GetDeductionsAsync(divisionId); + + List result = []; + + foreach (Stage groupStage in groupStages) + { + PaginatedResponse matches = await matchService.GetAllMatchesAsync(new GetMatchesFilteredRequest + { + StageId = groupStage.Id, + IsFinished = true, + PageSize = PaginationDefaults.MaxPageSize, + }); + + List rosterTeams = await GetAssignedTeamsAsync(groupStage.Id); + + result.Add(new GroupStandings + { + StageId = groupStage.Id, + StageName = groupStage.Name, + Positions = PositionCalculator.CalculatePositions(matches.Items, pointsForWin, pointsForLoss, deductions, rosterTeams), + }); + } + + return result; + } + + /// + /// Returns every team registered to the tournament that does not yet belong to any division. + /// + /// The id of the tournament. + public async Task> GetUnassignedTeamsAsync(Guid tournamentId) + { + List registeredTeams = [.. await teamRepository.FindAsync(team => team.TournamentId == tournamentId)]; + if (registeredTeams.Count == 0) + { + return []; + } + + List registeredTeamIds = [.. registeredTeams.Select(t => t.Id)]; + + IEnumerable assignments = await stageTeamMatchRepository.FindAsync(stm => + registeredTeamIds.Contains(stm.TeamId) + && stm.Stage!.Division.TournamentId == tournamentId); + + HashSet assignedTeamIds = [.. assignments.Select(a => a.TeamId)]; + + return [.. registeredTeams.Where(t => !assignedTeamIds.Contains(t.Id))]; + } + + /// + /// Reassigns a division to a different tournament in memory, validating only the target's existence. + /// + /// The division to reassign. Its Tournament navigation and TournamentId are mutated in place. + /// The id of the tournament the division should belong to. + /// True if the target tournament exists and the division was reassigned in memory; false if no tournament with that id exists. + public async Task TryAssignTournamentAsync(Division division, Guid tournamentId) + { + if (division.TournamentId == tournamentId) + { + return true; + } + + Tournament? targetTournament = await tournamentRepository.GetByIdAsync(tournamentId); + if (targetTournament is null) + { + return false; + } + + division.Tournament = targetTournament; + division.TournamentId = tournamentId; + + return true; + } +} diff --git a/Club12-Backend/Application/Services/MatchSeriesService.cs b/Club12-Backend/Application/Services/MatchSeriesService.cs new file mode 100644 index 0000000..3d112b0 --- /dev/null +++ b/Club12-Backend/Application/Services/MatchSeriesService.cs @@ -0,0 +1,171 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.MatchSeries.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Extensions; +using Application.Utils.Helper.Series; +using Application.Utils.Helper.Slug; + +using Domain.Constants; +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +using MatchType = Domain.Enums.MatchType; + +namespace Application.Services; + +/// +/// Service responsible for managing best-of-N playoff series between two teams at a single bracket round. +/// +public class MatchSeriesService(IUnitOfWork unitOfWork) : IMatchSeriesService +{ + private readonly IMatchSeriesRepository _matchSeriesRepository = unitOfWork.MatchSeriesRepository; + private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository; + private readonly IStageRepository _stageRepository = unitOfWork.StageRepository; + private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository; + private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository; + + public async Task GetSeriesByIdAsync(Guid seriesId) + { + return await _matchSeriesRepository.GetByIdAsync(seriesId, + includes: [s => s.HomeTeam!, s => s.VisitorTeam!, s => s.WinningTeam!, s => s.Matches]); + } + + public async Task> GetAllSeriesAsync(GetMatchSeriesFilteredRequest filter) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + + IEnumerable filteredSeries = await _matchSeriesRepository.FindAsync(expression, filter: filter, + includes: [s => s.HomeTeam!, s => s.VisitorTeam!, s => s.WinningTeam!, s => s.Matches]); + + int totalCount = await _matchSeriesRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredSeries + }; + } + + /// + /// Opens a best-of-N series between two teams already assigned to the stage, at most one per pair. + /// + public async Task CreateSeriesAsync(Guid stageId, Guid homeTeamId, Guid visitorTeamId) + { + if (homeTeamId == visitorTeamId) + { + throw new InvalidOperationException(ErrorMessages.MatchSeries.RequiresTwoDifferentTeams); + } + + Stage stage = await _stageRepository.GetByIdAsync(stageId) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + + await EnsureTeamAssignedToStageAsync(stageId, homeTeamId); + await EnsureTeamAssignedToStageAsync(stageId, visitorTeamId); + + bool alreadyExists = await _matchSeriesRepository.ExistsAsync(series => + series.StageId == stageId + && ((series.HomeTeamId == homeTeamId && series.VisitorTeamId == visitorTeamId) + || (series.HomeTeamId == visitorTeamId && series.VisitorTeamId == homeTeamId))); + + if (alreadyExists) + { + throw new InvalidOperationException(ErrorMessages.MatchSeries.AlreadyExistsForStage); + } + + MatchSeries seriesEntity = new() + { + StageId = stageId, + HomeTeamId = homeTeamId, + VisitorTeamId = visitorTeamId, + BestOf = stage.BestOf, + CreatedBy = AuditConstants.SystemUser, + }; + + await _matchSeriesRepository.AddAsync(seriesEntity); + return seriesEntity; + } + + /// + /// Adds the next game to a series, blocked once it already has a winner or has reached its BestOf count. + /// + public async Task AddGameToSeriesAsync(Guid seriesId, DateTime matchDate, Guid? venueId) + { + MatchSeries series = await _matchSeriesRepository.GetByIdAsync(seriesId, includes: [s => s.Matches]) + ?? throw new InvalidOperationException(ErrorMessages.MatchSeries.NotFound); + + if (series.WinningTeamId.HasValue) + { + throw new InvalidOperationException(ErrorMessages.MatchSeries.AlreadyDecided); + } + + if (series.Matches.Count >= series.BestOf) + { + throw new InvalidOperationException(ErrorMessages.MatchSeries.MaxGamesReached(series.BestOf)); + } + + Team? homeTeam = await _teamRepository.GetByIdAsync(series.HomeTeamId); + Team? visitorTeam = await _teamRepository.GetByIdAsync(series.VisitorTeamId); + + Match game = new() + { + StageId = series.StageId, + HomeTeamId = series.HomeTeamId, + VisitorTeamId = series.VisitorTeamId, + SeriesId = series.Id, + GameNumber = series.Matches.Count + 1, + MatchDate = matchDate, + VenueId = venueId, + Type = MatchType.Playoff, + Slug = string.Empty, + IsFinished = false, + CreatedBy = AuditConstants.SystemUser, + }; + + game.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + MatchSlugSourceBuilder.Build(homeTeam?.Name, visitorTeam?.Name, game.MatchDate), + candidate => _matchRepository.ExistsAsync(match => match.Slug == candidate)); + + await _matchRepository.AddAsync(game); + return game; + } + + /// + /// Re-evaluates a series' winner from its games, a no-op once it already has a WinningTeamId. + /// + public async Task RecalculateSeriesWinnerAsync(Guid seriesId) + { + MatchSeries? series = await _matchSeriesRepository.GetByIdAsync(seriesId, includes: [s => s.Matches]); + + if (series is null || series.WinningTeamId.HasValue) + { + return; + } + + Guid? winningTeamId = SeriesDecisionCalculator.DetermineWinner(series); + + if (winningTeamId.HasValue) + { + series.WinningTeamId = winningTeamId; + await _matchSeriesRepository.UpdateAsync(series); + } + } + + private async Task EnsureTeamAssignedToStageAsync(Guid stageId, Guid teamId) + { + bool isAssigned = await _stageTeamMatchRepository.ExistsAsync(stm => + stm.StageId == stageId && stm.TeamId == teamId); + + if (!isAssigned) + { + throw new InvalidOperationException(ErrorMessages.MatchSeries.TeamNotAssignedToStage(teamId)); + } + } +} diff --git a/Club12-Backend/Application/Services/MatchService.cs b/Club12-Backend/Application/Services/MatchService.cs new file mode 100644 index 0000000..9f47c60 --- /dev/null +++ b/Club12-Backend/Application/Services/MatchService.cs @@ -0,0 +1,570 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Constants.Stage; +using Application.Utils.Extensions; +using Application.Utils.Helper.MatchResult; +using Application.Utils.Helper.Playoff; +using Application.Utils.Helper.RoundRobin; +using Application.Utils.Helper.Slug; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using LinqKit; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +using MatchType = Domain.Enums.MatchType; + +namespace Application.Services; + +/// +/// Manages individual matches: CRUD, result and walkover and suspension transitions, and automated fixture generation. +/// +public class MatchService(IUnitOfWork unitOfWork, IStageService stageService) : IMatchService +{ + private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository; + private readonly IStageRepository _stageRepository = unitOfWork.StageRepository; + private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository; + private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository; + + public async Task CreateMatchAsync(Match matchEntity) + { + string homeTeamName = await ResolveTeamNameAsync(matchEntity.HomeTeamId); + string visitorTeamName = await ResolveTeamNameAsync(matchEntity.VisitorTeamId); + + matchEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + MatchSlugSourceBuilder.Build(homeTeamName, visitorTeamName, matchEntity.MatchDate), + candidate => _matchRepository.ExistsAsync(match => match.Slug == candidate)); + + await _matchRepository.AddAsync(matchEntity); + return matchEntity; + } + + public async Task GetMatchByIdAsync(Guid matchId) + { + return await _matchRepository.GetByIdAsync(matchId, includes: [m => m.HomeTeam!, m => m.VisitorTeam!]); + } + + /// + /// Retrieves a match by id or slug with its home and visitor teams, auto-detecting which one was passed. + /// + /// The match's GUID id or its slug. + /// The match entity if found; otherwise, null. + public async Task GetMatchByIdOrSlugAsync(string idOrSlug) + { + // Loads the full public-detail graph including both teams, venue, and scorers with their players so the match page can render the scoreboard and the per-team goleadores, since the generic include list cannot express the nested Scorers.Player path scorer-to-team attribution needs. + return await _matchRepository.GetDetailByIdOrSlugAsync(idOrSlug); + } + + public async Task GetMatchByIdWithScorersAsync(Guid matchId) + { + Match? match = await _matchRepository.GetByIdAsync(matchId, + includes: [m => m.HomeTeam!, + m => m.VisitorTeam!, + m => m.PlayerStatistics, + m => m.Venue!] + ); + + return match is null ? null : match; + } + + public async Task DeleteMatchAsync(Guid id) + { + await _matchRepository.RemoveAsync(match => match.Id == id); + } + + /// + /// True if another match is scheduled strictly within a 2-hour window of matchDate at the same venue. + /// + public async Task HasVenueScheduleConflictAsync(Guid venueId, DateTime matchDate, Guid excludeMatchId) + { + DateTime windowStart = matchDate.AddHours(-2); + DateTime windowEnd = matchDate.AddHours(2); + + return await _matchRepository.ExistsAsync(match => + match.Id != excludeMatchId + && match.VenueId == venueId + && match.MatchDate > windowStart + && match.MatchDate < windowEnd); + } + + public async Task UpdateMatchAsync(Match matchEntity) + { + await _matchRepository.UpdateAsync(matchEntity); + } + + /// + /// Loads a decisive final result for a match, rejecting a tied score. + /// + /// The id of the match to load. + /// The home team's final score. + /// The visitor team's final score. + /// The updated match, or null if no match with that id exists. + /// Thrown when the score is tied. + public async Task LoadMatchResultAsync(Guid matchId, int homeScore, int visitorScore) + { + Match? match = await _matchRepository.GetByIdAsync(matchId, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.Stage]); + + if (match is null) + { + return null; + } + + MatchResultFinalizer.ApplyResult(match, homeScore, visitorScore); + + await _matchRepository.UpdateAsync(match); + await stageService.TryAutoSeedPlayoffPhaseAsync(match.StageId); + return match; + } + + /// + /// Marks a match as a walkover, awarding the present team the win and the absent team zero. + /// + /// The id of the match. + /// The team that showed up, the winner by walkover. + /// Optional override for the present team's awarded score; defaults to the regulation value. + /// The updated match, or null if no match with that id exists. + /// Thrown when the present team is not part of the match. + public async Task LoadWalkOverAsync(Guid matchId, Guid presentTeamId, int? presentTeamScore) + { + Match? match = await _matchRepository.GetByIdAsync(matchId, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.Stage]); + + if (match is null) + { + return null; + } + + bool presentIsHome = match.HomeTeamId == presentTeamId; + bool presentIsVisitor = match.VisitorTeamId == presentTeamId; + + if (!presentIsHome && !presentIsVisitor) + { + throw new InvalidOperationException(ErrorMessages.Match.WalkOverTeamNotInMatch); + } + + int winnerScore = presentTeamScore ?? MatchDefaults.WalkOverWinnerScore; + + match.HomeScore = presentIsHome ? winnerScore : MatchDefaults.WalkOverLoserScore; + match.VisitorScore = presentIsHome ? MatchDefaults.WalkOverLoserScore : winnerScore; + + match.WinningTeam = presentIsHome ? match.HomeTeam : match.VisitorTeam; + match.WinningTeamId = presentTeamId; + + match.IsFinished = true; + match.Status = MatchStatus.WalkOver; + + await _matchRepository.UpdateAsync(match); + await stageService.TryAutoSeedPlayoffPhaseAsync(match.StageId); + return match; + } + + /// + /// Reprograms or suspends a match, moving it to a new date without touching its Round. + /// + /// The id of the match to suspend/reprogram. + /// Optional new calendar date; when null the existing date is kept. + /// The updated match, or null if no match with that id exists. + public async Task SuspendMatchAsync(Guid matchId, DateTime? newMatchDate) + { + Match? match = await _matchRepository.GetByIdAsync(matchId, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!]); + + if (match is null) + { + return null; + } + + match.Status = MatchStatus.Suspended; + + if (newMatchDate.HasValue) + { + match.MatchDate = newMatchDate.Value; + } + + await _matchRepository.UpdateAsync(match); + return match; + } + + public async Task> GetStageMatchesByRoundAsync(Guid stageId) + { + IEnumerable matches = await _matchRepository.FindAsync( + match => match.StageId == stageId, + includes: [match => match.HomeTeam!, match => match.VisitorTeam!, match => match.Venue!]); + + // Round is the canonical grouping: order by matchday, then by date within the round for a stable "Partido 1, Partido 2, …" order; matches without a round sort after the numbered ones. + return [.. matches + .OrderBy(match => match.Round ?? int.MaxValue) + .ThenBy(match => match.MatchDate)]; + } + + public async Task> GetAllMatchesAsync(GetMatchesFilteredRequest filter) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + + if (filter.DivisionId.HasValue) + { + Expression> divisionExpression = match => match.Stage.DivisionId == filter.DivisionId.Value; + expression = expression.And(divisionExpression); + + } + if (filter.TournamentId.HasValue) + { + Expression> tournamentExpression = match => match.Stage.Division.TournamentId == filter.TournamentId.Value; + expression = expression.And(tournamentExpression); + } + if (!string.IsNullOrWhiteSpace(filter.HomeTeamName)) + { + Expression> homeTeamExpression = match => match.HomeTeam != null && match.HomeTeam.Name.ToLower().Contains(filter.HomeTeamName.ToLower()); + expression = expression.And(homeTeamExpression); + } + if (!string.IsNullOrWhiteSpace(filter.VisitorTeamName)) + { + Expression> visitorTeamExpression = match => match.VisitorTeam != null && match.VisitorTeam.Name.ToLower().Contains(filter.VisitorTeamName.ToLower()); + expression = expression.And(visitorTeamExpression); + } + + IEnumerable filteredMatches = await _matchRepository.FindAsync(expression, filter: filter, includes: [match => match.HomeTeam!, + match => match.VisitorTeam!, + match => match.Venue!]); + + int totalCount = await _matchRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredMatches + }; + } + + /// + /// Builds a stage's fixture from its type, blocked once the stage already has matches. + /// + public async Task> CreateAutomatedMatchesAsync(Guid stageId) + { + Stage stage = await _stageRepository.GetByIdAsync(stageId, includes: [s => s.Matches, s => s.Division]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + + if (stage.Matches.Count > 0) + { + throw new InvalidOperationException(ErrorMessages.Match.StageAlreadyHasMatches); + } + + List matches = stage.StageType switch + { + StageType.Group => await BuildGroupStageMatchesAsync(stage), + StageType.QuarterFinal => await CreateKnockoutStageMatchesAsync(stage), + StageType.SemiFinal => await CreateKnockoutStageMatchesAsync(stage), + StageType.ThirdPlace => await CreateFinalStageMatchesAsync(stage), + StageType.Final => await CreateFinalStageMatchesAsync(stage), + _ => throw new NotSupportedException(ErrorMessages.Match.StageTypeNotSupportedForAutomatedCreation) + }; + + await AssignMatchSlugsAsync(matches); + + await _matchRepository.AddRangeAsync(matches); + return matches; + } + + /// + /// Resolves a team's name for slug composition without requiring the caller to have loaded Team. + /// + /// The team's id, or null when no team is assigned. + /// The team's name, or MatchSlugSourceBuilder.UnassignedTeamPlaceholder + /// when teamId is null or does not resolve to a team. + private async Task ResolveTeamNameAsync(Guid? teamId) + { + if (!teamId.HasValue) + { + return MatchSlugSourceBuilder.UnassignedTeamPlaceholder; + } + + Team? team = await _teamRepository.GetByIdAsync(teamId.Value); + return team?.Name ?? MatchSlugSourceBuilder.UnassignedTeamPlaceholder; + } + + /// + /// Assigns a unique slug to every match in a freshly built batch before it is persisted. + /// + private async Task AssignMatchSlugsAsync(List matches) + { + List teamIds = [.. matches + .SelectMany(match => new[] { match.HomeTeamId, match.VisitorTeamId }) + .Where(teamId => teamId.HasValue) + .Select(teamId => teamId!.Value) + .Distinct()]; + + Dictionary teamNamesById = teamIds.Count == 0 + ? [] + : (await _teamRepository.FindAsync(team => teamIds.Contains(team.Id))) + .ToDictionary(team => team.Id, team => team.Name); + + Dictionary baseSlugByMatch = matches.ToDictionary( + match => match, + match => SlugGenerator.GenerateSlug(MatchSlugSourceBuilder.Build( + ResolveTeamNameFromMap(match.HomeTeamId, teamNamesById), + ResolveTeamNameFromMap(match.VisitorTeamId, teamNamesById), + match.MatchDate))); + + HashSet baseSlugs = [.. baseSlugByMatch.Values.Distinct()]; + HashSet existingSlugs = [.. (await _matchRepository.FindAsync( + m => baseSlugs.Contains(m.Slug))) + .Select(m => m.Slug)]; + + HashSet slugsAssignedInBatch = []; + + foreach (Match match in matches) + { + string baseSlug = baseSlugByMatch[match]; + + // Fast, IO-free path: the prefetch already proved this exact base slug is free, and nothing earlier in this batch claimed it either. + if (!existingSlugs.Contains(baseSlug) && !slugsAssignedInBatch.Contains(baseSlug)) + { + match.Slug = baseSlug; + } + else + { + string homeTeamName = ResolveTeamNameFromMap(match.HomeTeamId, teamNamesById); + string visitorTeamName = ResolveTeamNameFromMap(match.VisitorTeamId, teamNamesById); + + match.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + MatchSlugSourceBuilder.Build(homeTeamName, visitorTeamName, match.MatchDate), + async candidate => slugsAssignedInBatch.Contains(candidate) + || existingSlugs.Contains(candidate) + || await _matchRepository.ExistsAsync(m => m.Slug == candidate)); + } + + slugsAssignedInBatch.Add(match.Slug); + } + } + + private static string ResolveTeamNameFromMap(Guid? teamId, IReadOnlyDictionary teamNamesById) + { + return teamId.HasValue && teamNamesById.TryGetValue(teamId.Value, out string? name) + ? name + : MatchSlugSourceBuilder.UnassignedTeamPlaceholder; + } + + /// + /// Builds an unpersisted match for a stage's automated fixture with a placeholder slug. + /// + private static Match BuildMatch(Stage stage, DateTime matchDate, MatchType matchType = MatchType.Playoff) + { + return new() + { + StageId = stage.Id, + Type = matchType, + Slug = string.Empty, + IsFinished = false, + MatchDate = matchDate, + CreatedBy = AuditConstants.SystemUser + }; + } + + private async Task ResolveGroupTeamCountAsync(Stage stage) + { + int totalGroups = await _stageRepository.CountAsync(s => + s.DivisionId == stage.DivisionId && s.StageType == StageType.Group); + + if (totalGroups <= 0) + { + throw new InvalidOperationException(ErrorMessages.Match.NoGroupStagesForDivision); + } + + int registeredTeams = await _teamRepository.CountAsync(team => team.TournamentId == stage.Division.TournamentId); + + if (registeredTeams <= 0) + { + throw new InvalidOperationException(ErrorMessages.Match.NoTeamsRegistered); + } + + if (registeredTeams % totalGroups != 0) + { + throw new InvalidOperationException( + ErrorMessages.Match.TeamsNotDistributableAcrossGroups(registeredTeams, totalGroups)); + } + + int teamsPerGroup = registeredTeams / totalGroups; + + return teamsPerGroup < 2 + ? throw new InvalidOperationException(ErrorMessages.Match.NotEnoughTeamsPerGroup) + : teamsPerGroup; + } + + /// + /// Resolves team identities to pair for a group stage with no explicit StageTeamMatch assignments yet. + /// + private async Task> ResolveGroupTeamIdsAsync(Stage stage, int expectedTeamCount) + { + int totalGroups = await _stageRepository.CountAsync(s => + s.DivisionId == stage.DivisionId && s.StageType == StageType.Group); + + if (totalGroups != 1) + { + return []; + } + + List registeredTeams = [.. await _teamRepository.FindAsync(team => team.TournamentId == stage.Division.TournamentId)]; + + return registeredTeams.Count == expectedTeamCount + ? [.. registeredTeams.Select(t => t.Id)] + : []; + } + + /// + /// A stage with explicit StageTeamMatch assignments always uses exactly those teams for pairing. + /// + private async Task> BuildGroupStageMatchesAsync(Stage stage) + { + List assignedTeamIds = [.. (await _stageTeamMatchRepository.FindAsync(stm => stm.StageId == stage.Id)) + .Select(stm => stm.TeamId)]; + + if (assignedTeamIds.Count >= 2) + { + return await CreateGroupStageMatchesAsync(stage, assignedTeamIds.Count, assignedTeamIds); + } + + int teamCount = await ResolveGroupTeamCountAsync(stage); + List teamIds = await ResolveGroupTeamIdsAsync(stage, teamCount); + return await CreateGroupStageMatchesAsync(stage, teamCount, teamIds); + } + + /// + /// Creates the group stage's matches from a round-robin fixture organised by matchday. + /// + private static Task> CreateGroupStageMatchesAsync(Stage stage, int totalTeams, List teamIds) + { + List matches = []; + + bool seeded = teamIds.Count == totalTeams; + + // When the roster is unknown we still need the round structure, meaning how many matches and each one's matchday, which depends only on the team count and legs, so schedule with throwaway placeholder ids and keep just the round numbers, leaving the teams unseeded. + IReadOnlyList rosterForSchedule = seeded + ? teamIds + : [.. Enumerable.Range(0, totalTeams).Select(_ => Guid.NewGuid())]; + + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = + RoundRobinScheduler.GenerateRounds(rosterForSchedule, stage.RoundRobinLegs); + + // Lay the jornadas out weekly, division-aware: regular zones keep the Sunday baseline, and a cross-division-cup stage shifts to a different weekday so a team playing both its zone and the cup never has two jornadas on the same day; this is coordinated here, not at the tournament level, so both the manual single-stage generate endpoint and the tournament-start fixture trigger get the same anti-collision schedule. + bool isCrossDivisionCup = stage.Division?.IsCrossDivisionCup ?? false; + + foreach ((Guid homeTeamId, Guid visitorTeamId, int round) in fixture) + { + Match match = BuildMatch(stage, RoundCalendar.DateForRound(stage.StartDate, round, isCrossDivisionCup), MatchType.Regular); + match.Round = round; + + if (seeded) + { + match.HomeTeamId = homeTeamId; + match.VisitorTeamId = visitorTeamId; + } + + matches.Add(match); + } + + return Task.FromResult(matches); + } + + private async Task> CreateKnockoutStageMatchesAsync(Stage stage) + { + List matches = []; + int matchCount = await ResolveKnockoutFirstRoundMatchCountAsync(stage); + + List matchDates = DistributeMatchDates(stage.StartDate, stage.EndDate, matchCount); + + for (int i = 0; i < matchCount; i++) + { + matches.Add(BuildMatch(stage, matchDates[i])); + } + + return matches; + } + + /// + /// Number of empty first-round matches to create for a bracket stage. + /// + private async Task ResolveKnockoutFirstRoundMatchCountAsync(Stage stage) + { + if (stage.Division.IsCrossDivisionCup) + { + List groupStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == stage.DivisionId && s.StageType == StageType.Group)]; + + if (groupStages.Count > 1) + { + int totalQualifiers = 0; + foreach (Stage groupStage in groupStages) + { + int groupSize = await _stageTeamMatchRepository.CountAsync(stm => stm.StageId == groupStage.Id); + totalQualifiers += Math.Min(stage.Division.QualifiersPerGroup, groupSize); + } + + return PlayoffSeeder.NextPowerOfTwo(totalQualifiers) / 2; + } + } + + return stage.StageType switch + { + StageType.QuarterFinal => KnockoutMatchCount.QuarterFinal, + StageType.SemiFinal => KnockoutMatchCount.SemiFinal, + _ => throw new InvalidOperationException(ErrorMessages.Match.InvalidKnockoutStageType) + }; + } + + private static Task> CreateFinalStageMatchesAsync(Stage stage) + { + List matches = []; + List matchDates = DistributeMatchDates(stage.StartDate, stage.EndDate, 1); + + matches.Add(BuildMatch(stage, matchDates[0])); + + return Task.FromResult(matches); + } + + private static List DistributeMatchDates(DateTime startDate, DateTime endDate, int matchCount) + { + if (matchCount <= 0) + { + throw new ArgumentException(ErrorMessages.Match.MatchCountMustBePositive, nameof(matchCount)); + } + + if (endDate < startDate) + { + throw new ArgumentException(ErrorMessages.Match.EndDateBeforeStartDate); + } + + List matchDates = []; + + if (matchCount == 1) + { + matchDates.Add(startDate.AddDays((endDate - startDate).TotalDays / 2)); + return matchDates; + } + + double totalDays = (endDate - startDate).TotalDays; + double interval = totalDays / (matchCount - 1); + + for (int i = 0; i < matchCount; i++) + { + DateTime matchDate = startDate.AddDays(interval * i); + matchDates.Add(matchDate); + } + + return matchDates; + } + + +} diff --git a/Club12-Backend/Application/Services/MedicalRecordService.cs b/Club12-Backend/Application/Services/MedicalRecordService.cs new file mode 100644 index 0000000..a447b42 --- /dev/null +++ b/Club12-Backend/Application/Services/MedicalRecordService.cs @@ -0,0 +1,101 @@ +using Application.DTOs.MedicalRecord.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Applies medical-record and eligibility operations to a player's season registration. +/// +public class MedicalRecordService(IUnitOfWork unitOfWork) : IMedicalRecordService +{ + private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository; + + /// + /// Records a new ficha médica upload and resets it to MedicalRecordStatus.Pending for review. + /// + public async Task RecordUploadAsync( + Guid playerId, Guid teamId, Guid tournamentId, string fileReference, string fileName, string actor) + { + PlayerTeamRegistration registration = await GetRegistrationAsync(playerId, teamId, tournamentId); + + // Once the ficha is Approved against a real stored file, the player is habilitado and the record is frozen so it can only be viewed or downloaded, never replaced by a new upload; an Approved row with a legacy or unresolvable reference from before the private-bucket relocation never actually habilitated the player and would otherwise have no path to fix it, so re-upload stays allowed for that shape. + if (registration.MedicalRecordStatus == MedicalRecordStatus.Approved + && PlayerTeamRegistration.IsStoredReference(registration.MedicalRecordFileUrl)) + { + throw new InvalidOperationException(ErrorMessages.MedicalRecord.AlreadyApproved); + } + + registration.MedicalRecordFileUrl = fileReference; + registration.MedicalRecordFileName = fileName; + // Uploading a new file always requires a fresh review — it never habilitates on its own. + registration.MedicalRecordStatus = MedicalRecordStatus.Pending; + registration.MedicalRecordReviewReason = null; + registration.MedicalRecordReviewedAt = null; + Touch(registration, actor); + + await _registrationRepository.UpdateAsync(registration); + + return MedicalRecordResponse.FromRegistration(registration); + } + + /// + /// Approves or rejects a ficha médica, blocking approval unless the ficha points at a stored file. + /// + public async Task ReviewAsync( + Guid playerId, Guid teamId, Guid tournamentId, bool approve, string? reason, string actor) + { + PlayerTeamRegistration registration = await GetRegistrationAsync(playerId, teamId, tournamentId); + + // A ficha can only be approved against a file that is actually stored; refs under the legacy medical-records prefix point into the old public bucket and no longer resolve, so they do not count as stored, though rejecting with no file stays legal since only the approve transition is guarded. + if (approve && !PlayerTeamRegistration.IsStoredReference(registration.MedicalRecordFileUrl)) + { + throw new InvalidOperationException(ErrorMessages.MedicalRecord.NoStoredFile); + } + + registration.MedicalRecordStatus = approve ? MedicalRecordStatus.Approved : MedicalRecordStatus.Rejected; + registration.MedicalRecordReviewReason = approve ? null : reason; + registration.MedicalRecordReviewedAt = DateTime.UtcNow; + Touch(registration, actor); + + await _registrationRepository.UpdateAsync(registration); + + return MedicalRecordResponse.FromRegistration(registration); + } + + public async Task GetAsync(Guid playerId, Guid teamId, Guid tournamentId) + { + PlayerTeamRegistration? registration = await FindRegistrationAsync(playerId, teamId, tournamentId); + return registration is null ? null : MedicalRecordResponse.FromRegistration(registration); + } + + private async Task GetRegistrationAsync(Guid playerId, Guid teamId, Guid tournamentId) + { + return await FindRegistrationAsync(playerId, teamId, tournamentId) + ?? throw new InvalidOperationException( + ErrorMessages.MedicalRecord.RegistrationNotFound(playerId, teamId, tournamentId)); + } + + private async Task FindRegistrationAsync(Guid playerId, Guid teamId, Guid tournamentId) + { + return (await _registrationRepository.FindAsync( + registration => registration.PlayerId == playerId + && registration.TeamId == teamId + && registration.TournamentId == tournamentId)) + .FirstOrDefault(); + } + + private static void Touch(PlayerTeamRegistration registration, string actor) + { + registration.DateUpdated = DateTime.UtcNow; + registration.UpdatedBy = string.IsNullOrWhiteSpace(actor) ? registration.UpdatedBy : actor; + } +} diff --git a/Club12-Backend/Application/Services/PlayerSanctionService.cs b/Club12-Backend/Application/Services/PlayerSanctionService.cs new file mode 100644 index 0000000..738d1d6 --- /dev/null +++ b/Club12-Backend/Application/Services/PlayerSanctionService.cs @@ -0,0 +1,298 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.PlayerSanction.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Extensions; +using Application.Utils.Helper.Slug; + +using Domain.Entities.Models; +using Domain.Enums; + +using LinqKit; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages sanctions issued to a player, team, or staff member, identified by SanctionSubjectType. +/// +public class PlayerSanctionService(IUnitOfWork unitOfWork) : IPlayerSanctionService +{ + private readonly IPlayerSanctionRepository _playerSanctionRepository = unitOfWork.PlayerSanctionRepository; + private readonly IPlayerRepository _playerRepository = unitOfWork.PlayerRepository; + private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository; + private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository; + + /// + /// Creates a sanction and assigns it a unique slug derived from its subject's resolved name and issue date. + /// + public async Task CreatePlayerSanctionAsync(PlayerSanction playerSanctionEntity) + { + string subjectName = await ResolveSubjectNameAsync(playerSanctionEntity); + + playerSanctionEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + $"{subjectName} {playerSanctionEntity.IssuedDate:yyyy-MM-dd}", + candidate => _playerSanctionRepository.ExistsAsync(sanction => sanction.Slug == candidate)); + + await _playerSanctionRepository.AddAsync(playerSanctionEntity); + return playerSanctionEntity; + } + + /// + /// Resolves the human-readable name of a sanction's subject, for a sensible slug. + /// + private async Task ResolveSubjectNameAsync(PlayerSanction sanction) + { + switch (sanction.SubjectType) + { + case SanctionSubjectType.Team: + if (sanction.TeamId.HasValue) + { + Team? team = await _teamRepository.GetByIdAsync(sanction.TeamId.Value); + return team?.Name ?? "equipo"; + } + return "equipo"; + + case SanctionSubjectType.Staff: + return string.IsNullOrWhiteSpace(sanction.StaffName) ? "staff" : sanction.StaffName; + + // SanctionSubjectType.Player and any future/default value. + default: + if (sanction.PlayerId.HasValue) + { + Player? player = await _playerRepository.GetByIdAsync(sanction.PlayerId.Value); + return player?.FullName ?? "jugador"; + } + return "jugador"; + } + } + + /// + /// Computes how many fechas of the sanction are still to be served. + /// + /// + /// The number of fechas remaining, or null when it cannot be computed by rounds: a staff + /// sanction, an unknown team, or a match with no round. + /// + public async Task GetFechasRemainingAsync(PlayerSanction sanction) + { + if (sanction.AppealStatus == SanctionAppealStatus.Accepted) + { + return 0; + } + + Guid? teamId = await ResolveSubjectTeamIdAsync(sanction); + if (!teamId.HasValue) + { + return null; + } + + Match? sanctionMatch = await _matchRepository.GetByIdAsync(sanction.MatchId); + if (sanctionMatch?.Round is not int sanctionRound) + { + return null; + } + + Guid teamValue = teamId.Value; + Guid stageId = sanctionMatch.StageId; + + int roundsServed = await _matchRepository.CountAsync(match => + match.StageId == stageId + && match.IsFinished + && match.Round != null + && match.Round > sanctionRound + && (match.HomeTeamId == teamValue || match.VisitorTeamId == teamValue)); + + return Math.Max(0, sanction.Duration - roundsServed); + } + + /// + /// Determines whether a player currently has any active sanction with fechas still to be served. + /// + public async Task HasActiveSanctionAsync(Guid playerId) + { + IEnumerable sanctions = await _playerSanctionRepository.FindAsync( + sanction => sanction.PlayerId == playerId + && sanction.SubjectType == SanctionSubjectType.Player, + includes: [sanction => sanction.Match]); + + foreach (PlayerSanction sanction in sanctions) + { + int? remaining = await GetFechasRemainingAsync(sanction); + + // A sanction whose fechas cannot be computed by rounds still counts as active while its duration is positive; the day-based sweep cleans it up later. + bool active = remaining.HasValue ? remaining.Value > 0 : sanction.Duration > 0; + if (active) + { + return true; + } + } + + return false; + } + + /// + /// Resolves the team whose rounds a sanction is served against, or none for a staff sanction. + /// + private async Task ResolveSubjectTeamIdAsync(PlayerSanction sanction) + { + switch (sanction.SubjectType) + { + case SanctionSubjectType.Team: + return sanction.TeamId; + + case SanctionSubjectType.Player: + if (sanction.Player is not null) + { + return sanction.Player.TeamId; + } + if (sanction.PlayerId.HasValue) + { + Player? player = await _playerRepository.GetByIdAsync(sanction.PlayerId.Value); + return player?.TeamId; + } + return null; + + case SanctionSubjectType.Staff: + default: + return null; + } + } + + /// + public async Task<(string? PlayerFullName, string? TeamName, string? StaffName)> ResolveSubjectAsync(PlayerSanction sanction) + { + switch (sanction.SubjectType) + { + case SanctionSubjectType.Team: + string? teamName = sanction.Team?.Name; + if (teamName is null && sanction.TeamId.HasValue) + { + Team? team = await _teamRepository.GetByIdAsync(sanction.TeamId.Value); + teamName = team?.Name; + } + return (null, teamName, null); + + case SanctionSubjectType.Staff: + return (null, null, sanction.StaffName); + + // SanctionSubjectType.Player and any future/default value. + default: + string? playerFullName = sanction.Player?.FullName; + if (playerFullName is null && sanction.PlayerId.HasValue) + { + Player? player = await _playerRepository.GetByIdAsync(sanction.PlayerId.Value); + playerFullName = player?.FullName; + } + return (playerFullName, null, null); + } + } + + public async Task GetPlayerSanctionByIdAsync(Guid playerSanctionId) + { + return await _playerSanctionRepository.GetByIdAsync(playerSanctionId); + } + + /// + /// Retrieves a player sanction by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The sanction's GUID id or its slug. + /// The matching player sanction, or null if not found. + public async Task GetPlayerSanctionByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid playerSanctionId)) + { + return await GetPlayerSanctionByIdAsync(playerSanctionId); + } + + IEnumerable sanctions = await _playerSanctionRepository.FindAsync( + sanction => sanction.Slug == idOrSlug); + + return sanctions.FirstOrDefault(); + } + + public async Task DeletePlayerSanctionAsync(Guid id) + { + await _playerSanctionRepository.RemoveAsync(playerSanction => playerSanction.Id == id); + } + + public async Task UpdatePlayerSanctionAsync(PlayerSanction playerSanctionEntity) + { + await _playerSanctionRepository.UpdateAsync(playerSanctionEntity); + } + + public async Task> GetExpiredSanctionsAsync(DateTime date) + { + return await _playerSanctionRepository.FindAsync( + playerSanction => playerSanction.IssuedDate.AddDays(playerSanction.Duration) <= date, + includes: [playerSanction => playerSanction.Player!]); + } + + /// + /// Retrieves a filtered, paginated list of sanctions with free-text and team matching. + /// + public async Task> GetPlayerSanctionsAsync(GetPlayerSanctionsFilteredRequest filter) + { + // Description is suppressed from the auto-generated filter so free-text search can also match the sanctioned player's name; accent-insensitivity is intentionally skipped since the required Postgres extension is not enabled and the SQLite test provider could not translate it. + Expression> expression = QueryableExtensions.ConstructFilterExpression( + filter, nameof(GetPlayerSanctionsFilteredRequest.Description)); + + if (!string.IsNullOrWhiteSpace(filter.Description)) + { + string searchTerm = filter.Description.ToLower(); + expression = expression.And(playerSanction => + playerSanction.Description.ToLower().Contains(searchTerm) + || (playerSanction.Player != null + && (playerSanction.Player.FirstName.ToLower().Contains(searchTerm) + || (playerSanction.Player.SecondName != null + && playerSanction.Player.SecondName.ToLower().Contains(searchTerm)) + || playerSanction.Player.LastName.ToLower().Contains(searchTerm)))); + } + + if (filter.TournamentId.HasValue) + { + expression = expression.And(playerSanction => playerSanction.Match.Stage != null + && playerSanction.Match.Stage.Division != null + && playerSanction.Match.Stage.Division.TournamentId == filter.TournamentId.Value); + } + + if (filter.DivisionId.HasValue) + { + expression = expression.And(playerSanction => playerSanction.Match.Stage != null + && playerSanction.Match.Stage.DivisionId == filter.DivisionId.Value); + } + + if (filter.StageId.HasValue) + { + expression = expression.And(playerSanction => playerSanction.Match.Stage != null + && playerSanction.Match.Stage.Id == filter.StageId.Value); + } + + if (filter.TeamId.HasValue) + { + // Matches both a player sanction whose player belongs to the team and a team-subject sanction targeting the team directly. + expression = expression.And(playerSanction => + (playerSanction.Player != null && playerSanction.Player.TeamId == filter.TeamId.Value) + || playerSanction.TeamId == filter.TeamId.Value); + } + + IEnumerable filteredSanctions = await _playerSanctionRepository.FindAsync(expression, + filter: filter, + includes: [playerSanction => playerSanction.Player!, playerSanction => playerSanction.Match]); + + int totalCount = await _playerSanctionRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredSanctions + }; + } +} diff --git a/Club12-Backend/Application/Services/PlayerService.cs b/Club12-Backend/Application/Services/PlayerService.cs new file mode 100644 index 0000000..7cafe6a --- /dev/null +++ b/Club12-Backend/Application/Services/PlayerService.cs @@ -0,0 +1,267 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Player.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Extensions; +using Application.Utils.Helper.Slug; +using Application.Utils.Options; + +using Domain.Constants; +using Domain.Entities.Models; + +using Microsoft.Extensions.Options; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages players and keeps Player.TeamId in sync with their season-scoped PlayerTeamRegistration. +/// +public class PlayerService( + IUnitOfWork unitOfWork, + IScorerRepository scorerRepository, + IOptions rosterOptions) : IPlayerService +{ + private readonly IPlayerRepository _playerRepository = unitOfWork.PlayerRepository; + private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository; + private readonly IPlayerStatisticRepository _statisticRepository = unitOfWork.PlayerStatisticRepository; + private readonly IPlayerSanctionRepository _sanctionRepository = unitOfWork.PlayerSanctionRepository; + private readonly IScorerRepository _scorerRepository = scorerRepository; + private readonly int _maxPlayersPerTeam = rosterOptions.Value.MaxPlayersPerTeam; + + public async Task CreatePlayerAsync(Player playerEntity, Guid tournamentId) + { + await EnsureDocumentNumberIsUniqueAsync(playerEntity.DocumentNumber, excludingPlayerId: null); + + playerEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + playerEntity.SlugSource, + candidate => _playerRepository.ExistsAsync(player => player.Slug == candidate)); + + await _playerRepository.AddAsync(playerEntity); + await EnsureRegistrationAsync(playerEntity, tournamentId); + + return playerEntity; + } + + /// + /// Guards the DB's IX_Players_DocumentNumber unique index with a friendly 409 instead of a raw 500. + /// + private async Task EnsureDocumentNumberIsUniqueAsync(string documentNumber, Guid? excludingPlayerId) + { + bool taken = await _playerRepository.ExistsAsync(player => + player.DocumentNumber == documentNumber + && (excludingPlayerId == null || player.Id != excludingPlayerId)); + + if (taken) + { + throw new InvalidOperationException( + ErrorMessages.Player.DuplicateDocumentNumber(documentNumber)); + } + } + + public async Task GetPlayerByIdAsync(Guid playerId) + { + return await _playerRepository.GetByIdAsync(playerId); + } + + /// + /// Retrieves a player by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The player's GUID id or its slug. + /// The matching player, or null if not found. + public async Task GetPlayerByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid playerId)) + { + return await GetPlayerByIdAsync(playerId); + } + + IEnumerable matches = await _playerRepository.FindAsync(player => player.Slug == idOrSlug); + return matches.FirstOrDefault(); + } + + public async Task DeletePlayerAsync(Guid id) + { + // A player who already has match statistics, scorer records, or sanctions is part of the tournament history, so deleting them would orphan those records and is blocked; otherwise their season registrations are cleaned up and the player is removed. + bool hasStatistics = await _statisticRepository.ExistsAsync(statistic => statistic.PlayerId == id); + bool hasScorers = await _scorerRepository.ExistsAsync(scorer => scorer.PlayerId == id); + bool hasSanctions = await _sanctionRepository.ExistsAsync(sanction => sanction.PlayerId == id); + + if (hasStatistics || hasScorers || hasSanctions) + { + throw new InvalidOperationException(ErrorMessages.Player.HasHistoryCannotDelete); + } + + await _registrationRepository.RemoveAsync(registration => registration.PlayerId == id); + await _playerRepository.RemoveAsync(player => player.Id == id); + } + + /// + public async Task RegisterPlayerToTeamAsync( + Guid playerId, Guid teamId, Guid tournamentId, int? jerseyNumber = null) + { + IEnumerable existing = await _registrationRepository.FindAsync( + registration => registration.PlayerId == playerId && registration.TournamentId == tournamentId); + PlayerTeamRegistration? registration = existing.FirstOrDefault(); + + // A player cannot be registered to two teams in the same tournament. + if (registration is not null && registration.TeamId != teamId) + { + throw new InvalidOperationException( + ErrorMessages.Roster.PlayerAlreadyInAnotherTeam(playerId, tournamentId)); + } + + // Dorsal must be unique within the same team and tournament, ignoring this player's own current registration. + if (jerseyNumber is not null) + { + bool dorsalTaken = await _registrationRepository.ExistsAsync(candidate => + candidate.TeamId == teamId + && candidate.TournamentId == tournamentId + && candidate.PlayerId != playerId + && candidate.JerseyNumber == jerseyNumber); + + if (dorsalTaken) + { + throw new InvalidOperationException( + ErrorMessages.Roster.DuplicateJerseyNumber(jerseyNumber.Value, teamId, tournamentId)); + } + } + + if (registration is null) + { + // Enforces the configurable roster-size cap only when adding a brand-new member, since re-registering an existing member does not grow the roster. + int currentRosterSize = await _registrationRepository.CountAsync( + candidate => candidate.TeamId == teamId && candidate.TournamentId == tournamentId); + + if (currentRosterSize >= _maxPlayersPerTeam) + { + throw new InvalidOperationException( + ErrorMessages.Roster.RosterFull(teamId, _maxPlayersPerTeam)); + } + + PlayerTeamRegistration created = new() + { + Id = Guid.Empty, + PlayerId = playerId, + TeamId = teamId, + TournamentId = tournamentId, + JerseyNumber = jerseyNumber, + DateCreated = DateTime.UtcNow, + CreatedBy = AuditConstants.SystemUser, + }; + + await _registrationRepository.AddAsync(created); + return created; + } + + // Same player, same team: keeps the dorsal in sync as an idempotent add or edit. + registration.JerseyNumber = jerseyNumber; + registration.DateUpdated = DateTime.UtcNow; + await _registrationRepository.UpdateAsync(registration); + return registration; + } + + public async Task UpdatePlayerAsync(Player playerEntity, Guid tournamentId) + { + // Validates the roster move before persisting the Player row since this repository commits immediately with no shared transaction, so persisting first and then failing the move would leave Player.TeamId pointing at a team the player was never validly registered to for this season. + await EnsureDocumentNumberIsUniqueAsync(playerEntity.DocumentNumber, excludingPlayerId: playerEntity.Id); + await ValidateRegistrationMoveAsync(playerEntity, tournamentId); + await _playerRepository.UpdateAsync(playerEntity); + await EnsureRegistrationAsync(playerEntity, tournamentId); + } + + /// + /// Mirrors RegisterPlayerToTeamAsync's roster-size cap for a mid-season team change. + /// + private async Task ValidateRegistrationMoveAsync(Player playerEntity, Guid tournamentId) + { + PlayerTeamRegistration? registration = (await _registrationRepository.FindAsync( + r => r.PlayerId == playerEntity.Id && r.TournamentId == tournamentId)).FirstOrDefault(); + + if (registration is null || registration.TeamId == playerEntity.TeamId) + { + return; + } + + bool hasStatistics = await _statisticRepository.ExistsAsync(statistic => statistic.PlayerId == playerEntity.Id); + bool hasScorers = await _scorerRepository.ExistsAsync(scorer => scorer.PlayerId == playerEntity.Id); + bool hasSanctions = await _sanctionRepository.ExistsAsync(sanction => sanction.PlayerId == playerEntity.Id); + + if (hasStatistics || hasScorers || hasSanctions) + { + throw new InvalidOperationException(ErrorMessages.Player.CannotMoveTeamWithHistory); + } + + int destinationRosterSize = await _registrationRepository.CountAsync( + candidate => candidate.TeamId == playerEntity.TeamId + && candidate.TournamentId == tournamentId + && candidate.PlayerId != playerEntity.Id); + + if (destinationRosterSize >= _maxPlayersPerTeam) + { + throw new InvalidOperationException( + ErrorMessages.Roster.RosterFull(playerEntity.TeamId, _maxPlayersPerTeam)); + } + } + + public async Task> GetAllPlayersAsync(PlayerFilterRequestBase filter) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + IEnumerable filteredPlayers = await _playerRepository.FindAsync(expression, filter: filter); + + int totalCount = await _playerRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredPlayers + }; + } + + /// + /// Ensures the player has exactly one PlayerTeamRegistration for tournamentId pointing at their current TeamId. + /// + private async Task EnsureRegistrationAsync(Player playerEntity, Guid tournamentId) + { + IEnumerable existing = await _registrationRepository.FindAsync( + registration => registration.PlayerId == playerEntity.Id && registration.TournamentId == tournamentId); + + PlayerTeamRegistration? registration = existing.FirstOrDefault(); + + if (registration is null) + { + await _registrationRepository.AddAsync(new PlayerTeamRegistration + { + Id = Guid.Empty, + PlayerId = playerEntity.Id, + TeamId = playerEntity.TeamId, + TournamentId = tournamentId, + DateCreated = DateTime.UtcNow, + CreatedBy = playerEntity.UpdatedBy ?? playerEntity.CreatedBy ?? AuditConstants.SystemUser, + }); + + return; + } + + if (registration.TeamId != playerEntity.TeamId) + { + // Capacity was already validated by ValidateRegistrationMoveAsync before the Player row was persisted. + registration.TeamId = playerEntity.TeamId; + + // The dorsal is unique per team, tournament, and jersey number, so carrying it over could collide with another player on the new team; it is reset instead, and the admin re-assigns one explicitly after the move. + registration.JerseyNumber = null; + + registration.DateUpdated = DateTime.UtcNow; + registration.UpdatedBy = playerEntity.UpdatedBy ?? playerEntity.CreatedBy; + await _registrationRepository.UpdateAsync(registration); + } + } +} diff --git a/Club12-Backend/Application/Services/PlayerStatisticService.cs b/Club12-Backend/Application/Services/PlayerStatisticService.cs new file mode 100644 index 0000000..6d88905 --- /dev/null +++ b/Club12-Backend/Application/Services/PlayerStatisticService.cs @@ -0,0 +1,324 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.PlayerStatistic.Request; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Extensions; +using Application.Utils.Helper.MatchResult; +using Application.Utils.Helper.Tournament; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using LinqKit; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Records player statistics, most notably match scoring sheets that derive a team's final score. +/// +public class PlayerStatisticService(IUnitOfWork unitOfWork, IStageService stageService) : IPlayerStatisticService +{ + private readonly IPlayerStatisticRepository _playerStatisticRepository = unitOfWork.PlayerStatisticRepository; + private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository; + private readonly IPlayerRepository _playerRepository = unitOfWork.PlayerRepository; + private readonly IPlayerTeamRegistrationRepository _playerTeamRegistrationRepository = unitOfWork.PlayerTeamRegistrationRepository; + + public async Task CreatePlayerStatisticAsync(PlayerStatistic playerStatisticEntity) + { + await _playerStatisticRepository.AddAsync(playerStatisticEntity); + return playerStatisticEntity; + } + + public async Task GetPlayerStatisticByIdAsync(Guid playerStatisticId) + { + return await _playerStatisticRepository.GetByIdAsync(playerStatisticId); + } + + public async Task DeletePlayerStatisticAsync(Guid id) + { + await _playerStatisticRepository.RemoveAsync(playerStatistic => playerStatistic.Id == id); + } + + public async Task UpdatePlayerStatisticAsync(PlayerStatistic playerStatisticEntity) + { + await _playerStatisticRepository.UpdateAsync(playerStatisticEntity); + } + + public async Task> GetPlayerStatisticsAsync(GetPlayerStatisticsFilteredRequest filter) + { + Expression> expression = + QueryableExtensions.ConstructFilterExpression(filter); + + if (filter.TeamId.HasValue) + { + Expression> teamExpression = + playerStatistic => playerStatistic.Player != null && playerStatistic.Player.TeamId == filter.TeamId.Value; + expression = expression.And(teamExpression); + } + + IEnumerable filteredStatistics = await _playerStatisticRepository.FindAsync( + expression, + includes: [playerStatistic => playerStatistic.Match!, playerStatistic => playerStatistic.Player!], + filter: filter); + + int totalCount = await _playerStatisticRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredStatistics + }; + } + + /// + /// Loads a whole team's scoring sheet for a match in one coherent operation. + /// + /// The match, team, and per-player points. + /// The persisted Points statistics for the team in this match. + /// Thrown when any coherence or eligibility check fails. + public async Task> LoadTeamMatchSheetAsync(LoadMatchSheetRequest request) + { + Match? match = await _matchRepository.GetByIdAsync(request.MatchId, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!]); + + if (match is null) + { + throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchNotFinished(request.MatchId)); + } + + bool teamIsHome = match.HomeTeamId == request.TeamId; + bool teamIsVisitor = match.VisitorTeamId == request.TeamId; + + if (!teamIsHome && !teamIsVisitor) + { + throw new InvalidOperationException(ErrorMessages.MatchSheet.TeamNotInMatch(request.TeamId)); + } + + int? teamScore = teamIsHome ? match.HomeScore : match.VisitorScore; + if (!match.IsFinished || teamScore is null) + { + throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchNotFinished(request.MatchId)); + } + + int playersSum = request.Scores.Sum(entry => entry.Points); + if (playersSum != teamScore.Value) + { + throw new InvalidOperationException(ErrorMessages.MatchSheet.ScoreMismatch(teamScore.Value, playersSum)); + } + + Team teamEntity = (teamIsHome ? match.HomeTeam : match.VisitorTeam) + ?? throw new InvalidOperationException(ErrorMessages.MatchSheet.TeamNotInMatch(request.TeamId)); + + await EnsureTeamMeetsHabilitadoMinimumAsync(teamEntity); + + List issues = await FindRosterEligibilityIssuesAsync(request.Scores, request.TeamId, teamEntity); + if (issues.Count > 0) + { + throw new InvalidOperationException( + ErrorMessages.MatchSheet.PlayersNotEligible([(teamEntity.Name, issues)])); + } + + await ReplaceTeamPointsForMatchAsync(request.MatchId, request.TeamId); + + List created = [.. request.Scores.Select(entry => new PlayerStatistic + { + MatchId = request.MatchId, + PlayerId = entry.PlayerId, + Value = entry.Points, + Type = StatisticType.Points, + CreatedBy = teamEntity.UpdatedBy ?? teamEntity.CreatedBy ?? AuditConstants.SystemUser, + })]; + + if (created.Count > 0) + { + await _playerStatisticRepository.AddRangeAsync(created); + } + + return created; + } + + /// + /// Finishes a match by loading both teams' scoring sheets in one coherent operation. + /// + /// The match and both teams' per-player points. + /// The finalized match with score, winner, and IsFinished all set, or null if no match with that id exists. + /// + /// Thrown when a player is ineligible/off the roster or the resulting + /// score is tied. + /// + public async Task LoadMatchResultFromSheetsAsync(LoadMatchResultFromSheetsRequest request) + { + Match? match = await _matchRepository.GetByIdAsync(request.MatchId, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.Stage]); + + if (match is null) + { + return null; + } + + Team homeTeam = match.HomeTeam + ?? throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchMissingTeams(request.MatchId)); + Team visitorTeam = match.VisitorTeam + ?? throw new InvalidOperationException(ErrorMessages.MatchSheet.MatchMissingTeams(request.MatchId)); + + await EnsureTeamMeetsHabilitadoMinimumAsync(homeTeam); + await EnsureTeamMeetsHabilitadoMinimumAsync(visitorTeam); + + List homeIssues = await FindRosterEligibilityIssuesAsync(request.HomeScores, homeTeam.Id, homeTeam); + List visitorIssues = await FindRosterEligibilityIssuesAsync(request.VisitorScores, visitorTeam.Id, visitorTeam); + if (homeIssues.Count > 0 || visitorIssues.Count > 0) + { + List<(string TeamName, List Issues)> issuesByTeam = []; + if (homeIssues.Count > 0) issuesByTeam.Add((homeTeam.Name, homeIssues)); + if (visitorIssues.Count > 0) issuesByTeam.Add((visitorTeam.Name, visitorIssues)); + + throw new InvalidOperationException(ErrorMessages.MatchSheet.PlayersNotEligible(issuesByTeam)); + } + + int homeScore = request.HomeScores.Sum(entry => entry.Points); + int visitorScore = request.VisitorScores.Sum(entry => entry.Points); + + // Validated above and the tie-check below both throw before this line touches the database, so nothing is written on a rejected sheet. + MatchResultFinalizer.ApplyResult(match, homeScore, visitorScore); + match.WentToOvertime = request.WentToOvertime; + await _matchRepository.UpdateAsync(match); + await stageService.TryAutoSeedPlayoffPhaseAsync(match.StageId); + + await ReplaceTeamPointsForMatchAsync(match.Id, homeTeam.Id); + await ReplaceTeamPointsForMatchAsync(match.Id, visitorTeam.Id); + + List created = [ + .. BuildPointsStatistics(match.Id, request.HomeScores, homeTeam), + .. BuildPointsStatistics(match.Id, request.VisitorScores, visitorTeam), + ]; + + if (created.Count > 0) + { + await _playerStatisticRepository.AddRangeAsync(created); + } + + return match; + } + + private static List BuildPointsStatistics(Guid matchId, List scores, Team teamEntity) => + [.. scores.Select(entry => new PlayerStatistic + { + MatchId = matchId, + PlayerId = entry.PlayerId, + Value = entry.Points, + Type = StatisticType.Points, + CreatedBy = teamEntity.UpdatedBy ?? teamEntity.CreatedBy ?? AuditConstants.SystemUser, + })]; + + /// + /// Enforces the walkover threshold: a team below MinPlayersPerTeam habilitado players must not record a normal result. + /// + private async Task EnsureTeamMeetsHabilitadoMinimumAsync(Team teamEntity) + { + if (teamEntity.TournamentId is null) + { + return; + } + + Guid tournamentId = teamEntity.TournamentId.Value; + + int habilitadoCount = (await _playerTeamRegistrationRepository.FindAsync( + registration => registration.TeamId == teamEntity.Id && registration.TournamentId == tournamentId)) + .Count(registration => registration.IsHabilitado); + + if (habilitadoCount < TournamentCompletabilityValidator.MinPlayersPerTeam) + { + throw new InvalidOperationException( + ErrorMessages.MatchSheet.TeamRequiresWalkOver(teamEntity.Name, habilitadoCount)); + } + } + + /// + /// Checks every listed player is registered and eligible, returning one reason per violation. + /// + private async Task> FindRosterEligibilityIssuesAsync( + List scores, Guid teamId, Team teamEntity) + { + List issues = []; + + List playerIds = [.. scores.Select(entry => entry.PlayerId).Distinct()]; + if (playerIds.Count == 0) + { + return issues; + } + + Dictionary playersById = (await _playerRepository.FindAsync(player => playerIds.Contains(player.Id))) + .ToDictionary(player => player.Id); + + if (teamEntity.TournamentId is null) + { + foreach (Guid playerId in playerIds) + { + issues.Add(ErrorMessages.MatchSheet.PlayerNotOnRosterReason(PlayerLabel(playersById, playerId))); + } + + return issues; + } + + Guid tournamentId = teamEntity.TournamentId.Value; + + Dictionary registrationsByPlayer = (await _playerTeamRegistrationRepository.FindAsync( + registration => registration.TeamId == teamId + && registration.TournamentId == tournamentId + && playerIds.Contains(registration.PlayerId))) + .ToDictionary(registration => registration.PlayerId); + + foreach (Guid playerId in playerIds) + { + string label = PlayerLabel(playersById, playerId); + + if (!registrationsByPlayer.TryGetValue(playerId, out PlayerTeamRegistration? registration)) + { + issues.Add(ErrorMessages.MatchSheet.PlayerNotOnRosterReason(label)); + continue; + } + + // Eligible only when not sanctioned and habilitado for this team and tournament: the medical record must be Approved and carry a real stored file, and a Pending or Rejected registration, including a brand-new season's which never inherits a prior approval, is not habilitado either. + bool sanctioned = !playersById.TryGetValue(playerId, out Player? player) || player.IsSanctioned; + if (sanctioned || !registration.IsHabilitado) + { + issues.Add(ErrorMessages.MatchSheet.PlayerNotEligibleReason(label)); + } + } + + return issues; + } + + /// + /// A human-readable label for a player in an error message, falling back to the id if no player is found. + /// + private static string PlayerLabel(Dictionary playersById, Guid playerId) => + playersById.TryGetValue(playerId, out Player? player) ? player.FullName : playerId.ToString(); + + /// + /// Removes the team's existing Points statistics for a match so a corrected sheet fully replaces the previous one. + /// + private async Task ReplaceTeamPointsForMatchAsync(Guid matchId, Guid teamId) + { + List existingIds = [.. (await _playerStatisticRepository.FindAsync( + statistic => statistic.MatchId == matchId && statistic.Type == StatisticType.Points, + includes: [statistic => statistic.Player!])) + .Where(statistic => statistic.Player != null && statistic.Player.TeamId == teamId) + .Select(statistic => statistic.Id)]; + + if (existingIds.Count > 0) + { + await _playerStatisticRepository.RemoveAsync(statistic => existingIds.Contains(statistic.Id)); + } + } +} diff --git a/Club12-Backend/Application/Services/RosterCopyService.cs b/Club12-Backend/Application/Services/RosterCopyService.cs new file mode 100644 index 0000000..bca6505 --- /dev/null +++ b/Club12-Backend/Application/Services/RosterCopyService.cs @@ -0,0 +1,64 @@ +using Application.DTOs.Roster.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; + +using Domain.Constants; +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Copies a roster from a previous season into a new season. +/// +public class RosterCopyService(IUnitOfWork unitOfWork) : IRosterCopyService +{ + private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository; + + /// + public async Task CopyRosterAsync( + Guid sourceTeamId, Guid sourceTournamentId, Guid targetTeamId, Guid targetTournamentId) + { + List sourceRegistrations = [.. await _registrationRepository.FindAsync( + registration => registration.TeamId == sourceTeamId + && registration.TournamentId == sourceTournamentId)]; + + if (sourceRegistrations.Count == 0) + { + return new RosterCopyResult { CopiedCount = 0, SkippedCount = 0 }; + } + + // A player may hold at most one registration per tournament, enforced by a unique PlayerId and TournamentId index, so any source player already registered to the target season is skipped, which keeps the copy idempotent and prevents a two-teams-in-one-season violation. + HashSet alreadyInTargetSeason = [.. (await _registrationRepository.FindAsync( + registration => registration.TournamentId == targetTournamentId)) + .Select(registration => registration.PlayerId)]; + + List newRegistrations = [.. sourceRegistrations + .Where(source => !alreadyInTargetSeason.Contains(source.PlayerId)) + .Select(source => new PlayerTeamRegistration + { + Id = Guid.Empty, + PlayerId = source.PlayerId, + TeamId = targetTeamId, + TournamentId = targetTournamentId, + // JerseyNumber, MedicalRecordStatus, and all medical fields are intentionally not copied, so each season starts with a fresh, un-habilitado registration. + DateCreated = DateTime.UtcNow, + CreatedBy = AuditConstants.SystemUser, + })]; + + if (newRegistrations.Count > 0) + { + await _registrationRepository.AddRangeAsync(newRegistrations); + } + + return new RosterCopyResult + { + CopiedCount = newRegistrations.Count, + SkippedCount = sourceRegistrations.Count - newRegistrations.Count, + }; + } +} diff --git a/Club12-Backend/Application/Services/ScorerService.cs b/Club12-Backend/Application/Services/ScorerService.cs new file mode 100644 index 0000000..298b4f3 --- /dev/null +++ b/Club12-Backend/Application/Services/ScorerService.cs @@ -0,0 +1,36 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; +using Application.DTOs.Scorer.Request; +using Application.DTOs.Scorer.Response; +using Application.Interfaces.Mappers; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Application.Services; + +public class ScorerService(IScorerRepository scorerRepository, IMatchService matchService, IScorerMapper mapper) : IScorerService +{ + public async Task> GetAllScorersByPlayerAsync(GetScorerFilteredRequest filter) + { + (IEnumerable items, int totalCount) = await scorerRepository.GetPlayerScoresAsync(filter); + + return new() + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = items + }; + } + + public async Task> GetAllScorersByTeamAsync(GetMatchesFilteredRequest filter) + { + PaginatedResponse matches = await matchService.GetAllMatchesAsync(filter); + return mapper.FromPaginatedMatchToPaginatedScorerByTeamResponse(matches); + } +} diff --git a/Club12-Backend/Application/Services/SeasonService.cs b/Club12-Backend/Application/Services/SeasonService.cs new file mode 100644 index 0000000..c8c37a1 --- /dev/null +++ b/Club12-Backend/Application/Services/SeasonService.cs @@ -0,0 +1,89 @@ +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Helper.Slug; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages seasons, the grouping above tournaments, deleting each one through its own tournaments. +/// +public class SeasonService(ISeasonRepository seasonRepository, ITournamentService tournamentService) : ISeasonService +{ + public async Task CreateSeasonAsync(Season seasonEntity) + { + seasonEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + seasonEntity.Name, + candidate => seasonRepository.ExistsAsync(season => season.Slug == candidate)); + + await seasonRepository.AddAsync(seasonEntity); + return seasonEntity; + } + + public async Task GetSeasonByIdAsync(Guid seasonId) + { + return await seasonRepository.GetByIdAsync(seasonId, includes: [season => season.Tournaments]); + } + + /// + /// Retrieves a season by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The season's GUID id or its slug. + /// The matching season, or null if not found. + public async Task GetSeasonByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid seasonId)) + { + return await GetSeasonByIdAsync(seasonId); + } + + IEnumerable matches = await seasonRepository.FindAsync( + season => season.Slug == idOrSlug, + includes: [season => season.Tournaments]); + + return matches.FirstOrDefault(); + } + + /// + /// Deletes a season and every tournament it groups, one by one through the tournament service. + /// + public async Task DeleteSeasonAsync(Guid id) + { + Season? season = await seasonRepository.GetByIdAsync(id, includes: [s => s.Tournaments]); + if (season is null) + { + return; + } + + foreach (Tournament tournament in season.Tournaments) + { + await tournamentService.DeleteTournamentAsync(tournament.Id); + } + + await seasonRepository.RemoveAsync(s => s.Id == id); + } + + public async Task UpdateSeasonAsync(Season seasonEntity) + { + await seasonRepository.UpdateAsync(seasonEntity); + } + + public async Task> GetAllSeasonsAsync() + { + IEnumerable seasons = await seasonRepository.FindAsync( + season => true, + includes: [season => season.Tournaments]); + + // Ordered in memory, newest season first with null years last and Name as a deterministic tiebreak, since the list is small and unpaginated and passing a SortBy through FindAsync would also truncate it to the default page size. + return seasons + .OrderByDescending(season => season.Year.HasValue) + .ThenByDescending(season => season.Year) + .ThenBy(season => season.Name, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/Club12-Backend/Application/Services/StageService.cs b/Club12-Backend/Application/Services/StageService.cs new file mode 100644 index 0000000..e08bd03 --- /dev/null +++ b/Club12-Backend/Application/Services/StageService.cs @@ -0,0 +1,1501 @@ +using Application.DTOs.Abstract.Request; +using Application.DTOs.Abstract.Response; +using Application.DTOs.Stage.Request; +using Application.DTOs.Stage.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Constants.Configuration; +using Application.Utils.Constants.Stage; +using Application.Utils.Extensions; +using Application.Utils.Helper.Playoff; +using Application.Utils.Helper.Slug; +using Application.Utils.Helper.StageHelper; +using Application.Utils.Helper.Standings; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using LinqKit; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using SubGroupDistributionHelper = Application.Utils.Helper.SubGroupDistribution.SubGroupDistribution; + +namespace Application.Services; + +/// +/// Manages a division's stages and their elimination bracket. +/// +public class StageService( + IUnitOfWork unitOfWork, + ILogger logger, + IConfiguration configuration, + IAuditService auditService) : IStageService +{ + private readonly IStageRepository _stageRepository = unitOfWork.StageRepository; + private readonly IDivisionRepository _divisionRepository = unitOfWork.DivisionRepository; + private readonly IStageTeamMatchRepository _stageTeamMatchRepository = unitOfWork.StageTeamMatchRepository; + private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository; + private readonly IDivisionTeamRegistrationRepository _divisionTeamRegistrationRepository = unitOfWork.DivisionTeamRegistrationRepository; + private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository; + private readonly IMatchSeriesRepository _matchSeriesRepository = unitOfWork.MatchSeriesRepository; + + /// + /// Signing secret for the draw-preview token, reusing the app's existing JWT secret rather than a new configuration key. + /// + private readonly string _drawTokenSecret = configuration.GetSection(ConfigurationKeys.Jwt.Key).Value + ?? throw new ArgumentNullException(nameof(configuration), ErrorMessages.Configuration.JwtMissing); + + /// + /// The elimination rounds a wizard-built cup can be made of, in bracket order. + /// + private static readonly StageType[] EliminationProgression = + [ + StageType.RoundOf16, + StageType.QuarterFinal, + StageType.SemiFinal, + StageType.Final, + ]; + + /// + /// The stage type immediately after current in EliminationProgression, or null when there is none. + /// + private static StageType? NextStageType(StageType current) + { + int index = Array.IndexOf(EliminationProgression, current); + if (index < 0 || index == EliminationProgression.Length - 1) + { + return null; + } + + return EliminationProgression[index + 1]; + } + + /// + /// This stage type's position in EliminationProgression, lower meaning earlier in the bracket. + /// + private static int EliminationDepth(StageType stageType) + { + int index = Array.IndexOf(EliminationProgression, stageType); + return index < 0 ? int.MaxValue : index; + } + + /// + /// Retrieves a stage by its unique identifier. + /// + /// The unique identifier of the stage. + /// The stage entity if found; otherwise, null. + public async Task GetStageByIdAsync(Guid stageId) + { + return await _stageRepository.GetByIdAsync(stageId, includes: [stage => stage.Division]); + } + + /// + /// Retrieves a stage by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The stage's GUID id or its slug. + /// The matching stage, or null if not found. + public async Task GetStageByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid stageId)) + { + return await GetStageByIdAsync(stageId); + } + + IEnumerable matches = await _stageRepository.FindAsync( + stage => stage.Slug == idOrSlug, + includes: [stage => stage.Division]); + + return matches.FirstOrDefault(); + } + + /// + /// Retrieves a paginated list of stages based on the provided filter criteria. + /// + /// Filtering and pagination options. + /// A paginated response containing the filtered stages. + public async Task> GetAllStagesAsync(GetStagesFilteredRequest filter) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + + if (filter.TournamentId.HasValue) + { + Expression> tournamentExpression = stage => stage.Division.TournamentId == filter.TournamentId.Value; + expression = expression.And(tournamentExpression); + } + + IEnumerable filteredPlayers = await _stageRepository.FindAsync(expression, filter: filter); + + int totalCount = await _stageRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredPlayers + }; + } + + /// + /// Deletes a stage by its unique identifier. + /// + /// The unique identifier of the stage to delete. + /// + /// Thrown when the stage's tournament has already started and its fixture is generated — + /// removing a phase then would corrupt the bracket or fixture — or when deleting this stage + /// would strand a 2+-group division without any elimination stage left to crown a champion. + /// + public async Task DeleteStageAsync(Guid id) + { + Stage? stage = await _stageRepository.GetByIdAsync(id); + + if (stage is not null) + { + await EnsureDivisionStructureEditableAsync(stage.DivisionId); + + if (stage.IsElimination) + { + await EnsureLastEliminationStageNotStrandingMultiGroupDivisionAsync(stage); + } + } + + await _stageRepository.RemoveAsync(stage => stage.Id == id); + } + + /// + /// Throws when deleting this elimination stage would leave a division split into 2+ groups + /// with no elimination stage left at all — orphaning its only path to an overall champion. + /// + private async Task EnsureLastEliminationStageNotStrandingMultiGroupDivisionAsync(Stage stage) + { + int groupStageCount = await _stageRepository.CountAsync( + s => s.DivisionId == stage.DivisionId && s.StageType == StageType.Group); + + if (groupStageCount < 2) + { + return; + } + + int remainingEliminationStages = await _stageRepository.CountAsync( + s => s.DivisionId == stage.DivisionId && s.IsElimination && s.Id != stage.Id); + + if (remainingEliminationStages == 0) + { + throw new InvalidOperationException(ErrorMessages.Stage.SubGroupsRequireCupToDetermineChampion); + } + } + + /// + /// Guards manual stage structure edits against the state of the division's tournament. + /// + /// The division whose tournament is checked. + /// + /// Thrown as a 409 when the tournament has already started or was canceled. + /// + private async Task EnsureDivisionStructureEditableAsync(Guid divisionId) + { + Division? division = await _divisionRepository.GetByIdAsync( + divisionId, includes: [division => division.Tournament]); + + if (division?.Tournament is null) + { + return; + } + + bool structureLocked = division.Tournament.Status + is TournamentStatus.Ongoing or TournamentStatus.Finished or TournamentStatus.Canceled; + + if (structureLocked) + { + throw new InvalidOperationException(ErrorMessages.Stage.StructureLockedTournamentStarted); + } + } + + /// + /// Updates an existing stage entity. + /// + /// The stage entity to update. + /// + /// Thrown as a 409 when the tournament has already started — editing a stage's type or dates + /// once the fixture is generated could desync it from the matches already built off it. + /// + public async Task UpdateStageAsync(Stage stageEntity) + { + await EnsureDivisionStructureEditableAsync(stageEntity.DivisionId); + + await _stageRepository.UpdateAsync(stageEntity); + } + + /// + /// Creates a new stage entity if it does not already exist in the specified division. + /// + /// The stage entity to create. + /// The created stage entity. + /// + /// Thrown if a stage with the same name already exists in the division, or if this Group stage + /// would bring the division to 2+ groups while it has no cup to determine a champion between them. + /// + public async Task CreateStageAsync(Stage stageEntity) + { + await EnsureDivisionStructureEditableAsync(stageEntity.DivisionId); + + bool existStage = await _stageRepository.ExistsAsync( + s => s.Name == stageEntity.Name && s.DivisionId == stageEntity.DivisionId); + + if (existStage) + { + throw new InvalidOperationException(ErrorMessages.Stage.AlreadyExistsInDivision(stageEntity.Name)); + } + + if (stageEntity.StageType == StageType.Group) + { + int existingGroupStageCount = await _stageRepository.CountAsync( + s => s.DivisionId == stageEntity.DivisionId && s.StageType == StageType.Group); + + if (existingGroupStageCount + 1 >= 2) + { + await EnsureDivisionHasCupForMultipleGroupsAsync(stageEntity.DivisionId); + } + } + + stageEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + stageEntity.Name, + candidate => _stageRepository.ExistsAsync(stage => stage.Slug == candidate)); + + await _stageRepository.AddAsync(stageEntity); + return stageEntity; + } + + /// + /// Throws when a regular division split into 2+ groups has no cup to crown a champion. + /// + private async Task EnsureDivisionHasCupForMultipleGroupsAsync(Guid divisionId) + { + Division division = await _divisionRepository.GetByIdAsync( + divisionId, includes: [d => d.PlayoffMappings]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.DivisionNotFound); + + if (division.IsCrossDivisionCup) + { + return; + } + + if (division.PlayoffMappings.Count == 0) + { + throw new InvalidOperationException(ErrorMessages.Stage.SubGroupsRequireCupToDetermineChampion); + } + } + + /// + /// Builds a new stage entity based on the provided parameters. + /// + /// The type of the stage. + /// The template to use for the stage. + /// The start date of the stage. + /// The division associated with the stage. + /// Multiplier for the stage duration in days. + /// A new stage entity. + private static Stage BuildStage(StageType stageType, Template template, DateTime startDate, Division division, int daysMultiplier = 1) + { + return new Stage + { + Id = Guid.Empty, + Name = template.Name, + Slug = string.Empty, + Description = template.Description, + StageType = stageType, + IsActive = true, + IsElimination = stageType != StageType.Group, + StartDate = startDate, + EndDate = startDate.AddDays(StageTemplate.DurationDays * daysMultiplier), + Division = division, + DivisionId = division.Id, + Matches = [], + DateCreated = DateTime.UtcNow, + CreatedBy = AuditConstants.SystemUser, + }; + } + + /// + /// Assigns a unique slug to every stage in a freshly built batch before it is persisted. + /// + private async Task AssignStageSlugsAsync(List stages) + { + HashSet slugsAssignedInBatch = []; + + foreach (Stage stage in stages) + { + stage.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + stage.Name, + async candidate => slugsAssignedInBatch.Contains(candidate) + || await _stageRepository.ExistsAsync(s => s.Slug == candidate)); + + slugsAssignedInBatch.Add(stage.Slug); + } + } + + /// + /// Assigns teams to a stage, either manually by team IDs or automatically based on available slots. + /// + /// The stage to assign teams to. + /// Optional list of team IDs to assign. + /// If true, assigns teams automatically based on available slots. + /// + /// Thrown if the stage already has the maximum number of teams, if too many teams are assigned, + /// or as a 409 if the tournament has already started — adding a team to a zone after the + /// fixture is generated would leave it without any matches of its own. + /// + public async Task AssignTeamsToStageAsync(Stage stage, List? teamIds = null, bool auto = false) + { + await EnsureDivisionStructureEditableAsync(stage.DivisionId); + + IEnumerable existingMatches = await _stageTeamMatchRepository.FindAsync(stageTeamMatch => stageTeamMatch.StageId == stage.Id); + + List filteredIds = []; + + if (!auto) + { + if (teamIds == null || teamIds.Count == 0) + { + return; + } + + filteredIds = [.. teamIds + .Distinct() + .Where(id => !existingMatches.Any(stm => stm.TeamId == id))]; + + await EnsureTeamsEnrolledInDivisionAsync(stage.DivisionId, filteredIds); + } + + int maxTeams = stage.StageType == StageType.Group + ? MaxTeams.GroupStageCap + : StageHelper.GetMaxTeamsForStage(stage.StageType); + int availableSlots = maxTeams - existingMatches.Count(); + + if (availableSlots <= 0) + { + throw new InvalidOperationException(ErrorMessages.Stage.MaxTeamsReached(maxTeams)); + } + + List newItems; + + if (!auto) + { + if (filteredIds.Count > availableSlots) + { + throw new InvalidOperationException(ErrorMessages.Stage.NotEnoughSlots(filteredIds.Count, availableSlots)); + } + + await EnsureNoCrossDivisionConflictAsync(stage, filteredIds); + + newItems = [.. filteredIds.Select(teamId => new StageTeamMatch + { + Id = Guid.Empty, + StageId = stage.Id, + TeamId = teamId, + DateCreated = DateTime.UtcNow, + CreatedBy = AuditConstants.SystemUser, + })]; + } + else + { + PaginatedFilterRequest filter = new() + { + PageSize = availableSlots, + }; + List teams = [.. await _teamRepository.FindAsync( + team => team.DivisionTeamRegistrations.Any(r => r.DivisionId == stage.DivisionId) + && !team.StageTeamMatches.Any(stm => stm.TeamId == team.Id && stm.StageId == stage.Id), filter: filter)]; + + if (!stage.Division.IsCrossDivisionCup) + { + List conflictingTeamIds = await FindTeamsInAnotherDivisionAsync(stage, [.. teams.Select(t => t.Id)]); + teams = [.. teams.Where(t => !conflictingTeamIds.Contains(t.Id))]; + } + + newItems = [.. teams.Select(t => new StageTeamMatch + { + Id = Guid.Empty, + StageId = stage.Id, + TeamId = t.Id, + CreatedBy = AuditConstants.SystemUser, + DateCreated = DateTime.UtcNow, + })]; + } + + if (newItems.Count != 0) + { + await _stageTeamMatchRepository.AddRangeAsync(newItems); + } + } + + /// + /// Throws when any of the given teams has no DivisionTeamRegistration for the division, since a stage placement is a subset of division enrollment, never the reverse. + /// + private async Task EnsureTeamsEnrolledInDivisionAsync(Guid divisionId, List teamIds) + { + if (teamIds.Count == 0) + { + return; + } + + List registeredIds = [.. (await _divisionTeamRegistrationRepository.FindAsync( + r => r.DivisionId == divisionId && teamIds.Contains(r.TeamId))) + .Select(r => r.TeamId)]; + + List missingIds = [.. teamIds.Except(registeredIds)]; + + if (missingIds.Count > 0) + { + throw new InvalidOperationException( + ErrorMessages.Stage.TeamNotEnrolledInDivision(string.Join(", ", missingIds))); + } + } + + /// + /// Throws if any given team is already assigned to a stage in a different division of the same tournament. + /// + private async Task EnsureNoCrossDivisionConflictAsync(Stage stage, IEnumerable teamIds) + { + if (stage.Division.IsCrossDivisionCup) + { + return; + } + + List conflictingTeamIds = await FindTeamsInAnotherDivisionAsync(stage, teamIds); + + if (conflictingTeamIds.Count > 0) + { + throw new InvalidOperationException( + ErrorMessages.Stage.ConflictingTeamAssignment(string.Join(", ", conflictingTeamIds))); + } + } + + /// + /// Returns the subset of teamIds already assigned to a stage in a different division of the same tournament. + /// + private async Task> FindTeamsInAnotherDivisionAsync(Stage stage, IEnumerable teamIds) + { + List teamIdList = [.. teamIds]; + if (teamIdList.Count == 0) + { + return []; + } + + IEnumerable conflicting = await _stageTeamMatchRepository.FindAsync(stm => + teamIdList.Contains(stm.TeamId) + && stm.Stage!.DivisionId != stage.DivisionId + && stm.Stage!.Division.TournamentId == stage.Division.TournamentId + && !stm.Stage!.Division.IsCrossDivisionCup); + + return [.. conflicting.Select(stm => stm.TeamId).Distinct()]; + } + + /// + /// Unassigns teams from a stage based on the provided team IDs. + /// + /// The stage to unassign teams from. + /// List of team IDs to unassign. + /// + /// Thrown as a 409 when the tournament has already started — removing a team from a zone after + /// the fixture is generated would leave its already-scheduled matches pointing at a team no + /// longer in that zone. + /// + public async Task UnassignTeamsFromStageAsync(Stage stage, List teamIds) + { + if (teamIds == null || teamIds.Count == 0) + { + return; + } + + await EnsureDivisionStructureEditableAsync(stage.DivisionId); + + await _stageTeamMatchRepository.RemoveAsync(stm => + stm.StageId == stage.Id && teamIds.Contains(stm.TeamId) + ); + } + + /// + /// Seeds an elimination stage's empty matches from group-stage standings in classic bracket order. A division split into more than one Group stage is seeded from the pooled top QualifiersPerGroup teams of every group instead of a single combined table. + /// + public async Task> SeedKnockoutStageAsync(Guid stageId) + { + Stage stage = await _stageRepository.GetByIdAsync(stageId, + includes: [s => s.Matches, s => s.StageTeamMatches, s => s.Division]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + + if (stage.Matches.Count == 0) + { + throw new InvalidOperationException(ErrorMessages.Stage.GenerateMatchesBeforeSeeding); + } + + if (stage.Matches.Any(m => m.HomeTeamId.HasValue || m.VisitorTeamId.HasValue)) + { + throw new InvalidOperationException(ErrorMessages.Stage.AlreadySeeded); + } + + List groupStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == stage.DivisionId && s.StageType == StageType.Group)]; + + if (groupStages.Count > 1) + { + return await SeedMultiGroupStageAsync(stage, groupStages); + } + + List assignedTeamIds = [.. stage.StageTeamMatches.Select(stm => stm.TeamId)]; + int slotCapacity = stage.Matches.Count * 2; + + if (assignedTeamIds.Count < 2 || assignedTeamIds.Count > slotCapacity) + { + throw new InvalidOperationException( + ErrorMessages.Stage.SeedTeamCountOutOfRange(assignedTeamIds.Count, slotCapacity)); + } + + List groupMatches = [.. await _matchRepository.FindAsync(m => + m.Stage.DivisionId == stage.DivisionId && m.Stage.StageType == StageType.Group, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.WinningTeam!])]; + + List standings = PositionCalculator.CalculatePositions( + groupMatches, stage.Division.PointsForWin, stage.Division.PointsForLoss); + + List orderedTeamIds = [.. standings + .Where(position => assignedTeamIds.Contains(position.TeamId)) + .Select(position => position.TeamId)]; + + if (orderedTeamIds.Count != assignedTeamIds.Count) + { + throw new InvalidOperationException(ErrorMessages.Stage.SeedMissingStandings); + } + + List orderedMatches = await FillStageWithSeedsAsync(stage, orderedTeamIds); + + await _matchRepository.UpdateRangeAsync(orderedMatches); + await TryAdvanceStageWinnerAsync(stage.Id); + + return orderedMatches; + } + + /// + /// Seeds a multi-group division's first elimination stage — a cross-division cup's pooled groups, or a regular zone's own sub-groups. + /// + private async Task> SeedMultiGroupStageAsync(Stage stage, List groupStages) + { + List groupMatches = [.. await _matchRepository.FindAsync(m => + m.Stage.DivisionId == stage.DivisionId && m.Stage.StageType == StageType.Group, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.WinningTeam!])]; + + List> standingsPerGroup = [.. groupStages + .Select(groupStage => (IReadOnlyList) PositionCalculator.CalculatePositions( + [.. groupMatches.Where(m => m.StageId == groupStage.Id)], + stage.Division.PointsForWin, + stage.Division.PointsForLoss))]; + + List orderedTeamIds = CrossCupGroupSeeder.ResolveSeedOrder( + standingsPerGroup, stage.Division.QualifiersPerGroup); + + List seeded = await FillStageWithSeedsAsync(stage, orderedTeamIds); + + await _matchRepository.UpdateRangeAsync(seeded); + await TryAdvanceStageWinnerAsync(stage.Id); + + return seeded; + } + + /// + /// Seeds every playoff cup of a division from its final group-stage standings. + /// + /// The division whose group stage has finished. + /// The seeded matches per destination cup, keyed by BracketName. + public async Task>> SeedPlayoffCupsAsync(Guid divisionId) + { + Division division = await _divisionRepository.GetByIdAsync( + divisionId, includes: [d => d.PlayoffMappings]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.DivisionNotFound); + + if (division.PlayoffMappings.Count == 0) + { + throw new InvalidOperationException(ErrorMessages.Playoff.NoMappingsConfigured); + } + + List groupStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == divisionId && s.StageType == StageType.Group)]; + + List groupMatches = [.. await _matchRepository.FindAsync(m => + m.Stage.DivisionId == divisionId && m.Stage.StageType == StageType.Group, + includes: [m => m.HomeTeam!, m => m.VisitorTeam!, m => m.WinningTeam!])]; + + List standings = groupStages.Count > 1 + ? CrossCupGroupSeeder.PoolAndOrder( + [.. groupStages.Select(groupStage => (IReadOnlyList) PositionCalculator.CalculatePositions( + [.. groupMatches.Where(m => m.StageId == groupStage.Id)], + division.PointsForWin, + division.PointsForLoss))], + division.QualifiersPerGroup) + : PositionCalculator.CalculatePositions(groupMatches, division.PointsForWin, division.PointsForLoss); + + Dictionary> qualifiersByCup = PlayoffQualificationResolver.Resolve( + [ + new PlayoffQualificationResolver.DivisionStandings + { + Standings = standings, + Mappings = [.. division.PlayoffMappings], + }, + ]); + + List eliminationStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == divisionId && s.StageType != StageType.Group, + includes: [s => s.Matches])]; + + Dictionary> seededByCup = []; + + foreach ((string destination, List orderedTeamIds) in qualifiersByCup) + { + if (orderedTeamIds.Count < 2) + { + continue; + } + + Stage cupStage = eliminationStages + .Where(s => s.BracketName == destination + && s.Matches.Count > 0 + && !s.Matches.Any(m => m.HomeTeamId.HasValue || m.VisitorTeamId.HasValue)) + .OrderBy(s => EliminationDepth(s.StageType)) + .FirstOrDefault() + ?? throw new InvalidOperationException(ErrorMessages.Playoff.CupStageNotFound(destination)); + + List seeded = await FillStageWithSeedsAsync(cupStage, orderedTeamIds); + await _matchRepository.UpdateRangeAsync(seeded); + seededByCup[destination] = seeded; + + await TryAdvanceStageWinnerAsync(cupStage.Id); + } + + return seededByCup; + } + + /// + public async Task TryAutoSeedPlayoffPhaseAsync(Guid finishedMatchStageId) + { + try + { + Stage? finishedStage = await _stageRepository.GetByIdAsync(finishedMatchStageId); + if (finishedStage is null || finishedStage.StageType != StageType.Group) + { + return; + } + + Guid divisionId = finishedStage.DivisionId; + + List groupStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == divisionId && s.StageType == StageType.Group, + includes: [s => s.Matches])]; + + bool groupPhaseComplete = groupStages.Count > 0 + && groupStages.TrueForAll(s => s.Matches.Count > 0 && s.Matches.All(m => m.IsFinished)); + + if (!groupPhaseComplete) + { + return; + } + + Division? division = await _divisionRepository.GetByIdAsync( + divisionId, includes: [d => d.PlayoffMappings]); + + if (division is null || division.PlayoffMappings.Count == 0) + { + return; + } + + List eliminationStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == divisionId && s.StageType != StageType.Group, + includes: [s => s.Matches])]; + + bool anyEliminationStageSeeded = eliminationStages + .Exists(s => s.Matches.Any(m => m.HomeTeamId.HasValue || m.VisitorTeamId.HasValue)); + + if (eliminationStages.Count == 0 || anyEliminationStageSeeded) + { + return; + } + + await SeedPlayoffCupsAsync(divisionId); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Auto-seed skipped for the group phase of stage {StageId}: the playoff cups " + + "could not be seeded automatically. An admin can still seed them by hand.", + finishedMatchStageId); + } + } + + /// + /// Creates whatever empty Match placeholders a groupless bracket's first draw is still missing. + /// + private async Task EnsureBracketSlotsExistAsync(Stage stage, int neededSlotCount) + { + int missing = neededSlotCount - stage.Matches.Count; + if (missing <= 0) + { + return; + } + + List newMatches = []; + HashSet slugsAssignedInBatch = []; + + for (int i = 0; i < missing; i++) + { + string slug = await SlugGenerator.GenerateUniqueSlugAsync( + $"{stage.Name}-{i + 1}", + async candidate => slugsAssignedInBatch.Contains(candidate) + || await _matchRepository.ExistsAsync(m => m.Slug == candidate)); + slugsAssignedInBatch.Add(slug); + + newMatches.Add(new Match + { + StageId = stage.Id, + Type = MatchType.Playoff, + Slug = slug, + IsFinished = false, + MatchDate = stage.StartDate.AddMinutes(i), + CreatedBy = AuditConstants.SystemUser, + }); + } + + await _matchRepository.AddRangeAsync(newMatches); + } + + /// + /// Pairs an ordered, best-seed-first list of team ids into a stage's empty matches in classic bracket seed order. + /// + private async Task> FillStageWithSeedsAsync(Stage stage, IReadOnlyList orderedTeamIds) + { + List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(orderedTeamIds); + + List orderedMatches = [.. stage.Matches.OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + + for (int i = 0; i < pairs.Count; i++) + { + Match slot = orderedMatches[i]; + slot.HomeTeamId = pairs[i].HomeTeamId; + slot.VisitorTeamId = pairs[i].VisitorTeamId; + + if (pairs[i].VisitorTeamId is null) + { + slot.IsFinished = true; + slot.WinningTeamId = pairs[i].HomeTeamId; + continue; + } + + if (stage.BestOf > 1) + { + MatchSeries series = await CreateSeriesForPairAsync( + stage.Id, stage.BestOf, pairs[i].HomeTeamId, pairs[i].VisitorTeamId!.Value); + slot.SeriesId = series.Id; + slot.GameNumber = 1; + } + } + + return orderedMatches; + } + + /// + /// Creates and persists a new best-of-N series between two teams at a stage. + /// + private async Task CreateSeriesForPairAsync(Guid stageId, int bestOf, Guid homeTeamId, Guid visitorTeamId) + { + MatchSeries series = new() + { + StageId = stageId, + HomeTeamId = homeTeamId, + VisitorTeamId = visitorTeamId, + BestOf = bestOf, + CreatedBy = AuditConstants.SystemUser, + }; + + await _matchSeriesRepository.AddAsync(series); + return series; + } + + /// + public async Task TryAdvanceStageWinnerAsync(Guid decidedStageId) + { + try + { + Stage? stage = await _stageRepository.GetByIdAsync( + decidedStageId, includes: [s => s.Matches, s => s.MatchSeries]); + + if (stage is null || stage.BracketName is null) + { + return; + } + + List orderedMatches = [.. stage.Matches + .Where(m => m.GameNumber is null or 1) + .OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + + await AdvanceWinnersToNextRoundAsync(stage, orderedMatches); + + if (stage.StageType == StageType.SemiFinal) + { + await AdvanceLosersToThirdPlaceAsync(stage, orderedMatches); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Could not advance the winner(s)/loser(s) from stage {StageId}. " + + "An admin can still fill the next round in by hand.", + decidedStageId); + } + } + + /// + /// Pushes each decided slot's winner into its next-round match. + /// + private async Task AdvanceWinnersToNextRoundAsync(Stage stage, List orderedMatches) + { + StageType? nextType = NextStageType(stage.StageType); + if (nextType is null) + { + return; + } + + Stage? nextStage = (await _stageRepository.FindAsync( + s => s.DivisionId == stage.DivisionId + && s.BracketName == stage.BracketName + && s.StageType == nextType.Value, + includes: [s => s.Matches])).FirstOrDefault(); + + if (nextStage is null) + { + return; + } + + List nextOrderedMatches = [.. nextStage.Matches + .Where(m => m.GameNumber is null or 1) + .OrderBy(m => m.MatchDate).ThenBy(m => m.Id)]; + + HashSet touched = []; + + for (int slotIndex = 0; slotIndex < orderedMatches.Count; slotIndex++) + { + Guid? winnerId = ResolveSlotWinner(orderedMatches[slotIndex], stage); + int nextSlotIndex = slotIndex / 2; + + if (winnerId is null || nextSlotIndex >= nextOrderedMatches.Count) + { + continue; + } + + Match target = nextOrderedMatches[nextSlotIndex]; + bool isHomeSlot = slotIndex % 2 == 0; + + if (isHomeSlot) + { + if (target.HomeTeamId == winnerId) + { + continue; + } + + target.HomeTeamId = winnerId; + } + else + { + if (target.VisitorTeamId == winnerId) + { + continue; + } + + target.VisitorTeamId = winnerId; + } + + touched.Add(target); + } + + if (touched.Count == 0) + { + return; + } + + if (nextStage.BestOf > 1) + { + foreach (Match target in touched) + { + if (target.HomeTeamId.HasValue && target.VisitorTeamId.HasValue && !target.SeriesId.HasValue) + { + MatchSeries series = await CreateSeriesForPairAsync( + nextStage.Id, nextStage.BestOf, target.HomeTeamId.Value, target.VisitorTeamId.Value); + target.SeriesId = series.Id; + target.GameNumber = 1; + } + } + } + + await _matchRepository.UpdateRangeAsync(touched); + } + + /// + /// Once a semifinal slot is decided, pushes its loser into the division's third-place decider. + /// + private async Task AdvanceLosersToThirdPlaceAsync(Stage semiFinalStage, List orderedMatches) + { + Stage? thirdPlaceStage = (await _stageRepository.FindAsync( + s => s.DivisionId == semiFinalStage.DivisionId + && s.BracketName == semiFinalStage.BracketName + && s.StageType == StageType.ThirdPlace, + includes: [s => s.Matches])).FirstOrDefault(); + + if (thirdPlaceStage is null) + { + return; + } + + Match? target = thirdPlaceStage.Matches + .Where(m => m.GameNumber is null or 1) + .OrderBy(m => m.MatchDate).ThenBy(m => m.Id) + .FirstOrDefault(); + + if (target is null) + { + return; + } + + bool touched = false; + + for (int slotIndex = 0; slotIndex < orderedMatches.Count && slotIndex < 2; slotIndex++) + { + Guid? loserId = ResolveSlotLoser(orderedMatches[slotIndex], semiFinalStage); + if (loserId is null) + { + continue; + } + + bool isHomeSlot = slotIndex % 2 == 0; + + if (isHomeSlot) + { + if (target.HomeTeamId == loserId) + { + continue; + } + + target.HomeTeamId = loserId; + } + else + { + if (target.VisitorTeamId == loserId) + { + continue; + } + + target.VisitorTeamId = loserId; + } + + touched = true; + } + + if (!touched) + { + return; + } + + if (thirdPlaceStage.BestOf > 1 + && target.HomeTeamId.HasValue && target.VisitorTeamId.HasValue + && !target.SeriesId.HasValue) + { + MatchSeries series = await CreateSeriesForPairAsync( + thirdPlaceStage.Id, thirdPlaceStage.BestOf, target.HomeTeamId.Value, target.VisitorTeamId.Value); + target.SeriesId = series.Id; + target.GameNumber = 1; + } + + await _matchRepository.UpdateRangeAsync([target]); + } + + /// + /// The winning team of one bracket slot, a single match or a whole series, or null if undecided. + /// + private static Guid? ResolveSlotWinner(Match slotFirstMatch, Stage stage) + { + if (slotFirstMatch.SeriesId.HasValue) + { + MatchSeries? series = stage.MatchSeries.FirstOrDefault(s => s.Id == slotFirstMatch.SeriesId.Value); + return series?.WinningTeamId; + } + + return slotFirstMatch.IsFinished ? slotFirstMatch.WinningTeamId : null; + } + + /// + /// The losing team of one decided bracket slot, the counterpart to ResolveSlotWinner. + /// + private static Guid? ResolveSlotLoser(Match slotFirstMatch, Stage stage) + { + Guid? winnerId = ResolveSlotWinner(slotFirstMatch, stage); + if (winnerId is null || slotFirstMatch.HomeTeamId is null || slotFirstMatch.VisitorTeamId is null) + { + return null; + } + + return winnerId == slotFirstMatch.HomeTeamId ? slotFirstMatch.VisitorTeamId : slotFirstMatch.HomeTeamId; + } + + /// + public async Task PreviewDrawAsync(Guid stageId, DrawMode mode, List? manualOrder = null) + { + Stage stage = await _stageRepository.GetByIdAsync(stageId, includes: [s => s.Division]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + + await EnsureGrouplessDivisionAsync(stage.DivisionId); + + List rosterTeamIds = await GetRosterTeamIdsAsync(stage.DivisionId); + List orderedTeamIds = ResolveOrderedTeamIds(mode, rosterTeamIds, manualOrder); + + List<(Guid HomeTeamId, Guid? VisitorTeamId)> pairs = PlayoffSeeder.SeedPairs(orderedTeamIds); + + return new DrawPreviewResult + { + Pairs = [.. pairs.Select(p => new DrawPairPreview { HomeTeamId = p.HomeTeamId, VisitorTeamId = p.VisitorTeamId })], + DrawToken = SignDrawToken(stageId, orderedTeamIds), + }; + } + + /// + public async Task> CommitDrawAsync(Guid stageId, DrawMode mode, string? drawToken = null, List? manualOrder = null) + { + Stage firstRoundStage = await _stageRepository.GetByIdAsync(stageId, includes: [s => s.Matches, s => s.Division]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + + await EnsureGrouplessDivisionAsync(firstRoundStage.DivisionId); + + List rosterTeamIds = await GetRosterTeamIdsAsync(firstRoundStage.DivisionId); + + List orderedTeamIds = mode == DrawMode.Random + ? VerifyDrawToken(drawToken, stageId, rosterTeamIds) + : ResolveOrderedTeamIds(DrawMode.Manual, rosterTeamIds, manualOrder); + + await EnsureBracketSlotsExistAsync(firstRoundStage, PlayoffSeeder.SeedPairs(orderedTeamIds).Count); + + await EnsureBracketDrawableAsync(firstRoundStage); + + List bracketStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == firstRoundStage.DivisionId && s.BracketName == firstRoundStage.BracketName, + includes: [s => s.Matches])]; + + await ResetBracketSeedingAsync(bracketStages); + + List orderedMatches = await FillStageWithSeedsAsync(firstRoundStage, orderedTeamIds); + await _matchRepository.UpdateRangeAsync(orderedMatches); + + firstRoundStage.DrawnAt = DateTime.UtcNow; + await _stageRepository.UpdateAsync(firstRoundStage); + + await TryAdvanceStageWinnerAsync(firstRoundStage.Id); + + await LogPlayoffDrawAsync(firstRoundStage, mode, orderedTeamIds.Count); + + return orderedMatches; + } + + /// + /// Returns every team id currently enrolled in the division's roster. + /// + private async Task> GetRosterTeamIdsAsync(Guid divisionId) + { + return [.. (await _divisionTeamRegistrationRepository.FindAsync(r => r.DivisionId == divisionId)) + .Select(r => r.TeamId)]; + } + + /// + /// Rejects a playoffs-only draw against a division that still has a group phase, since group-standings brackets are seeded by SeedKnockoutStageAsync instead. + /// + private async Task EnsureGrouplessDivisionAsync(Guid divisionId) + { + bool hasGroupStage = await _stageRepository.ExistsAsync( + s => s.DivisionId == divisionId && s.StageType == StageType.Group); + + if (hasGroupStage) + { + throw new InvalidOperationException(ErrorMessages.Stage.DrawRequiresGrouplessDivision); + } + } + + /// + /// Resolves the ordered team list a draw seeds from, shuffling for a random draw or validating a manual order against the roster set. + /// + private static List ResolveOrderedTeamIds(DrawMode mode, List rosterTeamIds, List? manualOrder) + { + if (mode == DrawMode.Manual) + { + if (manualOrder is null || !IsRosterPermutation(manualOrder, rosterTeamIds)) + { + throw new InvalidOperationException(ErrorMessages.Stage.ManualOrderNotRosterPermutation); + } + + return manualOrder; + } + + List shuffled = [.. rosterTeamIds]; + for (int i = shuffled.Count - 1; i > 0; i--) + { + int swapIndex = Random.Shared.Next(i + 1); + (shuffled[swapIndex], shuffled[i]) = (shuffled[i], shuffled[swapIndex]); + } + + return shuffled; + } + + /// + /// Whether candidate is exactly a reordering of roster, with no team missing, repeated, or foreign to the division. + /// + private static bool IsRosterPermutation(List candidate, List roster) + { + if (candidate.Count != roster.Count) + { + return false; + } + + HashSet candidateSet = [.. candidate]; + return candidateSet.Count == candidate.Count && candidateSet.SetEquals(roster); + } + + /// + /// Blocks a bracket draw once any real match in that division and bracket name has been played, excluding byes and still-empty slots so a freshly drawn bracket stays re-drawable. + /// + private async Task EnsureBracketDrawableAsync(Stage firstRoundStage) + { + bool anyPlayed = await _matchRepository.ExistsAsync(m => + m.Stage.DivisionId == firstRoundStage.DivisionId + && m.Stage.BracketName == firstRoundStage.BracketName + && m.HomeTeamId.HasValue && m.VisitorTeamId.HasValue + && (m.IsFinished || m.HomeScore.HasValue || m.VisitorScore.HasValue || m.Status == MatchStatus.Played)); + + if (anyPlayed) + { + throw new InvalidOperationException(ErrorMessages.Stage.BracketAlreadyPlayed); + } + } + + /// + /// Clears every match of the bracket's stages back to unseeded and removes their series, a no-op the first time a bracket is drawn. + /// + private async Task ResetBracketSeedingAsync(List bracketStages) + { + List matchesToReset = [.. bracketStages.SelectMany(s => s.Matches)]; + + if (matchesToReset.Count == 0) + { + return; + } + + foreach (Match match in matchesToReset) + { + match.HomeTeamId = null; + match.VisitorTeamId = null; + match.WinningTeamId = null; + match.HomeScore = null; + match.VisitorScore = null; + match.IsFinished = false; + match.Status = MatchStatus.Scheduled; + match.SeriesId = null; + match.GameNumber = null; + } + + await _matchRepository.UpdateRangeAsync(matchesToReset); + + List stageIds = [.. bracketStages.Select(s => s.Id)]; + await _matchSeriesRepository.RemoveAsync(series => stageIds.Contains(series.StageId)); + } + + /// + /// Writes the PlayoffDraw audit entry for a committed draw, logging any failure instead of raising it so an audit outage never blocks the draw itself. + /// + private async Task LogPlayoffDrawAsync(Stage firstRoundStage, DrawMode mode, int teamCount) + { + try + { + string detail = mode == DrawMode.Random + ? $"Sorteo aleatorio - {teamCount} equipos" + : $"Sorteo manual - {teamCount} equipos"; + + await auditService.LogAsync( + AuditAction.PlayoffDraw, + targetType: "Stage", + targetId: firstRoundStage.Id.ToString(), + targetName: firstRoundStage.Name, + detail: detail); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Could not write the PlayoffDraw audit entry for stage {StageId}. The draw itself still succeeded.", + firstRoundStage.Id); + } + } + + /// + /// Signs a base64url draw-preview token binding the stage id and exact seeded order, so a later commit call can replay the identical pairing. + /// + private string SignDrawToken(Guid stageId, List orderedTeamIds) + { + DrawTokenPayload payload = new() + { + StageId = stageId, + OrderedTeamIds = orderedTeamIds, + IssuedAtUtc = DateTime.UtcNow, + Nonce = Guid.NewGuid(), + }; + + string payloadPart = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload)); + string signaturePart = Base64UrlEncode(ComputeSignature(payloadPart)); + + return $"{payloadPart}.{signaturePart}"; + } + + /// + /// Verifies a draw token's signature, stage match, and exact roster-set match, throwing for anything tampered, expired, or mismatched. + /// + private List VerifyDrawToken(string? token, Guid expectedStageId, List expectedRosterTeamIds) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException(ErrorMessages.Stage.InvalidDrawToken); + } + + string[] parts = token.Split('.'); + if (parts.Length != 2) + { + throw new InvalidOperationException(ErrorMessages.Stage.InvalidDrawToken); + } + + DrawTokenPayload payload; + try + { + byte[] providedSignature = Base64UrlDecode(parts[1]); + byte[] expectedSignature = ComputeSignature(parts[0]); + + if (!CryptographicOperations.FixedTimeEquals(providedSignature, expectedSignature)) + { + throw new InvalidOperationException(ErrorMessages.Stage.InvalidDrawToken); + } + + payload = JsonSerializer.Deserialize(Base64UrlDecode(parts[0])) + ?? throw new InvalidOperationException(ErrorMessages.Stage.InvalidDrawToken); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + throw new InvalidOperationException(ErrorMessages.Stage.InvalidDrawToken); + } + + if (payload.StageId != expectedStageId || !IsRosterPermutation(payload.OrderedTeamIds, expectedRosterTeamIds)) + { + throw new InvalidOperationException(ErrorMessages.Stage.InvalidDrawToken); + } + + return payload.OrderedTeamIds; + } + + /// + /// The HMAC-SHA256 signature of value using the app's JWT signing secret. + /// + private byte[] ComputeSignature(string value) + { + using HMACSHA256 hmac = new(Encoding.UTF8.GetBytes(_drawTokenSecret)); + return hmac.ComputeHash(Encoding.UTF8.GetBytes(value)); + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static byte[] Base64UrlDecode(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + int remainder = padded.Length % 4; + if (remainder > 0) + { + padded += new string('=', 4 - remainder); + } + + return Convert.FromBase64String(padded); + } + + /// + public async Task> RebuildSubGroupsAsync(Guid divisionId, int subGroupCount) + { + if (subGroupCount < 1) + { + throw new InvalidOperationException(ErrorMessages.Stage.SubGroupCountMustBePositive); + } + + await EnsureDivisionStructureEditableAsync(divisionId); + + Division division = await _divisionRepository.GetByIdAsync( + divisionId, includes: [d => d.PlayoffMappings, d => d.Tournament]) + ?? throw new InvalidOperationException(ErrorMessages.Stage.DivisionNotFound); + + if (subGroupCount >= 2 && !division.IsCrossDivisionCup && division.PlayoffMappings.Count == 0) + { + throw new InvalidOperationException(ErrorMessages.Stage.SubGroupsRequireCupToDetermineChampion); + } + + List rosterTeamIds = await GetRosterTeamIdsAsync(divisionId); + + if (rosterTeamIds.Count > 0 && !SubGroupDistributionHelper.MeetsMinimumSize(rosterTeamIds.Count, subGroupCount)) + { + throw new InvalidOperationException( + ErrorMessages.Stage.SubGroupTooFewTeams(rosterTeamIds.Count, subGroupCount)); + } + + List existingGroupStageIds = [.. (await _stageRepository.FindAsync( + s => s.DivisionId == divisionId && s.StageType == StageType.Group)) + .Select(s => s.Id)]; + + if (existingGroupStageIds.Count > 0) + { + await _stageRepository.RemoveAsync(s => existingGroupStageIds.Contains(s.Id)); + } + + List newGroupStages = BuildSubGroupStages(division, subGroupCount); + await AssignStageSlugsAsync(newGroupStages); + await _stageRepository.AddRangeAsync(newGroupStages); + + if (rosterTeamIds.Count > 0) + { + await PlaceRosterIntoSubGroupsAsync(rosterTeamIds, newGroupStages); + } + + return newGroupStages; + } + + /// + public async Task AutoDistributeRosterAsync(Guid divisionId) + { + await EnsureDivisionStructureEditableAsync(divisionId); + + List rosterTeamIds = await GetRosterTeamIdsAsync(divisionId); + + List groupStages = [.. await _stageRepository.FindAsync( + s => s.DivisionId == divisionId && s.StageType == StageType.Group)]; + + if (groupStages.Count == 0) + { + return; + } + + List groupStageIds = [.. groupStages.Select(s => s.Id)]; + await _stageTeamMatchRepository.RemoveAsync(stm => groupStageIds.Contains(stm.StageId)); + + if (rosterTeamIds.Count > 0) + { + await PlaceRosterIntoSubGroupsAsync(rosterTeamIds, groupStages); + } + } + + /// + public async Task ReassignTeamToSubGroupAsync(Guid teamId, Guid fromStageId, Guid toStageId) + { + if (fromStageId == toStageId) + { + return; + } + + Stage fromStage = await _stageRepository.GetByIdAsync(fromStageId) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + Stage toStage = await _stageRepository.GetByIdAsync(toStageId) + ?? throw new InvalidOperationException(ErrorMessages.Stage.NotFoundGeneric); + + if (fromStage.DivisionId != toStage.DivisionId) + { + throw new InvalidOperationException(ErrorMessages.Stage.ReassignmentAcrossDivisionsNotAllowed); + } + + await EnsureDivisionStructureEditableAsync(fromStage.DivisionId); + + StageTeamMatch placement = (await _stageTeamMatchRepository.FindAsync( + stm => stm.StageId == fromStageId && stm.TeamId == teamId)).FirstOrDefault() + ?? throw new InvalidOperationException(ErrorMessages.Stage.TeamNotPlacedInSubGroup); + + int remainingInSource = await _stageTeamMatchRepository.CountAsync( + stm => stm.StageId == fromStageId) - 1; + + if (remainingInSource < SubGroupDistributionHelper.MinTeamsPerSubGroup) + { + throw new InvalidOperationException( + ErrorMessages.Stage.SubGroupReassignmentBelowMinimum(remainingInSource)); + } + + placement.StageId = toStageId; + await _stageTeamMatchRepository.UpdateAsync(placement); + } + + /// + /// Builds subGroupCount fresh Group stages named "Grupo A" onward, ordered, for a division rebuild. + /// + private static List BuildSubGroupStages(Division division, int subGroupCount) + { + List stages = []; + DateTime startDate = division.Tournament.StartDate; + + for (int i = 0; i < subGroupCount; i++) + { + Stage groupStage = BuildStage(StageType.Group, StageTemplate.Group, startDate, division, daysMultiplier: 2); + groupStage.Name = $"Grupo {(char) ('A' + i)}"; + groupStage.Order = i; + stages.Add(groupStage); + } + + return stages; + } + + /// + /// Deals the roster across the given sub-group stages using the balanced distribution rule and persists the resulting placements. + /// + private async Task PlaceRosterIntoSubGroupsAsync(List rosterTeamIds, List groupStages) + { + List> distribution = SubGroupDistributionHelper.Distribute(rosterTeamIds, groupStages.Count); + + List newMatches = []; + for (int i = 0; i < groupStages.Count; i++) + { + newMatches.AddRange(distribution[i].Select(teamId => new StageTeamMatch + { + StageId = groupStages[i].Id, + TeamId = teamId, + CreatedBy = AuditConstants.SystemUser, + DateCreated = DateTime.UtcNow, + })); + } + + if (newMatches.Count > 0) + { + await _stageTeamMatchRepository.AddRangeAsync(newMatches); + } + } + + /// + /// The signed payload carried by a draw-preview token, replayed verbatim by a matching commit call. + /// + private sealed class DrawTokenPayload + { + public Guid StageId { get; set; } + public List OrderedTeamIds { get; set; } = []; + public DateTime IssuedAtUtc { get; set; } + public Guid Nonce { get; set; } + } +} diff --git a/Club12-Backend/Application/Services/StageTeamMatchService.cs b/Club12-Backend/Application/Services/StageTeamMatchService.cs new file mode 100644 index 0000000..c055446 --- /dev/null +++ b/Club12-Backend/Application/Services/StageTeamMatchService.cs @@ -0,0 +1,38 @@ +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +public class StageTeamMatchService(IStageTeamMatchRepository stageTeamMatchRepository) : IStageTeamMatchService +{ + public async Task> GetAllStageTeamMatchByStageId(Guid stageId) + { + return [.. await stageTeamMatchRepository.FindAsync(stm => stm.StageId == stageId)]; + } + + public async Task InsertRangeAsync(List stageTeamMatches) + { + await stageTeamMatchRepository.AddRangeAsync(stageTeamMatches); + } + + public async Task RemoveWhereAsync(Expression> predicate) + { + await stageTeamMatchRepository.RemoveAsync(predicate); + } + + /// + /// True only when every id in TeamIds has a matching assignment row for the stage. + /// + public async Task AllTeamsAssignedToStage(Guid stageId, List TeamIds) + { + return await stageTeamMatchRepository.CountAsync(stm => stm.StageId == stageId + && TeamIds.Contains(stm.TeamId)) == TeamIds.Count; + } +} diff --git a/Club12-Backend/Application/Services/StatisticsService.cs b/Club12-Backend/Application/Services/StatisticsService.cs new file mode 100644 index 0000000..bdae62a --- /dev/null +++ b/Club12-Backend/Application/Services/StatisticsService.cs @@ -0,0 +1,20 @@ +using Application.DTOs.Statistics.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; + +using System; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Thin application service over IStatisticsRepository for the historical-statistics reads. +/// +public class StatisticsService(IStatisticsRepository statisticsRepository) : IStatisticsService +{ + public Task GetPlayerCardAsync(Guid playerId) => + statisticsRepository.GetPlayerCardAsync(playerId); + + public Task GetPlayerHistoryAsync(Guid playerId) => + statisticsRepository.GetPlayerHistoryAsync(playerId); +} diff --git a/Club12-Backend/Application/Services/TeamPointDeductionService.cs b/Club12-Backend/Application/Services/TeamPointDeductionService.cs new file mode 100644 index 0000000..ace2079 --- /dev/null +++ b/Club12-Backend/Application/Services/TeamPointDeductionService.cs @@ -0,0 +1,45 @@ +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Service managing disciplinary point deductions for teams within a division. +/// +public class TeamPointDeductionService( + ITeamPointDeductionRepository deductionRepository) : ITeamPointDeductionService +{ + /// + public async Task CreateAsync(TeamPointDeduction deduction) + { + await deductionRepository.AddAsync(deduction); + + // Reload with the team navigation so the response can name the team. + return await deductionRepository.GetByIdAsync( + deduction.Id, + includes: [entity => entity.Team!]) ?? deduction; + } + + /// + public async Task> GetByDivisionIdAsync(Guid divisionId) + { + IEnumerable deductions = await deductionRepository.FindAsync( + deduction => deduction.DivisionId == divisionId, + includes: [deduction => deduction.Team!]); + + return [.. deductions.OrderByDescending(deduction => deduction.DateCreated)]; + } + + /// + public async Task DeleteAsync(Guid id) + { + await deductionRepository.RemoveAsync(deduction => deduction.Id == id); + } +} diff --git a/Club12-Backend/Application/Services/TeamService.cs b/Club12-Backend/Application/Services/TeamService.cs new file mode 100644 index 0000000..33646c4 --- /dev/null +++ b/Club12-Backend/Application/Services/TeamService.cs @@ -0,0 +1,591 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Team.Request; +using Application.DTOs.Team.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Extensions; +using Application.Utils.Helper.Slug; +using Application.Utils.Helper.Standings; +using Application.Utils.Helper.TeamProfile; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using LinqKit; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Coordinates team lifecycle rules that don't belong to a single CRUD verb. +/// +public class TeamService( + IUnitOfWork unitOfWork, + IRosterCopyService rosterCopyService, + IDivisionService divisionService, + IClubService clubService, + ITeamPointDeductionRepository pointDeductionRepository, + IScorerRepository scorerRepository) : ITeamService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork; + private readonly IRosterCopyService _rosterCopyService = rosterCopyService; + private readonly IDivisionService _divisionService = divisionService; + private readonly IClubService _clubService = clubService; + private readonly ITeamRepository _teamRepository = unitOfWork.TeamRepository; + private readonly IPlayerTeamRegistrationRepository _registrationRepository = unitOfWork.PlayerTeamRegistrationRepository; + private readonly ITeamTournamentRegistrationRepository _tournamentRegistrationRepository = unitOfWork.TeamTournamentRegistrationRepository; + private readonly IDivisionRepository _divisionRepository = unitOfWork.DivisionRepository; + private readonly IMatchRepository _matchRepository = unitOfWork.MatchRepository; + private readonly ITournamentRepository _tournamentRepository = unitOfWork.TournamentRepository; + private readonly IPlayerSanctionRepository _sanctionRepository = unitOfWork.PlayerSanctionRepository; + private readonly IPlayerStatisticRepository _statisticRepository = unitOfWork.PlayerStatisticRepository; + private readonly IScorerRepository _scorerRepository = scorerRepository; + private readonly ITeamPointDeductionRepository _pointDeductionRepository = pointDeductionRepository; + + /// + /// Creates a team, generating a unique slug from its name, and eagerly links it into its club's history. + /// + /// The team entity to create. + /// The created team entity. + public async Task CreateTeamAsync(Team teamEntity) + { + teamEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + teamEntity.Name, + candidate => _teamRepository.ExistsAsync(team => team.Slug == candidate)); + + await _teamRepository.AddAsync(teamEntity); + + // Ensures the "Importar plantel de una temporada anterior" flow has a club history to search from day one, instead of only working after someone remembers to run the bulk backfill. + await _clubService.EnsureTeamLinkedToClubAsync(teamEntity); + + return teamEntity; + } + + /// + /// Retrieves a team by its unique identifier, with its Players roster scoped to one season. + /// + /// The unique identifier of the team. + /// + /// The season whose roster to attach; defaults to the team's own current + /// TournamentId when omitted. + /// + /// The team entity if found; otherwise, null. + public async Task GetTeamByIdAsync(Guid teamId, Guid? tournamentId = null) + { + Team? team = await _teamRepository.GetByIdAsync(teamId); + + if (team is null) + { + return null; + } + + await AttachSeasonRostersAsync([team], tournamentId); + return team; + } + + /// + /// Retrieves a team by id or slug with its Players roster scoped to one season. + /// + /// The team's GUID id or its slug. + /// + /// The season whose roster to attach; defaults to the team's own current + /// TournamentId when omitted. + /// + /// The team entity if found; otherwise, null. + public async Task GetTeamByIdOrSlugAsync(string idOrSlug, Guid? tournamentId = null) + { + if (Guid.TryParse(idOrSlug, out Guid teamId)) + { + return await GetTeamByIdAsync(teamId, tournamentId); + } + + IEnumerable matches = await _teamRepository.FindAsync(team => team.Slug == idOrSlug); + Team? team = matches.FirstOrDefault(); + + if (team is null) + { + return null; + } + + await AttachSeasonRostersAsync([team], tournamentId); + return team; + } + + /// + /// Deletes a team, blocking the delete whenever it carries history that would otherwise be orphaned. + /// + /// The unique identifier of the team to delete. + /// A task representing the asynchronous operation. + /// + /// Thrown as a 409 when the team has match history, sanctions, point deductions, or tournament + /// registrations. + /// + public async Task DeleteTeamAsync(Guid id) + { + bool hasMatchHistory = await _matchRepository.ExistsAsync( + match => match.HomeTeamId == id || match.VisitorTeamId == id || match.WinningTeamId == id); + bool hasSanctions = await _sanctionRepository.ExistsAsync(sanction => sanction.TeamId == id); + bool hasPointDeductions = await _pointDeductionRepository.ExistsAsync(deduction => deduction.TeamId == id); + bool hasTournamentRegistrations = await _tournamentRegistrationRepository.ExistsAsync( + registration => registration.TeamId == id); + + // Team.Players cascades on delete, so hasSanctions alone would let a player's own statistics, scorer records, or sanctions be silently wiped out without triggering PlayerService.DeletePlayerAsync's history guard. + bool hasPlayersWithHistory = + await _statisticRepository.ExistsAsync(statistic => statistic.Player!.TeamId == id) + || await _scorerRepository.ExistsAsync(scorer => scorer.Player!.TeamId == id) + || await _sanctionRepository.ExistsAsync(sanction => sanction.Player!.TeamId == id); + + if (hasMatchHistory || hasSanctions || hasPointDeductions || hasTournamentRegistrations || hasPlayersWithHistory) + { + throw new InvalidOperationException(ErrorMessages.Team.HasHistoryCannotDelete); + } + + await _teamRepository.RemoveAsync(team => team.Id == id); + } + + public async Task UpdateTeamAsync(Team teamEntity) + { + await _teamRepository.UpdateAsync(teamEntity); + } + + /// + public async Task EnsureTeamIdentityEditableAsync(Team existingTeam, string? requestedName, string? requestedThreeLetterCode) + { + // Only a supplied request field can change identity, and the three-letter code is normalized to upper-case on the way in, so it is compared case-insensitively. + bool nameChanged = requestedName is not null + && !string.Equals(requestedName, existingTeam.Name, StringComparison.Ordinal); + bool codeChanged = requestedThreeLetterCode is not null + && !string.Equals(requestedThreeLetterCode, existingTeam.ThreeLetterCode, StringComparison.OrdinalIgnoreCase); + + if (!nameChanged && !codeChanged) + { + return; + } + + // Identity is only frozen while the team is actually in an Ongoing tournament, via its denormalized current-season pointer. + if (existingTeam.TournamentId is not Guid tournamentId) + { + return; + } + + Tournament? tournament = await _tournamentRepository.GetByIdAsync(tournamentId); + if (tournament?.Status == TournamentStatus.Ongoing) + { + throw new InvalidOperationException(ErrorMessages.Team.IdentityFrozenWhileOngoing); + } + } + + public async Task UpdateTeamsAsync(IEnumerable teams) + { + await _teamRepository.UpdateRangeAsync(teams); + } + + /// + /// Retrieves a paginated list of teams filtered by stage and tournament, with players and stage team matches. + /// + /// The filter criteria for retrieving teams. + /// A paginated response containing the filtered teams. + public async Task> GetAllTeamsAsync(GetTeamsFilteredRequest filter) + { + // TournamentId is suppressed from the auto-generated filter because a team's participation in a tournament is the TeamTournamentRegistration join, not the denormalized current-season pointer, so a team appears for every season it is registered in. + Expression> expression = QueryableExtensions.ConstructFilterExpression( + filter, nameof(GetTeamsFilteredRequest.TournamentId)); + + if (filter.StageId.HasValue) + { + Expression> stageExpression = + team => team.StageTeamMatches.Any(stm => stm.StageId == filter.StageId.Value); + + expression = expression.And(stageExpression); + } + + if (filter.TournamentId.HasValue) + { + Expression> tournamentExpression = + team => team.TeamTournamentRegistrations.Any(r => r.TournamentId == filter.TournamentId.Value); + + expression = expression.And(tournamentExpression); + } + + List filteredTeams = [.. await _teamRepository.FindAsync( + expression, + includes: [team => team.StageTeamMatches, team => team.Tournament!], + filter: filter, + asSplitQuery: true)]; + + await AttachSeasonRostersAsync(filteredTeams, filter.TournamentId); + + int totalCount = await _teamRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredTeams + }; + } + + /// + /// Populates each team's Players collection with the roster registered for one season. + /// + /// The teams to attach a roster to, mutated in place. + /// + /// When set, every team's roster is scoped to this season instead of its own current + /// TournamentId, used when the caller already filtered teams by a specific tournament, or + /// explicitly asked to view a past season. + /// + private async Task AttachSeasonRostersAsync(List teams, Guid? tournamentIdOverride) + { + List teamIds = [.. teams.Select(t => t.Id)]; + + if (teamIds.Count == 0) + { + return; + } + + List registrations = [.. await _registrationRepository.FindAsync( + registration => teamIds.Contains(registration.TeamId), + includes: [registration => registration.Player!])]; + + ILookup registrationsByTeam = registrations.ToLookup(r => r.TeamId); + + foreach (Team team in teams) + { + Guid? season = tournamentIdOverride ?? team.TournamentId; + + team.Players = season is null + ? [] + : [.. registrationsByTeam[team.Id] + .Where(r => r.TournamentId == season.Value) + .Select(r => + { + // Surface the season-scoped eligibility onto the roster player so responses expose habilitado or not-habilitado per player, without persisting it back. + r.Player!.MedicalRecordStatus = r.MedicalRecordStatus; + // The medical-record eligibility flag is a bool only, never the storage path; see Player.HasMedicalRecordFile. + r.Player!.HasMedicalRecordFile = PlayerTeamRegistration.IsStoredReference(r.MedicalRecordFileUrl); + // Surface the season-scoped dorsal onto the roster player without persisting it back. + r.Player!.JerseyNumber = r.JerseyNumber; + return r.Player!; + })]; + } + } + + /// + /// Reconciles a tournament's team roster by upserting TeamTournamentRegistration rows for this tournament only. + /// + /// The tournament entity to register teams to. + /// A list of team IDs to be registered in the tournament. + /// A task representing the asynchronous operation. + public async Task RegisterTeamsToTournamentAsync(Tournament tournament, List teamIds) + { + // Structural guard: team registrations may only be added or removed while the tournament is OpenForRegistration, since once registration closes the roster is frozen. + if (tournament.Status != TournamentStatus.OpenForRegistration) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.StructuralEditNotAllowed(tournament.Status)); + } + + HashSet targetTeamIds = [.. teamIds]; + + List existingRegistrations = + [.. await _tournamentRegistrationRepository.FindAsync( + registration => registration.TournamentId == tournament.Id)]; + + // Remove only this tournament's registrations for teams no longer in the list, scoped by registration Id so no other season is affected. + List registrationIdsToRemove = [.. existingRegistrations + .Where(registration => !targetTeamIds.Contains(registration.TeamId)) + .Select(registration => registration.Id)]; + + if (registrationIdsToRemove.Count > 0) + { + await _tournamentRegistrationRepository.RemoveAsync( + registration => registrationIdsToRemove.Contains(registration.Id)); + } + + // Add a registration for every listed team that does not already have one for this tournament, an upsert that leaves existing rows untouched. + HashSet alreadyRegisteredTeamIds = [.. existingRegistrations.Select(registration => registration.TeamId)]; + + List registrationsToAdd = [.. targetTeamIds + .Where(teamId => !alreadyRegisteredTeamIds.Contains(teamId)) + .Select(teamId => new TeamTournamentRegistration + { + Id = Guid.Empty, + TeamId = teamId, + TournamentId = tournament.Id, + DateCreated = DateTime.UtcNow, + CreatedBy = tournament.UpdatedBy ?? tournament.CreatedBy ?? AuditConstants.SystemUser, + })]; + + if (registrationsToAdd.Count > 0) + { + await _tournamentRegistrationRepository.AddRangeAsync(registrationsToAdd); + } + + // Keep the denormalized current-season pointer in sync: listed teams point at this tournament, dropped teams pointing here are cleared, and teams pointing elsewhere are left alone. + List affectedTeams = [.. await _teamRepository.FindAsync(team => teamIds.Contains(team.Id) + || team.TournamentId == tournament.Id)]; + + foreach (Team team in affectedTeams) + { + team.TournamentId = targetTeamIds.Contains(team.Id) ? tournament.Id : null; + } + + if (affectedTeams.Count > 0) + { + await _teamRepository.UpdateRangeAsync(affectedTeams); + } + } + + /// + public async Task EnrollTeamAsync( + Tournament tournament, + Guid? existingTeamId, + string? newTeamName, + Guid? copyRosterFromTournamentId) + { + // Fail fast with the same rejection RegisterTeamsToTournamentAsync uses, mapped to 409, before doing any work. + if (tournament.Status != TournamentStatus.OpenForRegistration) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.StructuralEditNotAllowed(tournament.Status)); + } + + Guid enrolledTeamId = Guid.Empty; + + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + if (existingTeamId is Guid existing) + { + _ = await _teamRepository.GetByIdAsync(existing) + ?? throw new KeyNotFoundException(ErrorMessages.Team.NotFound(existing)); + + // A team can be enrolled only once per tournament, and since RegisterTeamsToTournamentAsync is an idempotent upsert that would silently no-op on a duplicate, surface a clean conflict here instead, mapped to 409. + bool alreadyEnrolled = await _tournamentRegistrationRepository.ExistsAsync( + registration => registration.TeamId == existing + && registration.TournamentId == tournament.Id); + + if (alreadyEnrolled) + { + throw new InvalidOperationException( + ErrorMessages.Team.AlreadyEnrolled(existing, tournament.Id)); + } + + enrolledTeamId = existing; + } + else + { + Team created = await CreateTeamAsync(BuildNewTeam(newTeamName!)); + enrolledTeamId = created.Id; + } + + // Additive enroll: keep every team already registered to this tournament and add the new one, since passing only the new id would make RegisterTeamsToTournamentAsync unregister the rest of the roster. + List targetTeamIds = + [ + .. (await _tournamentRegistrationRepository.FindAsync( + registration => registration.TournamentId == tournament.Id)) + .Select(registration => registration.TeamId), + enrolledTeamId, + ]; + + await RegisterTeamsToTournamentAsync(tournament, targetTeamIds); + + // Copy roster is only meaningful for an existing team; the caller guarantees copyRosterFromTournamentId is only set together with existingTeamId. + if (existingTeamId is Guid teamToCopyInto && copyRosterFromTournamentId is Guid sourceTournamentId) + { + await _rosterCopyService.CopyRosterAsync( + teamToCopyInto, sourceTournamentId, teamToCopyInto, tournament.Id); + } + }); + + // Reload with the roster scoped to THIS tournament for the response. + return (await GetTeamByIdAsync(enrolledTeamId, tournament.Id))!; + } + + /// + public async Task UnenrollTeamAsync(Tournament tournament, Guid teamId) + { + // Roster-editing window guard: teams may only be removed before the tournament starts, while OpenForRegistration or RegistrationClosed, mapped to 409 by the global handler. + if (tournament.Status is not (TournamentStatus.OpenForRegistration or TournamentStatus.RegistrationClosed)) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.UnenrollNotAllowed(tournament.Status)); + } + + bool isEnrolled = await _tournamentRegistrationRepository.ExistsAsync( + registration => registration.TeamId == teamId && registration.TournamentId == tournament.Id); + + if (!isEnrolled) + { + throw new KeyNotFoundException(ErrorMessages.Team.NotEnrolled(teamId, tournament.Id)); + } + + // Stage ids belonging to this tournament via its divisions, so only this tournament's assignments for the team are removed. + List divisionIds = [.. (await _unitOfWork.DivisionRepository.FindAsync( + division => division.TournamentId == tournament.Id)).Select(division => division.Id)]; + + List stageIds = divisionIds.Count == 0 + ? [] + : [.. (await _unitOfWork.StageRepository.FindAsync( + stage => divisionIds.Contains(stage.DivisionId))).Select(stage => stage.Id)]; + + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + await _tournamentRegistrationRepository.RemoveAsync( + registration => registration.TeamId == teamId && registration.TournamentId == tournament.Id); + + await _registrationRepository.RemoveAsync( + registration => registration.TeamId == teamId && registration.TournamentId == tournament.Id); + + if (stageIds.Count > 0) + { + await _unitOfWork.StageTeamMatchRepository.RemoveAsync( + match => match.TeamId == teamId && stageIds.Contains(match.StageId)); + } + + // Clear the denormalized current-season pointer only when it points at this tournament; a team pointing at another season is untouched. + Team? team = await _teamRepository.GetByIdAsync(teamId); + if (team is not null && team.TournamentId == tournament.Id) + { + team.TournamentId = null; + await _teamRepository.UpdateAsync(team); + } + }); + } + + /// + /// Builds a minimal brand-new team from just a name, for the new-team enrollment path. + /// + private static Team BuildNewTeam(string name) + { + string alphanumeric = new([.. name.Where(char.IsLetterOrDigit)]); + string threeLetterCode = (alphanumeric.Length >= 3 + ? alphanumeric[..3] + : alphanumeric.PadRight(3, 'X')).ToUpperInvariant(); + + return new Team + { + Name = name, + Slug = string.Empty, + ThreeLetterCode = threeLetterCode, + LogoUrl = string.Empty, + ShirtColor = string.Empty, + TournamentId = null, + Players = [], + CreatedBy = AuditConstants.SystemUser, + }; + } + + /// + public async Task GetTeamSummaryAsync(Guid teamId, Guid? tournamentId) + { + // No season context means there is no standing to report, since the team has no current tournament and none was requested. + if (tournamentId is null) + { + return null; + } + + // Preferring the team's real division over a cross-division cup, and ordering cups last, ensures the first group table containing the team is its own zone whenever it has one. + List divisions = [.. (await _divisionRepository.FindAsync( + division => division.TournamentId == tournamentId.Value)) + .OrderBy(division => division.IsCrossDivisionCup)]; + + foreach (Division division in divisions) + { + // Reuse the canonical per-group standings computation instead of recomputing it here, since a regular zone yields one group and a cross-division cup yields one per internal group. + List groups = await _divisionService.GetGroupStandingsByDivisionIdAsync(division.Id); + + var located = groups + .Select(group => new + { + Group = group, + Index = group.Positions.FindIndex(position => position.TeamId == teamId), + }) + .FirstOrDefault(candidate => candidate.Index >= 0); + + if (located is not null) + { + Position row = located.Group.Positions[located.Index]; + + return new TeamSummaryResponse + { + DivisionId = division.Id, + DivisionName = division.Name, + Position = located.Index + 1, + TotalTeams = located.Group.Positions.Count, + Played = row.MatchesPlayed, + Wins = row.Wins, + Losses = row.Losses, + PointsFor = row.PointsFor, + PointsAgainst = row.PointsAgainst, + PointsDifference = row.PointsDifference, + Points = row.Points, + }; + } + } + + // The team plays in no group-stage table for this tournament, whether playoff-only, unassigned, or with no finished matches yet. + return null; + } + + /// + public async Task> GetTeamMatchesAsync(Guid teamId, Guid? tournamentId) + { + if (tournamentId is null) + { + return []; + } + + // Queried directly rather than through the paginated match filter, which has no by-team predicate and would cap a tournament-wide read at one page. + List matches = [.. await _matchRepository.FindAsync( + match => (match.HomeTeamId == teamId || match.VisitorTeamId == teamId) + && match.Stage.Division.TournamentId == tournamentId.Value, + includes: [match => match.HomeTeam!, match => match.VisitorTeam!, match => match.Venue!])]; + + return [.. matches + .OrderBy(match => match.MatchDate) + .Select(match => TeamMatchMapper.Map(match, teamId))]; + } + + /// + public async Task> GetTeamParticipationsAsync(Guid teamId, Guid? currentTournamentId) + { + List registrations = [.. await _tournamentRegistrationRepository.FindAsync( + registration => registration.TeamId == teamId)]; + + if (registrations.Count == 0) + { + return []; + } + + List tournamentIds = [.. registrations.Select(registration => registration.TournamentId).Distinct()]; + + List tournaments = [.. await _tournamentRepository.FindAsync( + tournament => tournamentIds.Contains(tournament.Id), + includes: [tournament => tournament.Season!])]; + + return [.. tournaments + .Select(tournament => new TeamParticipationResponse + { + TournamentId = tournament.Id, + TournamentName = tournament.Name, + TournamentSlug = tournament.Slug, + Category = tournament.Category.ToString(), + SeasonId = tournament.SeasonId, + SeasonName = tournament.Season?.Name, + Year = tournament.Season?.Year, + IsCurrent = currentTournamentId.HasValue && tournament.Id == currentTournamentId.Value, + }) + // Newest first: known years descending, participations without a season year last, ties broken by tournament name. + .OrderByDescending(participation => participation.Year.HasValue) + .ThenByDescending(participation => participation.Year) + .ThenBy(participation => participation.TournamentName, StringComparer.OrdinalIgnoreCase)]; + } +} diff --git a/Club12-Backend/Application/Services/TeamStaffService.cs b/Club12-Backend/Application/Services/TeamStaffService.cs new file mode 100644 index 0000000..3a70e0a --- /dev/null +++ b/Club12-Backend/Application/Services/TeamStaffService.cs @@ -0,0 +1,45 @@ +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Service managing a team's technical staff for a given tournament and season. +/// +public class TeamStaffService( + ITeamStaffRepository staffRepository) : ITeamStaffService +{ + /// + public async Task CreateAsync(TeamStaff staff) + { + await staffRepository.AddAsync(staff); + + // Reload with the team navigation so the response can name the team. + return await staffRepository.GetByIdAsync( + staff.Id, + includes: [entity => entity.Team!]) ?? staff; + } + + /// + public async Task> GetByTeamAndTournamentAsync(Guid teamId, Guid tournamentId) + { + IEnumerable staff = await staffRepository.FindAsync( + member => member.TeamId == teamId && member.TournamentId == tournamentId, + includes: [member => member.Team!]); + + return [.. staff.OrderBy(member => member.DateCreated)]; + } + + /// + public async Task DeleteAsync(Guid id) + { + await staffRepository.RemoveAsync(member => member.Id == id); + } +} diff --git a/Club12-Backend/Application/Services/TournamentService.cs b/Club12-Backend/Application/Services/TournamentService.cs new file mode 100644 index 0000000..2eb4759 --- /dev/null +++ b/Club12-Backend/Application/Services/TournamentService.cs @@ -0,0 +1,517 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Match.Request; +using Application.DTOs.Stage.Request; +using Application.DTOs.Tournament.Request; +using Application.DTOs.Tournament.Response; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Constants.Pagination; +using Application.Utils.Extensions; +using Application.Utils.Helper.Slug; +using Application.Utils.Helper.Tournament; + +using Domain.Constants; +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Owns the tournament lifecycle: structural creation, status transitions, and their cascades. +/// +public class TournamentService( + ITournamentRepository tournamentRepository, + IStageService stageService, + IMatchService matchService, + IAuditService auditService, + IDivisionService divisionService, + IUnitOfWork unitOfWork) : ITournamentService +{ + public async Task CreateTournamentAsync(Tournament tournamentEntity) + { + tournamentEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + tournamentEntity.Name, + candidate => tournamentRepository.ExistsAsync(tournament => tournament.Slug == candidate)); + + await tournamentRepository.AddAsync(tournamentEntity); + return tournamentEntity; + } + + /// + public async Task CreateFullTournamentAsync(CreateFullTournamentRequest request) + { + Tournament tournament = new() + { + Name = request.Name, + Description = request.Description, + Slug = null!, + TeamRegistrationDeadline = request.TeamRegistrationDeadline, + StartDate = request.StartDate, + Category = request.Category, + SeasonId = request.SeasonId, + Status = TournamentStatus.OpenForRegistration, + Divisions = [], + Teams = [], + CreatedBy = AuditConstants.SystemUser, + }; + + await unitOfWork.ExecuteInTransactionAsync(async () => + { + await CreateTournamentAsync(tournament); + + foreach (CreateFullDivisionRequest divisionRequest in request.Divisions) + { + await CreateDivisionWithStagesAsync(tournament, divisionRequest); + } + }); + + return await GetTournamentByIdAsync(tournament.Id) ?? tournament; + } + + /// + public async Task AddFullDivisionAsync(Tournament tournament, CreateFullDivisionRequest divisionRequest) + { + Division division = null!; + + await unitOfWork.ExecuteInTransactionAsync(async () => + { + division = await CreateDivisionWithStagesAsync(tournament, divisionRequest); + }); + + return division; + } + + /// + /// Builds and persists one division and its stages from a CreateFullDivisionRequest. + /// + private async Task CreateDivisionWithStagesAsync( + Tournament tournament, CreateFullDivisionRequest divisionRequest) + { + Division division = new() + { + Name = divisionRequest.Name, + Slug = null!, + Tournament = tournament, + TournamentId = tournament.Id, + Category = divisionRequest.Category, + IsCrossDivisionCup = divisionRequest.IsCrossDivisionCup, + PointsForWin = divisionRequest.PointsForWin, + PointsForLoss = divisionRequest.PointsForLoss, + QualifiersPerGroup = divisionRequest.QualifiersPerGroup, + Stages = [], + CreatedBy = AuditConstants.SystemUser, + PlayoffMappings = (divisionRequest.PlayoffMappings ?? []) + .Select(mapping => new DivisionPlayoffMapping + { + FromPosition = mapping.FromPosition, + ToPosition = mapping.ToPosition, + Destination = mapping.Destination, + CreatedBy = AuditConstants.SystemUser, + }) + .ToList(), + }; + + await divisionService.CreateDivisionAsync(division); + + for (int i = 0; i < divisionRequest.Stages.Count; i++) + { + CreateFullStageRequest stageRequest = divisionRequest.Stages[i]; + Stage stage = new() + { + Name = stageRequest.Name, + Slug = null!, + Description = stageRequest.Description, + StageType = stageRequest.StageType, + IsActive = stageRequest.IsActive ?? true, + IsElimination = stageRequest.IsElimination ?? (stageRequest.StageType != StageType.Group), + StartDate = stageRequest.StartDate, + EndDate = stageRequest.EndDate, + DivisionId = division.Id, + Division = division, + BracketName = stageRequest.BracketName, + BestOf = stageRequest.BestOf, + RoundRobinLegs = stageRequest.RoundRobinLegs, + Order = i, + Matches = [], + CreatedBy = AuditConstants.SystemUser, + }; + + await stageService.CreateStageAsync(stage); + } + + return division; + } + + public async Task GetTournamentByIdAsync(Guid tournamentId) + { + return await tournamentRepository.GetByIdAsync( + tournamentId, + includes: [tournament => tournament.Divisions, tournament => tournament.Season!]); + } + + /// + /// Retrieves a tournament by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The tournament's GUID id or its slug. + /// The matching tournament, or null if not found. + public async Task GetTournamentByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid tournamentId)) + { + return await GetTournamentByIdAsync(tournamentId); + } + + IEnumerable matches = await tournamentRepository.FindAsync( + tournament => tournament.Slug == idOrSlug, + includes: [tournament => tournament.Divisions, tournament => tournament.Season!]); + + return matches.FirstOrDefault(); + } + + /// + public async Task GetTournamentStructureAsync(Guid tournamentId) + { + Tournament? tournament = await tournamentRepository.GetByIdAsync(tournamentId); + if (tournament is null) + { + return null; + } + + // IGenericRepository's includes only support a single level of + // .Include(), so Tournament.Divisions.Stages cannot be expressed + // directly — reuses the same two-step load EvaluateCompletabilityAsync + // already relies on for the same reason. + List divisions = [.. await unitOfWork.DivisionRepository.FindAsync( + division => division.TournamentId == tournamentId, + includes: [division => division.Stages, division => division.PlayoffMappings], + asSplitQuery: true)]; + + tournament.Divisions = divisions; + + return tournament; + } + + /// + /// Deletes a tournament, blocking the delete once it has real history. + /// + /// The id of the tournament to delete. + /// + /// Thrown as a 409 when the tournament has already started or has played matches. + /// + public async Task DeleteTournamentAsync(Guid id) + { + Tournament? tournament = await tournamentRepository.GetByIdAsync(id); + + // Nothing to delete, so keep the historical idempotent no-op behavior. + if (tournament is null) + { + return; + } + + bool hasStarted = tournament.Status is TournamentStatus.Ongoing or TournamentStatus.Finished; + bool hasPlayedMatches = await unitOfWork.MatchRepository.ExistsAsync( + match => match.IsFinished && match.Stage.Division.TournamentId == id); + + if (hasStarted || hasPlayedMatches) + { + throw new InvalidOperationException(ErrorMessages.Tournament.HasHistoryCannotDelete); + } + + await unitOfWork.ExecuteInTransactionAsync(async () => + { + // Clear the denormalized current-season pointer of any team pointing at this tournament before the cascade delete, since Team to Tournament is NoAction and a still-set pointer would abort the delete with an opaque FK error. + List pointingTeams = [.. await unitOfWork.TeamRepository.FindAsync( + team => team.TournamentId == id)]; + + foreach (Team team in pointingTeams) + { + team.TournamentId = null; + } + + if (pointingTeams.Count > 0) + { + await unitOfWork.TeamRepository.UpdateRangeAsync(pointingTeams); + } + + await tournamentRepository.RemoveAsync(tournament => tournament.Id == id); + }); + } + + public async Task UpdateTournamentAsync(Tournament tournamentEntity) + { + await tournamentRepository.UpdateAsync(tournamentEntity); + } + + /// + /// Drives the tournament status state machine, applying whichever cascade the new status implies. + /// + /// The tournament to transition. + /// The status to transition to. + /// Thrown when the tournament does not exist. + /// + /// Thrown as a 409 when the transition is invalid, or when moving to Ongoing while the + /// tournament fails the completability check. + /// + public async Task ChangeStatusAsync(Guid tournamentId, TournamentStatus newStatus) + { + Tournament tournament = await tournamentRepository.GetByIdAsync( + tournamentId, includes: [tournament => tournament.Divisions]) + ?? throw new KeyNotFoundException(ErrorMessages.Tournament.NotFound(tournamentId)); + + if (tournament.Status == newStatus) + { + return; + } + + if (!TournamentStatusTransitions.IsValidTransition(tournament.Status, newStatus)) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.InvalidStatusTransition(tournament.Status, newStatus)); + } + + // Fixture generation runs before the status is committed, so a generation failure leaves the tournament in its prior editable status instead of stranding it in Ongoing with a half-built fixture. + if (newStatus == TournamentStatus.Ongoing) + { + // The completability guard runs on the loaded graph before generating any fixture, so a violation aborts the transition, mapped to 409, before any half-built fixture is left. + IReadOnlyList issues = await EvaluateCompletabilityAsync(tournamentId); + if (issues.Count > 0) + { + throw new InvalidOperationException( + ErrorMessages.Tournament.NotCompletable(SummarizeIssues(issues))); + } + + // Wrapped in a transaction so a failure partway through one division's stages rolls back every match already generated in this attempt too, leaving a clean slate to retry from. + await unitOfWork.ExecuteInTransactionAsync(() => GenerateFixtureAsync(tournament)); + } + + // Moving Ongoing to RegistrationClosed reopens the assignment phase, so the fixture generated at start is torn down while team-to-zone assignments are kept, letting the organizer re-start to rebuild the fixture. + if (tournament.Status == TournamentStatus.Ongoing + && newStatus == TournamentStatus.RegistrationClosed) + { + await TeardownFixtureAsync(tournament); + } + + // Canceling a tournament, or force-closing it as Finished while matches are still pending, must not leave still-to-be-played fixtures dangling under a dead tournament, so the leftover matches need their own terminal state too. + if (newStatus is TournamentStatus.Canceled or TournamentStatus.Finished) + { + await CancelPendingMatchesAsync(tournamentId); + } + + TournamentStatus previousStatus = tournament.Status; + tournament.Status = newStatus; + await tournamentRepository.UpdateAsync(tournament); + + // Record the sensitive status change for traceability. + await auditService.LogAsync( + AuditAction.TournamentStatusChange, + targetType: nameof(Tournament), + targetId: tournamentId.ToString(), + targetName: tournament.Name, + detail: $"{previousStatus.ToSpanishLabel()} → {newStatus.ToSpanishLabel()}"); + } + + /// + /// Generates the automated fixture for every stage of every division in the tournament. + /// + private async Task GenerateFixtureAsync(Tournament tournament) + { + foreach (Division division in tournament.Divisions) + { + PaginatedResponse stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest + { + DivisionId = division.Id, + PageSize = PaginationDefaults.MaxPageSize, + }); + + foreach (Guid stageId in stages.Items.Select(stage => stage.Id)) + { + PaginatedResponse existingMatches = await matchService.GetAllMatchesAsync(new GetMatchesFilteredRequest + { + StageId = stageId, + PageSize = 1, + }); + + if (existingMatches.TotalCount > 0) + { + continue; + } + + await matchService.CreateAutomatedMatchesAsync(stageId); + } + } + } + + /// + /// Deletes every generated match and best-of-N series across the tournament, back to a fixture-less state. + /// + private async Task TeardownFixtureAsync(Tournament tournament) + { + List stageIds = []; + + foreach (Division division in tournament.Divisions) + { + PaginatedResponse stages = await stageService.GetAllStagesAsync(new GetStagesFilteredRequest + { + DivisionId = division.Id, + PageSize = PaginationDefaults.MaxPageSize, + }); + + stageIds.AddRange(stages.Items.Select(stage => stage.Id)); + } + + if (stageIds.Count == 0) + { + return; + } + + // Reverting is only safe while nothing has been played, since a finished match carries results and scorers that tearing down the fixture would lose. + int playedMatches = await unitOfWork.MatchRepository.CountAsync( + match => stageIds.Contains(match.StageId) && match.IsFinished); + + if (playedMatches > 0) + { + throw new InvalidOperationException(ErrorMessages.Tournament.CannotRevertWithPlayedMatches); + } + + // Matches are removed first, since they may reference a series, then the series. + await unitOfWork.MatchRepository.RemoveAsync(match => stageIds.Contains(match.StageId)); + await unitOfWork.MatchSeriesRepository.RemoveAsync(series => stageIds.Contains(series.StageId)); + } + + /// + /// Marks every not-yet-finished match of a tournament as MatchStatus.Canceled. + /// + private async Task CancelPendingMatchesAsync(Guid tournamentId) + { + List pendingMatches = [.. await unitOfWork.MatchRepository.FindAsync( + match => !match.IsFinished + && match.Status != MatchStatus.Canceled + && match.Stage.Division.TournamentId == tournamentId)]; + + if (pendingMatches.Count == 0) + { + return; + } + + foreach (Match match in pendingMatches) + { + match.Status = MatchStatus.Canceled; + } + + await unitOfWork.MatchRepository.UpdateRangeAsync(pendingMatches); + } + + /// + public async Task GetCompletabilityAsync(Guid tournamentId) + { + IReadOnlyList issues = await EvaluateCompletabilityAsync(tournamentId); + + return new TournamentCompletabilityResponse + { + CanStart = issues.Count == 0, + Issues = [.. issues], + }; + } + + /// + /// Loads the graph the completability validator needs and runs it against a throwaway tournament. + /// + private async Task> EvaluateCompletabilityAsync(Guid tournamentId) + { + bool exists = await tournamentRepository.ExistsAsync(tournament => tournament.Id == tournamentId); + if (!exists) + { + throw new KeyNotFoundException(ErrorMessages.Tournament.NotFound(tournamentId)); + } + + List divisions = [.. await unitOfWork.DivisionRepository.FindAsync( + division => division.TournamentId == tournamentId, + includes: [division => division.Stages, division => division.PlayoffMappings], + asSplitQuery: true)]; + + List stageIds = [.. divisions.SelectMany(division => division.Stages).Select(stage => stage.Id)]; + + List stageTeamMatches = stageIds.Count == 0 + ? [] + : [.. await unitOfWork.StageTeamMatchRepository.FindAsync( + match => stageIds.Contains(match.StageId), + includes: [match => match.Team!])]; + + ILookup matchesByStage = stageTeamMatches.ToLookup(match => match.StageId); + foreach (Stage stage in divisions.SelectMany(division => division.Stages)) + { + stage.StageTeamMatches = [.. matchesByStage[stage.Id]]; + } + + List registrations = + [.. await unitOfWork.TeamTournamentRegistrationRepository.FindAsync( + registration => registration.TournamentId == tournamentId, + includes: [registration => registration.Team!])]; + + // Only HABILITADO players count toward the minimum, per the owner's "no se puede arrancar torneos sin al menos 4 habilitados" rule, since a registration that is merely on the roster but Pending, Rejected, or Approved with no real stored file could never legally play. + Dictionary habilitadoPlayerCountsByTeam = (await unitOfWork.PlayerTeamRegistrationRepository.FindAsync( + registration => registration.TournamentId == tournamentId)) + .Where(registration => registration.IsHabilitado) + .GroupBy(registration => registration.TeamId) + .ToDictionary(group => group.Key, group => group.Count()); + + Tournament graph = new() + { + Name = string.Empty, + Description = string.Empty, + Slug = string.Empty, + TeamRegistrationDeadline = default, + StartDate = default, + Divisions = divisions, + Teams = [], + CreatedBy = AuditConstants.SystemUser, + }; + + return TournamentCompletabilityValidator.Validate(graph, registrations, habilitadoPlayerCountsByTeam); + } + + /// + /// Renders the structured completability issues into a compact summary for the blocking exception. + /// + private static string SummarizeIssues(IReadOnlyList issues) + { + return string.Join("; ", issues.Select(issue => issue.Code switch + { + CompletabilityIssueCodes.ZoneTooFewTeams => + $"zone '{issue.DivisionName}' has {issue.AssignedTeams} team(s), needs at least {TournamentCompletabilityValidator.MinTeamsPerZone}", + CompletabilityIssueCodes.TeamNotAssigned => + $"team '{issue.TeamName}' is not assigned to any zone", + CompletabilityIssueCodes.TeamInMultipleZones => + $"team '{issue.TeamName}' is assigned to more than one zone", + CompletabilityIssueCodes.PlayoffRangeExceedsTeams => + $"zone '{issue.DivisionName}' has a playoff range starting at position {issue.FromPosition} but only {issue.AssignedTeams} team(s) assigned", + CompletabilityIssueCodes.CrossCupGroupTooFewTeams => + $"cross-cup '{issue.DivisionName}' group has {issue.AssignedTeams} team(s), needs at least {TournamentCompletabilityValidator.MinTeamsPerZone}", + CompletabilityIssueCodes.TeamTooFewPlayers => + $"team '{issue.TeamName}' has {issue.PlayerCount} habilitado player(s), needs at least {TournamentCompletabilityValidator.MinPlayersPerTeam}", + _ => issue.Code, + })); + } + + public async Task> GetAllTournamentsAsync(GetTournamentsFilteredRequest filter) + { + Expression> expression = QueryableExtensions.ConstructFilterExpression(filter); + IEnumerable filteredTournaments = await tournamentRepository.FindAsync(expression, filter: filter); + int totalCount = await tournamentRepository.CountAsync(expression); + + return new PaginatedResponse + { + Page = filter.PageNumber, + PageSize = filter.PageSize, + TotalCount = totalCount, + Items = filteredTournaments + }; + } +} diff --git a/Club12-Backend/Application/Services/VenueService.cs b/Club12-Backend/Application/Services/VenueService.cs new file mode 100644 index 0000000..4186c82 --- /dev/null +++ b/Club12-Backend/Application/Services/VenueService.cs @@ -0,0 +1,80 @@ +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Application.Utils.Constants; +using Application.Utils.Helper.Slug; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Application.Services; + +/// +/// Manages venues, blocking deletion while any match still references the venue. +/// +public class VenueService(IVenueRepository venueRepository, IMatchRepository matchRepository) : IVenueService +{ + public async Task CreateVenueAsync(Venue venueEntity) + { + venueEntity.Slug = await SlugGenerator.GenerateUniqueSlugAsync( + venueEntity.Name, + candidate => venueRepository.ExistsAsync(venue => venue.Slug == candidate)); + + await venueRepository.AddAsync(venueEntity); + return venueEntity; + } + + public async Task GetVenueByIdAsync(Guid venueId) + { + return await venueRepository.GetByIdAsync(venueId); + } + + /// + /// Retrieves a venue by id or slug, auto-detecting which one was passed via GUID parsing. + /// + /// The venue's GUID id or its slug. + /// The matching venue, or null if not found. + public async Task GetVenueByIdOrSlugAsync(string idOrSlug) + { + if (Guid.TryParse(idOrSlug, out Guid venueId)) + { + return await GetVenueByIdAsync(venueId); + } + + IEnumerable matches = await venueRepository.FindAsync(venue => venue.Slug == idOrSlug); + return matches.FirstOrDefault(); + } + + /// + /// Deletes a venue. Blocked while any match still references it. + /// + /// The unique identifier of the venue to delete. + /// + /// Thrown as a 409 when the venue is referenced by one or more matches. + /// + public async Task DeleteVenueAsync(Guid id) + { + // Integrity: a venue referenced by one or more matches cannot be deleted, since that would leave those matches without their venue. + bool referencedByMatches = await matchRepository.ExistsAsync(match => match.VenueId == id); + + if (referencedByMatches) + { + throw new InvalidOperationException(ErrorMessages.Venue.ReferencedByMatches); + } + + await venueRepository.RemoveAsync(venue => venue.Id == id); + } + + public async Task UpdateVenueAsync(Venue venueEntity) + { + await venueRepository.UpdateAsync(venueEntity); + } + + public async Task> GetAllVenuesAsync() + { + return await venueRepository.FindAsync(venue => true); + } +} diff --git a/Club12-Backend/Application/Utils/Constants/Auth/CustomClaimTypes.cs b/Club12-Backend/Application/Utils/Constants/Auth/CustomClaimTypes.cs new file mode 100644 index 0000000..9a86dc4 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Auth/CustomClaimTypes.cs @@ -0,0 +1,12 @@ +namespace Application.Utils.Constants.Auth; + +/// +/// Application-specific JWT claim types that are not part of the standard ClaimTypes set. +/// +public static class CustomClaimTypes +{ + /// + /// Boolean claim, stored as the string true or false, indicating the user must change their password before most endpoints. + /// + public const string MustChangePassword = "must_change_password"; +} diff --git a/Club12-Backend/Application/Utils/Constants/Auth/TokenLifetime.cs b/Club12-Backend/Application/Utils/Constants/Auth/TokenLifetime.cs new file mode 100644 index 0000000..c646be2 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Auth/TokenLifetime.cs @@ -0,0 +1,14 @@ +namespace Application.Utils.Constants.Auth; + +/// +/// Durations and sizes applied when issuing JWT access/refresh tokens. +/// +public static class TokenLifetime +{ + public const int AccessTokenExpiryHours = 24; + + /// + /// Size, in bytes, of the cryptographically random refresh token. + /// + public const int RefreshTokenByteLength = 64; +} diff --git a/Club12-Backend/Application/Utils/Constants/Configuration/ConfigurationKeys.cs b/Club12-Backend/Application/Utils/Constants/Configuration/ConfigurationKeys.cs new file mode 100644 index 0000000..bd9d844 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Configuration/ConfigurationKeys.cs @@ -0,0 +1,54 @@ +namespace Application.Utils.Constants.Configuration; + +/// +/// Centralized configuration key paths read directly by Application and Infrastructure code. +/// +public static class ConfigurationKeys +{ + /// + /// Name of the connection string entry under ConnectionStrings, used for both the application database and the ASP.NET Core Identity database. + /// + public const string DbConnection = "DbConnection"; + + public static class Jwt + { + public const string Key = "JWT:Key"; + public const string Issuer = "JWT:Issuer"; + public const string Audience = "JWT:Audience"; + } + + public static class Frontend + { + public const string PasswordResetUrl = "Frontend:PasswordResetUrl"; + + /// + /// Base URL of the account-activation page the invited user lands on from the invitation email. + /// + public const string ActivationUrl = "Frontend:ActivationUrl"; + } + + public static class AdminUser + { + public const string Email = "AdminUser:Email"; + public const string Password = "AdminUser:Password"; + } + + public static class Backup + { + public const string PgDumpPath = "Backup:PgDumpPath"; + public const string PsqlPath = "Backup:PsqlPath"; + } + + public static class Supabase + { + public const string Section = "SupaBase"; + public const string ProjectUrl = "ProjectUrl"; + public const string ServiceRole = "ServiceRole"; + public const string BucketName = "BucketName"; + + /// + /// Name of the private Supabase bucket medical-record PDFs are stored in, separate from BucketName. + /// + public const string MedicalRecordsBucketName = "MedicalRecordsBucketName"; + } +} diff --git a/Club12-Backend/Application/Utils/Constants/ErrorMessages.cs b/Club12-Backend/Application/Utils/Constants/ErrorMessages.cs new file mode 100644 index 0000000..9af7c40 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/ErrorMessages.cs @@ -0,0 +1,608 @@ +namespace Application.Utils.Constants; + +/// +/// Centralized user-facing and exception message text, so a message never needs to be typed twice and a wording change happens in one place. +/// +public static class ErrorMessages +{ + public static class Media + { + public const string InvalidImageFile = "El archivo de la foto debe ser una imagen JPEG/PNG válida."; + } + + public static class Auth + { + public const string EmailAlreadyExists = "Ya existe un usuario con ese email."; + public const string InvalidCredentials = "Credenciales inválidas."; + public const string AccountDeactivated = "Esta cuenta está desactivada."; + public const string NoAccountForEmail = "No se encontró ninguna cuenta para ese email."; + public const string InvalidMagicLink = "El magic-link no es válido."; + public const string MagicLinkAlreadyUsed = "El magic-link no es válido o ya fue usado."; + public const string InvalidPasswordResetRequest = "Solicitud de restablecimiento de contraseña inválida."; + public const string InvalidRefreshToken = "El refresh token no es válido."; + public const string RefreshTokenExpired = "El refresh token venció. Iniciá sesión de nuevo."; + public const string RoleClaimMissing = "Falta el claim de rol en el token."; + public const string IdClaimMissing = "Falta el claim de id en el token."; + public const string AccessDenied = "Acceso denegado."; + + public static string UserCreationFailed(string errors) + { + return $"No se pudo crear el usuario: {errors}"; + } + + public static string RoleNotAllowedToCreate(string callerRole, string targetRole) + { + return $"El rol '{callerRole}' no puede crear usuarios con rol '{targetRole}'."; + } + } + + public static class User + { + public const string InsufficientPermissionsToListUsers = "No tenés permisos suficientes para listar usuarios."; + public const string CurrentPasswordRequired = "Debés indicar la contraseña actual para cambiar tu propia contraseña."; + public const string CannotChangeOwnActiveState = "No podés cambiar el estado activo de tu propia cuenta."; + public const string InsufficientPermissionsToDelete = "No tenés permisos suficientes para eliminar este usuario."; + public const string PasswordResetRestricted = "Solo Admins y Owners (para sus subordinados) pueden blanquear contraseñas."; + public const string CannotChangeOwnRole = "No podés cambiar tu propio rol."; + public const string InsufficientPermissionsToChangeRole = "No tenés permisos suficientes para cambiar el rol de este usuario."; + + public static string NotFound(string userId) + { + return $"No se encontró el usuario '{userId}'."; + } + + public static string InvalidRole(object role) + { + return $"'{role}' no es un rol válido."; + } + + public static string RoleNotAllowedToAssign(string callerRole, string targetRole) + { + return $"El rol '{callerRole}' no puede asignar el rol '{targetRole}'."; + } + } + + public static class Tournament + { + /// + /// Blocks reverting an Ongoing tournament that already has results. + /// + public const string CannotRevertWithPlayedMatches = + "No se puede revertir a borrador: el torneo ya tiene partidos jugados. " + + "Sólo se puede revertir un torneo en curso sin resultados cargados."; + + public static string NotFound(System.Guid tournamentId) + { + return $"No existe un torneo con id: {tournamentId}."; + } + + public static string InvalidStatusTransition( + Domain.Enums.TournamentStatus from, Domain.Enums.TournamentStatus to) + { + return $"No se puede cambiar el estado del torneo de '{from}' a '{to}'. " + + "El flujo permitido es Scheduled -> OpenForRegistration -> RegistrationClosed -> " + + "Ongoing -> Finished; un torneo puede pasar a Canceled desde cualquier estado no terminal, " + + "y Finished/Canceled son terminales."; + } + + public static string StructuralEditNotAllowed(Domain.Enums.TournamentStatus status) + { + return "Los cambios estructurales (divisiones e inscripción de equipos) solo se permiten " + + $"mientras el torneo está OpenForRegistration; este torneo está '{status}'."; + } + + public static string CategoryMismatch( + Domain.Enums.TournamentCategory divisionCategory, + Domain.Enums.TournamentCategory tournamentCategory) + { + return $"Una división '{divisionCategory}' no puede pertenecer a un torneo '{tournamentCategory}' " + + "(HU-48): la competencia femenina es un torneo aparte y no puede mezclarse con la masculina. " + + "Creá la división dentro de un torneo de la misma categoría."; + } + + public static string NotCompletable(string issueSummaries) + { + return "No se puede iniciar el torneo porque no está completo en su estado actual (HU-109): " + + $"{issueSummaries}. Resolvé todos los problemas antes de iniciar."; + } + + public static string UnenrollNotAllowed(Domain.Enums.TournamentStatus status) + { + return "Los equipos solo se pueden dar de baja mientras el torneo está OpenForRegistration o " + + $"RegistrationClosed; este torneo está '{status}'."; + } + + /// + /// User-facing Spanish message blocking deletion of a tournament that already started or has played matches, to avoid erasing competitive history. + /// + public const string HasHistoryCannotDelete = + "No se puede eliminar el torneo: tiene partidos jugados o ya arrancó."; + } + + public static class Division + { + /// + /// User-facing Spanish message blocking deletion of a division that already has played matches or point deductions, to avoid cascading away that competitive history. + /// + public const string HasHistoryCannotDelete = + "No se puede eliminar la división: tiene partidos jugados o deducciones de puntos registradas."; + + /// + /// User-facing Spanish message blocking deletion of a division whose tournament fixture is already generated. + /// + public const string StructureLockedTournamentStarted = + "No se puede eliminar la división: el torneo ya arrancó o fue cancelado."; + + public static string ConflictingRosterEnrollment(string teamIds) + { + return $"No se puede inscribir el/los equipo(s) {teamIds} en esta división: ya está(n) inscripto(s) en otra división del mismo torneo."; + } + } + + public static class Team + { + public static string NotFound(System.Guid teamId) + { + return $"No existe un equipo con id: {teamId}."; + } + + /// + /// User-facing Spanish message blocking deletion of a team that carries competitive history including match participations, sanctions, deductions or registrations. + /// + public const string HasHistoryCannotDelete = + "No se puede eliminar el equipo: tiene historial de partidos, sanciones, deducciones o inscripciones a torneos."; + + /// + /// User-facing Spanish message blocking edits to Team.Name or Team.ThreeLetterCode while the team plays in an Ongoing tournament. + /// + public const string IdentityFrozenWhileOngoing = + "No se puede cambiar el nombre ni la sigla del equipo mientras participa en un torneo en curso. " + + "Podés editar los colores y el escudo."; + + public static string NotInTournament(System.Guid teamId) + { + return $"El equipo '{teamId}' no está inscripto en ningún torneo actualmente, así que no se pueden registrar jugadores en él."; + } + + public static string AlreadyEnrolled(System.Guid teamId, System.Guid tournamentId) + { + return $"El equipo '{teamId}' ya está inscripto en el torneo '{tournamentId}'. Un equipo solo puede inscribirse una vez por torneo."; + } + + public static string NotEnrolled(System.Guid teamId, System.Guid tournamentId) + { + return $"El equipo '{teamId}' no está inscripto en el torneo '{tournamentId}'."; + } + } + + public static class Club + { + public static string NotFound(System.Guid clubId) + { + return $"No existe un club con id: {clubId}."; + } + + public const string CannotLinkToItself = + "Un club no puede vincularse a sí mismo como club matriz."; + + public const string ParentAlreadyHasParent = + "El club matriz elegido ya es una escuadra de otro club; solo se admite un nivel de vinculación."; + + public const string CannotBecomeChildWithExistingSquads = + "Este club ya tiene escuadras vinculadas, así que no puede pasar a ser una escuadra de otro club."; + + public const string NameRequired = + "El nombre del club es obligatorio."; + + public const string HasTeamsOrSquadsCannotDelete = + "No se puede eliminar un club con equipos o escuadras vinculadas."; + } + + public static class Roster + { + public static string PlayerAlreadyInAnotherTeam(System.Guid playerId, System.Guid tournamentId) + { + return $"El jugador '{playerId}' ya está registrado en otro equipo del torneo '{tournamentId}'. Un jugador no puede estar en dos equipos del mismo torneo."; + } + + public static string RosterFull(System.Guid teamId, int maxPlayers) + { + return $"El equipo '{teamId}' ya tiene el máximo de {maxPlayers} jugadores para este torneo."; + } + + public static string DuplicateJerseyNumber(int jerseyNumber, System.Guid teamId, System.Guid tournamentId) + { + return $"El dorsal {jerseyNumber} ya lo usa otro jugador del equipo '{teamId}' en el torneo '{tournamentId}'."; + } + } + + public static class Player + { + /// + /// User-facing Spanish message blocking deletion of a player who has match statistics, scorer records or sanctions, to avoid orphaning historical records. + /// + public const string HasHistoryCannotDelete = + "No se puede eliminar: el jugador tiene estadísticas o sanciones registradas."; + + /// + /// User-facing Spanish message blocking a mid-season team move for a player who already has match statistics, scorer records or sanctions that season. + /// + public const string CannotMoveTeamWithHistory = + "No se puede cambiar de equipo a un jugador que ya tiene estadísticas o sanciones registradas en esta temporada."; + + /// + /// User-facing Spanish message returned when a player is created or updated with a DocumentNumber another player already has. + /// + public static string DuplicateDocumentNumber(string documentNumber) + { + return $"Ya existe un jugador registrado con el documento {documentNumber}."; + } + } + + public static class Venue + { + /// + /// User-facing Spanish message blocking deletion of a venue that is referenced by one or more matches. + /// + public const string ReferencedByMatches = + "No se puede eliminar: la cancha tiene partidos asociados."; + } + + public static class Stage + { + public const string NotFoundGeneric = "No se encontró la fase."; + public const string DivisionNotFound = "No se encontró la división."; + public const string DivisionAlreadyHasStages = "No se puede procesar la solicitud porque la división ya tiene alguna fase."; + public const string GenerateMatchesBeforeSeeding = "Generá los partidos de esta fase antes de sembrarla."; + public const string AlreadySeeded = "Esta fase ya fue sembrada."; + public const string InvalidStageType = "Tipo de fase inválido."; + public const string SeedMissingStandings = "No se puede sembrar: todavía no todos los equipos asignados a esta fase tienen una posición de la fase de grupos finalizada."; + + /// + /// User-facing Spanish message blocking adding or removing a stage once the division's tournament has already started. + /// + public const string StructureLockedTournamentStarted = + "No se pueden agregar o quitar fases: el torneo ya arrancó o fue cancelado."; + + public static string NotFoundById(System.Guid id) + { + return $"No se encontró la fase con id {id}."; + } + + public static string NotFoundById(string idOrSlug) + { + return $"No se encontró la fase con id o slug {idOrSlug}."; + } + + public static string AlreadyExistsInDivision(string stageName) + { + return $"Ya existe una fase con el nombre '{stageName}' en la división actual."; + } + + public static string MaxTeamsReached(int maxTeams) + { + return $"Esta fase ya tiene el máximo de {maxTeams} equipos."; + } + + public static string NotEnoughSlots(int requested, int available) + { + return $"No se pueden agregar {requested} equipos. Solo hay {available} lugares disponibles."; + } + + public static string InvalidTournamentSize(int registeredTeams, string validSizes) + { + return $"Cantidad inválida de equipos inscriptos: {registeredTeams}. Los tamaños válidos son {validSizes} equipos."; + } + + public static string TeamsNotDivisibleForGroups(int registeredTeams, int groupSize) + { + return $"La cantidad de equipos inscriptos ({registeredTeams}) debe ser divisible por {groupSize} para generar las fases de grupos."; + } + + public static string ConflictingTeamAssignment(string teamIds) + { + return $"No se puede asignar el/los equipo(s) {teamIds} a esta división: ya está(n) asignado(s) a otra división del mismo torneo."; + } + + public static string TeamNotEnrolledInDivision(string teamIds) + { + return $"No se puede asignar el/los equipo(s) {teamIds} a esta fase: no está(n) inscripto(s) en el roster de la división."; + } + + public static string SeedTeamCountOutOfRange(int assignedCount, int slotCapacity) + { + return $"No se puede sembrar: hay {assignedCount} equipo(s) asignado(s) a esta fase, se esperaba entre 2 y {slotCapacity}. " + + "Una cantidad de equipos menor al cuadro completo está bien (los mejores seeds pasan con bye), pero no puede superar los lugares generados."; + } + + /// + /// User-facing Spanish message blocking a playoffs-only draw preview or commit on a division that still has a group phase. + /// + public const string DrawRequiresGrouplessDivision = + "El sorteo de llave es solo para divisiones sin fase de grupos; esta división ya tiene una fase de grupos."; + + /// + /// User-facing Spanish message rejecting a draw commit whose token is missing, tampered, expired, or does not match the stage or roster. + /// + public const string InvalidDrawToken = + "El token del sorteo no es válido o no corresponde a esta fase. Volvé a previsualizar el sorteo."; + + /// + /// User-facing Spanish message rejecting a manual seeding order that is not exactly a permutation of the division's enrolled roster. + /// + public const string ManualOrderNotRosterPermutation = + "El orden manual debe incluir exactamente a los equipos inscriptos en el roster de la división, sin repetir ni omitir ninguno."; + + /// + /// User-facing Spanish message blocking a bracket draw or re-draw once a real match of that bracket has already been played. + /// + public const string BracketAlreadyPlayed = + "No se puede sortear ni resortear esta llave: ya hay al menos un partido jugado en este cuadro."; + + /// + /// User-facing Spanish message rejecting a sub-group count below 1. + /// + public const string SubGroupCountMustBePositive = + "La cantidad de sub-grupos debe ser mayor o igual a 1."; + + /// + /// User-facing Spanish message rejecting a manual reassignment for a team not currently placed in the source sub-group. + /// + public const string TeamNotPlacedInSubGroup = + "El equipo no está ubicado en el sub-grupo de origen indicado."; + + /// + /// User-facing Spanish message rejecting a manual reassignment across two sub-groups of different divisions. + /// + public const string ReassignmentAcrossDivisionsNotAllowed = + "No se puede reasignar un equipo entre sub-grupos de distintas divisiones."; + + public static string SubGroupTooFewTeams(int teamCount, int subGroupCount) + { + return $"No se pueden crear {subGroupCount} sub-grupos con {teamCount} equipo(s) inscripto(s): cada sub-grupo necesita como mínimo 4 equipos. Elegí una cantidad de sub-grupos menor."; + } + + public static string SubGroupReassignmentBelowMinimum(int remainingTeams) + { + return $"No se puede mover el equipo: el sub-grupo de origen quedaría con {remainingTeams} equipo(s), por debajo del mínimo de 4."; + } + + /// + /// User-facing Spanish message rejecting a division split into 2+ groups that has no cup to determine an overall champion between them. + /// + public const string SubGroupsRequireCupToDetermineChampion = + "Una división con 2 o más grupos necesita al menos una copa: sin ella no hay forma de determinar un campeón entre los grupos."; + } + + public static class Match + { + public const string CannotUpdateStartedOrFinished = "No se puede editar un partido que ya arrancó o finalizó."; + public const string TeamsNotAssignedToStage = "No se puede actualizar la fecha del partido porque uno o ambos equipos no están asignados a la fase."; + public const string VenueScheduleConflict = + "Esa cancha ya tiene otro partido a menos de 2 horas de esa hora. " + + "Elegí otro horario (mínimo 2 horas de diferencia) u otra cancha."; + public const string StageAlreadyHasMatches = "No se puede procesar la solicitud porque la fase actual ya tiene partidos."; + public const string NoGroupStagesForDivision = "No se encontraron fases de grupos para la división."; + public const string NoTeamsRegistered = "No hay equipos inscriptos en el torneo."; + public const string NotEnoughTeamsPerGroup = "Se necesitan al menos 2 equipos por grupo para generar los partidos."; + public const string InvalidKnockoutStageType = "Tipo de fase eliminatoria inválido."; + public const string MatchCountMustBePositive = "La cantidad de partidos debe ser mayor a cero."; + public const string EndDateBeforeStartDate = "La fecha de fin debe ser posterior a la fecha de inicio."; + public const string StageTypeNotSupportedForAutomatedCreation = "Este tipo de fase no admite la generación automática de partidos."; + + // Basketball has no draws — a played match must have a winner. + public const string GroupStageTieNotAllowed = + "Un partido de fase de grupos no puede terminar empatado; el básquet no tiene empates. Cargá un resultado con un ganador."; + public const string PlayoffTieNotAllowed = + "Un partido de playoff no puede terminar empatado; debe resolverse en un tiempo suplementario. Cargá el resultado final con un ganador."; + + public const string WalkOverTeamNotInMatch = + "El equipo presente tiene que ser el local o el visitante de este partido."; + + public static string TeamsNotDistributableAcrossGroups(int registeredTeams, int totalGroups) + { + return $"Los equipos inscriptos ({registeredTeams}) no se pueden distribuir de forma pareja entre {totalGroups} grupos."; + } + } + + public static class MatchSheet + { + // The sum of a team's players' points must equal the team's final score. + public static string ScoreMismatch(int teamScore, int playersSum) + { + int difference = teamScore - playersSum; + return $"Los puntos de los jugadores no coinciden con el resultado del equipo: el equipo anotó {teamScore} " + + $"pero la suma de los jugadores cargados da {playersSum} (diferencia de {difference}). Corregí la planilla antes de guardar."; + } + + public static string MatchNotFinished(System.Guid matchId) + { + return $"No se puede cargar la planilla del partido {matchId} porque todavía no tiene un resultado final cargado."; + } + + public static string MatchMissingTeams(System.Guid matchId) + { + return $"El partido {matchId} todavía no tiene los dos equipos asignados, así que no se le puede cargar un resultado."; + } + + public static string TeamNotInMatch(System.Guid teamId) + { + return $"El equipo {teamId} no jugó este partido, así que no se pueden cargar sus puntos acá."; + } + + public static string PlayerNotOnRosterReason(string playerLabel) + { + return $"{playerLabel} (no está en el plantel del equipo para esta temporada)"; + } + + public static string PlayerNotEligibleReason(string playerLabel) + { + return $"{playerLabel} (no está habilitado: le falta la inscripción aprobada o tiene una sanción activa)"; + } + + /// + /// One combined error naming every ineligible or off-roster player across both teams' sheets, grouped by team. + /// + public static string PlayersNotEligible( + System.Collections.Generic.IEnumerable<(string TeamName, System.Collections.Generic.List Reasons)> issuesByTeam) + { + System.Collections.Generic.List groups = []; + foreach ((string teamName, System.Collections.Generic.List reasons) in issuesByTeam) + { + groups.Add($"{teamName}: {string.Join(", ", reasons)}."); + } + + return $"No se puede cargar el resultado porque hay jugadores no elegibles. {string.Join(" ", groups)}"; + } + + public static string TeamRequiresWalkOver(string teamName, int habilitadoCount) + { + return $"{teamName} tiene {habilitadoCount} jugador(es) habilitado(s) (mínimo 4), así que este partido no se puede " + + "cargar con un resultado normal. Cargalo como walkover."; + } + } + + public static class MedicalRecord + { + public const string InvalidPdfFile = "El archivo de la ficha médica debe ser un PDF válido."; + + /// + /// User-facing Spanish message returned when a new ficha upload is attempted on a registration whose medical record is already Approved. + /// + public const string AlreadyApproved = + "La ficha médica ya está aprobada; no se puede subir una nueva. Solo puede consultarse o descargarse."; + + /// + /// User-facing Spanish message returned when an approve is attempted against a registration with no real stored file. + /// + public const string NoStoredFile = + "No se puede aprobar la ficha médica: no hay un archivo cargado. Subí la ficha antes de aprobarla."; + + public static string RegistrationNotFound( + System.Guid playerId, System.Guid teamId, System.Guid tournamentId) + { + return $"El jugador {playerId} no tiene una inscripción al equipo {teamId} para el torneo " + + $"{tournamentId}, así que no se puede adjuntar ni revisar una ficha médica."; + } + } + + public static class MatchSeries + { + public const string RequiresTwoDifferentTeams = "Una serie requiere dos equipos distintos."; + public const string AlreadyExistsForStage = "Ya existe una serie entre estos dos equipos para esta fase."; + public const string NotFound = "No se encontró la serie."; + public const string AlreadyDecided = "No se puede agregar un partido a una serie que ya se definió."; + + public static string NotFoundById(System.Guid id) + { + return $"No se encontró la serie con id {id}."; + } + + public static string MaxGamesReached(int bestOf) + { + return $"Esta serie ya tiene el máximo de {bestOf} partidos."; + } + + public static string TeamNotAssignedToStage(System.Guid teamId) + { + return $"El equipo {teamId} no está asignado a esta fase."; + } + } + + public static class PlayerSanction + { + public const string AppealAlreadyPending = "Esta sanción ya tiene una apelación pendiente."; + public const string NoPendingAppealToResolve = "No hay ninguna apelación pendiente para resolver en esta sanción."; + } + + public static class Playoff + { + public const string NotEnoughRankedTeams = "Para sembrar se necesitan al menos dos equipos rankeados."; + public const string EmptyDestination = "Un rango de posiciones de playoff debe tener un destino no vacío."; + public const string NoMappingsConfigured = "Esta división no tiene ningún mapeo de rango de posiciones a playoff configurado."; + + public static string InvalidRange(int from, int to) + { + return $"Rango de posiciones de playoff inválido {from}-{to}: las posiciones empiezan en 1 y 'desde' debe ser menor o igual a 'hasta'."; + } + + public static string OverlappingRanges(int firstFrom, int firstTo, int secondFrom, int secondTo) + { + return $"Los rangos de posiciones de playoff {firstFrom}-{firstTo} y {secondFrom}-{secondTo} se superponen; cada posición debe mapear a un solo destino."; + } + + public static string CupStageNotFound(string destination) + { + return $"No se encontró una fase eliminatoria de primera ronda sin sembrar para el destino de playoff '{destination}'."; + } + + public static string InvalidQualifiersPerGroup(int qualifiersPerGroup) + { + return $"QualifiersPerGroup debe ser al menos 1, pero era {qualifiersPerGroup}."; + } + } + + public static class Backup + { + public const string RetentionCountNegative = "Retention count cannot be negative."; + public const string OperationInProgress = "A backup or restore operation is already in progress."; + } + + public static class Configuration + { + public const string ConnectionStringMissing = "The connection string should be initialized already."; + public const string JwtMissing = "The JWT is missing or empty in configuration."; + + public static string SmtpKeyMissing(string key) + { + return $"{key} is missing from configuration."; + } + + public static string KeyNotConfigured(string key) + { + return $"{key} is not configured."; + } + } + + public static class Storage + { + public static string UploadFailed(string reason) + { + return $"Error uploading file: {reason}"; + } + + public static string DeleteFailed(string reason) + { + return $"Error deleting file: {reason}"; + } + + public static string ListFailed(string reason) + { + return $"Error listing files: {reason}"; + } + + public static string RemoveFailed(string reason) + { + return $"Error removing file: {reason}"; + } + + public static string DownloadFailed(string reason) + { + return $"Error downloading file: {reason}"; + } + } + + public static class Query + { + public const string ContainsMethodNotFound = "The 'Contains' method could not be found on the string class."; + public const string ToLowerMethodNotFound = "The 'ToLower' method could not be found on the string class."; + } + + public static class Serialization + { + public static string InvalidDate(string? rawValue) + { + return $"Invalid date: '{rawValue}'."; + } + } +} diff --git a/Club12-Backend/Application/Utils/Constants/Pagination/PaginationDefaults.cs b/Club12-Backend/Application/Utils/Constants/Pagination/PaginationDefaults.cs new file mode 100644 index 0000000..fd6e18d --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Pagination/PaginationDefaults.cs @@ -0,0 +1,14 @@ +namespace Application.Utils.Constants.Pagination; + +/// +/// Default and boundary values applied to paginated list requests when the caller does not specify a page size, or supplies an out-of-range one. +/// +public static class PaginationDefaults +{ + public const int DefaultPageSize = 100; + + /// + /// Maximum allowed page size; requests above this are clamped down to it. + /// + public const int MaxPageSize = 100; +} diff --git a/Club12-Backend/Application/Utils/Constants/Scorer/ScoreConstants.cs b/Club12-Backend/Application/Utils/Constants/Scorer/ScoreConstants.cs new file mode 100644 index 0000000..101a4f1 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Scorer/ScoreConstants.cs @@ -0,0 +1,11 @@ +namespace Application.Utils.Constants.Scorer; + +/// +/// Points awarded per match outcome when computing the team scoreboard. +/// +public static class ScoreConstants +{ + public const int PointsForWin = 2; + public const int PointsForLoss = 1; + public const int PointsForDraw = 1; +} diff --git a/Club12-Backend/Application/Utils/Constants/Stage/KnockoutMatchCount.cs b/Club12-Backend/Application/Utils/Constants/Stage/KnockoutMatchCount.cs new file mode 100644 index 0000000..79301c2 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Stage/KnockoutMatchCount.cs @@ -0,0 +1,11 @@ +namespace Application.Utils.Constants.Stage; + +/// +/// Provides constant values representing the number of matches generated for each knockout-stage type during automated match creation. +/// +public static class KnockoutMatchCount +{ + public const int QuarterFinal = 4; + + public const int SemiFinal = 2; +} diff --git a/Club12-Backend/Application/Utils/Constants/Stage/MaxTeams.cs b/Club12-Backend/Application/Utils/Constants/Stage/MaxTeams.cs new file mode 100644 index 0000000..4133109 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Stage/MaxTeams.cs @@ -0,0 +1,22 @@ +namespace Application.Utils.Constants.Stage; + +public static class MaxTeams +{ + /// + /// Standard number of teams per group used to partition a division's registered teams into same-sized Group stages. + /// + public const int Group = 4; + + /// + /// Upper bound on how many teams a single manually-built Group stage, one zone's whole round-robin phase, may hold. + /// + public const int GroupStageCap = 32; + + public const int QuarterFinal = 8; + + public const int SemiFinal = 4; + + public const int ThirdPlace = 2; + + public const int Final = 2; +} diff --git a/Club12-Backend/Application/Utils/Constants/Stage/RoundRobinFormat.cs b/Club12-Backend/Application/Utils/Constants/Stage/RoundRobinFormat.cs new file mode 100644 index 0000000..ca62520 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Stage/RoundRobinFormat.cs @@ -0,0 +1,17 @@ +namespace Application.Utils.Constants.Stage; + +/// +/// Bounds for how many times each pair of teams plays within a single group stage, RoundRobinLegs. +/// +public static class RoundRobinFormat +{ + /// + /// Single round-robin: every pair plays once. + /// + public const int MinLegs = 1; + + /// + /// Upper bound to keep fixture size sane, a triple round-robin. + /// + public const int MaxLegs = 3; +} diff --git a/Club12-Backend/Application/Utils/Constants/Stage/StageTemplate.cs b/Club12-Backend/Application/Utils/Constants/Stage/StageTemplate.cs new file mode 100644 index 0000000..1410499 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Stage/StageTemplate.cs @@ -0,0 +1,28 @@ +namespace Application.Utils.Constants.Stage; + +public static class StageTemplate +{ + public static readonly Template Group = new("Fase de Grupos", "Fase de grupos todos contra todos."); + + public static readonly Template RoundOf16 = new("Octavos de Final", "Etapa eliminatoria con 16 equipos."); + + public static readonly Template QuarterFinal = new("Cuartos de Final", "Etapa eliminatoria con 8 equipos."); + + public static readonly Template SemiFinal = new("Semifinales", "Etapa eliminatoria con 4 equipos."); + + public static readonly Template Final = new("Final", "Partido final del campeonato."); + + public static readonly Template ThirdPlace = new("Tercer Puesto", "Partido para determinar el tercer lugar."); + + public static readonly int DurationDays = 7; + + /// + /// Standard rest gap, in days, inserted between two consecutive automated stages, from the group stage to the quarter-final. + /// + public static readonly int StandardGapDays = 2; + + /// + /// Shorter rest gap, in days, inserted between the semi-final and the third place match. + /// + public static readonly int ThirdPlaceGapDays = 1; +} \ No newline at end of file diff --git a/Club12-Backend/Application/Utils/Constants/Stage/Template.cs b/Club12-Backend/Application/Utils/Constants/Stage/Template.cs new file mode 100644 index 0000000..bbdd1cd --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Stage/Template.cs @@ -0,0 +1,3 @@ +namespace Application.Utils.Constants.Stage; + +public record Template(string Name, string Description); diff --git a/Club12-Backend/Application/Utils/Constants/Validation/ArgentinePhoneNumberAttribute.cs b/Club12-Backend/Application/Utils/Constants/Validation/ArgentinePhoneNumberAttribute.cs new file mode 100644 index 0000000..6953a4a --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/ArgentinePhoneNumberAttribute.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Application.Utils.Constants.Validation; + +/// +/// Validates that a string is a plausible Argentine phone number. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class ArgentinePhoneNumberAttribute : ValidationAttribute +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is not string phone || string.IsNullOrWhiteSpace(phone)) + { + return ValidationResult.Success; + } + + string trimmed = phone.Trim(); + bool hasOnlyAllowedChars = trimmed.All(c => char.IsDigit(c) || c is '+' or ' ' or '(' or ')' or '-'); + int digitCount = trimmed.Count(char.IsDigit); + + return hasOnlyAllowedChars && digitCount == 10 + ? ValidationResult.Success + : new ValidationResult( + ErrorMessage ?? ValidationPatterns.PhoneNumberError, + [validationContext.MemberName ?? string.Empty]); + } +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/DivisionFieldLengths.cs b/Club12-Backend/Application/Utils/Constants/Validation/DivisionFieldLengths.cs new file mode 100644 index 0000000..f9902f3 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/DivisionFieldLengths.cs @@ -0,0 +1,9 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Field length limits shared by every division-name-accepting request DTO and entity configuration. +/// +public static class DivisionFieldLengths +{ + public const int NameMaxLength = 100; +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/MinimumAgeAttribute.cs b/Club12-Backend/Application/Utils/Constants/Validation/MinimumAgeAttribute.cs new file mode 100644 index 0000000..23a0505 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/MinimumAgeAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.Utils.Constants.Validation; + +/// +/// Validates that a DateTime birth date is at least minimumYears years in the past, meaning the person is at least that many years old today. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class MinimumAgeAttribute(int minimumYears) : ValidationAttribute +{ + private readonly int _minimumYears = minimumYears; + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is not DateTime birthDate) + { + return ValidationResult.Success; + } + + DateTime cutoff = DateTime.UtcNow.Date.AddYears(-_minimumYears); + + return birthDate.Date <= cutoff + ? ValidationResult.Success + : new ValidationResult( + ErrorMessage ?? $"El jugador debe tener al menos {_minimumYears} años.", + [validationContext.MemberName ?? string.Empty]); + } +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/PlayerFieldLengths.cs b/Club12-Backend/Application/Utils/Constants/Validation/PlayerFieldLengths.cs new file mode 100644 index 0000000..0b652bd --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/PlayerFieldLengths.cs @@ -0,0 +1,12 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Field length limits shared by CreatePlayerRequest and UpdatePlayerRequest, so the two stay in sync. +/// +public static class PlayerFieldLengths +{ + public const int NameMaxLength = 70; + public const int DocumentNumberMaxLength = 15; + public const int PhoneNumberMaxLength = 15; + public const int SocialSecurityMaxLength = 100; +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/SanctionFieldLengths.cs b/Club12-Backend/Application/Utils/Constants/Validation/SanctionFieldLengths.cs new file mode 100644 index 0000000..dfa2263 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/SanctionFieldLengths.cs @@ -0,0 +1,22 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Field length limits shared across the player sanction request DTOs, so they stay in sync. +/// +public static class SanctionFieldLengths +{ + /// + /// Max length of the short sanction description on Create and Update requests. + /// + public const int DescriptionMaxLength = 255; + + /// + /// Min length of the free-text appeal reason / resolution notes. + /// + public const int LongTextMinLength = 1; + + /// + /// Max length of the free-text appeal reason / resolution notes. + /// + public const int LongTextMaxLength = 1000; +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/SeasonFieldLengths.cs b/Club12-Backend/Application/Utils/Constants/Validation/SeasonFieldLengths.cs new file mode 100644 index 0000000..e94271a --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/SeasonFieldLengths.cs @@ -0,0 +1,9 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Field length limits shared by CreateSeasonRequest and UpdateSeasonRequest, so the two stay in sync. +/// +public static class SeasonFieldLengths +{ + public const int NameMaxLength = 100; +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/UserFieldLengths.cs b/Club12-Backend/Application/Utils/Constants/Validation/UserFieldLengths.cs new file mode 100644 index 0000000..352293c --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/UserFieldLengths.cs @@ -0,0 +1,12 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Field length limits shared across the account and user request DTOs, so they stay in sync. +/// +public static class UserFieldLengths +{ + public const int UsernameMinLength = 3; + public const int UsernameMaxLength = 50; + public const int PasswordMinLength = 8; + public const int PhoneMaxLength = 15; +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/ValidationPatterns.cs b/Club12-Backend/Application/Utils/Constants/Validation/ValidationPatterns.cs new file mode 100644 index 0000000..c28be6b --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/ValidationPatterns.cs @@ -0,0 +1,27 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Shared validation regex patterns kept in one place so every request DTO and the frontend validators enforce the exact same plausible contact-field rules. +/// +public static class ValidationPatterns +{ + /// + /// Plausible phone number: only digits, spaces, plus, hyphen and parentheses, containing between 8 and 15 digits. + /// + public const string PhoneNumber = @"^(?=(?:\D*\d){8,15}\D*$)[+\d\s()-]+$"; + + /// + /// Human-facing message for a phone number that fails the pattern. + /// + public const string PhoneNumberError = "Invalid phone number format."; + + /// + /// A player's DNI or document number: digits only, 6 to 15 of them. + /// + public const string DocumentNumber = @"^\d{6,15}$"; + + /// + /// Human-facing message for a document number that fails the pattern. + /// + public const string DocumentNumberError = "El documento debe contener solo números."; +} diff --git a/Club12-Backend/Application/Utils/Constants/Validation/VenueFieldLengths.cs b/Club12-Backend/Application/Utils/Constants/Validation/VenueFieldLengths.cs new file mode 100644 index 0000000..4a98bb7 --- /dev/null +++ b/Club12-Backend/Application/Utils/Constants/Validation/VenueFieldLengths.cs @@ -0,0 +1,10 @@ +namespace Application.Utils.Constants.Validation; + +/// +/// Field length limits shared by CreateVenueRequest and UpdateVenueRequest, so the two stay in sync. +/// +public static class VenueFieldLengths +{ + public const int NameMaxLength = 50; + public const int AddressMaxLength = 200; +} diff --git a/Club12-Backend/Application/Utils/Extensions/ImageReferenceAttribute.cs b/Club12-Backend/Application/Utils/Extensions/ImageReferenceAttribute.cs new file mode 100644 index 0000000..1751150 --- /dev/null +++ b/Club12-Backend/Application/Utils/Extensions/ImageReferenceAttribute.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Application.Utils.Extensions; + +/// +/// Validates a property that carries an image reference: either an absolute http or https URL or a self-contained data:image URI. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +public sealed class ImageReferenceAttribute : ValidationAttribute +{ + private const string DataImagePrefix = "data:image/"; + + /// + public override bool IsValid(object? value) + { + // Null or empty means no image, which is the property's own business, decided by a Required attribute alongside this one. + if (value is null) + { + return true; + } + + if (value is not string reference || string.IsNullOrWhiteSpace(reference)) + { + return value is not string; + } + + if (reference.StartsWith(DataImagePrefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return Uri.TryCreate(reference, UriKind.Absolute, out Uri? uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } + + /// + public override string FormatErrorMessage(string name) => + $"The {name} field must be an http(s) URL or a data:image URI."; +} diff --git a/Club12-Backend/Application/Utils/Extensions/QueryableExtensions.cs b/Club12-Backend/Application/Utils/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..c4ac4e6 --- /dev/null +++ b/Club12-Backend/Application/Utils/Extensions/QueryableExtensions.cs @@ -0,0 +1,151 @@ +using Application.DTOs.Abstract.Request; +using Application.Utils.Constants; + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Application.Utils.Extensions; + +public static class QueryableExtensions +{ + /// + /// Builds a combined-AND filter expression by reflecting over T's properties that also exist on TEntity. + /// + /// The filter DTO whose non-empty, non-skipped properties become predicates. + /// + /// Names of filter properties whose auto-generated predicate must be + /// suppressed, letting the caller special-case them by resolving a + /// filter through a join instead of the entity's own FK-equality. Purely + /// additive: callers that pass nothing get the original behavior. + /// + /// Thrown when the 'Contains' method is not found. + public static Expression> ConstructFilterExpression(T filter, params string[] ignoredProperties) where T : PaginatedFilterRequest + { + PropertyInfo[] filterProperties = [.. typeof(T) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => !ShouldSkipProperty(p.Name) + && !ignoredProperties.Contains(p.Name) + && typeof(TEntity).GetProperty(p.Name) != null)]; + + bool allNullOrEmpty = Array.TrueForAll(filterProperties, property => + { + object? value = property.GetValue(filter); + return value == null || (property.PropertyType == typeof(string) && string.IsNullOrWhiteSpace((string) value)); + }); + + if (allNullOrEmpty) + { + ParameterExpression parameter = Expression.Parameter(typeof(TEntity), "x"); + Expression trueExpr = Expression.Constant(true); + return Expression.Lambda>(trueExpr, parameter); + } + + ParameterExpression parameterExpr = Expression.Parameter(typeof(TEntity), "x"); + Expression? finalExpression = null; + + MethodInfo containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]) + ?? throw new InvalidOperationException(ErrorMessages.Query.ContainsMethodNotFound); + + MethodInfo toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes) + ?? throw new InvalidCastException(ErrorMessages.Query.ToLowerMethodNotFound); + + foreach (PropertyInfo property in filterProperties) + { + object? filterValue = property.GetValue(filter); + + if (filterValue == null) + { + continue; + } + + if (property.PropertyType == typeof(string) && string.IsNullOrWhiteSpace((string) filterValue)) + { + continue; + } + + MemberExpression propertyAccess = Expression.Property(parameterExpr, property.Name); + Expression currentExpr; + + if (property.PropertyType == typeof(string) && filterValue is string stringValue) + { + Expression toLowerProperty = Expression.Call(propertyAccess, toLowerMethod); + Expression toLowerFilterValue = Expression.Constant(stringValue.ToLower()); + currentExpr = Expression.Call(toLowerProperty, containsMethod, toLowerFilterValue); + } + else + { + Expression constantExpr = Expression.Constant(filterValue, filterValue.GetType()); + + if (constantExpr.Type != propertyAccess.Type) + { + constantExpr = Expression.Convert(constantExpr, propertyAccess.Type); + } + + currentExpr = Expression.Equal(propertyAccess, constantExpr); + } + + finalExpression = finalExpression == null ? currentExpr : Expression.AndAlso(finalExpression, currentExpr); + } + + finalExpression ??= Expression.Constant(true); + + return Expression.Lambda>(finalExpression, parameterExpr); + } + + /// + /// Combines two entity predicates with a logical AND, rebinding the second predicate's parameter onto the first so the result is a single lambda EF Core can translate to SQL. + /// + public static Expression> AndAlso( + this Expression> left, + Expression> right) + { + ParameterExpression parameter = left.Parameters[0]; + Expression reboundRight = new ReplaceParameterVisitor(right.Parameters[0], parameter).Visit(right.Body); + Expression combined = Expression.AndAlso(left.Body, reboundRight); + return Expression.Lambda>(combined, parameter); + } + + private sealed class ReplaceParameterVisitor(ParameterExpression from, ParameterExpression to) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) + { + return node == from ? to : base.VisitParameter(node); + } + } + + public static IQueryable Paginate(this IQueryable source, int pageNumber, int pageSize) + { + return source.Skip((pageNumber - 1) * pageSize).Take(pageSize); + } + + public static IQueryable SortBy(this IQueryable source, IOrderRequest orderRequest) + { + if (string.IsNullOrEmpty(orderRequest.OrderBy)) + { + return source; + } + + ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); + MemberExpression property = Expression.Property(parameter, orderRequest.OrderBy); + LambdaExpression lambda = Expression.Lambda(property, parameter); + + MethodInfo method = orderRequest.Order == SortOrder.Ascending + ? typeof(Queryable).GetMethods().First(m => m.Name == "OrderBy" && m.GetParameters().Length == 2) + : typeof(Queryable).GetMethods().First(m => m.Name == "OrderByDescending" && m.GetParameters().Length == 2); + + MethodInfo genericMethod = method.MakeGenericMethod(typeof(T), property.Type); + + return (IQueryable) genericMethod.Invoke(null, [source, lambda])!; + } + + private static bool ShouldSkipProperty(string propertyName) + { + return propertyName is + nameof(PaginatedFilterRequest.PageSize) or + nameof(PaginatedFilterRequest.PageNumber) or + nameof(PaginatedFilterRequest.OrderBy) or + nameof(PaginatedFilterRequest.Order); + } +} diff --git a/Club12-Backend/Application/Utils/Extensions/TeamStaffRoleExtensions.cs b/Club12-Backend/Application/Utils/Extensions/TeamStaffRoleExtensions.cs new file mode 100644 index 0000000..d2219c5 --- /dev/null +++ b/Club12-Backend/Application/Utils/Extensions/TeamStaffRoleExtensions.cs @@ -0,0 +1,24 @@ +using Domain.Enums; + +using System; + +namespace Application.Utils.Extensions; + +/// +/// Presentation helpers for TeamStaffRole, mapping each role to its Spanish label for user-facing text. +/// +public static class TeamStaffRoleExtensions +{ + /// + /// Returns the Spanish, user-facing label for a team-staff role. + /// + public static string ToSpanishLabel(this TeamStaffRole role) + { + return role switch + { + TeamStaffRole.Coach => "DT", + TeamStaffRole.AssistantCoach => "Asistente", + _ => throw new ArgumentOutOfRangeException(nameof(role), role, null), + }; + } +} diff --git a/Club12-Backend/Application/Utils/Extensions/TournamentStatusExtensions.cs b/Club12-Backend/Application/Utils/Extensions/TournamentStatusExtensions.cs new file mode 100644 index 0000000..1777b4a --- /dev/null +++ b/Club12-Backend/Application/Utils/Extensions/TournamentStatusExtensions.cs @@ -0,0 +1,28 @@ +using Domain.Enums; + +using System; + +namespace Application.Utils.Extensions; + +/// +/// Presentation helpers for TournamentStatus, mapping each status to its Spanish label for user-facing text. +/// +public static class TournamentStatusExtensions +{ + /// + /// Returns the Spanish, user-facing label for a tournament status. + /// + public static string ToSpanishLabel(this TournamentStatus status) + { + return status switch + { + TournamentStatus.Scheduled => "Programado", + TournamentStatus.OpenForRegistration => "Inscripción abierta", + TournamentStatus.RegistrationClosed => "Inscripción cerrada", + TournamentStatus.Ongoing => "En curso", + TournamentStatus.Finished => "Finalizado", + TournamentStatus.Canceled => "Cancelado", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Champions/ChampionResolver.cs b/Club12-Backend/Application/Utils/Helper/Champions/ChampionResolver.cs new file mode 100644 index 0000000..d84c34f --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Champions/ChampionResolver.cs @@ -0,0 +1,261 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Champions; + +/// +/// Pure logic that resolves a division's podium from its elimination bracket, with no data access. +/// +public static class ChampionResolver +{ + /// + /// A team occupying a podium place. + /// + public sealed record TeamRef(Guid TeamId, string TeamName, string LogoUrl); + + /// + /// The three podium places; any place may be null when undecided. + /// + public sealed class Podium + { + public TeamRef? First { get; init; } + public TeamRef? Second { get; init; } + public TeamRef? Third { get; init; } + + /// + /// True when Third is null only because the top cup never had a real bracket round to draw a third place from. + /// + public bool ImplicitThirdFromStandings { get; init; } + } + + private static readonly HashSet BracketDepthStageTypes = + [ + StageType.RoundOf16, + StageType.QuarterFinal, + StageType.SemiFinal, + ]; + + /// + /// The champion of a single sub-cup, a playoff bracket, of a division. + /// + public sealed record CupChampion(string? CupName, int SeedOrder, TeamRef Champion); + + /// + /// Resolves the podium of a division that has a playoff. + /// + /// Every non-group stage of the division. + /// The division's position-range to cup mappings, which may be empty. + /// The finished/seeded matches of the elimination stages, with their team navigations loaded. + /// The division's best-of-N series, with their team navigations loaded. + public static Podium ResolvePlayoffPodium( + IReadOnlyList eliminationStages, + IReadOnlyList mappings, + IReadOnlyList eliminationMatches, + IReadOnlyList series) + { + string? topBracket = ResolveTopBracketName(eliminationStages, mappings); + + Stage? finalStage = eliminationStages + .Where(stage => stage.BracketName == topBracket && stage.StageType == StageType.Final) + .OrderBy(stage => stage.Order) + .FirstOrDefault(); + + Stage? thirdPlaceStage = eliminationStages + .Where(stage => stage.BracketName == topBracket && stage.StageType == StageType.ThirdPlace) + .OrderBy(stage => stage.Order) + .FirstOrDefault(); + + Dictionary teamsById = BuildTeamLookup(eliminationMatches, series); + + (Guid Winner, Guid Loser)? finalOutcome = finalStage is null + ? null + : ResolveStageOutcome(finalStage, eliminationMatches, series); + + (Guid Winner, Guid Loser)? thirdOutcome = thirdPlaceStage is null + ? null + : ResolveStageOutcome(thirdPlaceStage, eliminationMatches, series); + + TeamRef? third = thirdOutcome is null ? null : Lookup(teamsById, thirdOutcome.Value.Winner); + + bool hasBracketDepth = eliminationStages + .Any(stage => stage.BracketName == topBracket && BracketDepthStageTypes.Contains(stage.StageType)); + + return new Podium + { + First = finalOutcome is null ? null : Lookup(teamsById, finalOutcome.Value.Winner), + Second = finalOutcome is null ? null : Lookup(teamsById, finalOutcome.Value.Loser), + Third = third, + ImplicitThirdFromStandings = third is null && !hasBracketDepth, + }; + } + + /// + /// Resolves the champion of every sub-cup, a playoff bracket, of a division, not just the top one. + /// + public static IReadOnlyList ResolveCupChampions( + IReadOnlyList eliminationStages, + IReadOnlyList mappings, + IReadOnlyList eliminationMatches, + IReadOnlyList series) + { + List bracketNames = [.. eliminationStages + .Select(stage => stage.BracketName) + .Distinct()]; + + bool singleBracket = bracketNames.Count <= 1; + Dictionary teamsById = BuildTeamLookup(eliminationMatches, series); + + // The cup receiving the lowest FromPosition is the top seed tier; brackets unnamed by any mapping sort after, by name. + Dictionary seedByBracket = mappings + .GroupBy(mapping => mapping.Destination) + .ToDictionary(group => group.Key, group => group.Min(m => m.FromPosition)); + + List champions = []; + + foreach (string? bracketName in bracketNames) + { + Stage? finalStage = eliminationStages + .Where(stage => stage.BracketName == bracketName && stage.StageType == StageType.Final) + .OrderBy(stage => stage.Order) + .FirstOrDefault(); + + if (finalStage is null) + { + continue; + } + + (Guid Winner, Guid Loser)? outcome = ResolveStageOutcome(finalStage, eliminationMatches, series); + TeamRef? champion = outcome is null ? null : Lookup(teamsById, outcome.Value.Winner); + + if (champion is null) + { + continue; + } + + int seedOrder = bracketName is not null && seedByBracket.TryGetValue(bracketName, out int seed) + ? seed + : int.MaxValue; + + champions.Add(new CupChampion(singleBracket ? null : bracketName, seedOrder, champion)); + } + + return [.. champions + .OrderBy(c => c.SeedOrder) + .ThenBy(c => c.CupName, StringComparer.Ordinal)]; + } + + /// + /// Picks the division's top cup name. + /// + private static string? ResolveTopBracketName( + IReadOnlyList eliminationStages, + IReadOnlyList mappings) + { + List bracketNames = [.. eliminationStages + .Select(stage => stage.BracketName) + .Distinct()]; + + if (bracketNames.Count <= 1) + { + return bracketNames.FirstOrDefault(); + } + + DivisionPlayoffMapping? topMapping = mappings + .OrderBy(mapping => mapping.FromPosition) + .FirstOrDefault(); + + if (topMapping is not null && bracketNames.Contains(topMapping.Destination)) + { + return topMapping.Destination; + } + + return bracketNames + .Where(name => name is not null) + .OrderBy(name => name, StringComparer.Ordinal) + .FirstOrDefault() ?? bracketNames[0]; + } + + /// + /// Reads a single elimination stage's decided outcome as a winner and loser pair. + /// + private static (Guid Winner, Guid Loser)? ResolveStageOutcome( + Stage stage, + IReadOnlyList eliminationMatches, + IReadOnlyList series) + { + List stageSeries = [.. series.Where(s => s.StageId == stage.Id)]; + + if (stageSeries.Count > 0) + { + MatchSeries? decided = stageSeries.Find(s => s.WinningTeamId.HasValue); + if (decided is null) + { + return null; + } + + Guid seriesWinner = decided.WinningTeamId!.Value; + Guid seriesLoser = seriesWinner == decided.HomeTeamId ? decided.VisitorTeamId : decided.HomeTeamId; + return (seriesWinner, seriesLoser); + } + + Match? match = eliminationMatches + .Where(m => m.StageId == stage.Id + && m.IsFinished + && m.WinningTeamId.HasValue + && m.HomeTeamId.HasValue + && m.VisitorTeamId.HasValue) + .OrderByDescending(m => m.MatchDate) + .FirstOrDefault(); + + if (match is null) + { + return null; + } + + Guid winner = match.WinningTeamId!.Value; + Guid loser = winner == match.HomeTeamId!.Value ? match.VisitorTeamId!.Value : match.HomeTeamId!.Value; + return (winner, loser); + } + + private static Dictionary BuildTeamLookup( + IReadOnlyList eliminationMatches, + IReadOnlyList series) + { + Dictionary teamsById = []; + + foreach (Match match in eliminationMatches) + { + AddTeam(teamsById, match.HomeTeam); + AddTeam(teamsById, match.VisitorTeam); + AddTeam(teamsById, match.WinningTeam); + } + + foreach (MatchSeries s in series) + { + AddTeam(teamsById, s.HomeTeam); + AddTeam(teamsById, s.VisitorTeam); + AddTeam(teamsById, s.WinningTeam); + } + + return teamsById; + } + + private static void AddTeam(Dictionary teamsById, Team? team) + { + if (team is null || teamsById.ContainsKey(team.Id)) + { + return; + } + + teamsById[team.Id] = new TeamRef(team.Id, team.Name, team.LogoUrl); + } + + private static TeamRef? Lookup(Dictionary teamsById, Guid teamId) + { + return teamsById.TryGetValue(teamId, out TeamRef? team) ? team : null; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/MatchResult/MatchResultFinalizer.cs b/Club12-Backend/Application/Utils/Helper/MatchResult/MatchResultFinalizer.cs new file mode 100644 index 0000000..e68feff --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/MatchResult/MatchResultFinalizer.cs @@ -0,0 +1,44 @@ +using Application.Utils.Constants; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; + +namespace Application.Utils.Helper.MatchResult; + +/// +/// Single source of truth for turning a home/visitor score pair into a finished match. +/// +public static class MatchResultFinalizer +{ + /// + /// Applies a decisive final result to match, deriving the winning team from whichever score is higher. + /// + /// The match to finalize. Its Stage, HomeTeam, HomeTeamId and their visitor counterparts must be loaded. + /// The home team's final score. + /// The visitor team's final score. + /// Thrown when the score is tied. + public static void ApplyResult(Match match, int homeScore, int visitorScore) + { + ArgumentNullException.ThrowIfNull(match); + + if (homeScore == visitorScore) + { + throw new InvalidOperationException( + match.Stage.StageType == StageType.Group + ? ErrorMessages.Match.GroupStageTieNotAllowed + : ErrorMessages.Match.PlayoffTieNotAllowed); + } + + match.HomeScore = homeScore; + match.VisitorScore = visitorScore; + + bool homeWon = homeScore > visitorScore; + match.WinningTeam = homeWon ? match.HomeTeam : match.VisitorTeam; + match.WinningTeamId = homeWon ? match.HomeTeamId : match.VisitorTeamId; + + match.IsFinished = true; + match.Status = MatchStatus.Played; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Playoff/CrossCupGroupSeeder.cs b/Club12-Backend/Application/Utils/Helper/Playoff/CrossCupGroupSeeder.cs new file mode 100644 index 0000000..c15a8c2 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Playoff/CrossCupGroupSeeder.cs @@ -0,0 +1,56 @@ +using Application.Utils.Constants; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Playoff; + +/// +/// Resolves the single bracket seed order for a multi-group cross-division cup. +/// +public static class CrossCupGroupSeeder +{ + /// + /// Pools the top qualifiersPerGroup teams of each group's standings and returns them as one ordered seed list, best seed first, keeping each team's full Position (not just its id) so a caller can still apply position-range cup mappings over the pooled order. + /// + /// + /// Thrown when qualifiersPerGroup is less than 1, or when fewer than two teams are pooled across all groups, since a bracket needs at least two seeds. + /// + public static List PoolAndOrder( + IEnumerable> groupStandings, + int qualifiersPerGroup) + { + if (qualifiersPerGroup < 1) + { + throw new InvalidOperationException(ErrorMessages.Playoff.InvalidQualifiersPerGroup(qualifiersPerGroup)); + } + + List pooled = [.. groupStandings.SelectMany(standings => standings.Take(qualifiersPerGroup))]; + + if (pooled.Count < 2) + { + throw new InvalidOperationException(ErrorMessages.Playoff.NotEnoughRankedTeams); + } + + return [.. pooled + .OrderByDescending(position => position.Points) + .ThenByDescending(position => position.Wins) + .ThenByDescending(position => position.PointsDifference) + .ThenByDescending(position => position.PointsFor) + .ThenBy(position => position.TeamId)]; + } + + /// + /// Pools the top qualifiersPerGroup teams of each group's standings and returns them as one ordered seed list of team ids, best seed first. + /// + /// + /// Thrown when qualifiersPerGroup is less than 1, or when fewer than two teams are pooled across all groups, since a bracket needs at least two seeds. + /// + public static List ResolveSeedOrder( + IEnumerable> groupStandings, + int qualifiersPerGroup) => + [.. PoolAndOrder(groupStandings, qualifiersPerGroup).Select(position => position.TeamId)]; +} diff --git a/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffMappingValidator.cs b/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffMappingValidator.cs new file mode 100644 index 0000000..46cc147 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffMappingValidator.cs @@ -0,0 +1,49 @@ +using Application.Utils.Constants; + +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Playoff; + +/// +/// Validates a division's position-range to playoff-destination mappings. +/// +public static class PlayoffMappingValidator +{ + /// + /// Throws InvalidOperationException if any range is malformed or if two ranges overlap. + /// + public static void Validate(IEnumerable mappings) + { + List ordered = [.. mappings.OrderBy(m => m.FromPosition)]; + + foreach (DivisionPlayoffMapping mapping in ordered) + { + if (mapping.FromPosition < 1 || mapping.ToPosition < mapping.FromPosition) + { + throw new InvalidOperationException( + ErrorMessages.Playoff.InvalidRange(mapping.FromPosition, mapping.ToPosition)); + } + + if (string.IsNullOrWhiteSpace(mapping.Destination)) + { + throw new InvalidOperationException(ErrorMessages.Playoff.EmptyDestination); + } + } + + for (int i = 1; i < ordered.Count; i++) + { + // Ordered by FromPosition, so an overlap exists when the current range starts at or before the previous range's end. + if (ordered[i].FromPosition <= ordered[i - 1].ToPosition) + { + throw new InvalidOperationException( + ErrorMessages.Playoff.OverlappingRanges( + ordered[i - 1].FromPosition, ordered[i - 1].ToPosition, + ordered[i].FromPosition, ordered[i].ToPosition)); + } + } + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffQualificationResolver.cs b/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffQualificationResolver.cs new file mode 100644 index 0000000..d9e54d9 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffQualificationResolver.cs @@ -0,0 +1,59 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Playoff; + +/// +/// Resolves which teams qualify for each playoff cup from the final group-stage standings and the per-division position-range mappings. +/// +public static class PlayoffQualificationResolver +{ + /// + /// One division's contribution to the playoffs: its final standings, best team first, and its position-range to destination mappings. + /// + public sealed class DivisionStandings + { + public required IReadOnlyList Standings { get; init; } + public required IReadOnlyList Mappings { get; init; } + public int DivisionOrder { get; init; } + } + + /// + /// Returns, per destination cup, the ordered list of qualifying team ids gathered across every contributing division, best seed first. + /// + public static Dictionary> Resolve(IEnumerable divisions) + { + List<(string Destination, int Position, int DivisionOrder, Guid TeamId)> qualifiers = []; + + foreach (DivisionStandings division in divisions) + { + for (int i = 0; i < division.Standings.Count; i++) + { + int position = i + 1; + + DivisionPlayoffMapping? mapping = division.Mappings + .FirstOrDefault(m => position >= m.FromPosition && position <= m.ToPosition); + + if (mapping is null) + { + continue; + } + + qualifiers.Add((mapping.Destination, position, division.DivisionOrder, division.Standings[i].TeamId)); + } + } + + return qualifiers + .GroupBy(q => q.Destination) + .ToDictionary( + group => group.Key, + group => group + .OrderBy(q => q.Position) + .ThenBy(q => q.DivisionOrder) + .Select(q => q.TeamId) + .ToList()); + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffSeeder.cs b/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffSeeder.cs new file mode 100644 index 0000000..bf9df75 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Playoff/PlayoffSeeder.cs @@ -0,0 +1,80 @@ +using Application.Utils.Constants; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Playoff; + +/// +/// Pairs a ranked list of teams into first-round playoff matchups using the standard bracket seeding order. +/// +public static class PlayoffSeeder +{ + /// + /// Builds the first-round pairs for a ranked list of teams, best seed first. + /// + public static List<(Guid HomeTeamId, Guid? VisitorTeamId)> SeedPairs(IReadOnlyList orderedTeamIds) + { + int count = orderedTeamIds.Count; + + if (count < 2) + { + throw new InvalidOperationException(ErrorMessages.Playoff.NotEnoughRankedTeams); + } + + int bracketSize = NextPowerOfTwo(count); + List paddedSeeds = [.. orderedTeamIds.Select(id => (Guid?) id)]; + paddedSeeds.AddRange(Enumerable.Repeat((Guid?) null, bracketSize - count)); + + List bracketOrder = BuildSeedOrder(bracketSize); + + List<(Guid, Guid?)> pairs = []; + for (int i = 0; i < bracketSize; i += 2) + { + Guid? first = paddedSeeds[bracketOrder[i] - 1]; + Guid? second = paddedSeeds[bracketOrder[i + 1] - 1]; + + (Guid? home, Guid? visitor) = first is null ? (second, first) : (first, second); + + pairs.Add((home!.Value, visitor)); + } + + return pairs; + } + + /// + /// Smallest power of two greater than or equal to value, the bracket size a seed pool of that many teams pads up to. + /// + public static int NextPowerOfTwo(int value) + { + int power = 1; + while (power < value) + { + power *= 2; + } + return power; + } + + /// + /// Recursively builds the classic bracket seed-position order. + /// + private static List BuildSeedOrder(int size) + { + if (size == 1) + { + return [1]; + } + + List previous = BuildSeedOrder(size / 2); + List current = []; + + foreach (int seed in previous) + { + current.Add(seed); + current.Add(size + 1 - seed); + } + + return current; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Playoff/QualificationRangeBuilder.cs b/Club12-Backend/Application/Utils/Helper/Playoff/QualificationRangeBuilder.cs new file mode 100644 index 0000000..0184331 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Playoff/QualificationRangeBuilder.cs @@ -0,0 +1,38 @@ +using Application.DTOs.Divisions.Response; + +using Domain.Entities.Models; + +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Playoff; + +/// +/// Turns a division's playoff mappings into the ordered qualification ranges the public standings table paints. +/// +public static class QualificationRangeBuilder +{ + /// + /// Builds the ordered qualification ranges for a set of playoff mappings. + /// + /// The division's playoff mappings, in any order. + /// The ordered qualification ranges; empty when there are none. + public static List Build(IEnumerable? mappings) + { + if (mappings is null) + { + return []; + } + + return [.. mappings + .OrderBy(mapping => mapping.FromPosition) + .ThenBy(mapping => mapping.ToPosition) + .Select((mapping, index) => new QualificationRangeResponse + { + FromPosition = mapping.FromPosition, + ToPosition = mapping.ToPosition, + CupName = mapping.Destination, + Order = index, + })]; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/RoundRobin/RoundCalendar.cs b/Club12-Backend/Application/Utils/Helper/RoundRobin/RoundCalendar.cs new file mode 100644 index 0000000..b0cc119 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/RoundRobin/RoundCalendar.cs @@ -0,0 +1,44 @@ +using System; + +namespace Application.Utils.Helper.RoundRobin; + +/// +/// Maps a 1-based round, jornada, number to its default calendar date. +/// +public static class RoundCalendar +{ + /// + /// Weekday shift, in days, applied to a cross-division-cup group stage's jornadas relative to the regular-zone Sunday baseline. + /// + public const int CrossDivisionCupDayOffset = 3; + + /// + /// The default calendar date for a round: the first Sunday on or after startDate, advanced by one week per round. + /// + /// The stage's start date. + /// The 1-based round number. + public static DateTime SundayForRound(DateTime startDate, int round) + { + if (round < 1) + { + throw new ArgumentOutOfRangeException(nameof(round), round, "Round numbers are 1-based."); + } + + int daysUntilSunday = ((int)DayOfWeek.Sunday - (int)startDate.DayOfWeek + 7) % 7; + DateTime firstSunday = startDate.Date.AddDays(daysUntilSunday); + + return firstSunday.AddDays(7 * (round - 1)); + } + + /// + /// Division-aware default date for a jornada. + /// + /// The stage's start date, the anchor. + /// The 1-based round number. + /// Whether the stage's division is a cross-division cup. + public static DateTime DateForRound(DateTime startDate, int round, bool isCrossDivisionCup) + { + DateTime sunday = SundayForRound(startDate, round); + return isCrossDivisionCup ? sunday.AddDays(CrossDivisionCupDayOffset) : sunday; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/RoundRobin/RoundRobinScheduler.cs b/Club12-Backend/Application/Utils/Helper/RoundRobin/RoundRobinScheduler.cs new file mode 100644 index 0000000..e91d7cb --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/RoundRobin/RoundRobinScheduler.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.RoundRobin; + +/// +/// Generates a round-robin fixture using the standard circle method. +/// +public static class RoundRobinScheduler +{ + /// + /// A placeholder used internally to give an odd team count an even slot count during pairing; never appears in the returned fixture. + /// + private static readonly Guid Bye = Guid.Empty; + + /// + /// Builds the fixture for a group of teams, tagging every pairing with its 1-based round, jornada, number. + /// + /// The teams to schedule. An odd count is handled with a bye, one team sits out each round. + /// How many times each pair plays: 1 for single round-robin, 2 for double, and so on. + /// Optional source of randomness, for deterministic tests. + /// + /// One entry per match, each carrying the home/visitor teams and the round + /// it is played in. Empty when fewer than two teams are supplied. + /// + public static List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> GenerateRounds( + IReadOnlyList teamIds, int legs, Random? random = null) + { + if (teamIds.Count < 2) + { + return []; + } + + random ??= new Random(); + + // Shuffle the seating before applying the circle method so each generation yields a different-but-valid fixture while preserving the round structure. + List rotation = [.. Shuffle([.. teamIds], random)]; + if (rotation.Count % 2 != 0) + { + rotation.Add(Bye); + } + + int slotCount = rotation.Count; + int roundsPerLeg = slotCount - 1; + + List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = []; + + for (int leg = 0; leg < legs; leg++) + { + // Alternate home/away on odd legs so a double round-robin gives each pair one home and one away game. + bool swapHomeAway = leg % 2 == 1; + List current = [.. rotation]; + + for (int round = 0; round < roundsPerLeg; round++) + { + int roundNumber = (leg * roundsPerLeg) + round + 1; + + for (int i = 0; i < slotCount / 2; i++) + { + Guid a = current[i]; + Guid b = current[slotCount - 1 - i]; + if (a != Bye && b != Bye) + { + fixture.Add(swapHomeAway + ? (b, a, roundNumber) + : (a, b, roundNumber)); + } + } + + RotateInPlace(current); + } + } + + return fixture; + } + + /// + /// Builds the fixture as a flat list of pairings, discarding the round number. + /// + public static List<(Guid HomeTeamId, Guid VisitorTeamId)> GenerateFixture( + IReadOnlyList teamIds, int legs, Random? random = null) + { + return [.. GenerateRounds(teamIds, legs, random) + .Select(pairing => (pairing.HomeTeamId, pairing.VisitorTeamId))]; + } + + /// + /// One round of the circle method: the first slot stays fixed, every other slot rotates one position clockwise. + /// + private static void RotateInPlace(List slots) + { + Guid last = slots[^1]; + for (int i = slots.Count - 1; i > 1; i--) + { + slots[i] = slots[i - 1]; + } + slots[1] = last; + } + + private static List Shuffle(List items, Random random) + { + return [.. items.OrderBy(_ => random.Next())]; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Series/SeriesDecisionCalculator.cs b/Club12-Backend/Application/Utils/Helper/Series/SeriesDecisionCalculator.cs new file mode 100644 index 0000000..94b7275 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Series/SeriesDecisionCalculator.cs @@ -0,0 +1,29 @@ +using Domain.Entities.Models; + +using System; +using System.Linq; + +namespace Application.Utils.Helper.Series; + +/// +/// Determines whether a best-of-N playoff series has been decided yet. +/// +public static class SeriesDecisionCalculator +{ + /// + /// Returns the winning team's id once one team has won more than half of the series' BestOf games, based on finished games recorded so far. + /// + public static Guid? DetermineWinner(MatchSeries series) + { + int winsNeeded = (series.BestOf / 2) + 1; + + int homeWins = series.Matches.Count(game => game.IsFinished && game.WinningTeamId == series.HomeTeamId); + if (homeWins >= winsNeeded) + { + return series.HomeTeamId; + } + + int visitorWins = series.Matches.Count(game => game.IsFinished && game.WinningTeamId == series.VisitorTeamId); + return visitorWins >= winsNeeded ? series.VisitorTeamId : null; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Slug/MatchSlugSourceBuilder.cs b/Club12-Backend/Application/Utils/Helper/Slug/MatchSlugSourceBuilder.cs new file mode 100644 index 0000000..d587078 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Slug/MatchSlugSourceBuilder.cs @@ -0,0 +1,28 @@ +using System; + +namespace Application.Utils.Helper.Slug; + +/// +/// Composes the display string a Match's slug is generated from, fed through SlugGenerator.GenerateUniqueSlugAsync. +/// +public static class MatchSlugSourceBuilder +{ + /// + /// Placeholder team name used when a match's home or visitor team is not yet assigned. + /// + public const string UnassignedTeamPlaceholder = "TBD"; + + /// + /// Builds the slug source string for a match. + /// + /// The home team's name, or null/empty when not yet assigned. + /// The visitor team's name, or null/empty when not yet assigned. + /// The match's date. + public static string Build(string? homeTeamName, string? visitorTeamName, DateTime matchDate) + { + string home = string.IsNullOrWhiteSpace(homeTeamName) ? UnassignedTeamPlaceholder : homeTeamName; + string visitor = string.IsNullOrWhiteSpace(visitorTeamName) ? UnassignedTeamPlaceholder : visitorTeamName; + + return $"{home} vs {visitor} {matchDate:yyyy-MM-dd}"; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Slug/SlugGenerator.cs b/Club12-Backend/Application/Utils/Helper/Slug/SlugGenerator.cs new file mode 100644 index 0000000..61361a8 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Slug/SlugGenerator.cs @@ -0,0 +1,61 @@ +using System; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Application.Utils.Helper.Slug; + +/// +/// Generates lowercase, kebab-case, URL-friendly slugs from display names. +/// +public static partial class SlugGenerator +{ + /// + /// Converts text into a lowercase, kebab-case slug. + /// + /// The display name to convert. + /// The generated slug. Never null, may be empty for input with no alphanumeric characters. + public static string GenerateSlug(string text) + { + string normalized = text.Normalize(NormalizationForm.FormD); + StringBuilder withoutAccents = new(normalized.Length); + + foreach (char character in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.NonSpacingMark) + { + withoutAccents.Append(character); + } + } + + string lowercase = withoutAccents.ToString().Normalize(NormalizationForm.FormC).ToLowerInvariant(); + string hyphenated = NonAlphanumericRunRegex().Replace(lowercase, "-"); + + return hyphenated.Trim('-'); + } + + /// + /// Generates a slug from text and, when it collides with an existing slug, appends a numeric suffix until slugExists reports no collision. + /// + /// The display name to convert. + /// Predicate that checks whether a candidate slug is already taken. + /// A unique slug derived from text. + public static async Task GenerateUniqueSlugAsync(string text, Func> slugExists) + { + string baseSlug = GenerateSlug(text); + string candidate = baseSlug; + int suffix = 1; + + while (await slugExists(candidate)) + { + suffix++; + candidate = $"{baseSlug}-{suffix}"; + } + + return candidate; + } + + [GeneratedRegex("[^a-z0-9]+")] + private static partial Regex NonAlphanumericRunRegex(); +} diff --git a/Club12-Backend/Application/Utils/Helper/StageHelper/StageHelper.cs b/Club12-Backend/Application/Utils/Helper/StageHelper/StageHelper.cs new file mode 100644 index 0000000..c23a3b7 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/StageHelper/StageHelper.cs @@ -0,0 +1,24 @@ +using Application.Utils.Constants; +using Application.Utils.Constants.Stage; + +using Domain.Enums; + +using System; + +namespace Application.Utils.Helper.StageHelper; +public static class StageHelper +{ + /// Thrown when an invalid stage type is provided. + public static int GetMaxTeamsForStage(StageType stageType) + { + return stageType switch + { + StageType.Group => MaxTeams.Group, + StageType.QuarterFinal => MaxTeams.QuarterFinal, + StageType.SemiFinal => MaxTeams.SemiFinal, + StageType.ThirdPlace => MaxTeams.ThirdPlace, + StageType.Final => MaxTeams.Final, + _ => throw new ArgumentException(ErrorMessages.Stage.InvalidStageType) + }; + } +} \ No newline at end of file diff --git a/Club12-Backend/Application/Utils/Helper/Standings/GroupStandings.cs b/Club12-Backend/Application/Utils/Helper/Standings/GroupStandings.cs new file mode 100644 index 0000000..b53e1b3 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Standings/GroupStandings.cs @@ -0,0 +1,27 @@ +using Domain.Entities.Models; + +using System; +using System.Collections.Generic; + +namespace Application.Utils.Helper.Standings; + +/// +/// Standings for a single Group stage within a division. +/// +public class GroupStandings +{ + /// + /// The id of the Group stage these standings belong to. + /// + public required Guid StageId { get; set; } + + /// + /// The Group stage's name, used as the table label. + /// + public required string StageName { get; set; } + + /// + /// The ordered standings for the teams in this group. + /// + public required List Positions { get; set; } +} diff --git a/Club12-Backend/Application/Utils/Helper/Standings/PositionCalculator.cs b/Club12-Backend/Application/Utils/Helper/Standings/PositionCalculator.cs new file mode 100644 index 0000000..311f9b2 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Standings/PositionCalculator.cs @@ -0,0 +1,295 @@ +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.Standings; + +/// +/// Computes division standings from a set of matches. +/// +public static class PositionCalculator +{ + public const int DefaultPointsForWin = 2; + public const int DefaultPointsForLoss = 1; + + /// + /// Builds one Position per team that appears in at least one finished, fully-seeded match, ordered by the group-stage tiebreaker chain. + /// + /// The zone's matches. + /// Table points for a win. Defaults to 2. + /// Table points for a loss. Defaults to 1. + /// + /// Disciplinary point deductions to subtract from the affected teams' + /// totals before ranking. Each team's deductions are summed and subtracted + /// from its Position.Points; the amount and reason are + /// surfaced on Position.PointDeduction. The subtraction is + /// raw and is never clamped — a heavy penalty may legitimately drop a + /// team's total below zero and below other teams. Null/empty leaves every + /// total untouched. + /// + /// + /// Optional teams assigned to the zone. When provided, each is seeded into + /// the table at 0-0 so every assigned team shows from the start, before any + /// match is played. Null keeps the legacy behavior, only teams with a + /// finished match appear. + /// + public static List CalculatePositions( + IEnumerable matches, + int pointsForWin = DefaultPointsForWin, + int pointsForLoss = DefaultPointsForLoss, + IEnumerable? deductions = null, + IEnumerable? rosterTeams = null) + { + List finishedMatches = [.. matches.Where(IsSeededAndFinished)]; + + Dictionary positionsByTeamId = []; + + // Seed every team assigned to the zone at 0-0 so the table shows all of them before any match is played, instead of an empty standings table until the first result. + if (rosterTeams is not null) + { + foreach (Team team in rosterTeams) + { + EnsurePosition(positionsByTeamId, team); + } + } + + foreach (Match match in finishedMatches) + { + bool homeWon = match.WinningTeamId == match.HomeTeamId; + + ApplyResult(positionsByTeamId, match.HomeTeam!, match.HomeScore!.Value, match.VisitorScore!.Value, homeWon, pointsForWin, pointsForLoss); + ApplyResult(positionsByTeamId, match.VisitorTeam!, match.VisitorScore.Value, match.HomeScore.Value, !homeWon, pointsForWin, pointsForLoss); + } + + foreach (Position position in positionsByTeamId.Values) + { + position.PointsDifference = position.PointsFor - position.PointsAgainst; + } + + // Subtract disciplinary deductions before ranking so the tiebreaker chain sees the penalised totals and the table re-orders correctly around a deducted team. + ApplyDeductions(positionsByTeamId.Values, deductions); + + return OrderWithTiebreakers(positionsByTeamId.Values, finishedMatches, pointsForWin, pointsForLoss); + } + + /// + /// Subtracts each team's accumulated disciplinary deductions from its standings total. + /// + public static void ApplyDeductions( + IEnumerable positions, + IEnumerable? deductions) + { + if (deductions is null) + { + return; + } + + Dictionary> byTeam = deductions + .GroupBy(deduction => deduction.TeamId) + .ToDictionary(group => group.Key, group => group.ToList()); + + if (byTeam.Count == 0) + { + return; + } + + foreach (Position position in positions) + { + if (!byTeam.TryGetValue(position.TeamId, out List? teamDeductions)) + { + continue; + } + + int totalDeducted = teamDeductions.Sum(deduction => deduction.Points); + position.Points -= totalDeducted; + position.PointDeduction = new AppliedPointDeduction + { + Points = totalDeducted, + Reason = string.Join("; ", teamDeductions.Select(deduction => deduction.Reason)), + }; + } + } + + private static bool IsSeededAndFinished(Match match) + { + return match.IsFinished + && match.HomeTeam is not null && match.VisitorTeam is not null + && match.HomeScore is not null && match.VisitorScore is not null; + } + + /// + /// Orders the positions applying the tiebreaker chain stepwise: teams are first sorted by PTS, then PG, then whole-zone DG. + /// + private static List OrderWithTiebreakers( + IEnumerable positions, + IReadOnlyList matches, + int pointsForWin, + int pointsForLoss) + { + List ranked = [.. positions + .OrderByDescending(p => p.Points) + .ThenByDescending(p => p.Wins) + .ThenByDescending(p => p.PointsDifference)]; + + List result = []; + + int index = 0; + while (index < ranked.Count) + { + int groupEnd = index + 1; + while (groupEnd < ranked.Count && SharesBaseCriteria(ranked[index], ranked[groupEnd])) + { + groupEnd++; + } + + List tiedGroup = ranked.GetRange(index, groupEnd - index); + + if (tiedGroup.Count == 1) + { + result.Add(tiedGroup[0]); + } + else + { + result.AddRange(BreakTie(tiedGroup, matches, pointsForWin, pointsForLoss)); + } + + index = groupEnd; + } + + AssignResolvedBy(result); + + return result; + } + + private static bool SharesBaseCriteria(Position a, Position b) + { + return a.Points == b.Points && a.Wins == b.Wins && a.PointsDifference == b.PointsDifference; + } + + /// + /// Breaks a set of teams tied on PTS/PG/DG using the head-to-head mini-table, points earned only in games among the tied set. + /// + private static List BreakTie( + List tiedGroup, + IReadOnlyList matches, + int pointsForWin, + int pointsForLoss) + { + HashSet tiedIds = [.. tiedGroup.Select(p => p.TeamId)]; + + List h2hMatches = [.. matches.Where(m => + tiedIds.Contains(m.HomeTeamId!.Value) && tiedIds.Contains(m.VisitorTeamId!.Value))]; + + Dictionary h2hPoints = tiedGroup.ToDictionary(p => p.TeamId, _ => 0); + Dictionary h2hDifference = tiedGroup.ToDictionary(p => p.TeamId, _ => 0); + + foreach (Match match in h2hMatches) + { + Guid winnerId = match.WinningTeamId!.Value; + Guid homeId = match.HomeTeamId!.Value; + Guid visitorId = match.VisitorTeamId!.Value; + + h2hPoints[winnerId] += pointsForWin; + h2hPoints[winnerId == homeId ? visitorId : homeId] += pointsForLoss; + + int margin = match.HomeScore!.Value - match.VisitorScore!.Value; + h2hDifference[homeId] += margin; + h2hDifference[visitorId] -= margin; + } + + // DG en H2H only applies when the tied teams played each other more than once. + bool playedMoreThanOnce = h2hMatches.Count > 1; + + return [.. tiedGroup + .OrderByDescending(p => h2hPoints[p.TeamId]) + .ThenByDescending(p => playedMoreThanOnce ? h2hDifference[p.TeamId] : 0)]; + } + + /// + /// Records, for each team, the criterion that separated it from the team ranked immediately above it, so the standings UI can show why each tie was broken. + /// + private static void AssignResolvedBy(List ordered) + { + for (int i = 1; i < ordered.Count; i++) + { + Position above = ordered[i - 1]; + Position current = ordered[i]; + + if (above.Points != current.Points) + { + current.ResolvedBy = null; + } + else if (above.Wins != current.Wins) + { + current.ResolvedBy = TiebreakerCriterion.GamesWon; + } + else if (above.PointsDifference != current.PointsDifference) + { + current.ResolvedBy = TiebreakerCriterion.PointsDifference; + } + else + { + // Tied on all whole-zone criteria: the head-to-head chain put them in this order, and PG-in-H2H cannot cheaply be told apart from DG-in-H2H here, so HeadToHead is reported as the family. + current.ResolvedBy = TiebreakerCriterion.HeadToHead; + } + } + } + + /// + /// Returns the team's Position, creating a zeroed one on first touch. + /// + private static Position EnsurePosition( + Dictionary positionsByTeamId, + Team team) + { + if (!positionsByTeamId.TryGetValue(team.Id, out Position? position)) + { + position = new Position + { + TeamId = team.Id, + TeamName = team.Name, + LogoUrl = team.LogoUrl, + MatchesPlayed = 0, + Wins = 0, + Losses = 0, + PointsFor = 0, + PointsAgainst = 0, + PointsDifference = 0, + Points = 0, + }; + positionsByTeamId[team.Id] = position; + } + + return position; + } + + private static void ApplyResult( + Dictionary positionsByTeamId, + Team team, + int pointsFor, + int pointsAgainst, + bool won, + int pointsForWin, + int pointsForLoss) + { + Position position = EnsurePosition(positionsByTeamId, team); + + position.MatchesPlayed += 1; + position.PointsFor += pointsFor; + position.PointsAgainst += pointsAgainst; + + if (won) + { + position.Wins += 1; + position.Points += pointsForWin; + } + else + { + position.Losses += 1; + position.Points += pointsForLoss; + } + } +} diff --git a/Club12-Backend/Application/Utils/Helper/SubGroupDistribution/SubGroupDistribution.cs b/Club12-Backend/Application/Utils/Helper/SubGroupDistribution/SubGroupDistribution.cs new file mode 100644 index 0000000..d334ff6 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/SubGroupDistribution/SubGroupDistribution.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Helper.SubGroupDistribution; + +/// +/// Deals a division's roster into evenly balanced sub-groups, the shared algorithm behind rebuild and auto-distribute. +/// +public static class SubGroupDistribution +{ + /// + /// Minimum number of teams a single sub-group must receive for the split to stay playable. + /// + public const int MinTeamsPerSubGroup = 4; + + /// + /// Whether subGroupCount groups over totalTeams teams keeps every group at or above the minimum size, treating an empty roster as always valid. + /// + public static bool MeetsMinimumSize(int totalTeams, int subGroupCount) + { + if (totalTeams == 0) + { + return true; + } + + return totalTeams / subGroupCount >= MinTeamsPerSubGroup; + } + + /// + /// Randomly deals rosterTeamIds into subGroupCount groups by round robin, so no two groups differ in size by more than one team. + /// + public static List> Distribute(IReadOnlyList rosterTeamIds, int subGroupCount) + { + List shuffled = [.. rosterTeamIds]; + for (int i = shuffled.Count - 1; i > 0; i--) + { + int swapIndex = Random.Shared.Next(i + 1); + (shuffled[swapIndex], shuffled[i]) = (shuffled[i], shuffled[swapIndex]); + } + + List> groups = [.. Enumerable.Range(0, subGroupCount).Select(_ => new List())]; + + for (int i = 0; i < shuffled.Count; i++) + { + groups[i % subGroupCount].Add(shuffled[i]); + } + + return groups; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/SupabaseHelper/ISupabaseRawStorage.cs b/Club12-Backend/Application/Utils/Helper/SupabaseHelper/ISupabaseRawStorage.cs new file mode 100644 index 0000000..45e3864 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/SupabaseHelper/ISupabaseRawStorage.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Application.Utils.Helper.SupabaseHelper; + +/// +/// Narrow raw-storage boundary over the Supabase Storage client, implemented by SupabaseHelper. +/// +public interface ISupabaseRawStorage +{ + /// + /// Uploads raw content to objectPath in bucket. + /// + /// The exact destination path within the bucket. + /// The content stream to upload. + /// Target bucket; null uses the configured SupaBase:BucketName. + Task UploadRawAsync(string objectPath, Stream content, string? bucket = null); + + /// + /// Lists raw objects under prefix in bucket. + /// + /// The bucket-relative path prefix to list. + /// Target bucket; null uses the configured SupaBase:BucketName. + Task> ListRawAsync(string prefix, string? bucket = null); + + /// + /// Removes the raw object at objectPath in bucket. + /// + /// The exact object path within the bucket. + /// Target bucket; null uses the configured SupaBase:BucketName. + Task RemoveRawAsync(string objectPath, string? bucket = null); + + /// + /// Downloads the raw content of the object at objectPath in bucket. + /// + /// The exact object path within the bucket. + /// Target bucket; null uses the configured SupaBase:BucketName. + Task DownloadRawAsync(string objectPath, string? bucket = null); +} diff --git a/Club12-Backend/Application/Utils/Helper/SupabaseHelper/SupabaseStorageEntry.cs b/Club12-Backend/Application/Utils/Helper/SupabaseHelper/SupabaseStorageEntry.cs new file mode 100644 index 0000000..c7691f1 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/SupabaseHelper/SupabaseStorageEntry.cs @@ -0,0 +1,8 @@ +using System; + +namespace Application.Utils.Helper.SupabaseHelper; + +/// +/// Minimal listing metadata for a raw Supabase Storage object, decoupled from the third-party Supabase.Storage.FileObject type so fakes don't need to construct it. +/// +public sealed record SupabaseStorageEntry(string Name, DateTimeOffset? UpdatedAt); diff --git a/Club12-Backend/Application/Utils/Helper/TeamProfile/TeamMatchMapper.cs b/Club12-Backend/Application/Utils/Helper/TeamProfile/TeamMatchMapper.cs new file mode 100644 index 0000000..ebe33d7 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/TeamProfile/TeamMatchMapper.cs @@ -0,0 +1,53 @@ +using Application.DTOs.Team.Response; + +using Domain.Entities.Models; + +using System; + +namespace Application.Utils.Helper.TeamProfile; + +/// +/// Pure projection of a Match into a TeamMatchResponse oriented from one team's perspective. +/// +public static class TeamMatchMapper +{ + /// + /// Projects match from the point of view of the team identified by teamId. + /// + /// The match to project. Its HomeTeam/VisitorTeam/Venue + /// navigations should be loaded for names and logos to be populated. + /// The team whose perspective to orient the result to. + /// The team-oriented match projection. + public static TeamMatchResponse Map(Match match, Guid teamId) + { + bool isHome = match.HomeTeamId == teamId; + + Domain.Entities.Models.Team? opponent = isHome ? match.VisitorTeam : match.HomeTeam; + Guid? opponentId = isHome ? match.VisitorTeamId : match.HomeTeamId; + int? teamScore = isHome ? match.HomeScore : match.VisitorScore; + int? opponentScore = isHome ? match.VisitorScore : match.HomeScore; + + // Basketball has no draws, a finished match always has a winning team since a walkover sets one too, so the result is W when this team wins and L otherwise. + string? result = null; + if (match.IsFinished) + { + result = match.WinningTeamId == teamId ? "W" : "L"; + } + + return new TeamMatchResponse + { + MatchId = match.Id, + MatchDate = match.MatchDate, + IsFinished = match.IsFinished, + Status = match.Status.ToString(), + IsHome = isHome, + OpponentTeamId = opponentId ?? Guid.Empty, + OpponentName = opponent?.Name ?? string.Empty, + OpponentLogoUrl = opponent?.LogoUrl, + TeamScore = teamScore, + OpponentScore = opponentScore, + Result = result, + VenueName = match.Venue?.Name, + }; + } +} diff --git a/Club12-Backend/Application/Utils/Helper/Tournament/TournamentCompletabilityValidator.cs b/Club12-Backend/Application/Utils/Helper/Tournament/TournamentCompletabilityValidator.cs new file mode 100644 index 0000000..dd71a98 --- /dev/null +++ b/Club12-Backend/Application/Utils/Helper/Tournament/TournamentCompletabilityValidator.cs @@ -0,0 +1,247 @@ +using Application.DTOs.Tournament.Response; + +using Domain.Entities.Models; +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; + +using SubGroupDistributionHelper = Application.Utils.Helper.SubGroupDistribution.SubGroupDistribution; + +namespace Application.Utils.Helper.Tournament; + +/// +/// Single source of truth for whether a tournament can be completed once it is started. +/// +public static class TournamentCompletabilityValidator +{ + /// + /// Minimum number of teams a zone, and each cross-cup group, must have assigned for the competition it feeds to be playable or completable. + /// + public const int MinTeamsPerZone = 2; + + /// + /// Minimum number of HABILITADO players an enrolled team must have for the tournament to be completable. + /// + public const int MinPlayersPerTeam = 4; + + /// + /// Evaluates every completability rule against the loaded tournament graph and returns one issue per violation. + /// + /// + /// The tournament with its Divisions loaded, each division's Stages, and + /// each stage's StageTeamMatches and PlayoffMappings loaded. + /// + /// + /// The tournament's team registrations, the enrolled teams, each ideally with + /// its Team loaded so team names can be reported. + /// + /// + /// Each enrolled team's HABILITADO player count for this tournament, per + /// PlayerTeamRegistration.IsHabilitado — NOT its raw + /// registered-roster size, since a registered-but-not-yet-approved player + /// could never legally play anyway. Used by the TeamTooFewPlayers rule. + /// Omitted, null, skips that rule entirely — callers that don't have this + /// data loaded are unaffected. + /// + public static IReadOnlyList Validate( + Domain.Entities.Models.Tournament tournament, + IReadOnlyCollection enrolledRegistrations, + IReadOnlyDictionary? playerCountsByTeam = null) + { + ArgumentNullException.ThrowIfNull(tournament); + ArgumentNullException.ThrowIfNull(enrolledRegistrations); + + List issues = []; + + List regularDivisions = + [.. tournament.Divisions.Where(division => !division.IsCrossDivisionCup)]; + List crossCupDivisions = + [.. tournament.Divisions.Where(division => division.IsCrossDivisionCup)]; + + Dictionary teamNames = BuildTeamNames(tournament, enrolledRegistrations); + + Dictionary> assignedTeamsByDivision = regularDivisions + .ToDictionary(division => division.Id, division => GroupStageTeamIds(division)); + + Dictionary> zonesByTeam = []; + foreach ((Guid divisionId, HashSet teamIds) in assignedTeamsByDivision) + { + foreach (Guid teamId in teamIds) + { + if (!zonesByTeam.TryGetValue(teamId, out HashSet? zones)) + { + zonesByTeam[teamId] = zones = []; + } + + zones.Add(divisionId); + } + } + + foreach (Division division in regularDivisions) + { + int assigned = assignedTeamsByDivision[division.Id].Count; + if (assigned < MinTeamsPerZone) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.ZoneTooFewTeams, + DivisionName = division.Name, + AssignedTeams = assigned, + }); + } + } + + // A team assigned only to the cross cup does not satisfy zone assignment. + HashSet enrolledTeamIds = [.. enrolledRegistrations.Select(registration => registration.TeamId)]; + foreach (Guid teamId in enrolledTeamIds.OrderBy(id => ResolveTeamName(teamNames, id))) + { + if (!zonesByTeam.ContainsKey(teamId)) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.TeamNotAssigned, + TeamName = ResolveTeamName(teamNames, teamId), + }); + } + } + + foreach ((Guid teamId, HashSet zones) in zonesByTeam + .Where(entry => entry.Value.Count > 1) + .OrderBy(entry => ResolveTeamName(teamNames, entry.Key))) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.TeamInMultipleZones, + TeamName = ResolveTeamName(teamNames, teamId), + }); + } + + // A mapping whose FromPosition exceeds the zone's assigned team count seeds a cup from positions that will never exist. + foreach (Division division in regularDivisions) + { + int assigned = assignedTeamsByDivision[division.Id].Count; + foreach (DivisionPlayoffMapping mapping in division.PlayoffMappings + .Where(mapping => mapping.FromPosition > assigned) + .OrderBy(mapping => mapping.FromPosition)) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.PlayoffRangeExceedsTeams, + DivisionName = division.Name, + FromPosition = mapping.FromPosition, + AssignedTeams = assigned, + }); + } + } + + // A rebuild always deals a balanced split, so this mainly catches a hand-edited imbalance or a group left below the minimum after a manual move. + foreach (Division division in regularDivisions) + { + List subGroupStages = [.. division.Stages.Where(stage => stage.StageType == StageType.Group)]; + if (subGroupStages.Count <= 1) + { + continue; + } + + List subGroupSizes = [.. subGroupStages + .Select(stage => stage.StageTeamMatches.Select(match => match.TeamId).Distinct().Count())]; + + int smallestSubGroup = subGroupSizes.Min(); + int largestSubGroup = subGroupSizes.Max(); + + if (smallestSubGroup < SubGroupDistributionHelper.MinTeamsPerSubGroup || largestSubGroup - smallestSubGroup >= 2) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.SubGroupTooFewTeams, + DivisionName = division.Name, + AssignedTeams = smallestSubGroup, + }); + } + } + + foreach (Division crossCup in crossCupDivisions) + { + foreach (Stage groupStage in crossCup.Stages.Where(stage => stage.StageType == StageType.Group)) + { + int assigned = groupStage.StageTeamMatches.Select(match => match.TeamId).Distinct().Count(); + if (assigned < MinTeamsPerZone) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.CrossCupGroupTooFewTeams, + DivisionName = crossCup.Name, + AssignedTeams = assigned, + }); + } + } + } + + // Every enrolled team needs at least MinPlayersPerTeam HABILITADO players, basketball's minimum to field a legal lineup without a walkover, skipped when the caller did not load player-count data. + if (playerCountsByTeam is not null) + { + foreach (Guid teamId in enrolledTeamIds.OrderBy(id => ResolveTeamName(teamNames, id))) + { + int playerCount = playerCountsByTeam.GetValueOrDefault(teamId); + if (playerCount < MinPlayersPerTeam) + { + issues.Add(new CompletabilityIssue + { + Code = CompletabilityIssueCodes.TeamTooFewPlayers, + TeamName = ResolveTeamName(teamNames, teamId), + PlayerCount = playerCount, + }); + } + } + } + + return issues; + } + + /// + /// Distinct ids of the teams assigned to a division's Group stages via StageTeamMatch rows. + /// + private static HashSet GroupStageTeamIds(Division division) + { + return [.. division.Stages + .Where(stage => stage.StageType == StageType.Group) + .SelectMany(stage => stage.StageTeamMatches) + .Select(match => match.TeamId)]; + } + + /// + /// Builds a team-id to team-name lookup from every source that carries a Team navigation, so an issue can report a readable team name whichever rule fired. + /// + private static Dictionary BuildTeamNames( + Domain.Entities.Models.Tournament tournament, + IReadOnlyCollection enrolledRegistrations) + { + Dictionary names = []; + + foreach (TeamTournamentRegistration registration in enrolledRegistrations + .Where(registration => registration.Team is not null)) + { + names[registration.TeamId] = registration.Team!.Name; + } + + foreach (StageTeamMatch match in tournament.Divisions + .SelectMany(division => division.Stages) + .SelectMany(stage => stage.StageTeamMatches) + .Where(match => match.Team is not null)) + { + names[match.TeamId] = match.Team!.Name; + } + + return names; + } + + /// + /// Resolves a team's display name, falling back to its id when no Team navigation was loaded for it. + /// + private static string ResolveTeamName(Dictionary teamNames, Guid teamId) + { + return teamNames.TryGetValue(teamId, out string? name) ? name : teamId.ToString(); + } +} diff --git a/Club12-Backend/Application/Utils/Mappers/ScorerMapper.cs b/Club12-Backend/Application/Utils/Mappers/ScorerMapper.cs new file mode 100644 index 0000000..97dcb28 --- /dev/null +++ b/Club12-Backend/Application/Utils/Mappers/ScorerMapper.cs @@ -0,0 +1,135 @@ +using Application.DTOs.Abstract.Response; +using Application.DTOs.Scorer.Response; +using Application.Interfaces.Mappers; +using Application.Utils.Constants.Scorer; + +using Domain.Entities.Models; + +using Riok.Mapperly.Abstractions; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Application.Utils.Mappers; + + +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class ScorerMapper : IScorerMapper +{ + /// + /// Collapses one row per scoring event into a single row per player, summing their points across the paginated page. + /// + public PaginatedResponse FromPaginatedScorerToPaginatedScorerByPlayerResponse(PaginatedResponse paginatedScorers) + { + List groupedItems = [.. paginatedScorers.Items + .GroupBy(scorer => scorer.PlayerId) + .Select(group => new ScorerByPlayerResponse + { + PlayerId = group.Key, + Points = group.Sum(scorer => scorer.Points), + FullName = group + .Select(scorer => scorer.Player?.FullName) + .FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? string.Empty + }) + .OrderByDescending(item => item.Points)]; + + return new PaginatedResponse + { + Items = groupedItems, + Page = paginatedScorers.Page, + PageSize = paginatedScorers.PageSize, + TotalCount = groupedItems.Count + }; + } + + /// + /// Computes each team's league points across the given matches using ScoreConstants for win, draw and loss, skipping matches that aren't finished yet or don't have both teams assigned. + /// + public PaginatedResponse FromPaginatedMatchToPaginatedScorerByTeamResponse(PaginatedResponse paginatedMatches) + { + Dictionary scoreboard = []; + + foreach (Match match in paginatedMatches.Items) + { + if (match.HomeTeamId is null || match.VisitorTeamId is null) + { + continue; + } + + Guid homeTeamId = match.HomeTeamId.Value; + Guid visitorTeamId = match.VisitorTeamId.Value; + + string homeTeamName = match.HomeTeam?.Name ?? string.Empty; + string visitorTeamName = match.VisitorTeam?.Name ?? string.Empty; + + if (!scoreboard.TryGetValue(homeTeamId, out ScorerByTeamResponse? homeEntry)) + { + homeEntry = new ScorerByTeamResponse + { + TeamId = homeTeamId, + Name = homeTeamName, + Points = 0 + }; + scoreboard[homeTeamId] = homeEntry; + } + + if (!scoreboard.TryGetValue(visitorTeamId, out ScorerByTeamResponse? visitorEntry)) + { + visitorEntry = new ScorerByTeamResponse + { + TeamId = visitorTeamId, + Name = visitorTeamName, + Points = 0 + }; + scoreboard[visitorTeamId] = visitorEntry; + } + + if (!match.IsFinished) + { + continue; + } + + bool isDraw = match.HomeScore == match.VisitorScore; + bool homeWon = match.HomeScore > match.VisitorScore; + + if (isDraw) + { + homeEntry.Points += ScoreConstants.PointsForDraw; + visitorEntry.Points += ScoreConstants.PointsForDraw; + continue; + } + + if (homeWon) + { + homeEntry.Points += ScoreConstants.PointsForWin; + visitorEntry.Points += ScoreConstants.PointsForLoss; + } + else + { + homeEntry.Points += ScoreConstants.PointsForLoss; + visitorEntry.Points += ScoreConstants.PointsForWin; + } + } + + List items = [.. scoreboard.Values.OrderByDescending(x => x.Points)]; + + return new PaginatedResponse + { + Items = items, + Page = paginatedMatches.Page, + PageSize = paginatedMatches.PageSize, + TotalCount = items.Count + }; + } + + public ScorerByPlayerResponse FromScorerToScorerByPlayerResponse(Scorer scorer) + { + throw new System.NotImplementedException(); + } + + public ScorerByTeamResponse FromScorerToScorerByTeamResponse(Scorer scorer) + { + throw new System.NotImplementedException(); + } +} diff --git a/Club12-Backend/Application/Utils/Mappers/TeamMapper.cs b/Club12-Backend/Application/Utils/Mappers/TeamMapper.cs new file mode 100644 index 0000000..271ed2f --- /dev/null +++ b/Club12-Backend/Application/Utils/Mappers/TeamMapper.cs @@ -0,0 +1,13 @@ +using Application.Interfaces.Mappers; + +using Domain.Entities.Models; + +using Riok.Mapperly.Abstractions; + +namespace Application.Utils.Mappers; + +[Mapper] +public partial class TeamMapper : ITeamMapper +{ + public partial void ApplyUpdate(Team source, [MappingTarget] Team target); +} diff --git a/Club12-Backend/Application/Utils/Options/RosterOptions.cs b/Club12-Backend/Application/Utils/Options/RosterOptions.cs new file mode 100644 index 0000000..0053e5f --- /dev/null +++ b/Club12-Backend/Application/Utils/Options/RosterOptions.cs @@ -0,0 +1,17 @@ +namespace Application.Utils.Options; + +/// +/// Configurable roster limits. +/// +public class RosterOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Roster"; + + /// + /// Maximum number of players allowed in a single team's roster for one tournament season. + /// + public int MaxPlayersPerTeam { get; set; } = 25; +} diff --git a/Club12-Backend/Domain/Constants/AuditConstants.cs b/Club12-Backend/Domain/Constants/AuditConstants.cs new file mode 100644 index 0000000..96c8eab --- /dev/null +++ b/Club12-Backend/Domain/Constants/AuditConstants.cs @@ -0,0 +1,9 @@ +namespace Domain.Constants; + +/// +/// Identifies audit-trail entries created by an automated process rather than an authenticated user. +/// +public static class AuditConstants +{ + public const string SystemUser = "System"; +} diff --git a/Club12-Backend/Domain/Constants/MatchDefaults.cs b/Club12-Backend/Domain/Constants/MatchDefaults.cs new file mode 100644 index 0000000..b75a3e9 --- /dev/null +++ b/Club12-Backend/Domain/Constants/MatchDefaults.cs @@ -0,0 +1,17 @@ +namespace Domain.Constants; + +/// +/// Regulation defaults applied to matches when no explicit score is entered. +/// +public static class MatchDefaults +{ + /// + /// Default score awarded to the present team on a walkover. + /// + public const int WalkOverWinnerScore = 20; + + /// + /// Score assigned to the absent team on a walkover. + /// + public const int WalkOverLoserScore = 0; +} diff --git a/Club12-Backend/Domain/Domain.csproj b/Club12-Backend/Domain/Domain.csproj new file mode 100644 index 0000000..b00056a --- /dev/null +++ b/Club12-Backend/Domain/Domain.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + true + + \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/AppliedPointDeduction.cs b/Club12-Backend/Domain/Entities/Models/AppliedPointDeduction.cs new file mode 100644 index 0000000..305201d --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/AppliedPointDeduction.cs @@ -0,0 +1,17 @@ +namespace Domain.Entities.Models; + +/// +/// The point-deduction summary attached to a Position row when a team carries one or more disciplinary deductions in its division. +/// +public class AppliedPointDeduction +{ + /// + /// The total table points subtracted from the team, always positive. + /// + public required int Points { get; set; } + + /// + /// The combined disciplinary reasons, joined when a team has several deductions. + /// + public required string Reason { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/AuditLog.cs b/Club12-Backend/Domain/Entities/Models/AuditLog.cs new file mode 100644 index 0000000..6e141bb --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/AuditLog.cs @@ -0,0 +1,41 @@ +using Domain.Enums; + +namespace Domain.Entities.Models; + +/// +/// An immutable audit-trail entry recording a sensitive action, since the shared owner and admin accounts are otherwise indistinguishable. +/// +public class AuditLog : EntityBase +{ + /// + /// The sensitive action that was performed. + /// + public required AuditAction Action { get; set; } + + /// + /// Who performed the action — the authenticated caller's email, or AuditConstants.SystemUser for an automated process with no HTTP identity. + /// + public required string Actor { get; set; } + + /// + /// The kind of entity the action targeted, or null when the action is not tied to a single entity. + /// + public string? TargetType { get; set; } + + /// + /// Identifier of the targeted entity, when applicable. + /// + public string? TargetId { get; set; } + + /// + /// The target's human-readable name captured at write time, since the target may later be renamed or deleted. + /// + public string? TargetName { get; set; } + + /// + /// Free-form human-readable context describing the action in detail. + /// + public string? Detail { get; set; } + + // Timestamp is EntityBase.DateCreated in UTC. +} diff --git a/Club12-Backend/Domain/Entities/Models/BackupRecord.cs b/Club12-Backend/Domain/Entities/Models/BackupRecord.cs new file mode 100644 index 0000000..0539d31 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/BackupRecord.cs @@ -0,0 +1,24 @@ +using Domain.Enums; + +namespace Domain.Entities.Models; + +/// +/// Durable, queryable per-backup record, the source of truth for the admin backup listing. +/// +public class BackupRecord : EntityBase +{ + /// + /// Where the dump is stored, as an object key or file path understood by the IBackupStorage adapter that persisted it. + /// + public required string StoragePath { get; set; } + + /// + /// Size, in bytes, of the stored dump. + /// + public long SizeBytes { get; set; } + + /// + /// Whether this backup was triggered manually by an Admin or created by the scheduled backup job. + /// + public required BackupOrigin Origin { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/BlogPost.cs b/Club12-Backend/Domain/Entities/Models/BlogPost.cs new file mode 100644 index 0000000..205773d --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/BlogPost.cs @@ -0,0 +1,21 @@ +namespace Domain.Entities.Models; + +public class BlogPost : EntityBase +{ + public required string Author { get; set; } + public required string Title { get; set; } + + /// + /// The unique, URL-friendly identifier used in public blog post links, generated once from the title and never changed afterward. + /// + public required string Slug { get; set; } + + public int Views { get; set; } = 0; + public string? PhotoUrl { get; set; } + public required string MarkdownText { get; set; } + + /// + /// Publication state: when false the post is a draft, hidden from public views and visible only to Admin or Owner callers. + /// + public bool IsPublished { get; set; } = true; +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/Club.cs b/Club12-Backend/Domain/Entities/Models/Club.cs new file mode 100644 index 0000000..db1b619 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Club.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +/// +/// A club is the stable identity that persists across seasons, unlike a Team, which is a per-season registration record. +/// +public class Club : EntityBase +{ + /// + /// The club's stable display name. + /// + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public club links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + /// + /// Optional club crest or logo URL, independent of any season team logo. + /// + public string? LogoUrl { get; set; } + + /// + /// Every per-season Team that belongs to this club, across every tournament. + /// + public virtual ICollection Teams { get; set; } = []; + + /// + /// Optional link to the parent institution this club is a squad of, independent of tournament category. Flat, one level deep — a club with its own ChildClubs cannot itself have a ParentClubId. + /// + public Guid? ParentClubId { get; set; } + public Club? ParentClub { get; set; } + + /// + /// Other clubs (squads) that link to this club as their parent institution. + /// + public virtual ICollection ChildClubs { get; set; } = []; +} diff --git a/Club12-Backend/Domain/Entities/Models/Division.cs b/Club12-Backend/Domain/Entities/Models/Division.cs new file mode 100644 index 0000000..4008b3b --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Division.cs @@ -0,0 +1,56 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +public class Division : EntityBase +{ + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public division links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + public bool IsFinished { get; set; } = false; + + public required Tournament Tournament { get; set; } + + public Guid TournamentId { get; set; } + + /// + /// Competitive gender category of the division, which must match the parent Tournament's Category. + /// + public TournamentCategory Category { get; set; } = TournamentCategory.Masculine; + + public virtual required ICollection Stages { get; set; } + + /// + /// Marks a division that intentionally draws teams from across every other division in the tournament, rather than a team's single competitive tier. + /// + public bool IsCrossDivisionCup { get; set; } = false; + + /// + /// Points awarded for a win when building this division's standings, configurable per division. Defaults to 2. + /// + public int PointsForWin { get; set; } = 2; + + /// + /// Points awarded for a loss when building this division's standings. Defaults to 1. + /// + public int PointsForLoss { get; set; } = 1; + + /// + /// How many teams qualify to the bracket from each internal group of a multi-group cross-division cup. Defaults to 1. + /// + public int QualifiersPerGroup { get; set; } = 1; + + /// + /// Maps this division's final standings position ranges to playoff destinations. + /// + public virtual ICollection PlayoffMappings { get; set; } = []; + + public virtual ICollection DivisionTeamRegistrations { get; set; } = []; +} diff --git a/Club12-Backend/Domain/Entities/Models/DivisionPlayoffMapping.cs b/Club12-Backend/Domain/Entities/Models/DivisionPlayoffMapping.cs new file mode 100644 index 0000000..a0f625a --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/DivisionPlayoffMapping.cs @@ -0,0 +1,34 @@ +using System; + +namespace Domain.Entities.Models; + +/// +/// Maps a contiguous range of final group-stage standings positions in a division to a playoff destination. +/// +public class DivisionPlayoffMapping : EntityBase +{ + /// + /// The division whose standings this mapping applies to. + /// + public Guid DivisionId { get; set; } + + /// + /// The division whose standings this mapping applies to. + /// + public Division? Division { get; set; } + + /// + /// First, 1-based inclusive standings position in the range. + /// + public required int FromPosition { get; set; } + + /// + /// Last, 1-based inclusive standings position in the range, greater than or equal to FromPosition. + /// + public required int ToPosition { get; set; } + + /// + /// The playoff destination the teams in this range qualify for, matching the BracketName of that cup's elimination stages. + /// + public required string Destination { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/DivisionTeamRegistration.cs b/Club12-Backend/Domain/Entities/Models/DivisionTeamRegistration.cs new file mode 100644 index 0000000..f6fd6da --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/DivisionTeamRegistration.cs @@ -0,0 +1,15 @@ +using System; + +namespace Domain.Entities.Models; + +/// +/// The authoritative record that a team is enrolled in a division, independent of any stage placement. +/// +public class DivisionTeamRegistration : EntityBase +{ + public required Guid TeamId { get; set; } + public Team? Team { get; set; } + + public required Guid DivisionId { get; set; } + public Division? Division { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/EntityBase.cs b/Club12-Backend/Domain/Entities/Models/EntityBase.cs new file mode 100644 index 0000000..897e529 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/EntityBase.cs @@ -0,0 +1,23 @@ +using Domain.Constants; + +using System; + +namespace Domain.Entities.Models; + +/// +/// Base class for all domain entities, centralizing the identifier and the created and updated audit fields shared by every entity. +/// +public abstract class EntityBase +{ + public Guid Id { get; set; } = Guid.Empty; + + public DateTime DateCreated { get; set; } = DateTime.UtcNow; + + /// + /// Null until the entity is modified after creation. + /// + public DateTime? DateUpdated { get; set; } + + public required string CreatedBy { get; set; } = AuditConstants.SystemUser; + public string? UpdatedBy { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/Match.cs b/Club12-Backend/Domain/Entities/Models/Match.cs new file mode 100644 index 0000000..d4febc6 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Match.cs @@ -0,0 +1,80 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +public class Match : EntityBase +{ + public DateTime MatchDate { get; set; } + + /// + /// The 1-based matchday this match belongs to within its stage — the canonical fixture grouping key, not the calendar date. + /// + public int? Round { get; set; } + + public required MatchType Type { get; set; } + + /// + /// The unique, URL-friendly identifier used in public match links, generated once from the team names and match date and never changed afterward. + /// + public required string Slug { get; set; } + + public Team? HomeTeam { get; set; } + + public Guid? HomeTeamId { get; set; } + + public Team? VisitorTeam { get; set; } + + public Guid? VisitorTeamId { get; set; } + + /// + /// Null until the match result is loaded. + /// + public int? HomeScore { get; set; } + + /// + /// Null until the match result is loaded. + /// + public int? VisitorScore { get; set; } + + public required bool IsFinished { get; set; } + + /// + /// Whether the match was decided in overtime, purely informational and with no effect on scoring or standings. + /// + public bool WentToOvertime { get; set; } + + /// + /// The match's result lifecycle state: Scheduled, Played, Suspended, or WalkOver. + /// + public MatchStatus Status { get; set; } = MatchStatus.Scheduled; + + public Team? WinningTeam { get; set; } + + public Guid? WinningTeamId { get; set; } + + public Stage Stage { get; set; } = default!; + + public Guid? VenueId { get; set; } + + public Venue? Venue { get; set; } + + public Guid StageId { get; set; } + + public virtual ICollection PlayerStatistics { get; set; } = []; + + public virtual ICollection Scorers { get; set; } = []; + + /// + /// The best-of-N series this game belongs to, when the stage's BestOf is greater than 1, null for single-game rounds. + /// + public Guid? SeriesId { get; set; } + public MatchSeries? Series { get; set; } + + /// + /// The 1-based game number within its series, null when the match does not belong to a series. + /// + public int? GameNumber { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/MatchSeries.cs b/Club12-Backend/Domain/Entities/Models/MatchSeries.cs new file mode 100644 index 0000000..e09c410 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/MatchSeries.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +/// +/// A best-of-N playoff series between two teams at one bracket round, grouping the individual Match games that make it up. +/// +public class MatchSeries : EntityBase +{ + public required Guid StageId { get; set; } + public Stage? Stage { get; set; } + + public required Guid HomeTeamId { get; set; } + public Team? HomeTeam { get; set; } + + public required Guid VisitorTeamId { get; set; } + public Team? VisitorTeam { get; set; } + + /// + /// Number of games in this series, copied from the stage at creation time so a later change to Stage.BestOf never retroactively changes it. + /// + public required int BestOf { get; set; } + + /// + /// Set once one team has won the majority of the series' games. + /// + public Guid? WinningTeamId { get; set; } + public Team? WinningTeam { get; set; } + + public virtual ICollection Matches { get; set; } = []; +} diff --git a/Club12-Backend/Domain/Entities/Models/Player.cs b/Club12-Backend/Domain/Entities/Models/Player.cs new file mode 100644 index 0000000..20217ce --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Player.cs @@ -0,0 +1,90 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Domain.Entities.Models; + +public class Player : EntityBase +{ + public required string FirstName { get; set; } + public string? SecondName { get; set; } + public required string LastName { get; set; } + + /// + /// The unique, URL-friendly identifier used in public player links, generated once from the player's full name and never changed afterward. + /// + public required string Slug { get; set; } + + /// + /// The single canonical source string every slug is derived from, in the player's raw casing and with no document number. + /// + /// The player's last name. + /// The player's first name. + /// The optional second given name; blank is treated as absent. + /// The space-joined name source, formed by joining last name, first name, and second name with spaces. + public static string BuildSlugSource(string lastName, string firstName, string? secondName) + { + if (string.IsNullOrWhiteSpace(secondName)) + { + return $"{lastName} {firstName}"; + } + + return $"{lastName} {firstName} {secondName}"; + } + + /// + /// Computed and not persisted: the canonical slug source for this player. + /// + public string SlugSource => BuildSlugSource(LastName, FirstName, SecondName); + + /// + /// Computed and not persisted to the database: display name with the last name upper-cased. + /// + public string FullName => BuildSlugSource(LastName.ToUpper(), FirstName, SecondName); + + public required string DocumentNumber { get; set; } + public required bool IsSanctioned { get; set; } = false; + public string? PhoneNumber { get; set; } + public required DateTime BirthDate { get; set; } + public required string SocialSecurity { get; set; } + + /// + /// Denormalized convenience pointer to the player's current team, always kept in sync with their latest PlayerTeamRegistration. + /// + public required Team Team { get; set; } + public Guid TeamId { get; set; } + + public virtual ICollection Scorers { get; set; } = []; + + /// + /// Every season this player was registered to a team, the source of truth for roster membership. + /// + public virtual ICollection PlayerTeamRegistrations { get; set; } = []; + + /// + /// Transient and not persisted: the medical-record eligibility status of this player for the season roster currently being viewed. + /// + [NotMapped] + public MedicalRecordStatus? MedicalRecordStatus { get; set; } + + /// + /// Transient and not persisted: whether the season roster currently being viewed set a real, non-legacy stored medical-record file reference. + /// + [NotMapped] + public bool HasMedicalRecordFile { get; set; } + + /// + /// Transient and not persisted: whether the player is habilitado for the season roster currently being viewed. + /// + [NotMapped] + public bool IsHabilitado => + MedicalRecordStatus == Domain.Enums.MedicalRecordStatus.Approved && HasMedicalRecordFile; + + /// + /// Transient and not persisted: the player's jersey number for the season roster currently being viewed. + /// + [NotMapped] + public int? JerseyNumber { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/PlayerSanction.cs b/Club12-Backend/Domain/Entities/Models/PlayerSanction.cs new file mode 100644 index 0000000..5f4f342 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/PlayerSanction.cs @@ -0,0 +1,51 @@ +using Domain.Enums; + +using System; + +namespace Domain.Entities.Models; + +public class PlayerSanction : EntityBase +{ + /// + /// The sanction's length expressed in fechas, not calendar days: a value of N means the subject sits out the next N rounds. + /// + public required int Duration { get; set; } + public required DateTime IssuedDate { get; set; } + public required string Description { get; set; } + + /// + /// What kind of subject this sanction targets: a player, a team, or a staff member. Defaults to Player. + /// + public SanctionSubjectType SubjectType { get; set; } = SanctionSubjectType.Player; + + /// + /// The sanctioned player, only set and required when SubjectType is Player. + /// + public Player? Player { get; set; } + public Guid? PlayerId { get; set; } + + /// + /// The sanctioned team, only set when SubjectType is Team. + /// + public Team? Team { get; set; } + public Guid? TeamId { get; set; } + + /// + /// The sanctioned staff member's name, only set when SubjectType is Staff. + /// + public string? StaffName { get; set; } + + public required Match Match { get; set; } + public Guid MatchId { get; set; } + + /// + /// The unique, URL-friendly identifier used in public and admin sanction links, generated once from the subject's name and the issued date. + /// + public required string Slug { get; set; } + + public SanctionAppealStatus AppealStatus { get; set; } = SanctionAppealStatus.None; + public string? AppealReason { get; set; } + public DateTime? AppealDate { get; set; } + public string? AppealResolution { get; set; } + public DateTime? AppealResolvedDate { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/PlayerStatistic.cs b/Club12-Backend/Domain/Entities/Models/PlayerStatistic.cs new file mode 100644 index 0000000..545b71e --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/PlayerStatistic.cs @@ -0,0 +1,15 @@ +using Domain.Enums; + +using System; + +namespace Domain.Entities.Models; + +public class PlayerStatistic : EntityBase +{ + public required int Value { get; set; } + public StatisticType Type { get; set; } = StatisticType.Points; + public Match? Match { get; set; } + public required Guid MatchId { get; set; } + public Player? Player { get; set; } + public required Guid PlayerId { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/PlayerTeamRegistration.cs b/Club12-Backend/Domain/Entities/Models/PlayerTeamRegistration.cs new file mode 100644 index 0000000..b4fb389 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/PlayerTeamRegistration.cs @@ -0,0 +1,73 @@ +using Domain.Enums; + +using System; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Domain.Entities.Models; + +/// +/// Links a Player to a Team for exactly one season, the source of truth for roster membership, unlike the denormalized Player.TeamId pointer. +/// +public class PlayerTeamRegistration : EntityBase +{ + public required Guid PlayerId { get; set; } + public Player? Player { get; set; } + + public required Guid TeamId { get; set; } + public Team? Team { get; set; } + + /// + /// The season this registration belongs to, captured at registration time from the Team's TournamentId at that moment. + /// + public required Guid TournamentId { get; set; } + public Tournament? Tournament { get; set; } + + /// + /// The player's jersey number for this season registration, null when no number has been assigned yet. + /// + public int? JerseyNumber { get; set; } + + /// + /// Medical-record eligibility status for this season registration, defaulting to Pending. + /// + public MedicalRecordStatus MedicalRecordStatus { get; set; } = MedicalRecordStatus.Pending; + + /// + /// Storage reference of the uploaded medical-record file for this season, null until a file is uploaded. + /// + public string? MedicalRecordFileUrl { get; set; } + + /// + /// Original file name of the uploaded medical-record file, kept for display. + /// + public string? MedicalRecordFileName { get; set; } + + /// + /// Optional reason recorded when the medical record is rejected. + /// + public string? MedicalRecordReviewReason { get; set; } + + /// + /// When the medical record was last approved or rejected, null while still Pending. + /// + public DateTime? MedicalRecordReviewedAt { get; set; } + + /// + /// Refs written before the private-bucket relocation lived under this prefix inside public-images and no longer resolve. + /// + public const string LegacyReferencePrefix = "medical-records/"; + + /// + /// Whether fileReference is a real, resolvable stored file reference: non-null, non-whitespace, and not a legacy prefix ref. + /// + public static bool IsStoredReference(string? fileReference) => + !string.IsNullOrWhiteSpace(fileReference) + && !fileReference.StartsWith(LegacyReferencePrefix, StringComparison.Ordinal); + + /// + /// Whether this registration's player is habilitado for this season: Approved status and a real stored file reference. + /// + [NotMapped] + public bool IsHabilitado => + MedicalRecordStatus == Enums.MedicalRecordStatus.Approved && IsStoredReference(MedicalRecordFileUrl); +} diff --git a/Club12-Backend/Domain/Entities/Models/Position.cs b/Club12-Backend/Domain/Entities/Models/Position.cs new file mode 100644 index 0000000..95d491c --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Position.cs @@ -0,0 +1,53 @@ +using Domain.Enums; + +using System; + +namespace Domain.Entities.Models; + +/// +/// Represents the position of a team in a division, including its match statistics, used in the service layer. +/// +public class Position +{ + public required Guid TeamId { get; set; } + + public required string TeamName { get; set; } + + public required string LogoUrl { get; set; } + + public required int MatchesPlayed { get; set; } + + public required int Wins { get; set; } + + public required int Losses { get; set; } + + /// + /// Basketball score totaled across all matches, distinct from the standings score in Points. + /// + public required int PointsFor { get; set; } + + /// + /// Basketball score conceded, totaled across all matches, the counterpart to PointsFor. + /// + public required int PointsAgainst { get; set; } + + /// + /// The point difference, PointsFor minus PointsAgainst, for the team. + /// + public required int PointsDifference { get; set; } + + /// + /// The total table points accumulated by the team, calculated from the division's points-per-win and points-per-loss configuration. + /// + public required int Points { get; set; } + + /// + /// The tiebreaker criterion that separated this team from the team ranked immediately above it. + /// + public TiebreakerCriterion? ResolvedBy { get; set; } + + /// + /// The disciplinary point deduction applied to this team, null when the team has no deduction. + /// + public AppliedPointDeduction? PointDeduction { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/Scorer.cs b/Club12-Backend/Domain/Entities/Models/Scorer.cs new file mode 100644 index 0000000..06c95e7 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Scorer.cs @@ -0,0 +1,16 @@ +using System; + +namespace Domain.Entities.Models; + +public class Scorer : EntityBase +{ + public required Guid PlayerId { get; set; } + + public Player? Player { get; set; } + + public required int Points { get; set; } + + public required Guid MatchId { get; set; } + + public Match? Match { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/Season.cs b/Club12-Backend/Domain/Entities/Models/Season.cs new file mode 100644 index 0000000..6297b88 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Season.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +/// +/// Represents a season in the Club12 application, a top-level grouping that gathers several tournaments played in the same competitive period. +/// +public class Season : EntityBase +{ + /// + /// The display name of the season, in the form "Temporada XXVII". + /// + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public season links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + /// + /// The optional calendar year the season is played in. + /// + public int? Year { get; set; } + + /// + /// The tournaments grouped under this season, empty until tournaments are assigned since membership is optional. + /// + public virtual ICollection Tournaments { get; set; } = []; +} diff --git a/Club12-Backend/Domain/Entities/Models/Stage.cs b/Club12-Backend/Domain/Entities/Models/Stage.cs new file mode 100644 index 0000000..6155f95 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Stage.cs @@ -0,0 +1,49 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +public class Stage : EntityBase +{ + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public stage links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + public string? Description { get; set; } + public required StageType StageType { get; set; } + public required bool IsActive { get; set; } + public bool IsElimination { get; set; } + public required DateTime StartDate { get; set; } + public required DateTime EndDate { get; set; } + public required Guid DivisionId { get; set; } + public required Division Division { get; set; } + public virtual required ICollection Matches { get; set; } = []; + public int Order { get; set; } + public virtual ICollection StageTeamMatches { get; set; } = []; + + /// + /// Groups multiple parallel elimination brackets under the same division, null when the stage belongs to the division's default bracket. + /// + public string? BracketName { get; set; } + + /// + /// Number of games in a series between two teams at this round, defaulting to 1 for a single match deciding the round. + /// + public int BestOf { get; set; } = 1; + public virtual ICollection MatchSeries { get; set; } = []; + + /// + /// How many times each pair of teams plays within this group stage, defaulting to 1 for a single round-robin. + /// + public int RoundRobinLegs { get; set; } = 1; + + /// + /// When this bracket's seeding draw was committed, null until a draw runs. + /// + public DateTime? DrawnAt { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/StageTeamMatch.cs b/Club12-Backend/Domain/Entities/Models/StageTeamMatch.cs new file mode 100644 index 0000000..6ee7139 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/StageTeamMatch.cs @@ -0,0 +1,11 @@ +using System; + +namespace Domain.Entities.Models; + +public class StageTeamMatch : EntityBase +{ + public required Guid StageId { get; set; } + public Stage? Stage { get; set; } + public required Guid TeamId { get; set; } + public Team? Team { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/Team.cs b/Club12-Backend/Domain/Entities/Models/Team.cs new file mode 100644 index 0000000..7d54c18 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Team.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +public class Team : EntityBase +{ + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public team links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + public required string ThreeLetterCode { get; set; } + public required string LogoUrl { get; set; } + public required string ShirtColor { get; set; } + + /// + /// The jersey kit pattern applied over the primary ShirtColor, defaulting to solid. + /// + public string JerseyStyle { get; set; } = "solid"; + + /// + /// Optional secondary hex color used for the jersey pattern or trim, null when the kit is a plain solid with no accent. + /// + public string? ShirtSecondaryColor { get; set; } + + /// + /// Optional third hex color, used only by tri-color kit templates as a second accent alongside ShirtSecondaryColor. + /// + public string? ShirtTertiaryColor { get; set; } + + public Tournament? Tournament { get; set; } + public Guid? TournamentId { get; set; } + + /// + /// Optional link to the team's stable cross-season identity, purely additive and never breaking existing per-season Team behavior. + /// + public Guid? ClubId { get; set; } + public Club? Club { get; set; } + /// + /// Players whose denormalized current team pointer points here, not season-scoped. + /// + public virtual required ICollection Players { get; set; } = []; + public virtual ICollection StageTeamMatches { get; set; } = []; + + /// + /// Every player ever registered to this team, across every season it has belonged to, the source of truth for roster membership. + /// + public virtual ICollection PlayerTeamRegistrations { get; set; } = []; + + /// + /// Every tournament this team has ever been registered to, across every season, independent of the current TournamentId pointer. + /// + public virtual ICollection TeamTournamentRegistrations { get; set; } = []; + + public virtual ICollection DivisionTeamRegistrations { get; set; } = []; +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Entities/Models/TeamPointDeduction.cs b/Club12-Backend/Domain/Entities/Models/TeamPointDeduction.cs new file mode 100644 index 0000000..f87954c --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/TeamPointDeduction.cs @@ -0,0 +1,39 @@ +using System; + +namespace Domain.Entities.Models; + +/// +/// A disciplinary points penalty applied by an admin to a team within a division, subtracted from that team's standings total. +/// +public class TeamPointDeduction : EntityBase +{ + /// + /// The division whose standings the penalty affects. + /// + public Guid DivisionId { get; set; } + + /// + /// The division navigation, optional so the entity can be built from an id alone; deleting the division cascades its deductions away. + /// + public Division? Division { get; set; } + + /// + /// The penalised team. + /// + public Guid TeamId { get; set; } + + /// + /// The team navigation, optional so the entity can be built from an id alone; loaded when the caller needs the team's name. + /// + public Team? Team { get; set; } + + /// + /// The positive amount of table points to subtract from the team's total. + /// + public required int Points { get; set; } + + /// + /// The disciplinary reason for the deduction, shown next to the penalised team in the standings. + /// + public required string Reason { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/TeamStaff.cs b/Club12-Backend/Domain/Entities/Models/TeamStaff.cs new file mode 100644 index 0000000..8dd3867 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/TeamStaff.cs @@ -0,0 +1,41 @@ +using Domain.Enums; + +using System; + +namespace Domain.Entities.Models; + +/// +/// A member of a team's technical staff, scoped to one team within one tournament, mirroring the season scoping used by PlayerTeamRegistration. +/// +public class TeamStaff : EntityBase +{ + /// + /// The team this staff member belongs to. + /// + public Guid TeamId { get; set; } + + /// + /// The team navigation, optional so the entity can be built from an id alone; loaded when the caller needs the team's name. + /// + public Team? Team { get; set; } + + /// + /// The season this staff membership belongs to. + /// + public Guid TournamentId { get; set; } + + /// + /// The tournament navigation, optional so the entity can be built from an id alone. + /// + public Tournament? Tournament { get; set; } + + /// + /// The staff member's full name. + /// + public required string FullName { get; set; } + + /// + /// The role this person holds on the team's technical staff. + /// + public required TeamStaffRole Role { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/TeamTournamentRegistration.cs b/Club12-Backend/Domain/Entities/Models/TeamTournamentRegistration.cs new file mode 100644 index 0000000..55e8113 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/TeamTournamentRegistration.cs @@ -0,0 +1,18 @@ +using System; + +namespace Domain.Entities.Models; + +/// +/// Links a Team to a Tournament for exactly one season, the source of truth for a team's participation history, unlike the denormalized Team.TournamentId pointer. +/// +public class TeamTournamentRegistration : EntityBase +{ + public required Guid TeamId { get; set; } + public Team? Team { get; set; } + + /// + /// The season this registration belongs to, captured at registration time. + /// + public required Guid TournamentId { get; set; } + public Tournament? Tournament { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/Tournament.cs b/Club12-Backend/Domain/Entities/Models/Tournament.cs new file mode 100644 index 0000000..7ab8ad9 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Tournament.cs @@ -0,0 +1,46 @@ +using Domain.Enums; + +using System; +using System.Collections.Generic; + +namespace Domain.Entities.Models; + +public class Tournament : EntityBase +{ + public required string Description { get; set; } + + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public tournament links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + /// + /// The deadline for team registrations, which must be earlier than the tournament start date. + /// + public required DateTime TeamRegistrationDeadline { get; set; } + + public required DateTime StartDate { get; set; } + + public TournamentStatus Status { get; set; } = TournamentStatus.Scheduled; + + /// + /// Competitive gender category of the tournament, the source of truth for the no-mixing invariant across its divisions. + /// + public TournamentCategory Category { get; set; } = TournamentCategory.Masculine; + + /// + /// The optional id of the season this tournament belongs to, purely additive and never affecting the tournament's own Category. + /// + public Guid? SeasonId { get; set; } + + /// + /// The season this tournament belongs to, or null when it is not grouped under any season. + /// + public virtual Season? Season { get; set; } + + public virtual required ICollection Divisions { get; set; } + + public virtual required ICollection Teams { get; set; } +} diff --git a/Club12-Backend/Domain/Entities/Models/Venue.cs b/Club12-Backend/Domain/Entities/Models/Venue.cs new file mode 100644 index 0000000..0a38fb5 --- /dev/null +++ b/Club12-Backend/Domain/Entities/Models/Venue.cs @@ -0,0 +1,24 @@ +namespace Domain.Entities.Models; + +public class Venue : EntityBase +{ + public required string Name { get; set; } + + /// + /// The unique, URL-friendly identifier used in public venue links, generated once from the name and never changed afterward. + /// + public required string Slug { get; set; } + + public required string Address { get; set; } + public string? PhotoUrl { get; set; } + + /// + /// Optional geographic latitude of the venue, used to build a public map link, null when the venue has no coordinates yet. + /// + public double? Latitude { get; set; } + + /// + /// Optional geographic longitude of the venue, used to build a public map link, null when the venue has no coordinates yet. + /// + public double? Longitude { get; set; } +} \ No newline at end of file diff --git a/Club12-Backend/Domain/Enums/AuditAction.cs b/Club12-Backend/Domain/Enums/AuditAction.cs new file mode 100644 index 0000000..6a3e009 --- /dev/null +++ b/Club12-Backend/Domain/Enums/AuditAction.cs @@ -0,0 +1,32 @@ +namespace Domain.Enums; + +/// +/// The sensitive, auditable actions tracked for traceability, persisted as the enum name so the trail survives numeric reordering. +/// +public enum AuditAction +{ + /// + /// Full tournament-domain data wipe, triggered by DataMaintenanceService. + /// + DataWipe, + + /// + /// Database restore from a backup, triggered by BackupRestoreService. + /// + BackupRestore, + + /// + /// A tournament lifecycle status change, triggered by TournamentService. + /// + TournamentStatusChange, + + /// + /// An admin-triggered password reset, from user management. + /// + PasswordReset, + + /// + /// A bracket seeding draw, initial or re-draw, recorded for transparency. + /// + PlayoffDraw, +} diff --git a/Club12-Backend/Domain/Enums/BackupOrigin.cs b/Club12-Backend/Domain/Enums/BackupOrigin.cs new file mode 100644 index 0000000..2c74e89 --- /dev/null +++ b/Club12-Backend/Domain/Enums/BackupOrigin.cs @@ -0,0 +1,17 @@ +namespace Domain.Enums; + +/// +/// How a catalogued database backup was created. +/// +public enum BackupOrigin +{ + /// + /// Triggered on-demand by an Admin from the Data Administration panel. + /// + Manual, + + /// + /// Created automatically by the scheduled DatabaseBackupHostedService. + /// + Job +} diff --git a/Club12-Backend/Domain/Enums/DrawMode.cs b/Club12-Backend/Domain/Enums/DrawMode.cs new file mode 100644 index 0000000..99c24f2 --- /dev/null +++ b/Club12-Backend/Domain/Enums/DrawMode.cs @@ -0,0 +1,10 @@ +namespace Domain.Enums; + +/// +/// How a playoffs-only bracket's first-round team order is produced: shuffled server-side or set by an admin. +/// +public enum DrawMode +{ + Random, + Manual, +} diff --git a/Club12-Backend/Domain/Enums/MatchStatus.cs b/Club12-Backend/Domain/Enums/MatchStatus.cs new file mode 100644 index 0000000..653a902 --- /dev/null +++ b/Club12-Backend/Domain/Enums/MatchStatus.cs @@ -0,0 +1,32 @@ +namespace Domain.Enums; + +/// +/// The lifecycle state of a match's result, distinct from the boolean Match.IsFinished flag. +/// +public enum MatchStatus +{ + /// + /// The default state: the fixture exists but no result has been loaded yet. + /// + Scheduled, + + /// + /// The match was played and a decisive result was loaded. + /// + Played, + + /// + /// The match was postponed or suspended and has no result. + /// + Suspended, + + /// + /// One team did not show up, so the regulation default result was applied to the present team. + /// + WalkOver, + + /// + /// The match will never be played because its tournament was canceled or force-closed while it was still pending. + /// + Canceled +} diff --git a/Club12-Backend/Domain/Enums/MatchType.cs b/Club12-Backend/Domain/Enums/MatchType.cs new file mode 100644 index 0000000..1876336 --- /dev/null +++ b/Club12-Backend/Domain/Enums/MatchType.cs @@ -0,0 +1,7 @@ +namespace Domain.Enums; + +public enum MatchType +{ + Regular, + Playoff +} diff --git a/Club12-Backend/Domain/Enums/MedicalRecordStatus.cs b/Club12-Backend/Domain/Enums/MedicalRecordStatus.cs new file mode 100644 index 0000000..6366e5f --- /dev/null +++ b/Club12-Backend/Domain/Enums/MedicalRecordStatus.cs @@ -0,0 +1,22 @@ +namespace Domain.Enums; + +/// +/// Medical-record eligibility status of a player's season registration, scoped per player, team, and tournament. +/// +public enum MedicalRecordStatus +{ + /// + /// Default for a brand-new registration: no medical record uploaded yet, or uploaded but not yet reviewed. + /// + Pending, + + /// + /// The owner or admin reviewed and approved the medical record, making the player habilitado for that team and tournament. + /// + Approved, + + /// + /// The owner or admin rejected the medical record, usually with a reason, leaving the player not habilitado. + /// + Rejected, +} diff --git a/Club12-Backend/Domain/Enums/Roles.cs b/Club12-Backend/Domain/Enums/Roles.cs new file mode 100644 index 0000000..3d70d4e --- /dev/null +++ b/Club12-Backend/Domain/Enums/Roles.cs @@ -0,0 +1,16 @@ +namespace Domain.Enums; + +/// +/// Compile-time string constants for every UserRoleType value, used in authorization attributes to avoid magic strings. +/// +public static class Roles +{ + public const string Admin = nameof(UserRoleType.ADMIN); + public const string Owner = nameof(UserRoleType.OWNER); + public const string Guest = nameof(UserRoleType.GUEST); + + /// + /// Every staff role that may operate the panel: the Owner account and the Admin IT account. + /// + public const string AdminOrOwner = $"{Admin},{Owner}"; +} diff --git a/Club12-Backend/Domain/Enums/SanctionAppealStatus.cs b/Club12-Backend/Domain/Enums/SanctionAppealStatus.cs new file mode 100644 index 0000000..dfe2973 --- /dev/null +++ b/Club12-Backend/Domain/Enums/SanctionAppealStatus.cs @@ -0,0 +1,24 @@ +namespace Domain.Enums; + +public enum SanctionAppealStatus +{ + /// + /// No appeal has been submitted. + /// + None, + + /// + /// An appeal was submitted and is awaiting a decision. + /// + Pending, + + /// + /// The appeal was accepted and the sanction is overturned. + /// + Accepted, + + /// + /// The appeal was rejected and the sanction stands. + /// + Rejected +} diff --git a/Club12-Backend/Domain/Enums/SanctionSubjectType.cs b/Club12-Backend/Domain/Enums/SanctionSubjectType.cs new file mode 100644 index 0000000..bca3cc9 --- /dev/null +++ b/Club12-Backend/Domain/Enums/SanctionSubjectType.cs @@ -0,0 +1,22 @@ +namespace Domain.Enums; + +/// +/// The kind of subject a PlayerSanction is applied to: an individual player, a whole team, or a staff member. +/// +public enum SanctionSubjectType +{ + /// + /// The sanction targets an individual player, identified by PlayerSanction.PlayerId. + /// + Player, + + /// + /// The sanction targets a whole team as an institutional sanction, identified by PlayerSanction.TeamId. + /// + Team, + + /// + /// The sanction targets a staff member, identified only by PlayerSanction.StaffName. + /// + Staff +} diff --git a/Club12-Backend/Domain/Enums/StageType.cs b/Club12-Backend/Domain/Enums/StageType.cs new file mode 100644 index 0000000..716e6dd --- /dev/null +++ b/Club12-Backend/Domain/Enums/StageType.cs @@ -0,0 +1,19 @@ +namespace Domain.Enums; + +public enum StageType +{ + /// + /// Group stage, where teams play in a round-robin format. + /// + Group, + + /// + /// Round of 16 stage. + /// + RoundOf16, + + QuarterFinal, + SemiFinal, + ThirdPlace, + Final +} diff --git a/Club12-Backend/Domain/Enums/StatisticType.cs b/Club12-Backend/Domain/Enums/StatisticType.cs new file mode 100644 index 0000000..ea807af --- /dev/null +++ b/Club12-Backend/Domain/Enums/StatisticType.cs @@ -0,0 +1,7 @@ +namespace Domain.Enums; + +public enum StatisticType +{ + Points, + Assists +} diff --git a/Club12-Backend/Domain/Enums/TeamStaffRole.cs b/Club12-Backend/Domain/Enums/TeamStaffRole.cs new file mode 100644 index 0000000..0a200de --- /dev/null +++ b/Club12-Backend/Domain/Enums/TeamStaffRole.cs @@ -0,0 +1,17 @@ +namespace Domain.Enums; + +/// +/// The role a member of a team's technical staff holds for a given season. +/// +public enum TeamStaffRole +{ + /// + /// Head coach. + /// + Coach, + + /// + /// Assistant coach. + /// + AssistantCoach +} diff --git a/Club12-Backend/Domain/Enums/TiebreakerCriterion.cs b/Club12-Backend/Domain/Enums/TiebreakerCriterion.cs new file mode 100644 index 0000000..f899614 --- /dev/null +++ b/Club12-Backend/Domain/Enums/TiebreakerCriterion.cs @@ -0,0 +1,32 @@ +namespace Domain.Enums; + +/// +/// The group-stage tiebreaker criteria, in the exact priority order the club's rulebook applies them. +/// +public enum TiebreakerCriterion +{ + /// + /// Table points awarded for wins and losses. + /// + Points, + + /// + /// Games won across the whole zone. + /// + GamesWon, + + /// + /// Points for minus points against, across the whole zone. + /// + PointsDifference, + + /// + /// Head-to-head result among the tied teams. + /// + HeadToHead, + + /// + /// Points difference considering only the games played among the tied teams. + /// + HeadToHeadPointsDifference, +} diff --git a/Club12-Backend/Domain/Enums/TournamentCategory.cs b/Club12-Backend/Domain/Enums/TournamentCategory.cs new file mode 100644 index 0000000..3a86239 --- /dev/null +++ b/Club12-Backend/Domain/Enums/TournamentCategory.cs @@ -0,0 +1,17 @@ +namespace Domain.Enums; + +/// +/// Competitive gender category of a tournament, living on the Tournament as the source of truth every division must share. +/// +public enum TournamentCategory +{ + /// + /// Masculine competition, the default for existing or unspecified tournaments. + /// + Masculine = 0, + + /// + /// Feminine competition, played as its own separate tournament. + /// + Feminine = 1 +} diff --git a/Club12-Backend/Domain/Enums/TournamentStatus.cs b/Club12-Backend/Domain/Enums/TournamentStatus.cs new file mode 100644 index 0000000..4147691 --- /dev/null +++ b/Club12-Backend/Domain/Enums/TournamentStatus.cs @@ -0,0 +1,23 @@ +namespace Domain.Enums; + +/// +/// Lifecycle status of a Tournament; see TournamentStatusTransitions for the allowed moves between these values. +/// +public enum TournamentStatus +{ + /// + /// The tournament is scheduled but not yet open for team registrations. + /// + Scheduled, + OpenForRegistration, + /// + /// Registration has closed, freezing structural changes: the roster is fixed and teams are assigned to divisions. + /// + RegistrationClosed, + Ongoing, + Finished, + /// + /// The tournament has been canceled and will not take place. + /// + Canceled, +} diff --git a/Club12-Backend/Domain/Enums/TournamentStatusTransitions.cs b/Club12-Backend/Domain/Enums/TournamentStatusTransitions.cs new file mode 100644 index 0000000..b7a5827 --- /dev/null +++ b/Club12-Backend/Domain/Enums/TournamentStatusTransitions.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Domain.Enums; + +/// +/// Encodes the tournament lifecycle state machine, a linear happy path from Scheduled through Finished plus a Canceled escape from any non-terminal state. +/// +public static class TournamentStatusTransitions +{ + /// + /// The set of states each status may move to next, an absent status meaning an invalid transition. + /// + private static readonly IReadOnlyDictionary AllowedNextStates = + new Dictionary + { + [TournamentStatus.Scheduled] = [TournamentStatus.OpenForRegistration, TournamentStatus.Canceled], + [TournamentStatus.OpenForRegistration] = [TournamentStatus.RegistrationClosed, TournamentStatus.Canceled], + [TournamentStatus.RegistrationClosed] = [TournamentStatus.Ongoing, TournamentStatus.Canceled], + // Ongoing may revert to RegistrationClosed to fix a division mis-assignment, tearing down the generated fixture so restarting rebuilds it correctly. + [TournamentStatus.Ongoing] = [TournamentStatus.Finished, TournamentStatus.RegistrationClosed, TournamentStatus.Canceled], + [TournamentStatus.Finished] = [], + [TournamentStatus.Canceled] = [], + }; + + /// + /// Whether a status is terminal, meaning it has no outgoing transition. + /// + public static bool IsTerminal(TournamentStatus status) => + status is TournamentStatus.Finished or TournamentStatus.Canceled; + + /// + /// Whether transitioning from one given status to another is allowed, with a same-status no-op always considered valid. + /// + public static bool IsValidTransition(TournamentStatus from, TournamentStatus to) + { + if (from == to) + { + return true; + } + + return AllowedNextStates.TryGetValue(from, out TournamentStatus[]? nextStates) + && nextStates.Contains(to); + } +} diff --git a/Club12-Backend/Domain/Enums/UserRoleType.cs b/Club12-Backend/Domain/Enums/UserRoleType.cs new file mode 100644 index 0000000..17698d9 --- /dev/null +++ b/Club12-Backend/Domain/Enums/UserRoleType.cs @@ -0,0 +1,8 @@ +namespace Domain.Enums; + +public enum UserRoleType +{ + ADMIN = 0, + OWNER = 1, + GUEST = 2 +} diff --git a/Club12-Backend/Domain/Enums/UserRoleTypeExtensions.cs b/Club12-Backend/Domain/Enums/UserRoleTypeExtensions.cs new file mode 100644 index 0000000..515cd3a --- /dev/null +++ b/Club12-Backend/Domain/Enums/UserRoleTypeExtensions.cs @@ -0,0 +1,9 @@ +namespace Domain.Enums; + +public static class UserRoleTypeExtensions +{ + public static string ToRoleName(this UserRoleType role) + { + return role.ToString(); + } +}