From 35d842d6ee0cc93f95a177ff216db3767f6625c9 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 7 Feb 2026 01:16:07 +0100 Subject: [PATCH] Add explicit write transactions and deterministic ordering tests --- Endpoints/AdminWorkflowService.cs | 6 +++++ Endpoints/SuggestionWorkflowService.cs | 6 +++++ GameList.Tests/AdminTests.cs | 6 ++--- GameList.Tests/SuggestionTests.cs | 33 +++++++++----------------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index c7e6717..3e448d9 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -13,6 +13,8 @@ internal sealed class AdminWorkflowService(AppDbContext db) state.ResultsOpen = request.ResultsOpen; state.UpdatedAt = DateTimeOffset.UtcNow; + await using var tx = await db.Database.BeginTransactionAsync(); + if (request.ResultsOpen) { await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results)); @@ -23,6 +25,7 @@ internal sealed class AdminWorkflowService(AppDbContext db) } await db.SaveChangesAsync(); + await tx.CommitAsync(); var currentState = await db.AppState.AsNoTracking().FirstAsync(); return Results.Ok(new { @@ -207,6 +210,8 @@ internal sealed class AdminWorkflowService(AppDbContext db) public async Task ResetAsync() { + await using var tx = await db.Database.BeginTransactionAsync(); + await db.Votes.ExecuteDeleteAsync(); await db.Suggestions.ExecuteDeleteAsync(); @@ -215,6 +220,7 @@ internal sealed class AdminWorkflowService(AppDbContext db) state.ResultsOpen = false; state.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); + await tx.CommitAsync(); return Results.Ok(new { diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs index 57cb461..6587a62 100644 --- a/Endpoints/SuggestionWorkflowService.cs +++ b/Endpoints/SuggestionWorkflowService.cs @@ -67,6 +67,8 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact MaxPlayers = request.MaxPlayers }; + await using var tx = await db.Database.BeginTransactionAsync(); + db.Suggestions.Add(suggestion); if (usingJoker) @@ -76,6 +78,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact } await db.SaveChangesAsync(); + await tx.CommitAsync(); return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); } @@ -95,6 +98,8 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); + await using var tx = await db.Database.BeginTransactionAsync(); + await db.Suggestions .Where(s => s.ParentSuggestionId == suggestion.Id) .ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); @@ -103,6 +108,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact db.Suggestions.Remove(suggestion); await db.SaveChangesAsync(); + await tx.CommitAsync(); return Results.NoContent(); } diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index e6a6720..22a187d 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -244,11 +244,11 @@ public class AdminTests { var p = await db.Players.FirstAsync(x => !x.IsAdmin); p.VotesFinal = true; + var state = await db.AppState.FirstAsync(); + state.UpdatedAt = DateTimeOffset.UnixEpoch; await db.SaveChangesAsync(); }); - var beforeState = await factory.WithDbContextAsync(async db => await db.AppState.AsNoTracking().FirstAsync()); - await Task.Delay(5); var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false }); close.EnsureSuccessStatusCode(); @@ -259,7 +259,7 @@ public class AdminTests Assert.False(p.VotesFinal); var state = await db.AppState.AsNoTracking().FirstAsync(); Assert.False(state.ResultsOpen); - Assert.True(state.UpdatedAt > beforeState.UpdatedAt); + Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch); }); } diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index 1fdfa6d..7f7737c 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -365,28 +365,15 @@ public class SuggestionTests var client = factory.CreateClientWithCookies(); await client.RegisterAsync("mine"); - await client.PostAsJsonAsync("/api/suggestions", new + var secondId = await client.CreateSuggestionAsync("Second"); + var thirdId = await client.CreateSuggestionAsync("Third"); + await factory.WithDbContextAsync(async db => { - Name = "Second", - Genre = (string?)null, - Description = (string?)null, - ScreenshotUrl = (string?)null, - YoutubeUrl = (string?)null, - GameUrl = (string?)null, - MinPlayers = (int?)null, - MaxPlayers = (int?)null - }); - await Task.Delay(10); - await client.PostAsJsonAsync("/api/suggestions", new - { - Name = "Third", - Genre = (string?)null, - Description = (string?)null, - ScreenshotUrl = (string?)null, - YoutubeUrl = (string?)null, - GameUrl = (string?)null, - MinPlayers = (int?)null, - MaxPlayers = (int?)null + var second = await db.Suggestions.FindAsync(secondId); + var third = await db.Suggestions.FindAsync(thirdId); + second!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1); + third!.CreatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); }); var mine = await client.GetFromJsonAsync>("/api/suggestions/mine"); @@ -572,11 +559,13 @@ public class SuggestionTests await client.RegisterAsync("owner"); var id1 = await client.CreateSuggestionAsync("Alpha"); - await Task.Delay(10); var id2 = await client.CreateSuggestionAsync("Beta"); await factory.WithDbContextAsync(async db => { + var alpha = await db.Suggestions.FindAsync(id1); var beta = await db.Suggestions.FindAsync(id2); + alpha!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1); + beta!.CreatedAt = DateTimeOffset.UtcNow; beta!.ParentSuggestionId = id1; await db.SaveChangesAsync(); });