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