Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions backend/src/BilliardSystem.API/Endpoints/BilliardEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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." });
Expand Down Expand Up @@ -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())
{
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down
4 changes: 4 additions & 0 deletions backend/src/BilliardSystem.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,7 @@
app.MapFallbackToFile("index.html");

app.Run();

// Exposed so the integration tests can boot the real pipeline through
// WebApplicationFactory<Program>.
public partial class Program;
62 changes: 62 additions & 0 deletions backend/tests/BilliardSystem.Tests/AdminSessionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using BilliardSystem.Domain.Entities;
using FluentAssertions;

namespace BilliardSystem.Tests;

/// <summary>
/// Opaque refresh-token sessions: 30-day sliding expiry, revoked on rotation or
/// password reset.
/// </summary>
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.15.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
Expand Down
121 changes: 121 additions & 0 deletions backend/tests/BilliardSystem.Tests/BilliardTableLifecycleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using BilliardSystem.Domain.Entities;
using BilliardSystem.Domain.Enums;
using BilliardSystem.Domain.Events;
using FluentAssertions;

namespace BilliardSystem.Tests;

/// <summary>
/// Table lifecycle beyond the happy path: code normalization, out-of-service
/// transitions and waiter/check signalling.
/// </summary>
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<InvalidOperationException>();
}

[Fact]
public void Disable_WithActiveMatch_Throws()
{
var table = CreateTable();
table.StartSession(Guid.NewGuid(), "Blanco", "Amarillo", null);

var act = () => table.Disable();

act.Should().Throw<InvalidOperationException>();
}

[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<WaiterRequestedEvent>();

table.ClearDomainEvents();
table.MarkCheckRequested(matchId);
table.Status.Should().Be(BilliardTableStatus.WaitingForCheck);
table.DomainEvents.Should().ContainSingle().Which.Should().BeOfType<CheckRequestedEvent>();
}

[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<InvalidOperationException>();
}

[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<SessionEndedEvent>();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Boots the real API pipeline (<c>WebApplicationFactory</c>) against a throwaway
/// PostgreSQL container, so endpoints, EF Core (Postgres-only features such as
/// <c>ExecuteDelete</c>) and migrations are all exercised for real.
/// </summary>
public sealed class BilliardApiFactory : WebApplicationFactory<Program>, 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<string> 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<JsonElement>();
_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<BilliardApiFactory>;
Loading
Loading