From 5197f4b27b973f681e17f83465ab1b4dc4e0f61a Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:45:58 +0200 Subject: [PATCH 01/29] feat: add player scores to Match class --- TournamentAPI/Data/Models/Match.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TournamentAPI/Data/Models/Match.cs b/TournamentAPI/Data/Models/Match.cs index ca73e09..690d95e 100644 --- a/TournamentAPI/Data/Models/Match.cs +++ b/TournamentAPI/Data/Models/Match.cs @@ -11,6 +11,8 @@ public class Match : ISoftDeletable public int Player1Id { get; set; } public int? Player2Id { get; set; } public int? WinnerId { get; set; } + public int Player1Score { get; set; } + public int Player2Score { get; set; } [GraphQLIgnore] public bool IsDeleted { get; set; } From fb34c2f72ce1801301bb8ea0d87f6624d28905d4 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:47:10 +0200 Subject: [PATCH 02/29] feat: add new error codes and methods for match score validation --- TournamentAPI/Matches/MatchErrorCodes.cs | 2 ++ TournamentAPI/Matches/MatchErrors.cs | 17 +++++++++++++++++ TournamentAPI/Matches/MatchValidations.cs | 10 ++++++++++ 3 files changed, 29 insertions(+) diff --git a/TournamentAPI/Matches/MatchErrorCodes.cs b/TournamentAPI/Matches/MatchErrorCodes.cs index 49f4272..aa671c0 100644 --- a/TournamentAPI/Matches/MatchErrorCodes.cs +++ b/TournamentAPI/Matches/MatchErrorCodes.cs @@ -6,4 +6,6 @@ public static class MatchErrorCodes public const string MatchAlreadyPlayed = "Match.AlreadyPlayed"; public const string InvalidMatchWinner = "Match.InvalidWinner"; public const string TournamentNotClosed = "Match.TournamentNotClosed"; + public const string NegativeScore = "Match.NegativeScore"; + public const string WinnerScoreMismatch = "Match.WinnerScoreMismatch"; } diff --git a/TournamentAPI/Matches/MatchErrors.cs b/TournamentAPI/Matches/MatchErrors.cs index f5edfb0..a683e6f 100644 --- a/TournamentAPI/Matches/MatchErrors.cs +++ b/TournamentAPI/Matches/MatchErrors.cs @@ -30,4 +30,21 @@ public static IError TournamentNotClosed(int tournamentId) => .SetCode(MatchErrorCodes.TournamentNotClosed) .SetExtension("TournamentId", tournamentId) .Build(); + + public static IError NegativeScore(int matchId, int player1Score, int player2Score) => + ErrorBuilder.New() + .SetMessage("Match scores cannot be negative.") + .SetCode(MatchErrorCodes.NegativeScore) + .SetExtension("MatchId", matchId) + .SetExtension("Player1Score", player1Score) + .SetExtension("Player2Score", player2Score) + .Build(); + + public static IError WinnerScoreMismatch(int matchId, int winnerId) => + ErrorBuilder.New() + .SetMessage("The declared winner's score must be strictly greater than the opponent's score.") + .SetCode(MatchErrorCodes.WinnerScoreMismatch) + .SetExtension("MatchId", matchId) + .SetExtension("WinnerId", winnerId) + .Build(); } diff --git a/TournamentAPI/Matches/MatchValidations.cs b/TournamentAPI/Matches/MatchValidations.cs index d9b5b78..8d51127 100644 --- a/TournamentAPI/Matches/MatchValidations.cs +++ b/TournamentAPI/Matches/MatchValidations.cs @@ -15,4 +15,14 @@ public static class MatchValidations public static IError? ValidateWinnerIsParticipant(Match match, int winnerId) => winnerId != match.Player1Id && winnerId != match.Player2Id ? MatchErrors.InvalidMatchWinner(match.Id, winnerId) : null; + + public static IError? ValidateScoresAreNonNegative(int matchId, int player1Score, int player2Score) + => player1Score < 0 || player2Score < 0 ? MatchErrors.NegativeScore(matchId, player1Score, player2Score) : null; + + public static IError? ValidateWinnerHasHigherScore(Match match, int winnerId, int player1Score, int player2Score) + { + var winnerScore = winnerId == match.Player1Id ? player1Score : player2Score; + var opponentScore = winnerId == match.Player1Id ? player2Score : player1Score; + return winnerScore <= opponentScore ? MatchErrors.WinnerScoreMismatch(match.Id, winnerId) : null; + } } From 84209c6c22094ce221a178d083612f09498fd2cb Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:47:37 +0200 Subject: [PATCH 03/29] feat: add score validation to Play method --- TournamentAPI/Matches/MatchMutations.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index b874a76..6f11e8f 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -15,6 +15,8 @@ public static partial class MatchMutations public static async Task Play( int matchId, int winnerId, + int player1Score, + int player2Score, ClaimsPrincipal userClaims, IResolverContext resolverContext, ApplicationDbContext context, @@ -44,7 +46,15 @@ public static partial class MatchMutations if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId))) return null; + if (resolverContext.TryReportError(MatchValidations.ValidateScoresAreNonNegative(match.Id, player1Score, player2Score))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId, player1Score, player2Score))) + return null; + match.WinnerId = winnerId; + match.Player1Score = player1Score; + match.Player2Score = player2Score; try { From fec91a98ec40fde019c1ac2326e722b5e4d422e3 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:48:45 +0200 Subject: [PATCH 04/29] test: add player scores to match mutation tests --- .../Tests/Matches/MatchMutationTests.cs | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs index f4ad868..c42244b 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs @@ -18,6 +18,8 @@ public async Task Play_HandlesDbUpdateException_WhenRaceConditionOccurs() var password = "Password123!"; var matchId = 10; var winnerId = 8; + var player1Score = 1; + var player2Score = 3; using var client1 = CreateClient(); using var client2 = CreateClient(); @@ -39,7 +41,9 @@ public async Task Play_HandlesDbUpdateException_WhenRaceConditionOccurs() input = new { matchId = matchId, - winnerId = winnerId + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score } }; @@ -102,7 +106,9 @@ public async Task Play_ReturnsMatchNotFoundError_WhenMatchDoesNotExist() input = new { matchId = matchId, - winnerId = 1 + winnerId = 1, + player1Score = 3, + player2Score = 1 } }; @@ -139,6 +145,8 @@ public async Task Play_ReturnsTournamentNotOnwerError_WhenUserIsNotTournamentOwn var tournamentId = 4; var matchId = 9; var winnerId = 5; + var player1Score = 3; + var player2Score = 1; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -158,7 +166,9 @@ public async Task Play_ReturnsTournamentNotOnwerError_WhenUserIsNotTournamentOwn input = new { matchId = matchId, - winnerId = winnerId + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score } }; @@ -200,6 +210,8 @@ public async Task Play_ReturnsMatchAlreadyPlayedError_WhenMatchHasAlreadyBeenPla var password = "Password123!"; var matchId = 8; var winnerId = 2; + var player1Score = 3; + var player2Score = 1; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -219,7 +231,9 @@ public async Task Play_ReturnsMatchAlreadyPlayedError_WhenMatchHasAlreadyBeenPla input = new { matchId = matchId, - winnerId = winnerId + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score } }; @@ -255,6 +269,8 @@ public async Task Play_ReturnsInvalidMatchWinnerError_WhenWinnerIsNotMatchPartic var password = "Password123!"; var matchId = 10; var winnerId = 2; + var player1Score = 3; + var player2Score = 1; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -274,7 +290,9 @@ public async Task Play_ReturnsInvalidMatchWinnerError_WhenWinnerIsNotMatchPartic input = new { matchId = matchId, - winnerId = winnerId + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score } }; @@ -316,6 +334,8 @@ public async Task Play_Succeeds_WhenInputIsValid() var password = "Password123!"; var matchId = 9; var winnerId = 5; + var player1Score = 3; + var player2Score = 1; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -335,7 +355,9 @@ public async Task Play_Succeeds_WhenInputIsValid() input = new { matchId = matchId, - winnerId = winnerId + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score } }; @@ -354,5 +376,8 @@ public async Task Play_Succeeds_WhenInputIsValid() Assert.NotNull(match); Assert.Equal(winnerId, match.WinnerId); + Assert.Equal(player1Score, match.Player1Score); + Assert.Equal(player2Score, match.Player2Score); + } } } From 1a4be26fafb0a556afba1b38f198a06bebeed302 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:50:20 +0200 Subject: [PATCH 05/29] test: add validation tests for Play mutation errors Added `Play_ReturnsNegativeScoreError_WhenScoreIsNegative` to ensure the `Play` mutation returns an error when negative scores are provided. This test verifies error codes, messages, and extensions, and ensures the match remains unmodified in the database. Added `Play_ReturnsWinnerScoreMismatchError_WhenWinnerScoreIsNotHigher` to validate that the `Play` mutation enforces the rule that the winner's score must be higher. This test also verifies error codes, messages, and extensions, and ensures the match remains unmodified. --- .../Tests/Matches/MatchMutationTests.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs index c42244b..9db4fe0 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs @@ -379,5 +379,135 @@ public async Task Play_Succeeds_WhenInputIsValid() Assert.Equal(player1Score, match.Player1Score); Assert.Equal(player2Score, match.Player2Score); } + + [Fact] + public async Task Play_ReturnsNegativeScoreError_WhenScoreIsNegative() + { + // Arrange + var email = "carol@example.com"; + var password = "Password123!"; + var matchId = 9; + var winnerId = 5; + var player1Score = -1; + var player2Score = 1; + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var variables = new + { + input = new + { + matchId = matchId, + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + variables); + + // Assert + Assert.True(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.Play); + Assert.Null(response.Data.Play.Boolean); + Assert.NotNull(response.Errors); + + var error = response.Errors.First(); + Assert.NotNull(error); + Assert.NotNull(error.Extensions); + Assert.True(error.Extensions.ContainsKey("code")); + Assert.NotNull(error.Message); + + var expectedError = MatchErrors.NegativeScore(matchId, player1Score, player2Score); + Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + Assert.Equal(expectedError.Extensions!["MatchId"]?.ToString(), error.Extensions["MatchId"]?.ToString()); + Assert.Equal(expectedError.Extensions!["Player1Score"]?.ToString(), error.Extensions["Player1Score"]?.ToString()); + Assert.Equal(expectedError.Extensions!["Player2Score"]?.ToString(), error.Extensions["Player2Score"]?.ToString()); + + var match = await DbContext.Matches.AsNoTracking().FirstOrDefaultAsync(m => m.Id == matchId); + + Assert.NotNull(match); + Assert.Null(match.WinnerId); + } + + [Fact] + public async Task Play_ReturnsWinnerScoreMismatchError_WhenWinnerScoreIsNotHigher() + { + // Arrange + var email = "carol@example.com"; + var password = "Password123!"; + var matchId = 9; + var winnerId = 5; + var player1Score = 2; + var player2Score = 2; + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var variables = new + { + input = new + { + matchId = matchId, + winnerId = winnerId, + player1Score = player1Score, + player2Score = player2Score + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + variables); + + // Assert + Assert.True(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.Play); + Assert.Null(response.Data.Play.Boolean); + Assert.NotNull(response.Errors); + + var error = response.Errors.First(); + Assert.NotNull(error); + Assert.NotNull(error.Extensions); + Assert.True(error.Extensions.ContainsKey("code")); + Assert.NotNull(error.Message); + + var expectedError = MatchErrors.WinnerScoreMismatch(matchId, winnerId); + Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + Assert.Equal(expectedError.Extensions!["MatchId"]?.ToString(), error.Extensions["MatchId"]?.ToString()); + Assert.Equal(expectedError.Extensions!["WinnerId"]?.ToString(), error.Extensions["WinnerId"]?.ToString()); + + var match = await DbContext.Matches.AsNoTracking().FirstOrDefaultAsync(m => m.Id == matchId); + + Assert.NotNull(match); + Assert.Null(match.WinnerId); } } From e3207f9faa4ea7a06ecaec6391360d541fd2942e Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:51:02 +0200 Subject: [PATCH 06/29] test: add unit tests for MatchValidations methods Tests include: - Validation for non-negative scores for both players. - Validation for winner having a higher score than the opponent. - Edge cases such as tied scores and negative scores. --- .../Validations/MatchValidationsTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs index c0bc692..e6cc3d3 100644 --- a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs @@ -97,4 +97,72 @@ public void ValidateWinnerIsParticipant_WhenWinnerIsPlayer2_ReturnsNull() Assert.Null(error); } + + [Fact] + public void ValidateScoresAreNonNegative_WhenPlayer1ScoreIsNegative_ReturnsError() + { + IError? error = MatchValidations.ValidateScoresAreNonNegative(1, -1, 2); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.NegativeScore, error.Code); + } + + [Fact] + public void ValidateScoresAreNonNegative_WhenPlayer2ScoreIsNegative_ReturnsError() + { + IError? error = MatchValidations.ValidateScoresAreNonNegative(1, 2, -1); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.NegativeScore, error.Code); + } + + [Fact] + public void ValidateScoresAreNonNegative_WhenBothScoresAreNonNegative_ReturnsNull() + { + IError? error = MatchValidations.ValidateScoresAreNonNegative(1, 3, 1); + + Assert.Null(error); + } + + [Fact] + public void ValidateWinnerHasHigherScore_WhenScoresAreTied_ReturnsError() + { + var match = new Match { Id = 1, Player1Id = 2, Player2Id = 3 }; + + IError? error = MatchValidations.ValidateWinnerHasHigherScore(match, 2, 1, 1); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.WinnerScoreMismatch, error.Code); + } + + [Fact] + public void ValidateWinnerHasHigherScore_WhenWinnerScoreIsLower_ReturnsError() + { + var match = new Match { Id = 1, Player1Id = 2, Player2Id = 3 }; + + IError? error = MatchValidations.ValidateWinnerHasHigherScore(match, 2, 1, 3); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.WinnerScoreMismatch, error.Code); + } + + [Fact] + public void ValidateWinnerHasHigherScore_WhenPlayer1IsWinnerWithHigherScore_ReturnsNull() + { + var match = new Match { Id = 1, Player1Id = 2, Player2Id = 3 }; + + IError? error = MatchValidations.ValidateWinnerHasHigherScore(match, 2, 3, 1); + + Assert.Null(error); + } + + [Fact] + public void ValidateWinnerHasHigherScore_WhenPlayer2IsWinnerWithHigherScore_ReturnsNull() + { + var match = new Match { Id = 1, Player1Id = 2, Player2Id = 3 }; + + IError? error = MatchValidations.ValidateWinnerHasHigherScore(match, 3, 1, 3); + + Assert.Null(error); + } } From 71aa40823356e3c7208313ea0adfc57154c102f5 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 15 Sep 2026 15:55:27 +0200 Subject: [PATCH 07/29] test: add player scores to MatchNode response model class --- TournamentAPI.Shared/Models/ResponseModels.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index a401177..3499885 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -103,6 +103,8 @@ public class MatchNode public int Player1Id { get; set; } public int? Player2Id { get; set; } public int? WinnerId { get; set; } + public int Player1Score { get; set; } + public int Player2Score { get; set; } public ApplicationUserNode? Player1 { get; set; } public ApplicationUserNode? Player2 { get; set; } public ApplicationUserNode? Winner { get; set; } From 06f271f9b65b98cc6fb9e43b48e4bc1ff97efaa0 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:28:49 +0200 Subject: [PATCH 08/29] feat: add error for concurrent round updates Added a new constant `RoundDataChanged` to the `BracketErrorCodes` class to represent a new error code for concurrent round data changes. Introduced a new method `RoundDataChangedConcurrently` in the `BracketErrors` class to generate an error object with a descriptive message, error code, and `BracketId` as context. --- TournamentAPI/Brackets/BracketErrorCodes.cs | 1 + TournamentAPI/Brackets/BracketErrors.cs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/TournamentAPI/Brackets/BracketErrorCodes.cs b/TournamentAPI/Brackets/BracketErrorCodes.cs index c6bac7b..e76f7c8 100644 --- a/TournamentAPI/Brackets/BracketErrorCodes.cs +++ b/TournamentAPI/Brackets/BracketErrorCodes.cs @@ -12,4 +12,5 @@ public static class BracketErrorCodes public const string BracketAlreadyHasWinner = "Bracket.AlreadyHasWinner"; public const string NextRoundAlreadyGenerated = "Bracket.NextRoundAlreadyGenerated"; public const string RoundUpdateNotAllowed = "Bracket.RoundUpdateNotAllowed"; + public const string RoundDataChanged = "Bracket.RoundDataChanged"; } diff --git a/TournamentAPI/Brackets/BracketErrors.cs b/TournamentAPI/Brackets/BracketErrors.cs index d9d3a0b..a7521b9 100644 --- a/TournamentAPI/Brackets/BracketErrors.cs +++ b/TournamentAPI/Brackets/BracketErrors.cs @@ -64,4 +64,11 @@ public static IError RoundUpdateNotAllowed(int tournamentId) => .SetCode(BracketErrorCodes.RoundUpdateNotAllowed) .SetExtension("TournamentId", tournamentId) .Build(); + + public static IError RoundDataChangedConcurrently(int bracketId) => + ErrorBuilder.New() + .SetMessage("Round data changed since it was read. Please retry.") + .SetCode(BracketErrorCodes.RoundDataChanged) + .SetExtension("BracketId", bracketId) + .Build(); } From d365cca89d7fdec1b50b030464179e474839b118 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:31:35 +0200 Subject: [PATCH 09/29] feat: add concurrency handling when updating round --- TournamentAPI/Brackets/BracketMutations.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TournamentAPI/Brackets/BracketMutations.cs b/TournamentAPI/Brackets/BracketMutations.cs index bd59232..3c6fcd1 100644 --- a/TournamentAPI/Brackets/BracketMutations.cs +++ b/TournamentAPI/Brackets/BracketMutations.cs @@ -87,11 +87,21 @@ public static partial class BracketMutations var newMatches = BracketService.CreateNextRoundMatches(bracket.Id, roundNumber, winners); + foreach (var match in matchesInRound) + { + context.Entry(match).Property(m => m.WinnerId).IsModified = true; + } + try { context.Matches.AddRange(newMatches); await context.SaveChangesAsync(token); } + catch (DbUpdateConcurrencyException) + { + resolverContext.ReportError(BracketErrors.RoundDataChangedConcurrently(bracketId)); + return null; + } catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) { resolverContext.ReportError(BracketErrors.NextRoundAlreadyGenerated(bracketId)); From c70a00c8050a8685241b1ef32d8b8482b4d428ed Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:33:20 +0200 Subject: [PATCH 10/29] feat: add status to matches in BracketService --- TournamentAPI/Brackets/BracketService.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/TournamentAPI/Brackets/BracketService.cs b/TournamentAPI/Brackets/BracketService.cs index f31a589..d6f31b9 100644 --- a/TournamentAPI/Brackets/BracketService.cs +++ b/TournamentAPI/Brackets/BracketService.cs @@ -16,13 +16,16 @@ public static Bracket CreateBracket(int tournamentId, IList participantIds) for (int i = 0; i < shuffled.Count; i += 2) { + var isBye = i + 1 >= shuffled.Count; + bracket.Matches.Add(new Match { Round = 1, Player1Id = shuffled[i], - Player2Id = i + 1 < shuffled.Count ? shuffled[i + 1] : null, + Player2Id = isBye ? null : shuffled[i + 1], Bracket = bracket, - WinnerId = i + 1 < shuffled.Count ? null : shuffled[i] + WinnerId = isBye ? shuffled[i] : null, + Status = isBye ? MatchStatus.Played : MatchStatus.Scheduled }); } @@ -41,13 +44,16 @@ public static IList CreateNextRoundMatches(int bracketId, int roundNumber if (p2 != null && p2 < p1) (p1, p2) = (p2.Value, p1); + var isBye = p2 == null; + matches.Add(new Match { BracketId = bracketId, Round = roundNumber + 1, Player1Id = p1, Player2Id = p2, - WinnerId = p2 == null ? p1 : null, + WinnerId = isBye ? p1 : null, + Status = isBye ? MatchStatus.Played : MatchStatus.Scheduled }); } From 3de90acd9f0146178024278acb8eb53428aba008 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:34:04 +0200 Subject: [PATCH 11/29] fix: refine match completion validation logic Updated the `ValidateAllMatchesCompleted` method in the `BracketMutationValidations` class to check if any match's `Status` is not equal to `MatchStatus.Played` instead of checking for a `null` `WinnerId`. This improves the accuracy of the validation by relying on the `Status` property, which provides a more reliable indicator of match completion. --- TournamentAPI/Brackets/BracketMutationValidations.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TournamentAPI/Brackets/BracketMutationValidations.cs b/TournamentAPI/Brackets/BracketMutationValidations.cs index 40eee1e..918b3b3 100644 --- a/TournamentAPI/Brackets/BracketMutationValidations.cs +++ b/TournamentAPI/Brackets/BracketMutationValidations.cs @@ -26,7 +26,7 @@ public static class BracketMutationValidations => matchesInRound.Count == 0 ? BracketErrors.NoMatchesInRound(roundNumber) : null; public static IError? ValidateAllMatchesCompleted(ICollection matchesInRound, int roundNumber) - => matchesInRound.Any(m => m.WinnerId == null) ? BracketErrors.NotAllMatchesPlayed(roundNumber) : null; + => matchesInRound.Any(m => m.Status != MatchStatus.Played) ? BracketErrors.NotAllMatchesPlayed(roundNumber) : null; public static IError? ValidateNotFinalRound(IList winners, int bracketId) => winners.Count < 2 ? BracketErrors.BracketAlreadyHasWinner(bracketId) : null; From 97521fa41af88d69cf9f303b237097e282224875 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:34:36 +0200 Subject: [PATCH 12/29] feat: add MatchStatus and update Match properties Added a `Status` property to the `Match` class to track match status. Replaced the `Version` property with `RowVersion`, annotated with `[IsProjected(true)]`. Introduced a new `MatchStatus` enum with values `Scheduled`, `Played`, and `NeedsReplay` to represent different match states. --- TournamentAPI/Data/Models/Match.cs | 4 +++- TournamentAPI/Data/Models/MatchStatus.cs | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 TournamentAPI/Data/Models/MatchStatus.cs diff --git a/TournamentAPI/Data/Models/Match.cs b/TournamentAPI/Data/Models/Match.cs index 690d95e..eddceae 100644 --- a/TournamentAPI/Data/Models/Match.cs +++ b/TournamentAPI/Data/Models/Match.cs @@ -13,6 +13,7 @@ public class Match : ISoftDeletable public int? WinnerId { get; set; } public int Player1Score { get; set; } public int Player2Score { get; set; } + public MatchStatus Status { get; set; } [GraphQLIgnore] public bool IsDeleted { get; set; } @@ -31,5 +32,6 @@ public class Match : ISoftDeletable [Timestamp] [GraphQLIgnore] - public byte[] Version { get; set; } = null!; + [IsProjected(true)] + public byte[] RowVersion { get; set; } = null!; } diff --git a/TournamentAPI/Data/Models/MatchStatus.cs b/TournamentAPI/Data/Models/MatchStatus.cs new file mode 100644 index 0000000..2da674b --- /dev/null +++ b/TournamentAPI/Data/Models/MatchStatus.cs @@ -0,0 +1,8 @@ +namespace TournamentAPI.Data.Models; + +public enum MatchStatus +{ + Scheduled, + Played, + NeedsReplay +} From 30bd5f6a57da729daa64bf3b79437804135c4b6d Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:35:15 +0200 Subject: [PATCH 13/29] feat: add MatchCorrectionAudit entity and configuration Added a new `MatchCorrectionAudit` entity to track audit logs for match corrections. Updated `ApplicationDbContext` to include a `DbSet` for `MatchCorrectionAudits` and configured its relationships, primary key, and indexes in `OnModelCreating`. The `MatchCorrectionAudit` entity includes properties for tracking match corrections, such as previous and new statuses, player IDs, scores, and the user who performed the correction. Relationships to `Match` and `ApplicationUser` entities were defined with `DeleteBehavior.Restrict`. --- TournamentAPI/Data/ApplicationDbContext.cs | 27 +++++++++++++++++ .../Data/Models/MatchCorrectionAudit.cs | 29 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 TournamentAPI/Data/Models/MatchCorrectionAudit.cs diff --git a/TournamentAPI/Data/ApplicationDbContext.cs b/TournamentAPI/Data/ApplicationDbContext.cs index bb5b0e5..63993f9 100644 --- a/TournamentAPI/Data/ApplicationDbContext.cs +++ b/TournamentAPI/Data/ApplicationDbContext.cs @@ -16,6 +16,7 @@ public ApplicationDbContext(DbContextOptions options) : ba public DbSet Brackets { get; set; } public DbSet Matches { get; set; } public DbSet RefreshTokens { get; set; } + public DbSet MatchCorrectionAudits { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -115,5 +116,31 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(e => e.User) .WithMany() .HasForeignKey(e => e.UserId); + + builder.Entity().HasKey(e => e.Id); + + builder.Entity() + .HasOne() + .WithMany() + .HasForeignKey(e => e.MatchId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne() + .WithMany() + .HasForeignKey(e => e.TriggeredByMatchId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne() + .WithMany() + .HasForeignKey(e => e.PerformedByUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(e => e.MatchId); + + builder.Entity() + .HasIndex(e => e.CorrelationId); } } diff --git a/TournamentAPI/Data/Models/MatchCorrectionAudit.cs b/TournamentAPI/Data/Models/MatchCorrectionAudit.cs new file mode 100644 index 0000000..acd75a7 --- /dev/null +++ b/TournamentAPI/Data/Models/MatchCorrectionAudit.cs @@ -0,0 +1,29 @@ +namespace TournamentAPI.Data.Models; + +public class MatchCorrectionAudit +{ + public Guid Id { get; set; } + public int MatchId { get; set; } + public Guid CorrelationId { get; set; } + public int? TriggeredByMatchId { get; set; } + + public MatchStatus PreviousStatus { get; set; } + public MatchStatus NewStatus { get; set; } + + public int? PreviousWinnerId { get; set; } + public int? NewWinnerId { get; set; } + + public int PreviousPlayer1Id { get; set; } + public int NewPlayer1Id { get; set; } + public int? PreviousPlayer2Id { get; set; } + public int? NewPlayer2Id { get; set; } + + public int PreviousPlayer1Score { get; set; } + public int NewPlayer1Score { get; set; } + public int PreviousPlayer2Score { get; set; } + public int NewPlayer2Score { get; set; } + + public int PerformedByUserId { get; set; } + public DateTime PerformedAtUtc { get; set; } + public string? Notes { get; set; } +} From 9ec74f1b2c4dc2dafe5086a39fa35bd400ee11c9 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:45:49 +0200 Subject: [PATCH 14/29] feat: add new match correction error codes and handling methods Enhanced error handling by adding new error codes to the `MatchErrorCodes` class, including `MatchNotYetPlayed`, `MatchNeedsReplay`, `InvalidVersionToken`, `MatchVersionConflict`, and `MatchCorrectionFailed`. Added corresponding error methods to the `MatchErrors` class to generate detailed error messages for these scenarios, improving clarity and robustness in match operation error reporting. --- TournamentAPI/Matches/MatchErrorCodes.cs | 5 ++++ TournamentAPI/Matches/MatchErrors.cs | 35 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/TournamentAPI/Matches/MatchErrorCodes.cs b/TournamentAPI/Matches/MatchErrorCodes.cs index aa671c0..7f81300 100644 --- a/TournamentAPI/Matches/MatchErrorCodes.cs +++ b/TournamentAPI/Matches/MatchErrorCodes.cs @@ -8,4 +8,9 @@ public static class MatchErrorCodes public const string TournamentNotClosed = "Match.TournamentNotClosed"; public const string NegativeScore = "Match.NegativeScore"; public const string WinnerScoreMismatch = "Match.WinnerScoreMismatch"; + public const string MatchNotYetPlayed = "Match.NotYetPlayed"; + public const string MatchNeedsReplay = "Match.NeedsReplay"; + public const string InvalidVersionToken = "Match.InvalidVersionToken"; + public const string MatchVersionConflict = "Match.VersionConflict"; + public const string MatchCorrectionFailed = "Match.CorrectionFailed"; } diff --git a/TournamentAPI/Matches/MatchErrors.cs b/TournamentAPI/Matches/MatchErrors.cs index a683e6f..962e7b1 100644 --- a/TournamentAPI/Matches/MatchErrors.cs +++ b/TournamentAPI/Matches/MatchErrors.cs @@ -47,4 +47,39 @@ public static IError WinnerScoreMismatch(int matchId, int winnerId) => .SetExtension("MatchId", matchId) .SetExtension("WinnerId", winnerId) .Build(); + + public static IError MatchNotYetPlayed(int matchId) => + ErrorBuilder.New() + .SetMessage("Match has not been played yet. Use Play instead.") + .SetCode(MatchErrorCodes.MatchNotYetPlayed) + .SetExtension("MatchId", matchId) + .Build(); + + public static IError MatchNeedsReplay(int matchId) => + ErrorBuilder.New() + .SetMessage("Match was invalidated by an upstream correction. Replay it via Play first.") + .SetCode(MatchErrorCodes.MatchNeedsReplay) + .SetExtension("MatchId", matchId) + .Build(); + + public static IError InvalidVersionToken(int matchId) => + ErrorBuilder.New() + .SetMessage("The supplied version token is invalid.") + .SetCode(MatchErrorCodes.InvalidVersionToken) + .SetExtension("MatchId", matchId) + .Build(); + + public static IError MatchVersionConflict(int matchId) => + ErrorBuilder.New() + .SetMessage("Match was modified since the supplied version was read.") + .SetCode(MatchErrorCodes.MatchVersionConflict) + .SetExtension("MatchId", matchId) + .Build(); + + public static IError MatchCorrectionFailed(int matchId) => + ErrorBuilder.New() + .SetMessage("Match correction failed.") + .SetCode(MatchErrorCodes.MatchCorrectionFailed) + .SetExtension("MatchId", matchId) + .Build(); } From 5ee2c67ceb312eddd987600cf6f76588d213b286 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 09:59:36 +0200 Subject: [PATCH 15/29] feat: add replay handling and match correction logic Added logic to determine if a match requires a replay by checking its status and storing the result in the `isReplay` variable. Introduced variables to preserve the previous state of the match, including status, winner, player IDs, and scores. Updated the match's properties (`WinnerId`, `Player1Score`, `Player2Score`, and `Status`) to reflect the new state, explicitly setting the status to `MatchStatus.Played`. Implemented replay handling by invoking `MatchCorrectionService.ApplyCorrectionAsync` when a replay is detected, passing the previous match state and other relevant parameters. Ensured that corrections are applied before saving changes to the database asynchronously. --- TournamentAPI/Matches/MatchMutations.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index 6f11e8f..52bddd5 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -52,12 +52,37 @@ public static partial class MatchMutations if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId, player1Score, player2Score))) return null; + var isReplay = match.Status == MatchStatus.NeedsReplay; + var previousStatus = match.Status; + var previousWinnerId = match.WinnerId; + var previousPlayer1Id = match.Player1Id; + var previousPlayer2Id = match.Player2Id; + var previousPlayer1Score = match.Player1Score; + var previousPlayer2Score = match.Player2Score; + match.WinnerId = winnerId; match.Player1Score = player1Score; match.Player2Score = player2Score; + match.Status = MatchStatus.Played; try { + if (isReplay) + { + await MatchCorrectionService.ApplyCorrectionAsync( + context, + match, + previousStatus, + previousWinnerId, + previousPlayer1Id, + previousPlayer2Id, + previousPlayer1Score, + previousPlayer2Score, + userId, + Guid.NewGuid(), + token); + } + await context.SaveChangesAsync(token); return true; From 929b67607b8838e26813a9ce2d69a3c320a7e31a Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:05:33 +0200 Subject: [PATCH 16/29] feat: add match correction and propagation logic `MatchCascadePositionCalculator`: - Added `GetDownstreamMatchId` to calculate downstream match IDs in a tournament bracket. `MatchCorrectionService`: - Added `ApplyCorrectionAsync` to handle match corrections, including audit logging and downstream propagation. - Added `RecordIdempotentDuplicateAsync` to log duplicate correction requests. - Added `PropagateAsync` to propagate corrections downstream, updating match participants, status, and winner as needed. - Added `BuildAuditRow` to create detailed audit logs for corrections. --- .../Matches/MatchCascadePositionCalculator.cs | 29 +++ .../Matches/MatchCorrectionService.cs | 173 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 TournamentAPI/Matches/MatchCascadePositionCalculator.cs create mode 100644 TournamentAPI/Matches/MatchCorrectionService.cs diff --git a/TournamentAPI/Matches/MatchCascadePositionCalculator.cs b/TournamentAPI/Matches/MatchCascadePositionCalculator.cs new file mode 100644 index 0000000..e3f3606 --- /dev/null +++ b/TournamentAPI/Matches/MatchCascadePositionCalculator.cs @@ -0,0 +1,29 @@ +namespace TournamentAPI.Matches; + +public static class MatchCascadePositionCalculator +{ + public static int? GetDownstreamMatchId( + IReadOnlyList currentRoundMatchIdsAscending, + IReadOnlyList nextRoundMatchIdsAscending, + int matchId) + { + var position = -1; + for (var i = 0; i < currentRoundMatchIdsAscending.Count; i++) + { + if (currentRoundMatchIdsAscending[i] == matchId) + { + position = i; + break; + } + } + + if (position < 0) + return null; + + var downstreamPosition = position / 2; + if (downstreamPosition >= nextRoundMatchIdsAscending.Count) + return null; + + return nextRoundMatchIdsAscending[downstreamPosition]; + } +} diff --git a/TournamentAPI/Matches/MatchCorrectionService.cs b/TournamentAPI/Matches/MatchCorrectionService.cs new file mode 100644 index 0000000..873e8cd --- /dev/null +++ b/TournamentAPI/Matches/MatchCorrectionService.cs @@ -0,0 +1,173 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Matches; + +public static class MatchCorrectionService +{ + public static async Task ApplyCorrectionAsync( + ApplicationDbContext context, + Match match, + MatchStatus previousStatus, + int? previousWinnerId, + int previousPlayer1Id, + int? previousPlayer2Id, + int previousPlayer1Score, + int previousPlayer2Score, + int performedByUserId, + Guid correlationId, + CancellationToken token) + { + context.MatchCorrectionAudits.Add(BuildAuditRow( + match, + previousStatus, previousWinnerId, previousPlayer1Id, previousPlayer2Id, previousPlayer1Score, previousPlayer2Score, + correlationId, triggeredByMatchId: null, performedByUserId, notes: null)); + + if (previousWinnerId == match.WinnerId) + return; + + await PropagateAsync(context, match, previousWinnerId, performedByUserId, correlationId, token); + } + + public static async Task RecordIdempotentDuplicateAsync( + ApplicationDbContext context, + Match currentCommittedState, + int performedByUserId, + CancellationToken token) + { + context.MatchCorrectionAudits.Add(BuildAuditRow( + currentCommittedState, + currentCommittedState.Status, currentCommittedState.WinnerId, + currentCommittedState.Player1Id, currentCommittedState.Player2Id, + currentCommittedState.Player1Score, currentCommittedState.Player2Score, + correlationId: Guid.NewGuid(), + triggeredByMatchId: null, + performedByUserId, + notes: "Idempotent duplicate: retried request matched already-committed state.")); + + await context.SaveChangesAsync(token); + } + + private static async Task PropagateAsync( + ApplicationDbContext context, + Match sourceMatch, + int? sourceMatchPreviousWinnerId, + int performedByUserId, + Guid correlationId, + CancellationToken token) + { + var upstreamMatch = sourceMatch; + var upstreamPreviousWinnerId = sourceMatchPreviousWinnerId; + + while (true) + { + var currentRoundMatchIds = await context.Matches + .Where(m => m.BracketId == upstreamMatch.BracketId && m.Round == upstreamMatch.Round) + .OrderBy(m => m.Id) + .Select(m => m.Id) + .ToListAsync(token); + + var nextRoundMatches = await context.Matches + .Where(m => m.BracketId == upstreamMatch.BracketId && m.Round == upstreamMatch.Round + 1) + .OrderBy(m => m.Id) + .ToListAsync(token); + + var nextRoundMatchIds = nextRoundMatches.Select(m => m.Id).ToList(); + + var downstreamMatchId = MatchCascadePositionCalculator.GetDownstreamMatchId( + currentRoundMatchIds, nextRoundMatchIds, upstreamMatch.Id); + + if (downstreamMatchId is null) + return; + + var downstream = nextRoundMatches.Single(m => m.Id == downstreamMatchId); + + var previousStatus = downstream.Status; + var previousWinnerId = downstream.WinnerId; + var previousPlayer1Id = downstream.Player1Id; + var previousPlayer2Id = downstream.Player2Id; + var previousPlayer1Score = downstream.Player1Score; + var previousPlayer2Score = downstream.Player2Score; + + var newParticipantId = upstreamMatch.WinnerId!.Value; + + if (downstream.Player1Id == upstreamPreviousWinnerId) + downstream.Player1Id = newParticipantId; + else if (downstream.Player2Id == upstreamPreviousWinnerId) + downstream.Player2Id = newParticipantId; + + if (downstream.Player2Id is null) + { + downstream.WinnerId = downstream.Player1Id; + downstream.Status = MatchStatus.Played; + + context.MatchCorrectionAudits.Add(BuildAuditRow( + downstream, + previousStatus, previousWinnerId, previousPlayer1Id, previousPlayer2Id, previousPlayer1Score, previousPlayer2Score, + correlationId, upstreamMatch.Id, performedByUserId, + notes: "Auto-advanced bye after upstream correction.")); + + upstreamMatch = downstream; + upstreamPreviousWinnerId = previousWinnerId; + continue; + } + + if (downstream.Status == MatchStatus.Scheduled) + { + context.MatchCorrectionAudits.Add(BuildAuditRow( + downstream, + previousStatus, previousWinnerId, previousPlayer1Id, previousPlayer2Id, previousPlayer1Score, previousPlayer2Score, + correlationId, upstreamMatch.Id, performedByUserId, + notes: "Participant swapped after upstream correction; match not yet played.")); + + return; + } + + downstream.Status = MatchStatus.NeedsReplay; + + context.MatchCorrectionAudits.Add(BuildAuditRow( + downstream, + previousStatus, previousWinnerId, previousPlayer1Id, previousPlayer2Id, previousPlayer1Score, previousPlayer2Score, + correlationId, upstreamMatch.Id, performedByUserId, + notes: "Invalidated by upstream correction.")); + + return; + } + } + + private static MatchCorrectionAudit BuildAuditRow( + Match match, + MatchStatus previousStatus, + int? previousWinnerId, + int previousPlayer1Id, + int? previousPlayer2Id, + int previousPlayer1Score, + int previousPlayer2Score, + Guid correlationId, + int? triggeredByMatchId, + int performedByUserId, + string? notes) + => new() + { + Id = Guid.NewGuid(), + MatchId = match.Id, + CorrelationId = correlationId, + TriggeredByMatchId = triggeredByMatchId, + PreviousStatus = previousStatus, + NewStatus = match.Status, + PreviousWinnerId = previousWinnerId, + NewWinnerId = match.WinnerId, + PreviousPlayer1Id = previousPlayer1Id, + NewPlayer1Id = match.Player1Id, + PreviousPlayer2Id = previousPlayer2Id, + NewPlayer2Id = match.Player2Id, + PreviousPlayer1Score = previousPlayer1Score, + NewPlayer1Score = match.Player1Score, + PreviousPlayer2Score = previousPlayer2Score, + NewPlayer2Score = match.Player2Score, + PerformedByUserId = performedByUserId, + PerformedAtUtc = DateTime.UtcNow, + Notes = notes + }; +} From b6dd90c0b8c71cf6ea129feaaaaebfe7ab16b7d8 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:06:51 +0200 Subject: [PATCH 17/29] feat: add match result correction mutation with validations Added the `CorrectMatchResult` method in `MatchMutations` to allow authorized users to correct match results. This includes extensive validation checks for match existence, user ownership, tournament status, and score validity. Enhanced error handling ensures proper reporting of concurrency conflicts and database update failures. Introduced the `GetVersion` resolver in `MatchResolvers` to encode and return the `RowVersion` of a match for version tracking. Updated `ValidateMatchNotPlayed` in `MatchValidations` to use `MatchStatus` for determining if a match has been played. Added new validation methods: `ValidateMatchNotScheduled` and `ValidateMatchNotNeedsReplay`. --- TournamentAPI/Matches/MatchMutations.cs | 112 ++++++++++++++++++++++ TournamentAPI/Matches/MatchResolvers.cs | 4 + TournamentAPI/Matches/MatchValidations.cs | 8 +- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index 52bddd5..1c42ae2 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using System.Security.Claims; using TournamentAPI.Data; +using TournamentAPI.Data.Models; using TournamentAPI.Extensions; using TournamentAPI.Tournaments; @@ -93,4 +94,115 @@ await MatchCorrectionService.ApplyCorrectionAsync( return null; } } + + [Authorize] + public static async Task CorrectMatchResult( + int matchId, + int winnerId, + int player1Score, + int player2Score, + string version, + ClaimsPrincipal userClaims, + IResolverContext resolverContext, + ApplicationDbContext context, + CancellationToken token) + { + var userId = userClaims.GetUserId(); + + var match = await context.Matches + .Include(m => m.Bracket) + .ThenInclude(b => b.Tournament) + .FirstOrDefaultAsync(m => m.Id == matchId, token); + + if (resolverContext.TryReportError(MatchValidations.ValidateMatchExists(match, matchId))) + return null; + + var tournament = match!.Bracket.Tournament; + + if (resolverContext.TryReportError(TournamentValidations.ValidateIsOwner(tournament.OwnerId, userId, tournament.Id))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateTournamentIsClosed(tournament))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateMatchNotScheduled(match))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateMatchNotNeedsReplay(match))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateScoresAreNonNegative(match.Id, player1Score, player2Score))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId, player1Score, player2Score))) + return null; + + if (!MatchVersionCodec.TryDecode(version, out var decodedRowVersion)) + { + resolverContext.ReportError(MatchErrors.InvalidVersionToken(matchId)); + return null; + } + + context.Entry(match).Property(m => m.RowVersion).OriginalValue = decodedRowVersion; + + var previousStatus = match.Status; + var previousWinnerId = match.WinnerId; + var previousPlayer1Id = match.Player1Id; + var previousPlayer2Id = match.Player2Id; + var previousPlayer1Score = match.Player1Score; + var previousPlayer2Score = match.Player2Score; + + match.WinnerId = winnerId; + match.Player1Score = player1Score; + match.Player2Score = player2Score; + match.Status = MatchStatus.Played; + + try + { + await MatchCorrectionService.ApplyCorrectionAsync( + context, + match, + previousStatus, + previousWinnerId, + previousPlayer1Id, + previousPlayer2Id, + previousPlayer1Score, + previousPlayer2Score, + userId, + Guid.NewGuid(), + token); + + await context.SaveChangesAsync(token); + + return true; + } + catch (DbUpdateConcurrencyException) + { + context.ChangeTracker.Clear(); + + var currentState = await context.Matches + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == matchId, token); + + if (currentState is not null + && currentState.WinnerId == winnerId + && currentState.Player1Score == player1Score + && currentState.Player2Score == player2Score) + { + await MatchCorrectionService.RecordIdempotentDuplicateAsync(context, currentState, userId, token); + return true; + } + + resolverContext.ReportError(MatchErrors.MatchVersionConflict(matchId)); + return null; + } + catch (DbUpdateException) + { + resolverContext.ReportError(MatchErrors.MatchCorrectionFailed(matchId)); + return null; + } + } } diff --git a/TournamentAPI/Matches/MatchResolvers.cs b/TournamentAPI/Matches/MatchResolvers.cs index 1fdaed9..2aef089 100644 --- a/TournamentAPI/Matches/MatchResolvers.cs +++ b/TournamentAPI/Matches/MatchResolvers.cs @@ -23,4 +23,8 @@ public static partial class MatchResolvers ApplicationUserService applicationUserService, CancellationToken cancellationToken) => match.WinnerId is null ? null : await applicationUserService.GetApplicationUserByIdAsync(match.WinnerId.Value, cancellationToken); + + public static string GetVersion( + [Parent(requires: nameof(Match.RowVersion))] Match match) + => MatchVersionCodec.Encode(match.RowVersion); } diff --git a/TournamentAPI/Matches/MatchValidations.cs b/TournamentAPI/Matches/MatchValidations.cs index 8d51127..91114cf 100644 --- a/TournamentAPI/Matches/MatchValidations.cs +++ b/TournamentAPI/Matches/MatchValidations.cs @@ -11,7 +11,13 @@ public static class MatchValidations => tournament.Status != TournamentStatus.Closed ? MatchErrors.TournamentNotClosed(tournament.Id) : null; public static IError? ValidateMatchNotPlayed(Match match) - => match.WinnerId != null ? MatchErrors.MatchAlreadyPlayed(match.Id) : null; + => match.Status == MatchStatus.Played ? MatchErrors.MatchAlreadyPlayed(match.Id) : null; + + public static IError? ValidateMatchNotScheduled(Match match) + => match.Status == MatchStatus.Scheduled ? MatchErrors.MatchNotYetPlayed(match.Id) : null; + + public static IError? ValidateMatchNotNeedsReplay(Match match) + => match.Status == MatchStatus.NeedsReplay ? MatchErrors.MatchNeedsReplay(match.Id) : null; public static IError? ValidateWinnerIsParticipant(Match match, int winnerId) => winnerId != match.Player1Id && winnerId != match.Player2Id ? MatchErrors.InvalidMatchWinner(match.Id, winnerId) : null; From ebe75b329aa1bc5df510384c3d397cf557b5cff1 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:08:32 +0200 Subject: [PATCH 18/29] feat: add MatchVersionCodec for Base64 encoding/decoding --- TournamentAPI/Matches/MatchVersionCodec.cs | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 TournamentAPI/Matches/MatchVersionCodec.cs diff --git a/TournamentAPI/Matches/MatchVersionCodec.cs b/TournamentAPI/Matches/MatchVersionCodec.cs new file mode 100644 index 0000000..f34f04b --- /dev/null +++ b/TournamentAPI/Matches/MatchVersionCodec.cs @@ -0,0 +1,26 @@ +namespace TournamentAPI.Matches; + +public static class MatchVersionCodec +{ + public static string Encode(byte[] rowVersion) => Convert.ToBase64String(rowVersion); + + public static bool TryDecode(string? version, out byte[] rowVersion) + { + if (string.IsNullOrEmpty(version)) + { + rowVersion = []; + return false; + } + + try + { + rowVersion = Convert.FromBase64String(version); + return true; + } + catch (FormatException) + { + rowVersion = []; + return false; + } + } +} From 0efbd3322f6e8d6668235a262ec50d61305ac224 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:10:19 +0200 Subject: [PATCH 19/29] refactor: add Status property to Match objects to database seeders --- .../BenchmarkDatabaseSeeder.cs | 1 + TournamentAPI/Data/DatabaseSeeder.cs | 40 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/TournamentAPI.Benchmarks/BenchmarkDatabaseSeeder.cs b/TournamentAPI.Benchmarks/BenchmarkDatabaseSeeder.cs index b3b37a9..b11b1f1 100644 --- a/TournamentAPI.Benchmarks/BenchmarkDatabaseSeeder.cs +++ b/TournamentAPI.Benchmarks/BenchmarkDatabaseSeeder.cs @@ -90,6 +90,7 @@ public static async Task SeedAsync( Player1Id = selectedUsers[j * 2].Id, Player2Id = j * 2 + 1 < selectedUsers.Count ? selectedUsers[j * 2 + 1].Id : null, WinnerId = random.Next(2) == 0 ? selectedUsers[j * 2].Id : (j * 2 + 1 < selectedUsers.Count ? selectedUsers[j * 2 + 1].Id : selectedUsers[j * 2].Id), + Status = MatchStatus.Played, Bracket = tournament.Bracket }; tournament.Bracket.Matches.Add(match); diff --git a/TournamentAPI/Data/DatabaseSeeder.cs b/TournamentAPI/Data/DatabaseSeeder.cs index 5e47f92..2b72ee4 100644 --- a/TournamentAPI/Data/DatabaseSeeder.cs +++ b/TournamentAPI/Data/DatabaseSeeder.cs @@ -96,17 +96,17 @@ public static async Task SeedAsync( tournament3.Participants.Add(new TournamentParticipant { Tournament = tournament3, Participant = user8, SlotNumber = 8 }); // Round 1 - Quarter Finals (4 matches) - var match1 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user1.Id, Bracket = tournament3.Bracket }; - var match2 = new Match { Round = 1, Player1Id = user3.Id, Player2Id = user4.Id, WinnerId = user4.Id, Bracket = tournament3.Bracket }; - var match3 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = user5.Id, Bracket = tournament3.Bracket }; - var match4 = new Match { Round = 1, Player1Id = user7.Id, Player2Id = user8.Id, WinnerId = user7.Id, Bracket = tournament3.Bracket }; + var match1 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user1.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; + var match2 = new Match { Round = 1, Player1Id = user3.Id, Player2Id = user4.Id, WinnerId = user4.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; + var match3 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = user5.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; + var match4 = new Match { Round = 1, Player1Id = user7.Id, Player2Id = user8.Id, WinnerId = user7.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; // Round 2 - Semi Finals (2 matches) - var match5 = new Match { Round = 2, Player1Id = user1.Id, Player2Id = user4.Id, WinnerId = user1.Id, Bracket = tournament3.Bracket }; - var match6 = new Match { Round = 2, Player1Id = user5.Id, Player2Id = user7.Id, WinnerId = user5.Id, Bracket = tournament3.Bracket }; + var match5 = new Match { Round = 2, Player1Id = user1.Id, Player2Id = user4.Id, WinnerId = user1.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; + var match6 = new Match { Round = 2, Player1Id = user5.Id, Player2Id = user7.Id, WinnerId = user5.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; // Round 3 - Finals (1 match) - var match7 = new Match { Round = 3, Player1Id = user1.Id, Player2Id = user5.Id, WinnerId = user1.Id, Bracket = tournament3.Bracket }; + var match7 = new Match { Round = 3, Player1Id = user1.Id, Player2Id = user5.Id, WinnerId = user1.Id, Status = MatchStatus.Played, Bracket = tournament3.Bracket }; tournament3.Bracket.Matches.Add(match1); tournament3.Bracket.Matches.Add(match2); @@ -140,9 +140,9 @@ public static async Task SeedAsync( tournament4.Participants.Add(new TournamentParticipant { Tournament = tournament4, Participant = user8, SlotNumber = 6 }); // Round 1 - Semi Finals (2 matches, one completed, one in progress) - var match8 = new Match { Round = 1, Player1Id = user2.Id, Player2Id = user3.Id, WinnerId = user3.Id, Bracket = tournament4.Bracket }; - var match9 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = null, Bracket = tournament4.Bracket }; - var match10 = new Match { Round = 1, Player1Id = user7.Id, Player2Id = user8.Id, WinnerId = null, Bracket = tournament4.Bracket }; + var match8 = new Match { Round = 1, Player1Id = user2.Id, Player2Id = user3.Id, WinnerId = user3.Id, Status = MatchStatus.Played, Bracket = tournament4.Bracket }; + var match9 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = null, Status = MatchStatus.Scheduled, Bracket = tournament4.Bracket }; + var match10 = new Match { Round = 1, Player1Id = user7.Id, Player2Id = user8.Id, WinnerId = null, Status = MatchStatus.Scheduled, Bracket = tournament4.Bracket }; tournament4.Bracket.Matches.Add(match8); tournament4.Bracket.Matches.Add(match9); @@ -171,9 +171,9 @@ public static async Task SeedAsync( tournament5.Participants.Add(new TournamentParticipant { Tournament = tournament5, Participant = user8, SlotNumber = 5 }); // Round 1 - First round with bye (3 matches, one player gets bye) - var match11 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user3.Id, WinnerId = user1.Id, Bracket = tournament5.Bracket }; - var match12 = new Match { Round = 1, Player1Id = user4.Id, Player2Id = user6.Id, WinnerId = user6.Id, Bracket = tournament5.Bracket }; - var match13 = new Match { Round = 1, Player1Id = user8.Id, Player2Id = null, WinnerId = user8.Id, Bracket = tournament5.Bracket }; // Bye + var match11 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user3.Id, WinnerId = user1.Id, Status = MatchStatus.Played, Bracket = tournament5.Bracket }; + var match12 = new Match { Round = 1, Player1Id = user4.Id, Player2Id = user6.Id, WinnerId = user6.Id, Status = MatchStatus.Played, Bracket = tournament5.Bracket }; + var match13 = new Match { Round = 1, Player1Id = user8.Id, Player2Id = null, WinnerId = user8.Id, Status = MatchStatus.Played, Bracket = tournament5.Bracket }; // Bye tournament5.Bracket.Matches.Add(match11); tournament5.Bracket.Matches.Add(match12); @@ -217,8 +217,8 @@ public static async Task SeedAsync( tournament7.Participants.Add(new TournamentParticipant { Tournament = tournament7, Participant = user7, SlotNumber = 3 }); tournament7.Participants.Add(new TournamentParticipant { Tournament = tournament7, Participant = user8, SlotNumber = 4 }); - var match14 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user1.Id, Bracket = tournament7.Bracket }; - var match15 = new Match { Round = 1, Player1Id = user7.Id, Player2Id = user8.Id, WinnerId = user7.Id, Bracket = tournament7.Bracket }; + var match14 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user1.Id, Status = MatchStatus.Played, Bracket = tournament7.Bracket }; + var match15 = new Match { Round = 1, Player1Id = user7.Id, Player2Id = user8.Id, WinnerId = user7.Id, Status = MatchStatus.Played, Bracket = tournament7.Bracket }; tournament7.Bracket.Matches.Add(match14); tournament7.Bracket.Matches.Add(match15); @@ -300,8 +300,8 @@ public static async Task SeedAsync( tournament12.Participants.Add(new TournamentParticipant { Tournament = tournament12, Participant = user3, SlotNumber = 3 }); tournament12.Participants.Add(new TournamentParticipant { Tournament = tournament12, Participant = user4, SlotNumber = 4 }); - var match16 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user2.Id, Bracket = tournament12.Bracket }; - var match17 = new Match { Round = 1, Player1Id = user3.Id, Player2Id = user4.Id, WinnerId = user4.Id, Bracket = tournament12.Bracket }; + var match16 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user2.Id, Status = MatchStatus.Played, Bracket = tournament12.Bracket }; + var match17 = new Match { Round = 1, Player1Id = user3.Id, Player2Id = user4.Id, WinnerId = user4.Id, Status = MatchStatus.Played, Bracket = tournament12.Bracket }; tournament12.Bracket.Matches.Add(match16); tournament12.Bracket.Matches.Add(match17); @@ -373,11 +373,11 @@ public static async Task SeedAsync( tournament16.Participants.Add(new TournamentParticipant { Tournament = tournament16, Participant = user6, SlotNumber = 4 }); // Round 1 - Semi Finals (2 matches) - var match19 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user3.Id, WinnerId = user3.Id, Bracket = tournament16.Bracket }; - var match20 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = user6.Id, Bracket = tournament16.Bracket }; + var match19 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user3.Id, WinnerId = user3.Id, Status = MatchStatus.Played, Bracket = tournament16.Bracket }; + var match20 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = user6.Id, Status = MatchStatus.Played, Bracket = tournament16.Bracket }; // Round 2 - Final - var match21 = new Match { Round = 2, Player1Id = user3.Id, Player2Id = user6.Id, WinnerId = user6.Id, Bracket = tournament16.Bracket }; + var match21 = new Match { Round = 2, Player1Id = user3.Id, Player2Id = user6.Id, WinnerId = user6.Id, Status = MatchStatus.Played, Bracket = tournament16.Bracket }; tournament16.Bracket.Matches.Add(match19); tournament16.Bracket.Matches.Add(match20); From 51b6856fbbb9dca97596c8fcc121a54cbeddc06f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:11:42 +0200 Subject: [PATCH 20/29] test: add concurrency test for round updates --- .../Tests/Brackets/BracketMutationTests.cs | 85 ++++++++++++++++++- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs index 5015ded..0927c34 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using TournamentAPI.Brackets; using TournamentAPI.Data.Models; +using TournamentAPI.Matches; using TournamentAPI.Shared.Models; using TournamentAPI.Tournaments; @@ -535,10 +536,10 @@ public async Task UpdateRound_HandlesDbUpdateException_WhenRaceConditionOccurs() Assert.True(error.Extensions.ContainsKey("code")); Assert.NotNull(error.Message); - var expectedError = BracketErrors.NextRoundAlreadyGenerated(bracketId); - Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); - Assert.Equal(expectedError.Message, error.Message); - Assert.Equal(expectedError.Extensions!["BracketId"]!.ToString(), error.Extensions["BracketId"]?.ToString()); + var code = error.Extensions["code"]?.ToString(); + Assert.True( + code == BracketErrorCodes.RoundDataChanged || code == BracketErrorCodes.NextRoundAlreadyGenerated, + $"Expected either {BracketErrorCodes.RoundDataChanged} or {BracketErrorCodes.NextRoundAlreadyGenerated}, got {code}."); var matchesInDb = await DbContext.Matches .AsNoTracking() @@ -548,6 +549,82 @@ public async Task UpdateRound_HandlesDbUpdateException_WhenRaceConditionOccurs() Assert.Single(matchesInDb); } + [Fact] + public async Task UpdateRound_RacesWithConcurrentCorrectMatchResult_LoserGetsAConcurrencyError() + { + // Arrange + var email = "carol@example.com"; + var password = "Password123!"; + var bracketId = 4; // tournament 7: match14, match15, round 1 only, no round 2 generated + using var client1 = CreateClient(); + using var client2 = CreateClient(); + + var tokenResponse = await client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client1.SetAuthToken(tokenResponse.Data.LoginUser.String); + client2.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var match14 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 14); + var version = Convert.ToBase64String(match14.RowVersion); + + var updateRoundVariables = new + { + input = new + { + bracketId = bracketId, + roundNumber = 1 + } + }; + var correctMatchResultVariables = new + { + input = new + { + matchId = 14, + winnerId = 2, + player1Score = 1, + player2Score = 3, + version = version + } + }; + + // Act + var updateRoundTask = client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.UpdateRound, + updateRoundVariables); + var correctMatchResultTask = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + correctMatchResultVariables); + + await Task.WhenAll(updateRoundTask, correctMatchResultTask); + + var updateRoundResponse = updateRoundTask.Result; + var correctMatchResultResponse = correctMatchResultTask.Result; + + // Assert: exactly one of the two concurrent mutations loses the race + Assert.True(updateRoundResponse.HasErrors ^ correctMatchResultResponse.HasErrors); + + if (updateRoundResponse.HasErrors) + { + var error = updateRoundResponse.Errors!.First(); + var expectedError = BracketErrors.RoundDataChangedConcurrently(bracketId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + else + { + var error = correctMatchResultResponse.Errors!.First(); + var expectedError = MatchErrors.MatchVersionConflict(14); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + } + [Fact] public async Task UpdateRound_ReturnsBracketNotFoundError_WhenBracketDoesNotExist() { From 2ad6b2a560f459bfeb2ef81df13c3018e5e0b012 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:12:36 +0200 Subject: [PATCH 21/29] test: add tests for match correction mutations Implemented comprehensive test cases to cover: - Score-only corrections without cascading changes. - Winner changes with and without downstream matches. - Cascade handling for downstream matches. - Error handling for invalid inputs (e.g., non-existent matches, unauthorized users, negative scores, stale versions, etc.). - Concurrency handling for identical requests. - Audit logging with correlation IDs and chains. - Champion self-healing after invalidated final matches. --- .../Matches/MatchCorrectionMutationTests.cs | 580 ++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchCorrectionMutationTests.cs diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchCorrectionMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchCorrectionMutationTests.cs new file mode 100644 index 0000000..8d8844c --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchCorrectionMutationTests.cs @@ -0,0 +1,580 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; +using TournamentAPI.Matches; +using TournamentAPI.Shared.Helpers; +using TournamentAPI.Shared.Models; +using TournamentAPI.Tournaments; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Matches; + +public class MatchCorrectionMutationTests : BaseIntegrationTest +{ + public MatchCorrectionMutationTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + private async Task LoginAsync(string email, string password = "Password123!") + { + var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password } }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + return client; + } + + private async Task GetVersionAsync(int matchId) + { + var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == matchId); + return MatchVersionCodec.Encode(match.RowVersion); + } + + private static object CorrectMatchResultVariables(int matchId, int winnerId, int player1Score, int player2Score, string version) + => new { input = new { matchId, winnerId, player1Score, player2Score, version } }; + + [Fact] + public async Task CorrectMatchResult_ScoreOnlyCorrection_DoesNotChangeWinner_AndDoesNotCascade() + { + // Arrange + var matchId = 2; // tournament 3, round 1: carol vs david, winner david + var winnerId = 4; // david (unchanged) + using var client = await LoginAsync("alice@example.com"); + var version = await GetVersionAsync(matchId); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, winnerId, 1, 5, version)); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.CorrectMatchResult); + Assert.True(response.Data.CorrectMatchResult.Boolean); + + var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == matchId); + Assert.Equal(winnerId, match.WinnerId); + Assert.Equal(1, match.Player1Score); + Assert.Equal(5, match.Player2Score); + Assert.Equal(MatchStatus.Played, match.Status); + + var downstreamMatch = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 5); + Assert.Equal(1, downstreamMatch.Player1Id); + Assert.Equal(4, downstreamMatch.Player2Id); + Assert.Equal(1, downstreamMatch.WinnerId); + Assert.Equal(MatchStatus.Played, downstreamMatch.Status); + + var audits = await DbContext.MatchCorrectionAudits.AsNoTracking().Where(a => a.MatchId == matchId).ToListAsync(); + Assert.Single(audits); + Assert.Null(audits[0].TriggeredByMatchId); + } + + [Fact] + public async Task CorrectMatchResult_WinnerChanges_WhenNoDownstreamRoundGenerated_DoesNotCascade() + { + // Arrange + var matchId = 14; // tournament 7, round 1 only: alice vs bob, winner alice + var winnerId = 2; // bob + using var client = await LoginAsync("carol@example.com"); + var version = await GetVersionAsync(matchId); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, winnerId, 1, 3, version)); + + // Assert + Assert.False(response.HasErrors); + Assert.True(response.Data!.CorrectMatchResult!.Boolean); + + var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == matchId); + Assert.Equal(winnerId, match.WinnerId); + Assert.Equal(1, match.Player1Score); + Assert.Equal(3, match.Player2Score); + + var audits = await DbContext.MatchCorrectionAudits.AsNoTracking().Where(a => a.MatchId == matchId).ToListAsync(); + Assert.Single(audits); + } + + [Fact] + public async Task CorrectMatchResult_WinnerChanges_WhenDownstreamAlreadyPlayed_FlipsDownstreamToNeedsReplay_AndLeavesTwoHopsAwayUntouched() + { + // Arrange + var matchId = 1; // tournament 3, round 1: alice vs bob, winner alice + var winnerId = 2; // bob + using var client = await LoginAsync("alice@example.com"); + var version = await GetVersionAsync(matchId); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, winnerId, 1, 3, version)); + + // Assert + Assert.False(response.HasErrors); + Assert.True(response.Data!.CorrectMatchResult!.Boolean); + + var match5 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 5); + Assert.Equal(2, match5.Player1Id); // bob swapped in for alice + Assert.Equal(4, match5.Player2Id); // david, unchanged + Assert.Equal(1, match5.WinnerId); // stale winner (alice) preserved + Assert.Equal(MatchStatus.NeedsReplay, match5.Status); + + var match7 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 7); + Assert.Equal(1, match7.Player1Id); + Assert.Equal(5, match7.Player2Id); + Assert.Equal(1, match7.WinnerId); + Assert.Equal(MatchStatus.Played, match7.Status); + } + + [Fact] + public async Task Play_ReplayingNeedsReplayMatch_WithGenuinelyDifferentWinner_CascadesToNextHop() + { + // Arrange: correct match1 so match5 becomes NeedsReplay (alice swapped out for bob) + using var ownerClient = await LoginAsync("alice@example.com"); + var match1Version = await GetVersionAsync(1); + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(1, 2, 1, 3, match1Version)); + + // Act: replay match5 (now bob vs david) with david as the genuinely different winner + var response = await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new + { + input = new + { + matchId = 5, + winnerId = 4, // david + player1Score = 1, + player2Score = 3 + } + }); + + // Assert + Assert.False(response.HasErrors); + Assert.True(response.Data!.Play!.Boolean); + + var match5 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 5); + Assert.Equal(4, match5.WinnerId); + Assert.Equal(MatchStatus.Played, match5.Status); + + var match7 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 7); + Assert.Equal(4, match7.Player1Id); // david swapped in for the stale winner (alice) + Assert.Equal(5, match7.Player2Id); // emma, unchanged + Assert.Equal(1, match7.WinnerId); // stale winner (alice) preserved + Assert.Equal(MatchStatus.NeedsReplay, match7.Status); + } + + [Fact] + public async Task CorrectMatchResult_WhenDownstreamIsStillScheduled_OnlySwapsSlot_AndDoesNotChangeStatus() + { + // Arrange: build a custom bracket where round 2 has been generated but not yet played + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var bob = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "bob"); + var carol = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "carol"); + var david = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "david"); + + var tournament = new Tournament + { + Name = "Scheduled Downstream Test", + StartDate = DateTime.UtcNow.AddDays(-1), + Status = TournamentStatus.Closed, + OwnerId = alice.Id, + MaxParticipants = 4 + }; + var bracket = new Bracket { Tournament = tournament }; + var matchA = new Match { Round = 1, Player1Id = alice.Id, Player2Id = bob.Id, WinnerId = alice.Id, Status = MatchStatus.Played, Bracket = bracket }; + var matchB = new Match { Round = 1, Player1Id = carol.Id, Player2Id = david.Id, WinnerId = carol.Id, Status = MatchStatus.Played, Bracket = bracket }; + + DbContext.Tournaments.Add(tournament); + DbContext.Matches.AddRange(matchA, matchB); + await DbContext.SaveChangesAsync(); + + var matchC = new Match + { + Round = 2, + BracketId = bracket.Id, + Player1Id = Math.Min(alice.Id, carol.Id), + Player2Id = Math.Max(alice.Id, carol.Id), + WinnerId = null, + Status = MatchStatus.Scheduled + }; + DbContext.Matches.Add(matchC); + await DbContext.SaveChangesAsync(); + + using var client = await LoginAsync("alice@example.com"); + var version = await GetVersionAsync(matchA.Id); + + // Act: correct matchA's winner from alice to bob + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchA.Id, bob.Id, 1, 3, version)); + + // Assert + Assert.False(response.HasErrors); + Assert.True(response.Data!.CorrectMatchResult!.Boolean); + + var downstream = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == matchC.Id); + Assert.Equal(bob.Id, downstream.Player1Id); + Assert.Equal(carol.Id, downstream.Player2Id); + Assert.Null(downstream.WinnerId); + Assert.Equal(MatchStatus.Scheduled, downstream.Status); + } + + [Fact] + public async Task CorrectMatchResult_WhenDownstreamIsABye_AutoAdvancesAndContinuesTheWalk() + { + // Arrange: 6-participant bracket where round 1's third match feeds a round 2 bye, + // which in turn feeds round 3's final. + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var bob = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "bob"); + var carol = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "carol"); + var david = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "david"); + var emma = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "emma"); + var frank = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "frank"); + + var tournament = new Tournament + { + Name = "Bye Downstream Test", + StartDate = DateTime.UtcNow.AddDays(-1), + Status = TournamentStatus.Closed, + OwnerId = alice.Id, + MaxParticipants = 6 + }; + var bracket = new Bracket { Tournament = tournament }; + var match1 = new Match { Round = 1, Player1Id = alice.Id, Player2Id = bob.Id, WinnerId = alice.Id, Status = MatchStatus.Played, Bracket = bracket }; + var match2 = new Match { Round = 1, Player1Id = carol.Id, Player2Id = david.Id, WinnerId = carol.Id, Status = MatchStatus.Played, Bracket = bracket }; + var match3 = new Match { Round = 1, Player1Id = emma.Id, Player2Id = frank.Id, WinnerId = emma.Id, Status = MatchStatus.Played, Bracket = bracket }; + + DbContext.Tournaments.Add(tournament); + DbContext.Matches.AddRange(match1, match2, match3); + await DbContext.SaveChangesAsync(); + + var round2Pos0 = new Match + { + Round = 2, + BracketId = bracket.Id, + Player1Id = Math.Min(alice.Id, carol.Id), + Player2Id = Math.Max(alice.Id, carol.Id), + WinnerId = alice.Id, + Status = MatchStatus.Played + }; + var round2Bye = new Match + { + Round = 2, + BracketId = bracket.Id, + Player1Id = emma.Id, + Player2Id = null, + WinnerId = emma.Id, + Status = MatchStatus.Played + }; + + DbContext.Matches.AddRange(round2Pos0, round2Bye); + await DbContext.SaveChangesAsync(); + + var final = new Match + { + Round = 3, + BracketId = bracket.Id, + Player1Id = Math.Min(alice.Id, emma.Id), + Player2Id = Math.Max(alice.Id, emma.Id), + WinnerId = emma.Id, + Status = MatchStatus.Played + }; + DbContext.Matches.Add(final); + await DbContext.SaveChangesAsync(); + + using var client = await LoginAsync("alice@example.com"); + var version = await GetVersionAsync(match3.Id); + + // Act: correct match3's winner from emma to frank + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(match3.Id, frank.Id, 1, 3, version)); + + // Assert + Assert.False(response.HasErrors); + Assert.True(response.Data!.CorrectMatchResult!.Boolean); + + var byeAfter = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == round2Bye.Id); + Assert.Equal(frank.Id, byeAfter.Player1Id); + Assert.Null(byeAfter.Player2Id); + Assert.Equal(frank.Id, byeAfter.WinnerId); // auto-advanced + Assert.Equal(MatchStatus.Played, byeAfter.Status); + + var finalAfter = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == final.Id); + Assert.Equal(alice.Id, finalAfter.Player1Id); + Assert.Equal(frank.Id, finalAfter.Player2Id); // emma swapped out for frank + Assert.Equal(emma.Id, finalAfter.WinnerId); // stale winner preserved + Assert.Equal(MatchStatus.NeedsReplay, finalAfter.Status); // continued the walk into round 3 + } + + [Fact] + public async Task CorrectMatchResult_ReturnsMatchNotFoundError_WhenMatchDoesNotExist() + { + var matchId = 99999; + using var client = await LoginAsync("alice@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 1, 3, 1, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.MatchNotFound(matchId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsTournamentNotOwnerError_WhenUserIsNotOwner() + { + var matchId = 14; // owned by carol + using var client = await LoginAsync("alice@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 1, 3, 1, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = TournamentErrors.TournamentNotOwner(1, 7); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsTournamentNotClosedError_WhenTournamentIsNotClosed() + { + var matchId = 14; + var tournamentId = 7; + var tournament = await DbContext.Tournaments.FirstAsync(t => t.Id == tournamentId); + tournament.Status = TournamentStatus.Open; + await DbContext.SaveChangesAsync(); + + using var client = await LoginAsync("carol@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 1, 3, 1, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.TournamentNotClosed(tournamentId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsMatchNotYetPlayedError_WhenMatchIsScheduled() + { + var matchId = 9; // tournament 4, scheduled + using var client = await LoginAsync("carol@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 5, 3, 1, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.MatchNotYetPlayed(matchId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsMatchNeedsReplayError_WhenMatchNeedsReplay() + { + // Arrange: create a NeedsReplay match via a cascade + using var ownerClient = await LoginAsync("alice@example.com"); + var match1Version = await GetVersionAsync(1); + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(1, 2, 1, 3, match1Version)); + + // Act: attempt to correct match5, which is now NeedsReplay + var response = await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(5, 4, 1, 3, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.MatchNeedsReplay(5); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsInvalidMatchWinnerError_WhenWinnerIsNotMatchParticipant() + { + var matchId = 14; + using var client = await LoginAsync("carol@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 5, 3, 1, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.InvalidMatchWinner(matchId, 5); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsNegativeScoreError_WhenScoreIsNegative() + { + var matchId = 14; + using var client = await LoginAsync("carol@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 1, -1, 3, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.NegativeScore(matchId, -1, 3); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsWinnerScoreMismatchError_WhenWinnerScoreIsNotHigher() + { + var matchId = 14; + using var client = await LoginAsync("carol@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 1, 1, 3, "dummy")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.WinnerScoreMismatch(matchId, 1); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsInvalidVersionTokenError_WhenVersionIsMalformed() + { + var matchId = 14; + using var client = await LoginAsync("carol@example.com"); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 1, 3, 1, "not-valid-base64!!!")); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.InvalidVersionToken(matchId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ReturnsVersionConflictError_WhenTokenIsStaleAndTargetDiffersFromCommittedState() + { + var matchId = 15; // tournament 7: grace vs henry, winner grace + using var client = await LoginAsync("carol@example.com"); + var staleVersion = await GetVersionAsync(matchId); + + // First correction lands and moves the row's version forward. + var firstResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 7, 5, 1, staleVersion)); + Assert.False(firstResponse.HasErrors); + + // Second call reuses the now-stale version and asks for a different target. + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(matchId, 8, 1, 5, staleVersion)); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.MatchVersionConflict(matchId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } + + [Fact] + public async Task CorrectMatchResult_ConcurrentIdenticalRequests_BothSucceed_OneRealWriteOneIdempotentNoOp() + { + // Arrange + var matchId = 16; // tournament 12: alice vs bob, winner bob (unchanged target) + using var client1 = await LoginAsync("alice@example.com"); + using var client2 = await LoginAsync("alice@example.com"); + var version = await GetVersionAsync(matchId); + var variables = CorrectMatchResultVariables(matchId, 2, 1, 5, version); + + // Act + var task1 = client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, variables); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, variables); + + var results = await Task.WhenAll(task1, task2); + + // Assert + Assert.All(results, r => Assert.False(r.HasErrors)); + Assert.All(results, r => Assert.True(r.Data!.CorrectMatchResult!.Boolean)); + + var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == matchId); + Assert.Equal(2, match.WinnerId); + Assert.Equal(1, match.Player1Score); + Assert.Equal(5, match.Player2Score); + } + + [Fact] + public async Task CorrectMatchResult_CascadeAuditRows_ShareOneCorrelationId_AndChainViaTriggeredByMatchId() + { + // Arrange + using var client = await LoginAsync("alice@example.com"); + var version = await GetVersionAsync(1); + + // Act + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(1, 2, 1, 3, version)); + + // Assert + var sourceAudit = await DbContext.MatchCorrectionAudits.AsNoTracking().SingleAsync(a => a.MatchId == 1); + var cascadeAudit = await DbContext.MatchCorrectionAudits.AsNoTracking().SingleAsync(a => a.MatchId == 5); + + Assert.Null(sourceAudit.TriggeredByMatchId); + Assert.Equal(1, cascadeAudit.TriggeredByMatchId); + Assert.Equal(sourceAudit.CorrelationId, cascadeAudit.CorrelationId); + } + + [Fact] + public async Task ChampionSelfHealing_DropsChampionWhileFinalNeedsReplay_ThenReflectsNewChampionAfterReplay() + { + // Arrange: correct match1 (alice -> bob), invalidating match5, then replay match5 with david, + // which cascades into the final (match7), invalidating alice's championship. + using var ownerClient = await LoginAsync("alice@example.com"); + var match1Version = await GetVersionAsync(1); + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + CorrectMatchResultVariables(1, 2, 1, 3, match1Version)); + + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = 5, winnerId = 4, player1Score = 1, player2Score = 3 } }); + + // Act (part 1): the final (match7) is now NeedsReplay - alice should no longer be champion + using var aliceClient = await LoginAsync("alice@example.com"); + var midResponse = await aliceClient.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + Assert.False(midResponse.HasErrors); + var midWonNames = midResponse.Data!.Me!.WonTournaments!.Nodes!.Select(t => t.Name).ToList(); + Assert.DoesNotContain("Winter Championship 2024", midWonNames); + + // Act (part 2): replay the final with emma as the new champion + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = 7, winnerId = 5, player1Score = 1, player2Score = 3 } }); + + using var emmaClient = await LoginAsync("emma@example.com"); + var finalResponse = await emmaClient.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + // Assert + Assert.False(finalResponse.HasErrors); + var emmaWonNames = finalResponse.Data!.Me!.WonTournaments!.Nodes!.Select(t => t.Name).ToList(); + Assert.Contains("Winter Championship 2024", emmaWonNames); + } +} From 2ea4ecd7b87013f861a71db313872ca81cb24f41 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:13:27 +0200 Subject: [PATCH 22/29] test: add test for replaying match with stale winner The test ensures: - Replay operation completes successfully without errors. - Match status updates to `Played` and winner remains unchanged. - Downstream matches are updated correctly. - An additional audit row is written to `MatchCorrectionAudits`. --- .../Tests/Matches/MatchMutationTests.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs index 9db4fe0..89a017d 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; using TournamentAPI.Matches; using TournamentAPI.Shared.Models; using TournamentAPI.Tournaments; @@ -10,6 +11,68 @@ public MatchMutationTests(IntegrationTestWebAppFactory factory) : base(factory) { } + [Fact] + public async Task Play_ReplayingNeedsReplayMatch_WithSameStaleWinner_NoCascade_WritesOneAuditRow() + { + // Arrange: correct match2 (tournament 3: carol vs david, winner david -> carol), which + // swaps match5's Player2 slot (david -> carol) but leaves match5's stale winner (alice, + // Player1) as a still-valid participant, so replaying match5 with that same stale winner + // is a legitimate no-op correction rather than an InvalidMatchWinner failure. + var email = "alice@example.com"; + var password = "Password123!"; + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password } }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var match2 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 2); + var version = Convert.ToBase64String(match2.RowVersion); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.CorrectMatchResult, + new { input = new { matchId = 2, winnerId = 3, player1Score = 3, player2Score = 1, version } }); + + var match5BeforeReplay = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 5); + Assert.Equal(MatchStatus.NeedsReplay, match5BeforeReplay.Status); + Assert.Equal(1, match5BeforeReplay.WinnerId); + + var auditCountBeforeReplay = await DbContext.MatchCorrectionAudits.AsNoTracking().CountAsync(a => a.MatchId == 5); + + var variables = new + { + input = new + { + matchId = 5, + winnerId = 1, // alice, the same stale winner + player1Score = 3, + player2Score = 1 + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + variables); + + // Assert + Assert.False(response.HasErrors); + Assert.True(response.Data.Play.Boolean); + + var match5 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 5); + Assert.Equal(1, match5.WinnerId); + Assert.Equal(MatchStatus.Played, match5.Status); + + var match7 = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Id == 7); + Assert.Equal(1, match7.Player1Id); + Assert.Equal(5, match7.Player2Id); + Assert.Equal(MatchStatus.Played, match7.Status); + + var auditCountAfterReplay = await DbContext.MatchCorrectionAudits.AsNoTracking().CountAsync(a => a.MatchId == 5); + Assert.Equal(auditCountBeforeReplay + 1, auditCountAfterReplay); + } + [Fact] public async Task Play_HandlesDbUpdateException_WhenRaceConditionOccurs() { From 0ef0239bfff77d81ac945b6caa8e996a20e1d022 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:18:12 +0200 Subject: [PATCH 23/29] test: add new match metadata and result correction support Added `Status` and `Version` properties to the `MatchNode` class to store additional metadata about matches. Introduced the `CorrectMatchResultResponse` and `CorrectMatchResultResult` classes to handle responses for correcting match results. Added the `CorrectMatchResult` GraphQL mutation to enable correcting match results programmatically. Also added the `GetMatchesForRoundWithStatusAndVersion` query to retrieve matches for a specific round, including the new `Status` and `Version` fields for better data handling and visibility. --- TournamentAPI.Shared/Models/ResponseModels.cs | 12 ++++++++++ .../MutationExamples/MatchMutations.cs | 8 +++++++ .../QueryExamples/MatchQueries.cs | 24 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index 3499885..724fbd2 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -105,6 +105,8 @@ public class MatchNode public int? WinnerId { get; set; } public int Player1Score { get; set; } public int Player2Score { get; set; } + public string? Status { get; set; } + public string? Version { get; set; } public ApplicationUserNode? Player1 { get; set; } public ApplicationUserNode? Player2 { get; set; } public ApplicationUserNode? Winner { get; set; } @@ -195,6 +197,16 @@ public class PlayMatchResult public bool? Boolean { get; set; } } +public class CorrectMatchResultResponse +{ + public CorrectMatchResultResult? CorrectMatchResult { get; set; } +} + +public class CorrectMatchResultResult +{ + public bool? Boolean { get; set; } +} + public class UpdateRoundResponse { public UpdateRoundResult? UpdateRound { get; set; } diff --git a/TournamentAPI.Shared/MutationExamples/MatchMutations.cs b/TournamentAPI.Shared/MutationExamples/MatchMutations.cs index fe61ea5..7d7b2a7 100644 --- a/TournamentAPI.Shared/MutationExamples/MatchMutations.cs +++ b/TournamentAPI.Shared/MutationExamples/MatchMutations.cs @@ -10,5 +10,13 @@ mutation Play($input: PlayInput!) { } } """; + + public const string CorrectMatchResult = """ + mutation CorrectMatchResult($input: CorrectMatchResultInput!) { + correctMatchResult(input: $input) { + boolean + } + } + """; } } diff --git a/TournamentAPI.Shared/QueryExamples/MatchQueries.cs b/TournamentAPI.Shared/QueryExamples/MatchQueries.cs index c01b209..a39db1e 100644 --- a/TournamentAPI.Shared/QueryExamples/MatchQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/MatchQueries.cs @@ -64,5 +64,29 @@ query GetMatchesForRound($tournamentId: Int!, $roundNumber: Int! ) { } } """; + + public const string GetMatchesForRoundWithStatusAndVersion = """ + query GetMatchesForRound($tournamentId: Int!, $roundNumber: Int! ) { + tournamentById(id: $tournamentId) { + bracket { + matchesByBracket(first: 10, where: { round: { eq: $roundNumber } }) { + totalCount + edges { + node { + bracketId + id + player1Id + player2Id + round + winnerId + status + version + } + } + } + } + } + } + """; } } From b676994e3d16b618d2d688fcf2231b6081b09ec9 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:19:30 +0200 Subject: [PATCH 24/29] test: add unit tests for MatchCascadePositionCalculator Implemented the following unit tests for the `GetDownstreamMatchId` method: - Verifies correct downstream match ID for even positions. - Verifies correct downstream match ID for odd positions. - Ensures `null` is returned when the next round is not generated. - Ensures `null` is returned for invalid match IDs. - Ensures `null` is returned for trailing byes not yet generated. --- .../MatchCascadePositionCalculatorTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs diff --git a/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs b/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs new file mode 100644 index 0000000..2998132 --- /dev/null +++ b/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs @@ -0,0 +1,61 @@ +using TournamentAPI.Matches; + +namespace TournamentAPI.UnitTests.Services; + +public class MatchCascadePositionCalculatorTests +{ + [Fact] + public void GetDownstreamMatchId_WhenMatchIsAtEvenPosition_ReturnsFirstNextRoundMatch() + { + var currentRound = new List { 10, 11, 12, 13 }; + var nextRound = new List { 20, 21 }; + + var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 10); + + Assert.Equal(20, result); + } + + [Fact] + public void GetDownstreamMatchId_WhenMatchIsAtOddPosition_ReturnsSameNextRoundMatchAsItsPair() + { + var currentRound = new List { 10, 11, 12, 13 }; + var nextRound = new List { 20, 21 }; + + var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 11); + + Assert.Equal(20, result); + } + + [Fact] + public void GetDownstreamMatchId_WhenNextRoundNotGenerated_ReturnsNull() + { + var currentRound = new List { 10, 11 }; + var nextRound = new List(); + + var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 10); + + Assert.Null(result); + } + + [Fact] + public void GetDownstreamMatchId_WhenMatchIdNotInCurrentRound_ReturnsNull() + { + var currentRound = new List { 10, 11 }; + var nextRound = new List { 20 }; + + var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 999); + + Assert.Null(result); + } + + [Fact] + public void GetDownstreamMatchId_WhenPositionIsTrailingByeNotYetGenerated_ReturnsNull() + { + var currentRound = new List { 10, 11, 12 }; + var nextRound = new List { 20 }; + + var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 12); + + Assert.Null(result); + } +} From 0748d51cbf99ae67943ea92e59bd50cce2827c78 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:20:30 +0200 Subject: [PATCH 25/29] test: add unit tests for MatchVersionCodec - Added `EncodeThenTryDecode_RoundTripsToTheOriginalBytes` to verify encoding and decoding round-trip functionality. - Added `TryDecode_WhenInputIsNull_ReturnsFalse` to ensure null input handling. - Added `TryDecode_WhenInputIsEmpty_ReturnsFalse` to ensure empty string handling. - Added `TryDecode_WhenInputIsMalformedBase64_ReturnsFalse` to validate malformed Base64 input handling. --- .../Services/MatchVersionCodecTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 TournamentAPI.UnitTests/Services/MatchVersionCodecTests.cs diff --git a/TournamentAPI.UnitTests/Services/MatchVersionCodecTests.cs b/TournamentAPI.UnitTests/Services/MatchVersionCodecTests.cs new file mode 100644 index 0000000..552f476 --- /dev/null +++ b/TournamentAPI.UnitTests/Services/MatchVersionCodecTests.cs @@ -0,0 +1,45 @@ +using TournamentAPI.Matches; + +namespace TournamentAPI.UnitTests.Services; + +public class MatchVersionCodecTests +{ + [Fact] + public void EncodeThenTryDecode_RoundTripsToTheOriginalBytes() + { + byte[] rowVersion = [1, 2, 3, 4, 5, 6, 7, 8]; + + var encoded = MatchVersionCodec.Encode(rowVersion); + var decoded = MatchVersionCodec.TryDecode(encoded, out var result); + + Assert.True(decoded); + Assert.Equal(rowVersion, result); + } + + [Fact] + public void TryDecode_WhenInputIsNull_ReturnsFalse() + { + var decoded = MatchVersionCodec.TryDecode(null, out var result); + + Assert.False(decoded); + Assert.Empty(result); + } + + [Fact] + public void TryDecode_WhenInputIsEmpty_ReturnsFalse() + { + var decoded = MatchVersionCodec.TryDecode(string.Empty, out var result); + + Assert.False(decoded); + Assert.Empty(result); + } + + [Fact] + public void TryDecode_WhenInputIsMalformedBase64_ReturnsFalse() + { + var decoded = MatchVersionCodec.TryDecode("not-valid-base64!!", out var result); + + Assert.False(decoded); + Assert.Empty(result); + } +} From bf285d6f9c193e9dc22009f6886bab10e41afcf9 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:22:15 +0200 Subject: [PATCH 26/29] test: ensure ValidateAllMatchesCompleted returns error when one of them needs replay - Updated `ValidateAllMatchesCompleted_WhenSomeMatchesHaveNoWinner_ReturnsError` to use `MatchStatus.Played` and `MatchStatus.Scheduled`. - Updated `ValidateAllMatchesCompleted_WhenAllMatchesHaveWinner_ReturnsNull` to use `MatchStatus.Played`. - Added `ValidateAllMatchesCompleted_WhenAMatchNeedsReplay_ReturnsError` to test replay-needed matches and validate error handling. --- .../BracketMutationValidationsTests.cs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs b/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs index 32460b8..7b36e2e 100644 --- a/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs @@ -161,8 +161,8 @@ public void ValidateAllMatchesCompleted_WhenSomeMatchesHaveNoWinner_ReturnsError { var matches = new List { - new() { WinnerId = 1 }, - new() { WinnerId = null } + new() { WinnerId = 1, Status = MatchStatus.Played }, + new() { WinnerId = null, Status = MatchStatus.Scheduled } }; IError? error = BracketMutationValidations.ValidateAllMatchesCompleted(matches, 1); @@ -176,8 +176,8 @@ public void ValidateAllMatchesCompleted_WhenAllMatchesHaveWinner_ReturnsNull() { var matches = new List { - new() { WinnerId = 1 }, - new() { WinnerId = 2 } + new() { WinnerId = 1, Status = MatchStatus.Played }, + new() { WinnerId = 2, Status = MatchStatus.Played } }; IError? error = BracketMutationValidations.ValidateAllMatchesCompleted(matches, 1); @@ -185,6 +185,21 @@ public void ValidateAllMatchesCompleted_WhenAllMatchesHaveWinner_ReturnsNull() Assert.Null(error); } + [Fact] + public void ValidateAllMatchesCompleted_WhenAMatchNeedsReplay_ReturnsError() + { + var matches = new List + { + new() { WinnerId = 1, Status = MatchStatus.Played }, + new() { WinnerId = 2, Status = MatchStatus.NeedsReplay } + }; + + IError? error = BracketMutationValidations.ValidateAllMatchesCompleted(matches, 1); + + Assert.NotNull(error); + Assert.Equal(BracketErrorCodes.NotAllMatchesPlayed, error.Code); + } + [Theory] [InlineData(0)] [InlineData(1)] From 5933ed8d6fd4be81750ff96cdae440a8ecb39eb0 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:23:06 +0200 Subject: [PATCH 27/29] test: add tests for MatchValidations with new statuses Introduced new test methods: - `ValidateMatchNotPlayed` now tests for `NeedsReplay`. - `ValidateMatchNotScheduled` tests for `Scheduled`, `Played`, and `NeedsReplay`. - `ValidateMatchNotNeedsReplay` tests for `NeedsReplay`, `Scheduled`, and `Played`. --- .../Validations/MatchValidationsTests.cs | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs index e6cc3d3..be07ed3 100644 --- a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs @@ -47,9 +47,9 @@ public void ValidateTournamentIsClosed_WhenTournamentIsClosed_ReturnsNull() } [Fact] - public void ValidateMatchNotPlayed_WhenMatchAlreadyHasWinner_ReturnsError() + public void ValidateMatchNotPlayed_WhenMatchIsPlayed_ReturnsError() { - var match = new Match { Id = 1, WinnerId = 5 }; + var match = new Match { Id = 1, WinnerId = 5, Status = MatchStatus.Played }; IError? error = MatchValidations.ValidateMatchNotPlayed(match); @@ -58,15 +58,87 @@ public void ValidateMatchNotPlayed_WhenMatchAlreadyHasWinner_ReturnsError() } [Fact] - public void ValidateMatchNotPlayed_WhenMatchHasNoWinner_ReturnsNull() + public void ValidateMatchNotPlayed_WhenMatchIsScheduled_ReturnsNull() { - var match = new Match { Id = 1, WinnerId = null }; + var match = new Match { Id = 1, WinnerId = null, Status = MatchStatus.Scheduled }; IError? error = MatchValidations.ValidateMatchNotPlayed(match); Assert.Null(error); } + [Fact] + public void ValidateMatchNotPlayed_WhenMatchNeedsReplay_ReturnsNull() + { + var match = new Match { Id = 1, WinnerId = 5, Status = MatchStatus.NeedsReplay }; + + IError? error = MatchValidations.ValidateMatchNotPlayed(match); + + Assert.Null(error); + } + + [Fact] + public void ValidateMatchNotScheduled_WhenMatchIsScheduled_ReturnsError() + { + var match = new Match { Id = 1, Status = MatchStatus.Scheduled }; + + IError? error = MatchValidations.ValidateMatchNotScheduled(match); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.MatchNotYetPlayed, error.Code); + } + + [Fact] + public void ValidateMatchNotScheduled_WhenMatchIsPlayed_ReturnsNull() + { + var match = new Match { Id = 1, Status = MatchStatus.Played }; + + IError? error = MatchValidations.ValidateMatchNotScheduled(match); + + Assert.Null(error); + } + + [Fact] + public void ValidateMatchNotScheduled_WhenMatchNeedsReplay_ReturnsNull() + { + var match = new Match { Id = 1, Status = MatchStatus.NeedsReplay }; + + IError? error = MatchValidations.ValidateMatchNotScheduled(match); + + Assert.Null(error); + } + + [Fact] + public void ValidateMatchNotNeedsReplay_WhenMatchNeedsReplay_ReturnsError() + { + var match = new Match { Id = 1, Status = MatchStatus.NeedsReplay }; + + IError? error = MatchValidations.ValidateMatchNotNeedsReplay(match); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.MatchNeedsReplay, error.Code); + } + + [Fact] + public void ValidateMatchNotNeedsReplay_WhenMatchIsScheduled_ReturnsNull() + { + var match = new Match { Id = 1, Status = MatchStatus.Scheduled }; + + IError? error = MatchValidations.ValidateMatchNotNeedsReplay(match); + + Assert.Null(error); + } + + [Fact] + public void ValidateMatchNotNeedsReplay_WhenMatchIsPlayed_ReturnsNull() + { + var match = new Match { Id = 1, Status = MatchStatus.Played }; + + IError? error = MatchValidations.ValidateMatchNotNeedsReplay(match); + + Assert.Null(error); + } + [Fact] public void ValidateWinnerIsParticipant_WhenWinnerIsNotAParticipant_ReturnsError() { From d03dc418289371a6559fe800b5baae848b975fe1 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:31:16 +0200 Subject: [PATCH 28/29] feat: add match status filter when loading won matches by user --- TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs b/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs index c0d9f0a..1922045 100644 --- a/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs +++ b/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using TournamentAPI.Data; +using TournamentAPI.Data.Models; namespace TournamentAPI.Users; @@ -15,7 +16,7 @@ CancellationToken cancellationToken { winnerIds = [.. winnerIds.OrderBy(x => x)]; return await context.Matches - .Where(m => m.WinnerId.HasValue && winnerIds.Contains(m.WinnerId.Value)) + .Where(m => m.WinnerId.HasValue && winnerIds.Contains(m.WinnerId.Value) && m.Status == MatchStatus.Played) .Where(m => m.Round == m.Bracket.Matches.Max(x => x.Round) && m.Bracket.Matches.Count(o => o.Round == m.Round) == 1) .GroupBy(m => m.WinnerId!.Value) From 75acfef98ee2e9c447beedb0a9d778b684bdd59f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 10:32:05 +0200 Subject: [PATCH 29/29] docs: add reasoning behind match result correction and concurrency guards --- ...match_result_correction_and_concurrency.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/adr/0006_match_result_correction_and_concurrency.md diff --git a/docs/adr/0006_match_result_correction_and_concurrency.md b/docs/adr/0006_match_result_correction_and_concurrency.md new file mode 100644 index 0000000..7e0eefc --- /dev/null +++ b/docs/adr/0006_match_result_correction_and_concurrency.md @@ -0,0 +1,107 @@ +# Title +Match Result Correction: Status Model, Cascade Propagation, and Concurrency Guards + +# Date +15/09/2026 + +## Status +Accepted + +## Context +`Play` sets a match's winner once: `WinnerId != null` is the only "already played" signal, and +`MatchValidations.ValidateMatchNotPlayed` locks the match permanently once it's set. There's no way to +correct a mistaken result. That's risky once `UpdateRound` has already built later rounds +from the wrong winner, since those later matches may themselves already be played on top of it, nothing +flags a match's result as untrustworthy, and nothing propagates a correction into rounds already built +from it. + +Separately, `UpdateRound` reads a round's matches with a bare, unlocked `SELECT` and inserts the next +round from that snapshot with no check that the rows are still in the state they were read in. A +correction landing on one of those rows between the read and the insert's commit could silently bake a +stale winner into the next round, undetected. + +## Considered Options + +### For correcting a mistaken result +1. Allow re-calling `Play` on an already-played match + - Pros: minimal change, no new mutation. + - Cons: doesn't fix downstream matches already generated or played from the wrong winner; a bracket + can end up with conflicting winners for the same slot across rounds, silently. +2. New `CorrectMatchResult` mutation, an explicit `Scheduled`/`Played`/`NeedsReplay` match status, and a + positional cascade. (chosen) + - Pros: makes "this result may now be wrong" an explicit, queryable state instead of silent + corruption; reuses the pairing logic already in `BracketService.CreateNextRoundMatches`, so no new + bracket-topology concept is introduced; never guesses a downstream outcome, it stops at the + boundary of what's knowable and waits for a human to replay the invalidated match; gives a full + audit trail for disputes. + - Cons: more moving parts (new enum, mutation, cascade service, audit table); every existing + match-construction path (`BracketService`, `DatabaseSeeder`, the benchmark seeder) needs to + populate the new `Status` field. +3. Regenerate the entire bracket from round 1 on any correction + - Pros: no positional cascade math needed. + - Cons: destroys match history and scores for every round, including ones unrelated to the + correction, the opposite of what issue #102 needs (a targeted correction with an audit trail, not + a reset). + +### For the `UpdateRound`-vs-correction race +1. Pessimistic locking (`SELECT ... WITH (UPDLOCK, HOLDLOCK)`) on the round's matches while + `UpdateRound` reads them + - Pros: eliminates the race outright, holding the lock across the whole read-then-insert operation. + - Cons: rejected previously in ADR #0004 for the same reasons: raw SQL Server lock hints aren't used + anywhere else in this codebase and carry deadlock/contention risk under load, and the trade-offs + haven't changed since that decision. +2. Re-affirm the round's matches as `Modified` and include them in `UpdateRound`'s single + `SaveChangesAsync`, relying on the existing `[Timestamp]` concurrency token (chosen) + - Pros: stays inside the optimistic-concurrency idiom this codebase already established for `Match` + (reused again below for `CorrectMatchResult`); one atomic check, no new infrastructure, no lock + hints, no raw SQL. + - Cons: `UpdateRound` now bumps the `RowVersion` of every match it reads for that round, even when + nothing else changed, which can produce a conflict for an unrelated concurrent reader or writer + holding an older version token, accepted under the same "some wasted retries are fine" trade-off + ADR #0002 already made for round advancement. + +## Decision + +### Status model +`Match` gets an explicit `Status` (`Scheduled`/`Played`/`NeedsReplay`), replacing `WinnerId != null` as +the "already played" signal. A new `CorrectMatchResult` mutation lets the tournament owner correct a +`Played` match's winner and scores. `Play` becomes tri-state: allowed on `Scheduled` or `NeedsReplay`, +blocked only on `Played`. + +### Cascade propagation +Whenever a match's recorded winner changes, from either mutation, a single shared propagation routine +walks forward through the bracket using the same `(BracketId, Round)`-ordinal pairing `BracketService` +already uses to build rounds: + +- It swaps the changed participant into the one downstream match at that position. A match has at most + one downstream match, so this is always a single forward walk, never a tree. +- If that downstream match had already been decided (`Played` or `NeedsReplay`), it flips to + `NeedsReplay` and the walk stops there. It never guesses who would have won a downstream match; a + human has to replay the invalidated match, which re-triggers the same propagation for whatever comes + after it. +- A downstream match that turns out to be a bye (single participant) is auto-advanced and the walk + continues, since nothing was actually decided there, consistent with how byes are already + auto-resolved today, at every round, by `BracketService`. +- Every match the walk touches gets a row in a new, internal-only `MatchCorrectionAudit` table, correlated by a + per-invocation id and pointing at the match whose change caused it, for dispute investigation. +- The whole walk for one `CorrectMatchResult`/replay call is applied in one `SaveChangesAsync`, so no + reader ever observes a half-applied cascade. + +### Concurrency guard for corrections +`CorrectMatchResult` takes the match's `[Timestamp]` token, exposed to clients for the first time as a +base64-encoded `version` string alongside the underlying `RowVersion` column, and sets it as the EF +entity's original concurrency value before saving. That way a client acting on stale data gets a genuine +`DbUpdateConcurrencyException` even though the server itself re-read the row moments earlier. On that +conflict, we re-read the committed row: if it already matches exactly what was requested (same target +winner and scores), we treat it as a successful idempotent no-op, still logging a duplicate audit row, +rather than surfacing an error, since that shape means the client's own request already landed and it's +simply seeing its own prior success reflected back as a "conflict." + +### Concurrency guard for `UpdateRound` +The round's matches it reads are marked `Modified` (a forced no-op reaffirmation) and included in the +same `SaveChangesAsync` that inserts the next round. If any of them changed underneath it, for example +via a concurrent correction, or even a concurrent legitimate `Play` call on another match in the same +round, the whole insert rolls back atomically and a new "round data changed since you read it, please +retry" error is reported. This is caught before, and is distinct from, the existing `DbUpdateException` +with unique-constraint guard that already protects against two concurrent `UpdateRound` calls generating +the same round twice. Both guards stay, since they protect against two different races.