diff --git a/backend/src/BilliardSystem.API/Endpoints/BilliardEndpoints.cs b/backend/src/BilliardSystem.API/Endpoints/BilliardEndpoints.cs index ca6f19f..a7058ae 100644 --- a/backend/src/BilliardSystem.API/Endpoints/BilliardEndpoints.cs +++ b/backend/src/BilliardSystem.API/Endpoints/BilliardEndpoints.cs @@ -195,7 +195,7 @@ public static IEndpointRouteBuilder MapBilliardEndpoints(this IEndpointRouteBuil } var pendingExists = await dbContext.RecoveryRequests.AnyAsync( - r => r.UserId == user.Id && !r.IsResolved && !r.IsExpired(), cancellationToken); + r => r.UserId == user.Id && r.ResolvedAt == null && r.ExpiresAt > DateTimeOffset.UtcNow, cancellationToken); if (pendingExists) { return Results.Ok(new { message = "Ya hay un código activo. Solicítalo a tu administrador." }); @@ -233,7 +233,7 @@ public static IEndpointRouteBuilder MapBilliardEndpoints(this IEndpointRouteBuil var codeHash = HashToken(request.Code); var recovery = await dbContext.RecoveryRequests.FirstOrDefaultAsync( - r => r.UserId == user.Id && r.CodeHash == codeHash && !r.IsResolved, cancellationToken); + r => r.UserId == user.Id && r.CodeHash == codeHash && r.ResolvedAt == null, cancellationToken); if (recovery is null || recovery.IsExpired()) { @@ -303,7 +303,7 @@ public static IEndpointRouteBuilder MapBilliardEndpoints(this IEndpointRouteBuil .AsNoTracking() .Include(r => r.User) .Include(r => r.Tenant) - .Where(r => !r.IsResolved && r.ExpiresAt > DateTimeOffset.UtcNow) + .Where(r => r.ResolvedAt == null && r.ExpiresAt > DateTimeOffset.UtcNow) .OrderByDescending(r => r.CreatedAt) .Select(r => new RecoveryCodeResponse(r.Id, r.Tenant!.Name, r.User!.UserName, r.CreatedAt, r.ExpiresAt)) .ToListAsync(ct); @@ -315,7 +315,7 @@ public static IEndpointRouteBuilder MapBilliardEndpoints(this IEndpointRouteBuil { var request = await dbContext.RecoveryRequests .Include(r => r.User) - .FirstOrDefaultAsync(r => r.Id == id && !r.IsResolved, ct); + .FirstOrDefaultAsync(r => r.Id == id && r.ResolvedAt == null, ct); if (request is null) return Results.NotFound(); // Persist the hash of the code being shown, otherwise /auth/reset can @@ -665,6 +665,7 @@ await WriteAuditAsync(dbContext, AuditActionType.SessionEnded, null, table.Id, s table.StartSession(match.Id, request.WhitePlayerName, request.YellowPlayerName, null); dbContext.MatchHistories.Add(match); await dbContext.SaveChangesAsync(ct); + await MarkIdempotentAsync(dbContext, request.TransactionId, ct); await hub.Clients.Group($"table:{id}").SendAsync("SessionStarted", new { tableId = table.Id, matchId = match.Id }, ct); await hub.Clients.Group($"admins:{tenant.Id}").SendAsync("TableStateUpdated", new { tableId = table.Id, status = "Occupied" }, ct); @@ -754,6 +755,7 @@ await WriteAuditAsync(dbContext, AuditActionType.SessionEnded, null, table.Id, s var consumption = match.AddConsumption(product.Id, product.Name, product.Price, request.Quantity); dbContext.MatchConsumptions.Add(consumption); await dbContext.SaveChangesAsync(ct); + await MarkIdempotentAsync(dbContext, request.TransactionId, ct); await hub.Clients.Group($"table:{id}").SendAsync("ConsumptionAdded", new { @@ -782,6 +784,7 @@ await WriteAuditAsync(dbContext, AuditActionType.SessionEnded, null, table.Id, s match.UpdateConsumption(consumptionId, request.Quantity); await dbContext.SaveChangesAsync(ct); + await MarkIdempotentAsync(dbContext, request.TransactionId, ct); await hub.Clients.Group($"table:{id}").SendAsync("ConsumptionAdded", new { @@ -809,6 +812,7 @@ await WriteAuditAsync(dbContext, AuditActionType.SessionEnded, null, table.Id, s match.RemoveConsumption(consumptionId); await dbContext.SaveChangesAsync(ct); + await MarkIdempotentAsync(dbContext, transactionId, ct); await hub.Clients.Group($"table:{id}").SendAsync("ConsumptionAdded", new { diff --git a/backend/src/BilliardSystem.API/Program.cs b/backend/src/BilliardSystem.API/Program.cs index ed7c135..81a0617 100644 --- a/backend/src/BilliardSystem.API/Program.cs +++ b/backend/src/BilliardSystem.API/Program.cs @@ -112,3 +112,7 @@ app.MapFallbackToFile("index.html"); app.Run(); + +// Exposed so the integration tests can boot the real pipeline through +// WebApplicationFactory. +public partial class Program; diff --git a/backend/tests/BilliardSystem.Tests/AdminSessionTests.cs b/backend/tests/BilliardSystem.Tests/AdminSessionTests.cs new file mode 100644 index 0000000..2a1fafd --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/AdminSessionTests.cs @@ -0,0 +1,62 @@ +using BilliardSystem.Domain.Entities; +using FluentAssertions; + +namespace BilliardSystem.Tests; + +/// +/// Opaque refresh-token sessions: 30-day sliding expiry, revoked on rotation or +/// password reset. +/// +public sealed class AdminSessionTests +{ + private static AdminSession Create(DateTimeOffset? expiresAt = null) => + new("token-hash", expiresAt ?? DateTimeOffset.UtcNow.AddDays(30), Guid.NewGuid(), Guid.NewGuid()); + + [Fact] + public void NewSession_IsValid() + { + Create().IsValid().Should().BeTrue(); + } + + [Fact] + public void RevokedSession_IsInvalid() + { + var session = Create(); + + session.Revoke(); + + session.IsRevoked.Should().BeTrue(); + session.IsValid().Should().BeFalse(); + } + + [Fact] + public void ExpiredSession_IsInvalid() + { + var session = Create(DateTimeOffset.UtcNow.AddSeconds(-1)); + + session.IsValid().Should().BeFalse(); + } + + [Fact] + public void Touch_ExtendsExpiryToThirtyDaysFromLastUse() + { + var session = Create(DateTimeOffset.UtcNow.AddDays(1)); + var before = session.ExpiresAt; + + session.Touch(); + + session.ExpiresAt.Should().BeAfter(before); + session.ExpiresAt.Should().BeCloseTo(DateTimeOffset.UtcNow.AddDays(30), TimeSpan.FromMinutes(1)); + } + + [Fact] + public void Touch_NeverShrinksA_LongerExpiry() + { + var farFuture = DateTimeOffset.UtcNow.AddDays(90); + var session = Create(farFuture); + + session.Touch(); + + session.ExpiresAt.Should().Be(farFuture); + } +} diff --git a/backend/tests/BilliardSystem.Tests/BilliardSystem.Tests.csproj b/backend/tests/BilliardSystem.Tests/BilliardSystem.Tests.csproj index 072f476..4fedb40 100644 --- a/backend/tests/BilliardSystem.Tests/BilliardSystem.Tests.csproj +++ b/backend/tests/BilliardSystem.Tests/BilliardSystem.Tests.csproj @@ -10,9 +10,11 @@ + + diff --git a/backend/tests/BilliardSystem.Tests/BilliardTableLifecycleTests.cs b/backend/tests/BilliardSystem.Tests/BilliardTableLifecycleTests.cs new file mode 100644 index 0000000..5203209 --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/BilliardTableLifecycleTests.cs @@ -0,0 +1,121 @@ +using BilliardSystem.Domain.Entities; +using BilliardSystem.Domain.Enums; +using BilliardSystem.Domain.Events; +using FluentAssertions; + +namespace BilliardSystem.Tests; + +/// +/// Table lifecycle beyond the happy path: code normalization, out-of-service +/// transitions and waiter/check signalling. +/// +public sealed class BilliardTableLifecycleTests +{ + private static BilliardTable CreateTable() => new("Mesa 1", 12000m, Guid.NewGuid()); + + [Fact] + public void SetCode_TrimsAndUppercases() + { + var table = CreateTable(); + + table.SetCode(" m1 "); + + table.Code.Should().Be("M1"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SetCode_WithBlankValue_Throws(string code) + { + var table = CreateTable(); + + var act = () => table.SetCode(code); + + act.Should().Throw(); + } + + [Fact] + public void Disable_WithActiveMatch_Throws() + { + var table = CreateTable(); + table.StartSession(Guid.NewGuid(), "Blanco", "Amarillo", null); + + var act = () => table.Disable(); + + act.Should().Throw(); + } + + [Fact] + public void Disable_WhenIdle_BecomesOutOfService_AndEnableRestores() + { + var table = CreateTable(); + + table.Disable(); + table.IsActive.Should().BeFalse(); + table.Status.Should().Be(BilliardTableStatus.OutOfService); + + table.Enable(); + table.IsActive.Should().BeTrue(); + table.Status.Should().Be(BilliardTableStatus.Available); + } + + [Fact] + public void MarkWaiterRequested_And_MarkCheckRequested_RaiseEvents() + { + var table = CreateTable(); + var matchId = Guid.NewGuid(); + table.StartSession(matchId, "Blanco", "Amarillo", null); + table.ClearDomainEvents(); + + table.MarkWaiterRequested(matchId); + table.Status.Should().Be(BilliardTableStatus.WaitingForWaiter); + table.DomainEvents.Should().ContainSingle().Which.Should().BeOfType(); + + table.ClearDomainEvents(); + table.MarkCheckRequested(matchId); + table.Status.Should().Be(BilliardTableStatus.WaitingForCheck); + table.DomainEvents.Should().ContainSingle().Which.Should().BeOfType(); + } + + [Fact] + public void MarkAttended_ReturnsToOccupied_OnlyFromWaitingStates() + { + var table = CreateTable(); + var matchId = Guid.NewGuid(); + table.StartSession(matchId, "Blanco", "Amarillo", null); + + table.MarkAttended(); + table.Status.Should().Be(BilliardTableStatus.Occupied, "an occupied table ignores attendance"); + + table.MarkCheckRequested(matchId); + table.MarkAttended(); + table.Status.Should().Be(BilliardTableStatus.Occupied); + } + + [Fact] + public void EndSession_WithWrongMatch_Throws() + { + var table = CreateTable(); + table.StartSession(Guid.NewGuid(), "Blanco", "Amarillo", null); + + var act = () => table.EndSession(Guid.NewGuid(), null); + + act.Should().Throw(); + } + + [Fact] + public void EndSession_ClearsActiveMatch_AndRaisesEvent() + { + var table = CreateTable(); + var matchId = Guid.NewGuid(); + table.StartSession(matchId, "Blanco", "Amarillo", null); + table.ClearDomainEvents(); + + table.EndSession(matchId, Guid.NewGuid()); + + table.ActiveMatchId.Should().BeNull(); + table.Status.Should().Be(BilliardTableStatus.Available); + table.DomainEvents.Should().ContainSingle().Which.Should().BeOfType(); + } +} diff --git a/backend/tests/BilliardSystem.Tests/Integration/BilliardApiFactory.cs b/backend/tests/BilliardSystem.Tests/Integration/BilliardApiFactory.cs new file mode 100644 index 0000000..8981f78 --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/Integration/BilliardApiFactory.cs @@ -0,0 +1,61 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Testcontainers.PostgreSql; + +namespace BilliardSystem.Tests.Integration; + +/// +/// Boots the real API pipeline (WebApplicationFactory) against a throwaway +/// PostgreSQL container, so endpoints, EF Core (Postgres-only features such as +/// ExecuteDelete) and migrations are all exercised for real. +/// +public sealed class BilliardApiFactory : WebApplicationFactory, IAsyncLifetime +{ + private const string SuperUserName = "superadmin"; + private const string SuperPassword = "SuperAdmin123!"; + + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + + private string? _superAdminToken; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + } + + public async Task GetSuperAdminTokenAsync() + { + if (_superAdminToken is not null) + { + return _superAdminToken; + } + + var response = await CreateClient() + .PostAsJsonAsync("/api/auth/login", new { userName = SuperUserName, password = SuperPassword }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + _superAdminToken = body.GetProperty("accessToken").GetString()!; + return _superAdminToken; + } + + async Task IAsyncLifetime.InitializeAsync() + { + await _postgres.StartAsync(); + // Read by WebApplication.CreateBuilder when the app is first booted, so the + // API migrates and seeds the throwaway container instead of the local DB. + Environment.SetEnvironmentVariable("ConnectionStrings__BilliardDatabase", _postgres.GetConnectionString()); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await _postgres.DisposeAsync(); + await base.DisposeAsync(); + } +} + +[CollectionDefinition("api")] +public sealed class ApiCollection : ICollectionFixture; diff --git a/backend/tests/BilliardSystem.Tests/Integration/IdempotencyTests.cs b/backend/tests/BilliardSystem.Tests/Integration/IdempotencyTests.cs new file mode 100644 index 0000000..c5a1b2d --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/Integration/IdempotencyTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; + +namespace BilliardSystem.Tests.Integration; + +/// +/// Offline clients retry writes with the same transaction id. Every mutating +/// endpoint must apply the operation at most once. +/// +[Collection("api")] +public sealed class IdempotencyTests +{ + private readonly BilliardApiFactory _factory; + + public IdempotencyTests(BilliardApiFactory factory) => _factory = factory; + + [Fact] + public async Task RepeatedTransactionIds_ApplyEachOperationOnlyOnce() + { + var client = _factory.CreateClient(); + + var tables = await client.GetFromJsonAsync("/api/t/demo/tables"); + var tableId = tables.EnumerateArray().First().GetProperty("id").GetString()!; + var baseUrl = $"/api/t/demo/tables/{tableId}"; + + // start + var startTx = Guid.NewGuid(); + var startPayload = new + { + whitePlayerName = "Ana", + yellowPlayerName = "Beto", + gameMode = "Managed", + transactionId = startTx, + }; + var firstStart = await client.PostAsJsonAsync($"{baseUrl}/start", startPayload); + firstStart.StatusCode.Should().Be(HttpStatusCode.OK); + var matchId = (await firstStart.Content.ReadFromJsonAsync()).GetProperty("matchId").GetString()!; + + (await client.PostAsJsonAsync($"{baseUrl}/start", startPayload)).StatusCode.Should().Be(HttpStatusCode.OK); + + var detailAfterStart = await client.GetFromJsonAsync(baseUrl); + detailAfterStart.GetProperty("activeMatchId").GetString().Should().Be(matchId); + + // a different transaction cannot start a second match on an occupied table + var conflictingStart = await client.PostAsJsonAsync($"{baseUrl}/start", new + { + whitePlayerName = "Ana", + yellowPlayerName = "Beto", + gameMode = "Managed", + transactionId = Guid.NewGuid(), + }); + conflictingStart.IsSuccessStatusCode.Should().BeFalse(); + + // consumption applies once + var products = await client.GetFromJsonAsync("/api/t/demo/products"); + var product = products.EnumerateArray().First(); + var consumptionTx = Guid.NewGuid(); + var consumptionPayload = new { productId = product.GetProperty("id").GetString(), quantity = 2, transactionId = consumptionTx }; + + (await client.PostAsJsonAsync($"{baseUrl}/consumption", consumptionPayload)).StatusCode.Should().Be(HttpStatusCode.OK); + (await client.PostAsJsonAsync($"{baseUrl}/consumption", consumptionPayload)).StatusCode.Should().Be(HttpStatusCode.OK); + + var detailAfterConsumption = await client.GetFromJsonAsync(baseUrl); + detailAfterConsumption.GetProperty("activeMatch").GetProperty("consumptions").GetArrayLength().Should().Be(1); + + // score applies once + var scoreTx = Guid.NewGuid(); + var scorePayload = new { playerColor = "white", delta = 3, transactionId = scoreTx }; + (await client.PostAsJsonAsync($"{baseUrl}/score", scorePayload)).StatusCode.Should().Be(HttpStatusCode.OK); + (await client.PostAsJsonAsync($"{baseUrl}/score", scorePayload)).StatusCode.Should().Be(HttpStatusCode.OK); + + var detailAfterScore = await client.GetFromJsonAsync(baseUrl); + detailAfterScore.GetProperty("activeMatch").GetProperty("whiteScore").GetInt32().Should().Be(3); + + // finish applies once and frees the table + var finishPayload = new { transactionId = Guid.NewGuid() }; + (await client.PostAsJsonAsync($"{baseUrl}/finish", finishPayload)).StatusCode.Should().Be(HttpStatusCode.OK); + (await client.PostAsJsonAsync($"{baseUrl}/finish", finishPayload)).StatusCode.Should().Be(HttpStatusCode.OK); + + var detailAfterFinish = await client.GetFromJsonAsync(baseUrl); + detailAfterFinish.GetProperty("activeMatchId").ValueKind.Should().Be(JsonValueKind.Null); + } +} diff --git a/backend/tests/BilliardSystem.Tests/Integration/MultiTenantSlugTests.cs b/backend/tests/BilliardSystem.Tests/Integration/MultiTenantSlugTests.cs new file mode 100644 index 0000000..e4cc4b6 --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/Integration/MultiTenantSlugTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; + +namespace BilliardSystem.Tests.Integration; + +/// +/// Multi-tenant routing by slug: each hall's catalogue is addressed through +/// /t/{slug}/... and unknown slugs are rejected. +/// +[Collection("api")] +public sealed class MultiTenantSlugTests +{ + private readonly BilliardApiFactory _factory; + + public MultiTenantSlugTests(BilliardApiFactory factory) => _factory = factory; + + [Fact] + public async Task DemoHall_ExposesTablesAndProductsBySlug() + { + var client = _factory.CreateClient(); + + var tables = await client.GetFromJsonAsync("/api/t/demo/tables"); + tables.GetArrayLength().Should().BePositive(); + tables.EnumerateArray().Select(t => t.GetProperty("code").GetString()).Should().Contain("M1"); + + var products = await client.GetFromJsonAsync("/api/t/demo/products"); + products.GetArrayLength().Should().BePositive(); + products.EnumerateArray().Should().OnlyContain(p => p.GetProperty("price").GetDecimal() > 0m); + } + + [Fact] + public async Task UnknownSlug_ReturnsNotFound() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/t/no-such-hall/tables"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Table_CanBeResolvedByCodeWithinTheTenant() + { + var client = _factory.CreateClient(); + + var detail = await client.GetFromJsonAsync("/api/t/demo/tables/M1"); + + detail.GetProperty("code").GetString().Should().Be("M1"); + } +} diff --git a/backend/tests/BilliardSystem.Tests/Integration/PasswordRecoveryFlowTests.cs b/backend/tests/BilliardSystem.Tests/Integration/PasswordRecoveryFlowTests.cs new file mode 100644 index 0000000..38543da --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/Integration/PasswordRecoveryFlowTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; + +namespace BilliardSystem.Tests.Integration; + +/// +/// End-to-end password recovery: an admin loses the password, the super admin +/// reveals a code and the admin resets with that exact code. This is the flow +/// that was broken when the revealed code hash was not persisted. +/// +[Collection("api")] +public sealed class PasswordRecoveryFlowTests +{ + private readonly BilliardApiFactory _factory; + + public PasswordRecoveryFlowTests(BilliardApiFactory factory) => _factory = factory; + + [Fact] + public async Task RevealedCode_ResetsThePassword_EndToEnd() + { + var anonymous = _factory.CreateClient(); + var superAdmin = _factory.CreateClient(); + superAdmin.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", await _factory.GetSuperAdminTokenAsync()); + + // Super admin creates a hall; its administrator user name is the slug. + var localName = "Local " + Guid.NewGuid().ToString("N")[..8]; + var createLocal = await superAdmin.PostAsJsonAsync( + "/api/super/locals", new { name = localName, initialPassword = "admin123" }); + createLocal.StatusCode.Should().Be(HttpStatusCode.OK); + var slug = (await createLocal.Content.ReadFromJsonAsync()).GetProperty("slug").GetString()!; + + // The admin requests a recovery; the super admin lists and reveals it. + (await anonymous.PostAsJsonAsync("/api/auth/forgot", new { userName = slug })) + .StatusCode.Should().Be(HttpStatusCode.OK); + + var recoveries = await superAdmin.GetFromJsonAsync("/api/super/recoveries"); + var request = recoveries.EnumerateArray() + .First(r => r.GetProperty("userName").GetString() == slug); + var requestId = request.GetProperty("id").GetString()!; + + // Reveal must persist the disclosed code, otherwise /auth/reset can never match. + var reveal = await superAdmin.PostAsync($"/api/super/recoveries/{requestId}/reveal", null); + reveal.StatusCode.Should().Be(HttpStatusCode.OK); + var code = (await reveal.Content.ReadFromJsonAsync()).GetProperty("code").GetString()!; + code.Should().MatchRegex("^\\d{8}$"); + + var reset = await anonymous.PostAsJsonAsync( + "/api/auth/reset", new { userName = slug, code, newPassword = "NewSecret123!" }); + reset.StatusCode.Should().Be(HttpStatusCode.OK); + + var relogin = await anonymous.PostAsJsonAsync( + "/api/auth/login", new { userName = slug, password = "NewSecret123!" }); + relogin.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/backend/tests/BilliardSystem.Tests/MatchHistoryBehaviorTests.cs b/backend/tests/BilliardSystem.Tests/MatchHistoryBehaviorTests.cs new file mode 100644 index 0000000..a1962e8 --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/MatchHistoryBehaviorTests.cs @@ -0,0 +1,131 @@ +using BilliardSystem.Domain.Entities; +using BilliardSystem.Domain.Enums; +using FluentAssertions; + +namespace BilliardSystem.Tests; + +/// +/// Scoring and consumption rules of a match beyond the happy path: colour +/// handling, quantity bounds and free-mode billing. +/// +public sealed class MatchHistoryBehaviorTests +{ + private static MatchHistory CreateGame(GameMode mode = GameMode.Managed) => + new(Guid.NewGuid(), "Blanco", "Amarillo", 12000m, openedByUserId: null, mode, Guid.NewGuid()); + + [Theory] + [InlineData("white")] + [InlineData("WHITE")] + [InlineData("Yellow")] + [InlineData("YELLOW")] + public void AddScore_AcceptsPlayerColorCaseInsensitively(string color) + { + var match = CreateGame(); + + var log = match.AddScore(color, 2, userId: null); + + log.PlayerColor.Should().Be(color.Equals("yellow", StringComparison.OrdinalIgnoreCase) ? "Yellow" : "White"); + match.TotalCarambolas.Should().Be(2); + } + + [Fact] + public void RenamePlayer_UpdatesOnlyTheRequestedSide() + { + var match = CreateGame(); + + match.RenamePlayer("yellow", "Nuevo Amarillo"); + match.RenamePlayer("white", "Nuevo Blanco"); + + match.YellowPlayerName.Should().Be("Nuevo Amarillo"); + match.WhitePlayerName.Should().Be("Nuevo Blanco"); + } + + [Fact] + public void UpdateConsumption_RecalculatesTotal() + { + var match = CreateGame(); + var consumption = match.AddConsumption(Guid.NewGuid(), "Cerveza", 5000m, 1); + + match.UpdateConsumption(consumption.Id, 3); + + consumption.Quantity.Should().Be(3); + match.ConsumptionTotal.Should().Be(15000m); + } + + [Theory] + [InlineData(0)] + [InlineData(1000)] + public void UpdateConsumption_OutsideAllowedRange_Throws(int quantity) + { + var match = CreateGame(); + var consumption = match.AddConsumption(Guid.NewGuid(), "Cerveza", 5000m, 1); + + var act = () => match.UpdateConsumption(consumption.Id, quantity); + + act.Should().Throw(); + } + + [Fact] + public void UpdateConsumption_UnknownId_Throws() + { + var match = CreateGame(); + + var act = () => match.UpdateConsumption(Guid.NewGuid(), 2); + + act.Should().Throw(); + } + + [Fact] + public void RemoveConsumption_RemovesAndRecalculatesTotal() + { + var match = CreateGame(); + var first = match.AddConsumption(Guid.NewGuid(), "Agua", 3000m, 2); + match.AddConsumption(Guid.NewGuid(), "Cerveza", 5000m, 1); + + match.RemoveConsumption(first.Id); + + match.Consumptions.Should().HaveCount(1); + match.ConsumptionTotal.Should().Be(5000m); + } + + [Fact] + public void Close_InFreeMode_ZeroesTableAndConsumptionTotals() + { + var match = CreateGame(GameMode.FreeMode); + + match.Close(match.StartedAt.AddMinutes(30), tableTotal: 6000m, consumptionTotal: 5000m, closedByUserId: null); + + match.TableTotal.Should().Be(0m); + match.ConsumptionTotal.Should().Be(0m); + match.GrandTotal.Should().Be(0m); + } + + [Fact] + public void CloseRound_SetsWinnerAndResetsScoresAndLogs() + { + var match = CreateGame(); + match.AddScore("white", 5, null); + match.AddScore("yellow", 2, null); + + var round = match.CloseRound(match.StartedAt.AddSeconds(40)); + + round.WinnerName.Should().Be("Blanco"); + round.WhiteScore.Should().Be(5); + round.YellowScore.Should().Be(2); + match.WhiteScore.Should().Be(0); + match.YellowScore.Should().Be(0); + match.ScoreLogs.Should().BeEmpty(); + } + + [Fact] + public void CloseRound_WithTiedScore_HasNoWinner() + { + var match = CreateGame(); + match.AddScore("white", 3, null); + match.AddScore("yellow", 3, null); + + var round = match.CloseRound(match.StartedAt.AddSeconds(10)); + + round.WinnerName.Should().BeNull(); + } +} diff --git a/backend/tests/BilliardSystem.Tests/PasswordHasherTests.cs b/backend/tests/BilliardSystem.Tests/PasswordHasherTests.cs new file mode 100644 index 0000000..69854e9 --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/PasswordHasherTests.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using BilliardSystem.Domain.Common; +using FluentAssertions; + +namespace BilliardSystem.Tests; + +public sealed class PasswordHasherTests +{ + [Fact] + public void Hash_ProducesVersionedPbkdf2Hash() + { + var hash = PasswordHasher.Hash("SuperSecret1"); + + hash.Should().StartWith("v2."); + PasswordHasher.IsLegacyHash(hash).Should().BeFalse(); + } + + [Fact] + public void Hash_IsSalted_SoTheSamePasswordYieldsDifferentHashes() + { + PasswordHasher.Hash("SuperSecret1").Should().NotBe(PasswordHasher.Hash("SuperSecret1")); + } + + [Fact] + public void Verify_AcceptsTheCorrectPassword_AndRejectsOthers() + { + var hash = PasswordHasher.Hash("SuperSecret1"); + + PasswordHasher.Verify("SuperSecret1", hash).Should().BeTrue(); + PasswordHasher.Verify("wrong", hash).Should().BeFalse(); + } + + [Fact] + public void Verify_RejectsMalformedVersionedHash() + { + PasswordHasher.Verify("x", "v2.not-a-valid-hash").Should().BeFalse(); + } + + [Fact] + public void Verify_StillAcceptsLegacyUnsaltedSha256Hash() + { + var legacy = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("legacy-pass"))); + + PasswordHasher.IsLegacyHash(legacy).Should().BeTrue(); + PasswordHasher.Verify("legacy-pass", legacy).Should().BeTrue(); + PasswordHasher.Verify("nope", legacy).Should().BeFalse(); + } +} diff --git a/backend/tests/BilliardSystem.Tests/RecoveryRequestTests.cs b/backend/tests/BilliardSystem.Tests/RecoveryRequestTests.cs new file mode 100644 index 0000000..0509645 --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/RecoveryRequestTests.cs @@ -0,0 +1,54 @@ +using BilliardSystem.Domain.Entities; +using FluentAssertions; + +namespace BilliardSystem.Tests; + +/// +/// Covers the password-recovery reveal -> reset invariant: the code disclosed by +/// the super-admin (reveal) must be the one /auth/reset matches, and the +/// request is only resolved on a successful reset. +/// +public sealed class RecoveryRequestTests +{ + private static RecoveryRequest Create(DateTimeOffset? expiresAt = null) => + new(Guid.NewGuid(), Guid.NewGuid(), "hash-from-creation", expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(30)); + + [Fact] + public void NewRequest_StartsWithCreationHash_AndIsPending() + { + var request = Create(); + + request.CodeHash.Should().Be("hash-from-creation"); + request.IsResolved.Should().BeFalse(); + request.ResolvedAt.Should().BeNull(); + } + + [Fact] + public void ReplaceCode_SwapsStoredHash_WithoutResolving() + { + var request = Create(); + + request.ReplaceCode("hash-of-revealed-code"); + + request.CodeHash.Should().Be("hash-of-revealed-code"); + request.IsResolved.Should().BeFalse("resolution happens on a successful reset, not on reveal"); + } + + [Fact] + public void Resolve_MarksRequestResolved() + { + var request = Create(); + + request.Resolve(); + + request.IsResolved.Should().BeTrue(); + request.ResolvedAt.Should().NotBeNull(); + } + + [Fact] + public void IsExpired_IsFalseBeforeDeadline_AndTrueAfter() + { + Create(DateTimeOffset.UtcNow.AddMinutes(1)).IsExpired().Should().BeFalse(); + Create(DateTimeOffset.UtcNow.AddMinutes(-1)).IsExpired().Should().BeTrue(); + } +} diff --git a/backend/tests/BilliardSystem.Tests/TenantTests.cs b/backend/tests/BilliardSystem.Tests/TenantTests.cs new file mode 100644 index 0000000..83248dc --- /dev/null +++ b/backend/tests/BilliardSystem.Tests/TenantTests.cs @@ -0,0 +1,53 @@ +using BilliardSystem.Domain.Entities; +using FluentAssertions; + +namespace BilliardSystem.Tests; + +/// +/// Multi-tenant slug generation: lowercase, accent-stripped, dash-separated and +/// stable enough to be used in /t/{slug}/... URLs. +/// +public sealed class TenantTests +{ + [Theory] + [InlineData("Billar Tres Bandas", "billar-tres-bandas")] + [InlineData("Café Ñandú", "cafe-nandu")] + [InlineData("Billar_Test", "billar-test")] + [InlineData(" Hola ", "hola")] + [InlineData("A B", "a-b")] + [InlineData("Bar #1 @ Centro!", "bar-1-centro")] + [InlineData("!!!", "local")] + public void Slug_IsNormalized(string name, string expected) + { + new Tenant(name).Slug.Should().Be(expected); + } + + [Fact] + public void NewTenant_IsActive() + { + new Tenant("Demo").IsActive.Should().BeTrue(); + } + + [Fact] + public void Rename_RegeneratesSlug() + { + var tenant = new Tenant("Uno"); + + tenant.Rename("Dos Local"); + + tenant.Name.Should().Be("Dos Local"); + tenant.Slug.Should().Be("dos-local"); + } + + [Fact] + public void Deactivate_And_Activate_FlipTheFlag() + { + var tenant = new Tenant("Demo"); + + tenant.Deactivate(); + tenant.IsActive.Should().BeFalse(); + + tenant.Activate(); + tenant.IsActive.Should().BeTrue(); + } +} diff --git a/coverlet.runsettings b/coverlet.runsettings index 8738bc0..31bda12 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -8,11 +8,23 @@ [*.Tests]* Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute - 1 + **/Migrations/*.Designer.cs,**/Migrations/*ModelSnapshot.cs + + 80 line total diff --git a/frontend/karma.conf.js b/frontend/karma.conf.js index 0b5cff5..7ed4a24 100644 --- a/frontend/karma.conf.js +++ b/frontend/karma.conf.js @@ -21,14 +21,15 @@ module.exports = function (config) { dir: require('path').join(__dirname, './coverage/billiard-frontend'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }], - // Baseline thresholds. The frontend test suite is still small, so these are - // a floor to prevent regressions, not a quality target yet. + // Thresholds raised with the service specs (auth + API contract tests): + // measured coverage is ~96/65/98/96, so these leave margin while still + // failing if the frontend logic regresses. check: { global: { - statements: 40, - branches: 20, - functions: 30, - lines: 40, + statements: 90, + branches: 60, + functions: 90, + lines: 90, }, }, }, diff --git a/frontend/src/app/core/api.service.spec.ts b/frontend/src/app/core/api.service.spec.ts new file mode 100644 index 0000000..1f134d7 --- /dev/null +++ b/frontend/src/app/core/api.service.spec.ts @@ -0,0 +1,243 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ApiService } from './api.service'; + +/** + * Contract tests: pin the URL, HTTP verb and body of every endpoint the frontend + * calls, so a backend route change cannot silently break the client. + */ +describe('ApiService', () => { + let service: ApiService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ApiService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ApiService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + it('calls the auth endpoints', async () => { + const login = service.login('demo', 'secret'); + http.expectOne({ url: '/api/auth/login', method: 'POST' }).flush({}); + await login; + + const refresh = service.refresh('r'); + http.expectOne({ url: '/api/auth/refresh', method: 'POST' }).flush({}); + await refresh; + + const logout = service.logout('r'); + http.expectOne({ url: '/api/auth/logout', method: 'POST' }).flush(null); + await logout; + + const forgot = service.forgotPassword('demo'); + http.expectOne({ url: '/api/auth/forgot', method: 'POST' }).flush(null); + await forgot; + + const reset = service.resetPassword('demo', '12345678', 'newpassword'); + http.expectOne({ url: '/api/auth/reset', method: 'POST' }).flush(null); + await reset; + + const change = service.changePassword('u1', 'old', 'new'); + http.expectOne({ url: '/api/auth/change-password', method: 'POST' }).flush(null); + await change; + + const force = service.forceChangePassword('new'); + http.expectOne({ url: '/api/auth/force-change-password', method: 'POST' }).flush({}); + await force; + + expect().nothing(); + }); + + it('calls the admin table endpoints', async () => { + const list = service.getTables(); + http.expectOne({ url: '/api/tables', method: 'GET' }).flush([]); + await list; + + const create = service.createTable('Mesa 2', 9000, 'M2'); + const createReq = http.expectOne({ url: '/api/tables', method: 'POST' }); + expect(createReq.request.body).toEqual({ name: 'Mesa 2', hourlyRate: 9000, code: 'M2' }); + createReq.flush({}); + await create; + + const update = service.updateTable('t1', 'Mesa 2', 9000); + http.expectOne({ url: '/api/tables/t1', method: 'PUT' }).flush({}); + await update; + + const rates = service.updateAllRates(10000); + http.expectOne({ url: '/api/tables/rate/all', method: 'PUT' }).flush({ updated: 3 }); + await rates; + + const attend = service.attendTable('t1'); + http.expectOne({ url: '/api/tables/t1/attend', method: 'POST' }).flush({}); + await attend; + + const disable = service.disableTable('t1'); + http.expectOne({ url: '/api/tables/t1/disable', method: 'POST' }).flush({}); + await disable; + + const enable = service.enableTable('t1'); + http.expectOne({ url: '/api/tables/t1/enable', method: 'POST' }).flush({}); + await enable; + + const remove = service.deleteTable('t1'); + http.expectOne({ url: '/api/tables/t1', method: 'DELETE' }).flush({ ok: true }); + await remove; + }); + + it('defaults tenant reads to the demo slug', async () => { + const tables = service.getTenantTables(''); + http.expectOne({ url: '/api/t/demo/tables', method: 'GET' }).flush([]); + await tables; + + const table = service.getTenantTable('', 'M1'); + http.expectOne({ url: '/api/t/demo/tables/M1', method: 'GET' }).flush({}); + await table; + + const products = service.getTenantProducts('demo'); + http.expectOne({ url: '/api/t/demo/products', method: 'GET' }).flush([]); + await products; + + expect().nothing(); + }); + + it('calls the player session endpoints with transaction ids', async () => { + const start = service.startSession('demo', 't1', 'A', 'B', 'Managed', 'tx-start'); + const startReq = http.expectOne({ url: '/api/t/demo/tables/t1/start', method: 'POST' }); + expect(startReq.request.body).toEqual({ + whitePlayerName: 'A', + yellowPlayerName: 'B', + gameMode: 'Managed', + transactionId: 'tx-start', + }); + startReq.flush({ tableId: 't1', matchId: 'm1' }); + await start; + + const score = service.score('demo', 't1', 'yellow', 4, 'tx-score'); + const scoreReq = http.expectOne({ url: '/api/t/demo/tables/t1/score', method: 'POST' }); + expect(scoreReq.request.body).toEqual({ + playerColor: 'yellow', + delta: 4, + transactionId: 'tx-score', + }); + scoreReq.flush({ newScore: 4 }); + await score; + + const rename = service.renamePlayers('demo', 't1', 'A', 'B', 'tx-rename'); + http.expectOne({ url: '/api/t/demo/tables/t1/players', method: 'POST' }).flush(null); + await rename; + + const waiter = service.callWaiter('demo', 't1'); + http.expectOne({ url: '/api/t/demo/tables/t1/call-waiter', method: 'POST' }).flush(null); + await waiter; + + const check = service.requestCheck('demo', 't1'); + http.expectOne({ url: '/api/t/demo/tables/t1/request-check', method: 'POST' }).flush(null); + await check; + + const consumption = service.addConsumption('demo', 't1', 'p1', 2, 'tx-add'); + http.expectOne({ url: '/api/t/demo/tables/t1/consumption', method: 'POST' }).flush({ + consumptionTotal: 6000, + }); + await consumption; + + const updateConsumption = service.updateConsumption('demo', 't1', 'c1', 3, 'tx-upd'); + http.expectOne({ url: '/api/t/demo/tables/t1/consumption/c1', method: 'PUT' }).flush({ + consumptionTotal: 9000, + }); + await updateConsumption; + + const deleteConsumption = service.deleteConsumption('demo', 't1', 'c1'); + http.expectOne({ url: '/api/t/demo/tables/t1/consumption/c1', method: 'DELETE' }).flush({ + consumptionTotal: 0, + }); + await deleteConsumption; + + const finish = service.finishSession('demo', 't1', 'tx-finish'); + http.expectOne({ url: '/api/t/demo/tables/t1/finish', method: 'POST' }).flush({}); + await finish; + + const round = service.finishRound('demo', 't1', 'tx-round'); + http.expectOne({ url: '/api/t/demo/tables/t1/finish-round', method: 'POST' }).flush({}); + await round; + + const rounds = service.getRounds('demo', 't1'); + http.expectOne({ url: '/api/t/demo/tables/t1/rounds', method: 'GET' }).flush({}); + await rounds; + }); + + it('calls the catalog, settings, history and dashboard endpoints', async () => { + const products = service.getProducts(); + http.expectOne({ url: '/api/products', method: 'GET' }).flush([]); + await products; + + const create = service.createProduct('Agua', 3000); + http.expectOne({ url: '/api/products', method: 'POST' }).flush({}); + await create; + + const update = service.updateProduct('p1', 'Agua', 3500); + http.expectOne({ url: '/api/products/p1', method: 'PUT' }).flush(null); + await update; + + const deactivate = service.deactivateProduct('p1'); + http.expectOne({ url: '/api/products/p1', method: 'DELETE' }).flush(null); + await deactivate; + + const settings = service.getSettings(); + http.expectOne({ url: '/api/settings', method: 'GET' }).flush({}); + await settings; + + const saveSettings = service.updateSettings({ HourlyRate: '12000' }); + http.expectOne({ url: '/api/settings', method: 'PUT' }).flush(null); + await saveSettings; + + const matches = service.getMatches(); + http.expectOne({ url: '/api/matches', method: 'GET' }).flush([]); + await matches; + + const match = service.getMatch('m1'); + http.expectOne({ url: '/api/matches/m1', method: 'GET' }).flush({}); + await match; + + const summary = service.getDashboardSummary(); + http.expectOne({ url: '/api/dashboard/summary', method: 'GET' }).flush({}); + await summary; + + const top = service.getTopProducts(); + http.expectOne({ url: '/api/dashboard/top-products', method: 'GET' }).flush([]); + await top; + + const audit = service.getAuditLogs(); + http.expectOne({ url: '/api/audit/logs', method: 'GET' }).flush([]); + await audit; + + expect().nothing(); + }); + + it('calls the super-admin endpoints', async () => { + const locals = service.getSuperLocals(); + http.expectOne({ url: '/api/super/locals', method: 'GET' }).flush([]); + await locals; + + const create = service.createLocal('Nuevo Local', 'admin123'); + const createReq = http.expectOne({ url: '/api/super/locals', method: 'POST' }); + expect(createReq.request.body).toEqual({ name: 'Nuevo Local', initialPassword: 'admin123' }); + createReq.flush({}); + await create; + + const recoveries = service.getSuperRecoveries(); + http.expectOne({ url: '/api/super/recoveries', method: 'GET' }).flush([]); + await recoveries; + + const reveal = service.revealRecovery('r1'); + http + .expectOne({ url: '/api/super/recoveries/r1/reveal', method: 'POST' }) + .flush({ code: '12345678' }); + await reveal; + }); +}); diff --git a/frontend/src/app/core/auth.service.spec.ts b/frontend/src/app/core/auth.service.spec.ts new file mode 100644 index 0000000..41b8d3e --- /dev/null +++ b/frontend/src/app/core/auth.service.spec.ts @@ -0,0 +1,113 @@ +import { TestBed } from '@angular/core/testing'; + +import { ApiService } from './api.service'; +import { AuthService } from './auth.service'; +import { LoginResponse } from './models'; + +describe('AuthService', () => { + let service: AuthService; + let api: jasmine.SpyObj; + + const response: LoginResponse = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + userName: 'Admin', + role: 'Administrator', + tenantName: 'Demo Hall', + tenantSlug: 'demo', + mustChangePassword: false, + }; + + beforeEach(() => { + localStorage.clear(); + api = jasmine.createSpyObj('ApiService', [ + 'login', + 'refresh', + 'forceChangePassword', + 'logout', + ]); + TestBed.configureTestingModule({ + providers: [AuthService, { provide: ApiService, useValue: api }], + }); + service = TestBed.inject(AuthService); + }); + + afterEach(() => localStorage.clear()); + + it('starts unauthenticated', () => { + expect(service.isAuthenticated()).toBeFalse(); + expect(service.getToken()).toBeNull(); + expect(service.getUser()).toBeNull(); + expect(service.getTenantSlug()).toBeNull(); + }); + + it('stores tokens and user info on login', async () => { + api.login.and.resolveTo(response); + + const result = await service.login('demo', 'secret'); + + expect(result).toBe(response); + expect(service.isAuthenticated()).toBeTrue(); + expect(service.getToken()).toBe('access-token'); + expect(service.getTenantSlug()).toBe('demo'); + expect(service.isSuperAdmin()).toBeFalse(); + expect(service.mustChangePassword()).toBeFalse(); + }); + + it('returns null when the stored user payload is not valid JSON', () => { + localStorage.setItem('billiard-user', '{not-json'); + + expect(service.getUser()).toBeNull(); + }); + + it('detects super admins and pending password changes', () => { + localStorage.setItem( + 'billiard-user', + JSON.stringify({ + name: 'Root', + role: 'SuperAdmin', + tenantSlug: null, + mustChangePassword: true, + }), + ); + + expect(service.isSuperAdmin()).toBeTrue(); + expect(service.mustChangePassword()).toBeTrue(); + }); + + it('throws when refreshing without a refresh token', async () => { + await expectAsync(service.refresh()).toBeRejectedWithError('No refresh token'); + }); + + it('rotates tokens on refresh', async () => { + localStorage.setItem('billiard-refresh-token', 'old-refresh'); + api.refresh.and.resolveTo({ + ...response, + accessToken: 'new-access', + refreshToken: 'new-refresh', + }); + + await service.refresh(); + + expect(api.refresh).toHaveBeenCalledWith('old-refresh'); + expect(service.getToken()).toBe('new-access'); + }); + + it('clears the session on logout even if the API call fails', async () => { + api.login.and.resolveTo(response); + api.logout.and.rejectWith(new Error('offline')); + await service.login('demo', 'secret'); + + await service.logout(); + + expect(api.logout).toHaveBeenCalledWith('refresh-token'); + expect(service.isAuthenticated()).toBeFalse(); + expect(service.getUser()).toBeNull(); + }); + + it('does not call the API on logout without a session', async () => { + await service.logout(); + + expect(api.logout).not.toHaveBeenCalled(); + }); +});