From 4417962a29f1edb2f001ecd0abdddb29f8994fbf Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 21:31:03 +0200 Subject: [PATCH 01/25] feat: add Drawn status to MatchStatus enum Added a new Drawn value to the MatchStatus enum in TournamentAPI.Data.Models to represent matches that end in a draw. --- TournamentAPI/Data/Models/MatchStatus.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Data/Models/MatchStatus.cs b/TournamentAPI/Data/Models/MatchStatus.cs index 2da674b..7628287 100644 --- a/TournamentAPI/Data/Models/MatchStatus.cs +++ b/TournamentAPI/Data/Models/MatchStatus.cs @@ -4,5 +4,6 @@ public enum MatchStatus { Scheduled, Played, - NeedsReplay + NeedsReplay, + Drawn } From e4e927abbb108305bbe4d4c2a67e44cabc5438a0 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 21:31:31 +0200 Subject: [PATCH 02/25] feat: add TournamentFormat enum and format property to Tournament Introduced TournamentFormat enum with SingleElimination and RoundRobin values. Added Format property to Tournament class, defaulting to SingleElimination, to specify tournament format. --- TournamentAPI/Data/Models/Tournament.cs | 1 + TournamentAPI/Data/Models/TournamentFormat.cs | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 TournamentAPI/Data/Models/TournamentFormat.cs diff --git a/TournamentAPI/Data/Models/Tournament.cs b/TournamentAPI/Data/Models/Tournament.cs index e41d8d3..98b1d29 100644 --- a/TournamentAPI/Data/Models/Tournament.cs +++ b/TournamentAPI/Data/Models/Tournament.cs @@ -11,6 +11,7 @@ public class Tournament : ISoftDeletable public int OwnerId { get; set; } public int MaxParticipants { get; set; } public int? ChampionId { get; set; } + public TournamentFormat Format { get; set; } = TournamentFormat.SingleElimination; [GraphQLIgnore] public bool IsDeleted { get; set; } diff --git a/TournamentAPI/Data/Models/TournamentFormat.cs b/TournamentAPI/Data/Models/TournamentFormat.cs new file mode 100644 index 0000000..7253de9 --- /dev/null +++ b/TournamentAPI/Data/Models/TournamentFormat.cs @@ -0,0 +1,7 @@ +namespace TournamentAPI.Data.Models; + +public enum TournamentFormat +{ + SingleElimination, + RoundRobin +} From 3f8dbb7cf9358a254df95506a1c1ed17e2dff0f9 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 21:32:07 +0200 Subject: [PATCH 03/25] feat: add optional Format to CreateTournamentInput The CreateTournamentInput record now includes an optional TournamentFormat property named Format, defaulting to null. This enables specifying a tournament format during creation without making it mandatory. --- TournamentAPI/Tournaments/CreateTournamentInput.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TournamentAPI/Tournaments/CreateTournamentInput.cs b/TournamentAPI/Tournaments/CreateTournamentInput.cs index e447de8..d75b394 100644 --- a/TournamentAPI/Tournaments/CreateTournamentInput.cs +++ b/TournamentAPI/Tournaments/CreateTournamentInput.cs @@ -2,4 +2,4 @@ namespace TournamentAPI.Tournaments; -public record CreateTournamentInput(string Name, DateTime StartDate, TournamentStatus Status, int MaxParticipants); +public record CreateTournamentInput(string Name, DateTime StartDate, TournamentStatus Status, int MaxParticipants, TournamentFormat? Format = null); From c3f1f97fea25f983a6b191e531579fb4c1403aff Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 21:35:58 +0200 Subject: [PATCH 04/25] feat: support draws in matches with validation and error codes Added error codes/builders for draw validation. Updated Play and CorrectMatchResult to accept nullable winnerId and set status to Drawn. Added validation for allowed formats and equal scores on draws. Refactored validation order for draw rules. Updated ValidateMatchNotPlayed for Drawn status. --- TournamentAPI/Matches/MatchErrorCodes.cs | 2 ++ TournamentAPI/Matches/MatchErrors.cs | 16 +++++++++++ TournamentAPI/Matches/MatchMutations.cs | 34 +++++++++++++++++------ TournamentAPI/Matches/MatchValidations.cs | 8 +++++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/TournamentAPI/Matches/MatchErrorCodes.cs b/TournamentAPI/Matches/MatchErrorCodes.cs index 7f81300..b1f4564 100644 --- a/TournamentAPI/Matches/MatchErrorCodes.cs +++ b/TournamentAPI/Matches/MatchErrorCodes.cs @@ -13,4 +13,6 @@ public static class MatchErrorCodes public const string InvalidVersionToken = "Match.InvalidVersionToken"; public const string MatchVersionConflict = "Match.VersionConflict"; public const string MatchCorrectionFailed = "Match.CorrectionFailed"; + public const string DrawNotAllowedForFormat = "Match.DrawNotAllowedForFormat"; + public const string DrawRequiresEqualScores = "Match.DrawRequiresEqualScores"; } diff --git a/TournamentAPI/Matches/MatchErrors.cs b/TournamentAPI/Matches/MatchErrors.cs index 962e7b1..48beccc 100644 --- a/TournamentAPI/Matches/MatchErrors.cs +++ b/TournamentAPI/Matches/MatchErrors.cs @@ -82,4 +82,20 @@ public static IError MatchCorrectionFailed(int matchId) => .SetCode(MatchErrorCodes.MatchCorrectionFailed) .SetExtension("MatchId", matchId) .Build(); + + public static IError DrawNotAllowedForFormat(int matchId) => + ErrorBuilder.New() + .SetMessage("A draw is not allowed for this tournament's format.") + .SetCode(MatchErrorCodes.DrawNotAllowedForFormat) + .SetExtension("MatchId", matchId) + .Build(); + + public static IError DrawRequiresEqualScores(int matchId, int player1Score, int player2Score) => + ErrorBuilder.New() + .SetMessage("A draw requires both players to have equal scores.") + .SetCode(MatchErrorCodes.DrawRequiresEqualScores) + .SetExtension("MatchId", matchId) + .SetExtension("Player1Score", player1Score) + .SetExtension("Player2Score", player2Score) + .Build(); } diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index 4ebca4c..71d24ac 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -18,7 +18,7 @@ public static partial class MatchMutations [Authorize] public static async Task Play( int matchId, - int winnerId, + int? winnerId, int player1Score, int player2Score, ClaimsPrincipal userClaims, @@ -52,15 +52,24 @@ public static partial class MatchMutations if (resolverContext.TryReportError(MatchValidations.ValidateMatchNotPlayed(match))) return null; - if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId))) + if (resolverContext.TryReportError(MatchValidations.ValidateDrawAllowedForFormat(tournament, match.Id, winnerId))) return null; if (resolverContext.TryReportError(MatchValidations.ValidateScoresAreNonNegative(match.Id, player1Score, player2Score))) return null; - if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId, player1Score, player2Score))) + if (resolverContext.TryReportError(MatchValidations.ValidateDrawHasEqualScores(match.Id, winnerId, player1Score, player2Score))) return null; + if (winnerId is not null) + { + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId.Value))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId.Value, player1Score, player2Score))) + return null; + } + var isReplay = match.Status == MatchStatus.NeedsReplay; var previousStatus = match.Status; var previousWinnerId = match.WinnerId; @@ -72,7 +81,7 @@ public static partial class MatchMutations match.WinnerId = winnerId; match.Player1Score = player1Score; match.Player2Score = player2Score; - match.Status = MatchStatus.Played; + match.Status = winnerId is null ? MatchStatus.Drawn : MatchStatus.Played; try { @@ -112,7 +121,7 @@ public static partial class MatchMutations [Authorize] public static async Task CorrectMatchResult( int matchId, - int winnerId, + int? winnerId, int player1Score, int player2Score, string version, @@ -156,15 +165,24 @@ public static partial class MatchMutations if (resolverContext.TryReportError(MatchValidations.ValidateMatchNotNeedsReplay(match))) return null; - if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId))) + if (resolverContext.TryReportError(MatchValidations.ValidateDrawAllowedForFormat(tournament, match.Id, winnerId))) return null; if (resolverContext.TryReportError(MatchValidations.ValidateScoresAreNonNegative(match.Id, player1Score, player2Score))) return null; - if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId, player1Score, player2Score))) + if (resolverContext.TryReportError(MatchValidations.ValidateDrawHasEqualScores(match.Id, winnerId, player1Score, player2Score))) return null; + if (winnerId is not null) + { + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId.Value))) + return null; + + if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId.Value, player1Score, player2Score))) + return null; + } + if (!MatchVersionCodec.TryDecode(version, out var decodedRowVersion)) { resolverContext.ReportError(MatchErrors.InvalidVersionToken(matchId)); @@ -183,7 +201,7 @@ public static partial class MatchMutations match.WinnerId = winnerId; match.Player1Score = player1Score; match.Player2Score = player2Score; - match.Status = MatchStatus.Played; + match.Status = winnerId is null ? MatchStatus.Drawn : MatchStatus.Played; try { diff --git a/TournamentAPI/Matches/MatchValidations.cs b/TournamentAPI/Matches/MatchValidations.cs index 25dc961..aa526d3 100644 --- a/TournamentAPI/Matches/MatchValidations.cs +++ b/TournamentAPI/Matches/MatchValidations.cs @@ -11,7 +11,7 @@ public static class MatchValidations => tournament.Status is TournamentStatus.Closed or TournamentStatus.Completed ? null : MatchErrors.TournamentNotClosed(tournament.Id); public static IError? ValidateMatchNotPlayed(Match match) - => match.Status == MatchStatus.Played ? MatchErrors.MatchAlreadyPlayed(match.Id) : null; + => match.Status is MatchStatus.Played or MatchStatus.Drawn ? MatchErrors.MatchAlreadyPlayed(match.Id) : null; public static IError? ValidateMatchNotScheduled(Match match) => match.Status == MatchStatus.Scheduled ? MatchErrors.MatchNotYetPlayed(match.Id) : null; @@ -31,4 +31,10 @@ public static class MatchValidations var opponentScore = winnerId == match.Player1Id ? player2Score : player1Score; return winnerScore <= opponentScore ? MatchErrors.WinnerScoreMismatch(match.Id, winnerId) : null; } + + public static IError? ValidateDrawAllowedForFormat(Tournament tournament, int matchId, int? winnerId) + => winnerId is null && tournament.Format != TournamentFormat.RoundRobin ? MatchErrors.DrawNotAllowedForFormat(matchId) : null; + + public static IError? ValidateDrawHasEqualScores(int matchId, int? winnerId, int player1Score, int player2Score) + => winnerId is null && player1Score != player2Score ? MatchErrors.DrawRequiresEqualScores(matchId, player1Score, player2Score) : null; } From 27afcc0b493aecb2f254f804082d13cd4baa96c1 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 21:38:36 +0200 Subject: [PATCH 05/25] feat: support multiple tournament formats in MatchMutations - MatchCorrectionService.ApplyCorrection now takes allowCascade to control cascading corrections. - BracketCompletionService replaced with IBracketCompletionStrategy collection; strategy selected by tournament format. - allowCascade set to true only for SingleElimination tournaments. - bracketCompletionService.SyncChampionAsync calls replaced with completionStrategy.SyncCompletionAsync. --- TournamentAPI/Matches/MatchCorrectionService.cs | 3 ++- TournamentAPI/Matches/MatchMutations.cs | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/TournamentAPI/Matches/MatchCorrectionService.cs b/TournamentAPI/Matches/MatchCorrectionService.cs index 96a78e2..38987a7 100644 --- a/TournamentAPI/Matches/MatchCorrectionService.cs +++ b/TournamentAPI/Matches/MatchCorrectionService.cs @@ -22,6 +22,7 @@ public async Task ApplyCorrectionAsync( int previousPlayer2Score, int performedByUserId, Guid correlationId, + bool allowCascade, CancellationToken token) { using var activity = TournamentActivitySource.Instance.StartActivity("MatchCorrectionService.ApplyCorrection"); @@ -33,7 +34,7 @@ public async Task ApplyCorrectionAsync( previousStatus, previousWinnerId, previousPlayer1Id, previousPlayer2Id, previousPlayer1Score, previousPlayer2Score, correlationId, triggeredByMatchId: null, performedByUserId, notes: null)); - if (previousWinnerId == match.WinnerId) + if (!allowCascade || previousWinnerId == match.WinnerId) return match; logger.LogInformation( diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index 71d24ac..0a5f600 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -25,7 +25,7 @@ public static partial class MatchMutations IResolverContext resolverContext, ApplicationDbContext context, MatchCorrectionService matchCorrectionService, - BracketCompletionService bracketCompletionService, + IEnumerable completionStrategies, MatchMetrics matchMetrics, CancellationToken token) { @@ -100,10 +100,12 @@ public static partial class MatchMutations previousPlayer2Score, userId, Guid.NewGuid(), + allowCascade: tournament.Format == TournamentFormat.SingleElimination, token); } - await bracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + var completionStrategy = completionStrategies.Single(s => s.Format == tournament.Format); + await completionStrategy.SyncCompletionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); await context.SaveChangesAsync(token); @@ -130,7 +132,7 @@ public static partial class MatchMutations ApplicationDbContext context, ILoggerFactory loggerFactory, MatchCorrectionService matchCorrectionService, - BracketCompletionService bracketCompletionService, + IEnumerable completionStrategies, MatchMetrics matchMetrics, CancellationToken token) { @@ -177,7 +179,7 @@ public static partial class MatchMutations if (winnerId is not null) { if (resolverContext.TryReportError(MatchValidations.ValidateWinnerIsParticipant(match, winnerId.Value))) - return null; + return null; if (resolverContext.TryReportError(MatchValidations.ValidateWinnerHasHigherScore(match, winnerId.Value, player1Score, player2Score))) return null; @@ -216,9 +218,11 @@ public static partial class MatchMutations previousPlayer2Score, userId, Guid.NewGuid(), + allowCascade: tournament.Format == TournamentFormat.SingleElimination, token); - await bracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + var completionStrategy = completionStrategies.Single(s => s.Format == tournament.Format); + await completionStrategy.SyncCompletionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); await context.SaveChangesAsync(token); From b81e6e806361ae6cfeed5a275baee2691cbacd6c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:12:23 +0200 Subject: [PATCH 06/25] feat: add Standings feature with round-robin computation Introduced StandingsEntry, StandingsDataLoaders, StandingsLookupService, and StandingsResolvers to support standings for Bracket. StandingsService computes points, wins, draws, losses, rank, matches played/remaining, and applies head-to-head tiebreakers for round-robin tournaments. --- TournamentAPI/Standings/StandingEntry.cs | 13 ++ .../Standings/StandingsDataLoaders.cs | 28 ++++ .../Standings/StandingsLookupService.cs | 14 ++ TournamentAPI/Standings/StandingsResolvers.cs | 25 ++++ TournamentAPI/Standings/StandingsService.cs | 130 ++++++++++++++++++ 5 files changed, 210 insertions(+) create mode 100644 TournamentAPI/Standings/StandingEntry.cs create mode 100644 TournamentAPI/Standings/StandingsDataLoaders.cs create mode 100644 TournamentAPI/Standings/StandingsLookupService.cs create mode 100644 TournamentAPI/Standings/StandingsResolvers.cs create mode 100644 TournamentAPI/Standings/StandingsService.cs diff --git a/TournamentAPI/Standings/StandingEntry.cs b/TournamentAPI/Standings/StandingEntry.cs new file mode 100644 index 0000000..ce55f00 --- /dev/null +++ b/TournamentAPI/Standings/StandingEntry.cs @@ -0,0 +1,13 @@ +namespace TournamentAPI.Standings; + +public class StandingEntry +{ + public int ParticipantId { get; set; } + public int Wins { get; set; } + public int Draws { get; set; } + public int Losses { get; set; } + public int Points { get; set; } + public int Rank { get; set; } + public int MatchesPlayed { get; set; } + public int MatchesRemaining { get; set; } +} diff --git a/TournamentAPI/Standings/StandingsDataLoaders.cs b/TournamentAPI/Standings/StandingsDataLoaders.cs new file mode 100644 index 0000000..1f4023e --- /dev/null +++ b/TournamentAPI/Standings/StandingsDataLoaders.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Standings; + +[DataLoaderGroup("StandingsBatchingContext")] +internal static class StandingsDataLoaders +{ + [DataLoader] + public static async Task>> GetMatchesByBracketIdAsync( + IReadOnlyList bracketIds, + ApplicationDbContext context, + CancellationToken cancellationToken + ) + { + bracketIds = [.. bracketIds.OrderBy(x => x)]; + + var matches = await context.Matches + .AsNoTracking() + .Where(m => bracketIds.Contains(m.BracketId)) + .ToListAsync(cancellationToken); + + return matches + .GroupBy(m => m.BracketId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + } +} diff --git a/TournamentAPI/Standings/StandingsLookupService.cs b/TournamentAPI/Standings/StandingsLookupService.cs new file mode 100644 index 0000000..32546c8 --- /dev/null +++ b/TournamentAPI/Standings/StandingsLookupService.cs @@ -0,0 +1,14 @@ +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Standings; + +public class StandingsLookupService(IStandingsBatchingContext batchingContext) +{ + public async Task> GetMatchesByBracketIdAsync( + int bracketId, + CancellationToken cancellationToken = default + ) + { + return await batchingContext.MatchesByBracketId.LoadAsync(bracketId, cancellationToken) ?? []; + } +} diff --git a/TournamentAPI/Standings/StandingsResolvers.cs b/TournamentAPI/Standings/StandingsResolvers.cs new file mode 100644 index 0000000..8c79cf4 --- /dev/null +++ b/TournamentAPI/Standings/StandingsResolvers.cs @@ -0,0 +1,25 @@ +using TournamentAPI.Data.Models; +using TournamentAPI.Tournaments; + +namespace TournamentAPI.Standings; + +[ObjectType] +public static partial class StandingsResolvers +{ + public static async Task> GetStandings( + [Parent] Bracket bracket, + TournamentLookupService tournamentLookupService, + StandingsLookupService standingsLookupService, + StandingsService standingsService, + CancellationToken cancellationToken) + { + var tournament = await tournamentLookupService.GetTournamentByIdAsync(bracket.TournamentId, cancellationToken); + + if (tournament?.Format != TournamentFormat.RoundRobin) + return []; + + var matches = await standingsLookupService.GetMatchesByBracketIdAsync(bracket.Id, cancellationToken); + + return standingsService.ComputeStandings(matches); + } +} diff --git a/TournamentAPI/Standings/StandingsService.cs b/TournamentAPI/Standings/StandingsService.cs new file mode 100644 index 0000000..f220280 --- /dev/null +++ b/TournamentAPI/Standings/StandingsService.cs @@ -0,0 +1,130 @@ +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Standings; + +public class StandingsService +{ + private const int WinPoints = 3; + private const int DrawPoints = 1; + + public IReadOnlyList ComputeStandings(IReadOnlyList matches) + { + var tallies = new Dictionary(); + + foreach (var match in matches) + { + var player1 = GetTally(tallies, match.Player1Id); + var player2 = match.Player2Id is int player2Id ? GetTally(tallies, player2Id) : null; + + if (match.Status == MatchStatus.Played) + { + var winnerId = match.WinnerId!.Value; + var winner = winnerId == match.Player1Id ? player1 : player2!; + var loser = ReferenceEquals(winner, player1) ? player2 : player1; + + winner.Wins++; + + if (loser is not null) + loser.Losses++; + } + else if (match.Status == MatchStatus.Drawn) + { + player1.Draws++; + player2!.Draws++; + } + else if (match.Status == MatchStatus.Scheduled) + { + player1.MatchesRemaining++; + + if (player2 is not null) + player2.MatchesRemaining++; + } + } + + foreach (var match in matches) + { + if (match.Player2Id is not int player2Id) + continue; + + var player1 = tallies[match.Player1Id]; + var player2 = tallies[player2Id]; + + if (player1.Points != player2.Points) + continue; + + if (match.Status == MatchStatus.Played) + { + var winner = match.WinnerId == match.Player1Id ? player1 : player2; + winner.HeadToHeadScore += WinPoints; + } + else if (match.Status == MatchStatus.Drawn) + { + player1.HeadToHeadScore += DrawPoints; + player2.HeadToHeadScore += DrawPoints; + } + } + + var ordered = new List(tallies.Values); + ordered.Sort(static (a, b) => + { + var byPoints = b.Points.CompareTo(a.Points); + if (byPoints != 0) + return byPoints; + + var byHeadToHead = b.HeadToHeadScore.CompareTo(a.HeadToHeadScore); + return byHeadToHead != 0 ? byHeadToHead : a.ParticipantId.CompareTo(b.ParticipantId); + }); + + var entries = new List(ordered.Count); + var currentRank = 0; + + for (var i = 0; i < ordered.Count; i++) + { + var tally = ordered[i]; + + if (i == 0 + || tally.Points != ordered[i - 1].Points + || tally.HeadToHeadScore != ordered[i - 1].HeadToHeadScore) + { + currentRank = i + 1; + } + + entries.Add(new StandingEntry + { + ParticipantId = tally.ParticipantId, + Wins = tally.Wins, + Draws = tally.Draws, + Losses = tally.Losses, + Points = tally.Points, + Rank = currentRank, + MatchesPlayed = tally.Wins + tally.Draws + tally.Losses, + MatchesRemaining = tally.MatchesRemaining + }); + } + + return entries; + } + + private static Tally GetTally(Dictionary tallies, int participantId) + { + if (!tallies.TryGetValue(participantId, out var tally)) + { + tally = new Tally(participantId); + tallies.Add(participantId, tally); + } + + return tally; + } + + private sealed class Tally(int participantId) + { + public int ParticipantId { get; } = participantId; + public int Wins; + public int Draws; + public int Losses; + public int MatchesRemaining; + public int HeadToHeadScore; + + public int Points => (Wins * WinPoints) + (Draws * DrawPoints); + } +} From 8a1f81196b7b5baaf8ac2ef231bf22b565a2259c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:16:26 +0200 Subject: [PATCH 07/25] feat: enable support for multiple tournament formats in BracketMutations Refactored bracket generation and completion to use IBracketGenerationStrategy and IBracketCompletionStrategy interfaces, enabling support for multiple tournament formats. Implemented RoundRobinBracketStrategy and RoundRobinCompletionStrategy for round-robin tournaments. Renamed and adapted single elimination services to strategy implementations. Updated BracketMutations to resolve strategies by format, improving extensibility and separation of concerns. --- TournamentAPI/Brackets/BracketMutations.cs | 7 ++- .../Brackets/IBracketCompletionStrategy.cs | 16 ++++++ .../Brackets/IBracketGenerationStrategy.cs | 10 ++++ .../Brackets/RoundRobinBracketStrategy.cs | 55 +++++++++++++++++++ .../Brackets/RoundRobinCompletionStrategy.cs | 34 ++++++++++++ ...cs => SingleEliminationBracketStrategy.cs} | 4 +- ...=> SingleEliminationCompletionStrategy.cs} | 6 +- 7 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 TournamentAPI/Brackets/IBracketCompletionStrategy.cs create mode 100644 TournamentAPI/Brackets/IBracketGenerationStrategy.cs create mode 100644 TournamentAPI/Brackets/RoundRobinBracketStrategy.cs create mode 100644 TournamentAPI/Brackets/RoundRobinCompletionStrategy.cs rename TournamentAPI/Brackets/{BracketService.cs => SingleEliminationBracketStrategy.cs} (93%) rename TournamentAPI/Brackets/{BracketCompletionService.cs => SingleEliminationCompletionStrategy.cs} (86%) diff --git a/TournamentAPI/Brackets/BracketMutations.cs b/TournamentAPI/Brackets/BracketMutations.cs index e9168d6..56f7307 100644 --- a/TournamentAPI/Brackets/BracketMutations.cs +++ b/TournamentAPI/Brackets/BracketMutations.cs @@ -23,7 +23,7 @@ public static partial class BracketMutations ClaimsPrincipal userClaims, ApplicationDbContext context, IResolverContext resolverContext, - BracketService bracketService, + IEnumerable generationStrategies, BracketMetrics bracketMetrics, CancellationToken token) { @@ -45,7 +45,8 @@ public static partial class BracketMutations if (resolverContext.TryReportError(BracketMutationValidations.ValidateEnoughParticipants(tournament.Participants.Count, tournamentId))) return null; var participantIds = tournament.Participants.Select(p => p.ParticipantId).ToList(); - var bracket = bracketService.CreateBracket(tournamentId, participantIds); + var generationStrategy = generationStrategies.Single(s => s.Format == tournament.Format); + var bracket = generationStrategy.CreateBracket(tournamentId, participantIds); try { @@ -73,7 +74,7 @@ public static partial class BracketMutations ClaimsPrincipal userClaims, ApplicationDbContext context, IResolverContext resolverContext, - BracketService bracketService, + SingleEliminationBracketStrategy bracketService, CancellationToken token) { using var _ = resolverContext.PushEntityContext("Bracket", bracketId); diff --git a/TournamentAPI/Brackets/IBracketCompletionStrategy.cs b/TournamentAPI/Brackets/IBracketCompletionStrategy.cs new file mode 100644 index 0000000..282503a --- /dev/null +++ b/TournamentAPI/Brackets/IBracketCompletionStrategy.cs @@ -0,0 +1,16 @@ +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Brackets; + +public interface IBracketCompletionStrategy +{ + TournamentFormat Format { get; } + + Task SyncCompletionAsync( + ApplicationDbContext context, + Tournament tournament, + int bracketId, + int frontierRound, + CancellationToken token); +} diff --git a/TournamentAPI/Brackets/IBracketGenerationStrategy.cs b/TournamentAPI/Brackets/IBracketGenerationStrategy.cs new file mode 100644 index 0000000..05778f3 --- /dev/null +++ b/TournamentAPI/Brackets/IBracketGenerationStrategy.cs @@ -0,0 +1,10 @@ +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Brackets; + +public interface IBracketGenerationStrategy +{ + TournamentFormat Format { get; } + + Bracket CreateBracket(int tournamentId, IList participantIds); +} diff --git a/TournamentAPI/Brackets/RoundRobinBracketStrategy.cs b/TournamentAPI/Brackets/RoundRobinBracketStrategy.cs new file mode 100644 index 0000000..f005980 --- /dev/null +++ b/TournamentAPI/Brackets/RoundRobinBracketStrategy.cs @@ -0,0 +1,55 @@ +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Brackets; + +public class RoundRobinBracketStrategy : IBracketGenerationStrategy +{ + private const int ByeSeat = -1; + + public TournamentFormat Format => TournamentFormat.RoundRobin; + + public Bracket CreateBracket(int tournamentId, IList participantIds) + { + var bracket = new Bracket + { + TournamentId = tournamentId, + Matches = new List() + }; + + var rotation = new List(participantIds); + if (rotation.Count % 2 != 0) + rotation.Add(ByeSeat); + + var seatCount = rotation.Count; + var roundCount = seatCount - 1; + + for (var round = 1; round <= roundCount; round++) + { + for (var i = 0; i < seatCount / 2; i++) + { + var seatA = rotation[i]; + var seatB = rotation[seatCount - 1 - i]; + + if (seatA == ByeSeat || seatB == ByeSeat) + continue; + + bracket.Matches.Add(new Match + { + Round = round, + Player1Id = seatA, + Player2Id = seatB, + Bracket = bracket, + WinnerId = null, + Status = MatchStatus.Scheduled + }); + } + + var lastSeat = rotation[seatCount - 1]; + for (var i = seatCount - 1; i > 1; i--) + rotation[i] = rotation[i - 1]; + rotation[1] = lastSeat; + } + + return bracket; + } +} diff --git a/TournamentAPI/Brackets/RoundRobinCompletionStrategy.cs b/TournamentAPI/Brackets/RoundRobinCompletionStrategy.cs new file mode 100644 index 0000000..0820e66 --- /dev/null +++ b/TournamentAPI/Brackets/RoundRobinCompletionStrategy.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; +using TournamentAPI.Standings; + +namespace TournamentAPI.Brackets; + +public class RoundRobinCompletionStrategy(StandingsService standingsService) : IBracketCompletionStrategy +{ + public TournamentFormat Format => TournamentFormat.RoundRobin; + + public async Task SyncCompletionAsync( + ApplicationDbContext context, + Tournament tournament, + int bracketId, + int frontierRound, + CancellationToken token) + { + var matches = await context.Matches + .Where(m => m.BracketId == bracketId) + .ToListAsync(token); + + var isComplete = matches.All(m => m.Status != MatchStatus.Scheduled); + + if (!isComplete) + return; + + var standings = standingsService.ComputeStandings(matches); + var leaders = standings.Where(s => s.Rank == 1).ToList(); + + tournament.Status = TournamentStatus.Completed; + tournament.ChampionId = leaders.Count == 1 ? leaders[0].ParticipantId : null; + } +} diff --git a/TournamentAPI/Brackets/BracketService.cs b/TournamentAPI/Brackets/SingleEliminationBracketStrategy.cs similarity index 93% rename from TournamentAPI/Brackets/BracketService.cs rename to TournamentAPI/Brackets/SingleEliminationBracketStrategy.cs index eab0b82..47e79b0 100644 --- a/TournamentAPI/Brackets/BracketService.cs +++ b/TournamentAPI/Brackets/SingleEliminationBracketStrategy.cs @@ -3,8 +3,10 @@ namespace TournamentAPI.Brackets; -public class BracketService(ILogger logger) +public class SingleEliminationBracketStrategy(ILogger logger) : IBracketGenerationStrategy { + public TournamentFormat Format => TournamentFormat.SingleElimination; + public Bracket CreateBracket(int tournamentId, IList participantIds) { using var activity = TournamentActivitySource.Instance.StartActivity("BracketService.CreateBracket"); diff --git a/TournamentAPI/Brackets/BracketCompletionService.cs b/TournamentAPI/Brackets/SingleEliminationCompletionStrategy.cs similarity index 86% rename from TournamentAPI/Brackets/BracketCompletionService.cs rename to TournamentAPI/Brackets/SingleEliminationCompletionStrategy.cs index 94ed8cf..9d4b044 100644 --- a/TournamentAPI/Brackets/BracketCompletionService.cs +++ b/TournamentAPI/Brackets/SingleEliminationCompletionStrategy.cs @@ -5,9 +5,11 @@ namespace TournamentAPI.Brackets; -public class BracketCompletionService(ILogger logger) +public class SingleEliminationCompletionStrategy(ILogger logger) : IBracketCompletionStrategy { - public async Task SyncChampionAsync( + public TournamentFormat Format => TournamentFormat.SingleElimination; + + public async Task SyncCompletionAsync( ApplicationDbContext context, Tournament tournament, int bracketId, From 884ae13ca74687cec819b2b2c465525484df526c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:17:22 +0200 Subject: [PATCH 08/25] feat: add validation for round advancement by tournament format Added Bracket.RoundAdvancementNotSupportedForFormat error code and generator. Implemented ValidateFormatSupportsRoundAdvancement to restrict round advancement to SingleElimination formats. Integrated validation into BracketMutations to block unsupported operations. --- TournamentAPI/Brackets/BracketErrorCodes.cs | 1 + TournamentAPI/Brackets/BracketErrors.cs | 7 +++++++ TournamentAPI/Brackets/BracketMutationValidations.cs | 3 +++ TournamentAPI/Brackets/BracketMutations.cs | 1 + 4 files changed, 12 insertions(+) diff --git a/TournamentAPI/Brackets/BracketErrorCodes.cs b/TournamentAPI/Brackets/BracketErrorCodes.cs index e76f7c8..0a5bc62 100644 --- a/TournamentAPI/Brackets/BracketErrorCodes.cs +++ b/TournamentAPI/Brackets/BracketErrorCodes.cs @@ -13,4 +13,5 @@ public static class BracketErrorCodes public const string NextRoundAlreadyGenerated = "Bracket.NextRoundAlreadyGenerated"; public const string RoundUpdateNotAllowed = "Bracket.RoundUpdateNotAllowed"; public const string RoundDataChanged = "Bracket.RoundDataChanged"; + public const string RoundAdvancementNotSupportedForFormat = "Bracket.RoundAdvancementNotSupportedForFormat"; } diff --git a/TournamentAPI/Brackets/BracketErrors.cs b/TournamentAPI/Brackets/BracketErrors.cs index a7521b9..c877a54 100644 --- a/TournamentAPI/Brackets/BracketErrors.cs +++ b/TournamentAPI/Brackets/BracketErrors.cs @@ -71,4 +71,11 @@ public static IError RoundDataChangedConcurrently(int bracketId) => .SetCode(BracketErrorCodes.RoundDataChanged) .SetExtension("BracketId", bracketId) .Build(); + + public static IError RoundAdvancementNotSupportedForFormat(int tournamentId) => + ErrorBuilder.New() + .SetMessage("Round advancement is not supported for this tournament's format.") + .SetCode(BracketErrorCodes.RoundAdvancementNotSupportedForFormat) + .SetExtension("TournamentId", tournamentId) + .Build(); } diff --git a/TournamentAPI/Brackets/BracketMutationValidations.cs b/TournamentAPI/Brackets/BracketMutationValidations.cs index 918b3b3..2a7630c 100644 --- a/TournamentAPI/Brackets/BracketMutationValidations.cs +++ b/TournamentAPI/Brackets/BracketMutationValidations.cs @@ -30,4 +30,7 @@ public static class BracketMutationValidations public static IError? ValidateNotFinalRound(IList winners, int bracketId) => winners.Count < 2 ? BracketErrors.BracketAlreadyHasWinner(bracketId) : null; + + public static IError? ValidateFormatSupportsRoundAdvancement(Tournament tournament) + => tournament.Format != TournamentFormat.SingleElimination ? BracketErrors.RoundAdvancementNotSupportedForFormat(tournament.Id) : null; } diff --git a/TournamentAPI/Brackets/BracketMutations.cs b/TournamentAPI/Brackets/BracketMutations.cs index 56f7307..b48f582 100644 --- a/TournamentAPI/Brackets/BracketMutations.cs +++ b/TournamentAPI/Brackets/BracketMutations.cs @@ -92,6 +92,7 @@ public static partial class BracketMutations if (resolverContext.TryReportError(BracketMutationValidations.ValidateBracketExists(bracket, bracketId))) return null; if (resolverContext.TryReportError(TournamentValidations.ValidateIsOwner(bracket!.Tournament.OwnerId, userId, bracket.TournamentId))) return null; if (resolverContext.TryReportError(BracketMutationValidations.ValidateTournamentIsClosedForRoundUpdate(bracket.Tournament))) return null; + if (resolverContext.TryReportError(BracketMutationValidations.ValidateFormatSupportsRoundAdvancement(bracket.Tournament))) return null; if (resolverContext.TryReportError(BracketMutationValidations.ValidateNextRoundNotGenerated(bracket.Matches, roundNumber, bracketId))) return null; var matchesInRound = bracket.Matches.Where(m => m.Round == roundNumber).OrderBy(m => m.Id).ToList(); From ac43d35f8d165a8ade08682cf31a52cec83e0d20 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:18:55 +0200 Subject: [PATCH 09/25] test: add integration tests for round-robin bracket behavior - Added test to verify round-robin bracket generation with odd participants creates correct matches and byes. - Added test to ensure advancing round in round-robin returns "format not supported" error. --- .../Tests/Brackets/BracketMutationTests.cs | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs index c2e277c..418f10c 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs @@ -1265,4 +1265,145 @@ await client.ExecuteMutationAsync( Assert.Equal(TournamentStatus.Closed, tournamentAfter.Status); Assert.Null(tournamentAfter.ChampionId); } + + [Fact] + public async Task GenerateBracket_RoundRobin_WithOddParticipantCount_CreatesFullScheduleWithOneByeEach() + { + // Arrange: 5 participants (odd) → 5*4/2 = 10 matches, every pair exactly once, one bye each. + 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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Round Robin Fixture", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 5, + format = "ROUND_ROBIN" + } + }); + + Assert.False(createResponse.HasErrors); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + var participantIds = new[] { 1, 2, 3, 4, 5 }; + foreach (var participantId in participantIds) + { + var addResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + Assert.False(addResponse.HasErrors); + } + + var closeResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + Assert.False(closeResponse.HasErrors); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + // Assert + Assert.False(response.HasErrors); + + var matches = await DbContext.Matches + .AsNoTracking() + .Where(m => m.Bracket.TournamentId == tournamentId) + .ToListAsync(); + + Assert.Equal(10, matches.Count); + + var pairs = matches + .Where(m => m.Player2Id != null) + .Select(m => (Math.Min(m.Player1Id, m.Player2Id!.Value), Math.Max(m.Player1Id, m.Player2Id!.Value))) + .ToList(); + Assert.Equal(pairs.Count, pairs.Distinct().Count()); + + var expectedPairs = + (from a in participantIds + from b in participantIds + where a < b + select (a, b)) + .OrderBy(p => p) + .ToList(); + Assert.Equal(expectedPairs, pairs.OrderBy(p => p).ToList()); + + // Byes aren't persisted as Match rows: each participant is simply missing from one round. + Assert.DoesNotContain(matches, m => m.Player2Id == null); + + foreach (var participantId in participantIds) + { + var matchesPlayed = matches.Count(m => m.Player1Id == participantId || m.Player2Id == participantId); + Assert.Equal(participantIds.Length - 1, matchesPlayed); + } + } + + [Fact] + public async Task UpdateRound_ReturnsFormatNotSupportedError_WhenBracketIsRoundRobin() + { + // Arrange: a Round Robin tournament with a generated bracket has no next-round-from-winners concept. + 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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Round Robin UpdateRound Fixture", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 4, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 1, 2, 3, 4 }) + { + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var bracketId = await DbContext.Brackets.AsNoTracking().Where(b => b.TournamentId == tournamentId).Select(b => b.Id).SingleAsync(); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.UpdateRound, + new { input = new { bracketId, roundNumber = 1 } }); + + // Assert + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = BracketErrors.RoundAdvancementNotSupportedForFormat(tournamentId); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + } } From e489aaa2c98ecf1b6150fafb923c2394149e9e32 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:20:40 +0200 Subject: [PATCH 10/25] test: add integration tests for null winnerId in match play - Round-robin: Accepts draw with null winnerId and equal scores. - Round-robin: Rejects null winnerId with unequal scores, returns error. - Single elimination: Rejects null winnerId regardless of scores, returns error. --- .../Tests/Matches/MatchMutationTests.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs index 89a017d..80fa25a 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs @@ -5,6 +5,7 @@ using TournamentAPI.Tournaments; namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Matches; + public class MatchMutationTests : BaseIntegrationTest { public MatchMutationTests(IntegrationTestWebAppFactory factory) : base(factory) @@ -573,4 +574,160 @@ public async Task Play_ReturnsWinnerScoreMismatchError_WhenWinnerScoreIsNotHighe Assert.NotNull(match); Assert.Null(match.WinnerId); } + + [Fact] + public async Task Play_RoundRobin_WithNullWinnerIdAndEqualScores_SucceedsAsADraw() + { + // Arrange + 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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Round Robin Draw Fixture", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 4, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 1, 2, 3, 4 }) + { + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match.Id, winnerId = (int?)null, player1Score = 1, player2Score = 1 } }); + + // Assert + Assert.False(response.HasErrors); + + var matchAfter = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == match.Id); + Assert.Equal(MatchStatus.Drawn, matchAfter.Status); + Assert.Null(matchAfter.WinnerId); + } + + [Fact] + public async Task Play_RoundRobin_WithNullWinnerIdAndUnequalScores_IsRejected() + { + // Arrange + 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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Round Robin Bad Draw Fixture", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 4, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 1, 2, 3, 4 }) + { + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match.Id, winnerId = (int?)null, player1Score = 2, player2Score = 1 } }); + + // Assert + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.DrawRequiresEqualScores(match.Id, 2, 1); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + + var matchAfter = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == match.Id); + Assert.Equal(MatchStatus.Scheduled, matchAfter.Status); + } + + [Fact] + public async Task Play_SingleElimination_WithNullWinnerId_IsRejected() + { + // Arrange: tournament 10 ("Trio Tournament") is Single Elimination; regression boundary check + // distinct from the Round Robin draw tests above. + var email = "bob@example.com"; + var password = "Password123!"; + var tournamentId = 10; + 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); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var match = await DbContext.Matches + .AsNoTracking() + .FirstAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match.Id, winnerId = (int?)null, player1Score = 1, player2Score = 1 } }); + + // Assert + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = MatchErrors.DrawNotAllowedForFormat(match.Id); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + + var matchAfter = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == match.Id); + Assert.Equal(MatchStatus.Scheduled, matchAfter.Status); + } } From 679df7f31b47e6b9baed6b62eb59f3c155da18a5 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:21:49 +0200 Subject: [PATCH 11/25] test: add integration tests for tournament standings logic Added StandingsQueryTests to verify standings and champion. Covers round-robin tournament creation, match play, and asserts correct standings in both in-progress and completed states, including points, wins, matches, and champion. --- .../Tests/Standings/StandingsQueryTests.cs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/GraphQL/Tests/Standings/StandingsQueryTests.cs diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Standings/StandingsQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Standings/StandingsQueryTests.cs new file mode 100644 index 0000000..50ab98b --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Standings/StandingsQueryTests.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Standings; + +public class StandingsQueryTests : BaseIntegrationTest +{ + public StandingsQueryTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + [Fact] + public async Task Standings_ReflectMidTournamentAndFinalCompletionWithCorrectChampion() + { + // Arrange: 4 participants, owned by emma (id 5) who is not herself a participant. + var email = "emma@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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Standings Fixture", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 4, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 1, 2, 3, 4 }) + { + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var bracketId = await DbContext.Brackets.AsNoTracking().Where(b => b.TournamentId == tournamentId).Select(b => b.Id).SingleAsync(); + + async Task MatchIdAsync(int a, int b) => + (await DbContext.Matches.AsNoTracking().SingleAsync(m => + m.BracketId == bracketId && + ((m.Player1Id == a && m.Player2Id == b) || (m.Player1Id == b && m.Player2Id == a)))).Id; + + var match1v2 = await MatchIdAsync(1, 2); + var match1v3 = await MatchIdAsync(1, 3); + var match1v4 = await MatchIdAsync(1, 4); + var match2v3 = await MatchIdAsync(2, 3); + var match2v4 = await MatchIdAsync(2, 4); + var match3v4 = await MatchIdAsync(3, 4); + + // Act 1: play only two matches, both won by participant 1. + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match1v2, winnerId = 1, player1Score = 3, player2Score = 1 } }); + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match1v3, winnerId = 1, player1Score = 3, player2Score = 1 } }); + + var midResponse = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithBracketStandings, + new { id = tournamentId }); + + // Assert 1: standings only reflect completed matches. + Assert.False(midResponse.HasErrors); + var midStandings = midResponse.Data!.TournamentById!.Bracket!.Standings!; + + var p1Mid = midStandings.Single(s => s.ParticipantId == 1); + Assert.Equal(2, p1Mid.Wins); + Assert.Equal(6, p1Mid.Points); + Assert.Equal(2, p1Mid.MatchesPlayed); + Assert.Equal(1, p1Mid.MatchesRemaining); + + var p4Mid = midStandings.Single(s => s.ParticipantId == 4); + Assert.Equal(0, p4Mid.Points); + Assert.Equal(0, p4Mid.MatchesPlayed); + Assert.Equal(3, p4Mid.MatchesRemaining); + + Assert.Equal("CLOSED", midResponse.Data.TournamentById.Status); + + // Act 2: play the remaining matches to completion. + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match1v4, winnerId = 1, player1Score = 3, player2Score = 1 } }); + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match2v3, winnerId = 2, player1Score = 2, player2Score = 1 } }); + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match2v4, winnerId = (int?)null, player1Score = 1, player2Score = 1 } }); + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match3v4, winnerId = 3, player1Score = 2, player2Score = 1 } }); + + var finalResponse = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithBracketStandings, + new { id = tournamentId }); + + // Assert 2: tournament completed, champion is the rank-1 participant. + Assert.False(finalResponse.HasErrors); + Assert.Equal("COMPLETED", finalResponse.Data!.TournamentById!.Status); + Assert.Equal(1, finalResponse.Data.TournamentById.ChampionId); + + var finalStandings = finalResponse.Data.TournamentById.Bracket!.Standings!; + var champion = finalStandings.Single(s => s.Rank == 1); + Assert.Equal(1, champion.ParticipantId); + Assert.Equal(9, champion.Points); + + var tournamentInDb = await DbContext.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournamentId); + Assert.Equal(TournamentStatus.Completed, tournamentInDb.Status); + Assert.Equal(1, tournamentInDb.ChampionId); + } +} From 1dbce8ef0f557db0cea3578bcb930cef4f67d66b Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:23:32 +0200 Subject: [PATCH 12/25] test: add integration tests for tournament defaults and withdrawals Added four integration tests to TournamentMutationTests: - Verified default format is single elimination if unspecified. - Tested organizer and participant withdrawal, ensuring forfeits and removal. - Confirmed unrelated users cannot withdraw others (auth error). Covers creation defaults, withdrawal scenarios, and authorization. --- .../Tournaments/TournamentMutationTests.cs | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs index 69dc2fd..c1ea9b6 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs @@ -2072,4 +2072,286 @@ public async Task DeleteTournament_ReturnsCannotDeleteWithBracketError_WhenTourn Assert.NotNull(tournamentInDb); } + + [Fact] + public async Task CreateTournament_WithoutFormat_DefaultsToSingleEliminationAndFullFlowIsUnaffected() + { + // Arrange + 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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Default Format Fixture", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 4 + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + var tournamentAfterCreate = await DbContext.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournamentId); + Assert.Equal(TournamentFormat.SingleElimination, tournamentAfterCreate.Format); + + foreach (var participantId in new[] { 1, 2, 3, 4 }) + { + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + var generateResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + Assert.False(generateResponse.HasErrors); + + var bracketId = await DbContext.Brackets.AsNoTracking().Where(b => b.TournamentId == tournamentId).Select(b => b.Id).SingleAsync(); + var roundOneMatches = await DbContext.Matches.AsNoTracking().Where(m => m.BracketId == bracketId && m.Round == 1).ToListAsync(); + + foreach (var match in roundOneMatches) + { + var playResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = match.Id, winnerId = match.Player1Id, player1Score = 3, player2Score = 1 } }); + Assert.False(playResponse.HasErrors); + } + + var updateRoundResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.UpdateRound, + new { input = new { bracketId, roundNumber = 1 } }); + Assert.False(updateRoundResponse.HasErrors); + + var finalMatch = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.BracketId == bracketId && m.Round == 2); + + var finalPlayResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + new { input = new { matchId = finalMatch.Id, winnerId = finalMatch.Player1Id, player1Score = 3, player2Score = 1 } }); + + // Assert + Assert.False(finalPlayResponse.HasErrors); + + var tournamentAfter = await DbContext.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournamentId); + Assert.Equal(TournamentStatus.Completed, tournamentAfter.Status); + Assert.Equal(finalMatch.Player1Id, tournamentAfter.ChampionId); + } + + [Fact] + public async Task WithdrawParticipant_AsOrganizer_ForfeitsRemainingMatchesAndRemovesParticipant() + { + // Arrange + 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 createResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Withdrawal Fixture Organizer", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 5, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 2, 3, 4, 5, 6 }) + { + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var withdrawingParticipantId = 2; + var remainingScheduledMatches = await DbContext.Matches + .AsNoTracking() + .Where(m => m.Bracket.TournamentId == tournamentId + && m.Status == MatchStatus.Scheduled + && (m.Player1Id == withdrawingParticipantId || m.Player2Id == withdrawingParticipantId)) + .ToListAsync(); + Assert.NotEmpty(remainingScheduledMatches); + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.WithdrawParticipant, + new { input = new { tournamentId, participantId = withdrawingParticipantId } }); + + // Assert + Assert.False(response.HasErrors); + + foreach (var beforeMatch in remainingScheduledMatches) + { + var afterMatch = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == beforeMatch.Id); + Assert.Equal(MatchStatus.Played, afterMatch.Status); + + var expectedWinnerId = beforeMatch.Player1Id == withdrawingParticipantId ? beforeMatch.Player2Id : beforeMatch.Player1Id; + Assert.Equal(expectedWinnerId, afterMatch.WinnerId); + } + + var remainingParticipant = await DbContext.TournamentParticipants + .AsNoTracking() + .FirstOrDefaultAsync(tp => tp.TournamentId == tournamentId && tp.ParticipantId == withdrawingParticipantId); + Assert.Null(remainingParticipant); + } + + [Fact] + public async Task WithdrawParticipant_AsTheParticipantThemselves_SucceedsIdentically() + { + // Arrange + var ownerEmail = "alice@example.com"; + var password = "Password123!"; + using var ownerClient = CreateClient(); + + var ownerTokenResponse = await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = ownerEmail, password } }); + ownerClient.SetAuthToken(ownerTokenResponse.Data.LoginUser.String); + + var createResponse = await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Withdrawal Fixture Self", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 5, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 2, 3, 4, 5, 6 }) + { + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + // carol (id 3) withdraws herself + using var carolClient = CreateClient(); + var carolTokenResponse = await carolClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "carol@example.com", password } }); + carolClient.SetAuthToken(carolTokenResponse.Data.LoginUser.String); + + // Act + var response = await carolClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.WithdrawParticipant, + new { input = new { tournamentId, participantId = 3 } }); + + // Assert + Assert.False(response.HasErrors); + + var remainingParticipant = await DbContext.TournamentParticipants + .AsNoTracking() + .FirstOrDefaultAsync(tp => tp.TournamentId == tournamentId && tp.ParticipantId == 3); + Assert.Null(remainingParticipant); + } + + [Fact] + public async Task WithdrawParticipant_AsUnrelatedThirdUser_IsRejected() + { + // Arrange + var ownerEmail = "alice@example.com"; + var password = "Password123!"; + using var ownerClient = CreateClient(); + + var ownerTokenResponse = await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = ownerEmail, password } }); + ownerClient.SetAuthToken(ownerTokenResponse.Data.LoginUser.String); + + var createResponse = await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = "Withdrawal Fixture Unauthorized", + startDate = DateTime.UtcNow.AddDays(1), + status = "OPEN", + maxParticipants = 5, + format = "ROUND_ROBIN" + } + }); + var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id; + + foreach (var participantId in new[] { 2, 3, 4, 5, 6 }) + { + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId, userId = participantId } }); + } + + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId, status = "CLOSED" } }); + + await ownerClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + // emma (id 5) is an unrelated third party trying to withdraw carol (id 3) + using var emmaClient = CreateClient(); + var emmaTokenResponse = await emmaClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "emma@example.com", password } }); + emmaClient.SetAuthToken(emmaTokenResponse.Data.LoginUser.String); + + // Act + var response = await emmaClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.WithdrawParticipant, + new { input = new { tournamentId, participantId = 3 } }); + + // Assert + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = TournamentErrors.NotAuthorizedForWithdrawal(5, tournamentId, 3); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + + var remainingParticipant = await DbContext.TournamentParticipants + .AsNoTracking() + .FirstOrDefaultAsync(tp => tp.TournamentId == tournamentId && tp.ParticipantId == 3); + Assert.NotNull(remainingParticipant); + } } From 33b78646435f1ff64140890c226c60cd2f658661 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:24:25 +0200 Subject: [PATCH 13/25] test: add format, standings, and withdrawal support to tournament response model, mutation and query examples - Added nullable Format to TournamentNode for tournament format. - Extended BracketNode with nullable Standings (list of StandingEntryNode). - Introduced StandingEntryNode for participant standings (wins, draws, losses, points, rank, matches played/remaining). - Added WithdrawParticipantResponse and WithdrawParticipantResult classes for withdrawal modeling. - Added WithdrawParticipant mutation to TournamentMutations. - Added CreateTournamentWithFormatReturn mutation to return format on creation. - Added GetByIdWithBracketStandings query to fetch tournament with bracket standings. --- TournamentAPI.Shared/Models/ResponseModels.cs | 24 ++++++++++++++++++ .../MutationExamples/TournamentMutations.cs | 24 ++++++++++++++++++ .../QueryExamples/TournamentQueries.cs | 25 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index 1f6a81d..dc3307c 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -35,6 +35,7 @@ public class TournamentNode public int MaxParticipants { get; set; } public bool IsActive { get; set; } public int? ChampionId { get; set; } + public string? Format { get; set; } public ApplicationUserNode? Owner { get; set; } public ApplicationUserNode? Champion { get; set; } public BracketNode? Bracket { get; set; } @@ -81,6 +82,19 @@ public class BracketNode public int Id { get; set; } public int TournamentId { get; set; } public MatchesByBracketConnection? MatchesByBracket { get; set; } + public List? Standings { get; set; } +} + +public class StandingEntryNode +{ + public int ParticipantId { get; set; } + public int Wins { get; set; } + public int Draws { get; set; } + public int Losses { get; set; } + public int Points { get; set; } + public int Rank { get; set; } + public int MatchesPlayed { get; set; } + public int MatchesRemaining { get; set; } } public class MatchesByBracketConnection @@ -160,6 +174,16 @@ public class DeleteTournamentResult public bool? Boolean { get; set; } } +public class WithdrawParticipantResponse +{ + public WithdrawParticipantResult? WithdrawParticipant { get; set; } +} + +public class WithdrawParticipantResult +{ + public bool? Boolean { get; set; } +} + public class AddParticipantResponse { public AddParticipantResult? AddParticipant { get; set; } diff --git a/TournamentAPI.Shared/MutationExamples/TournamentMutations.cs b/TournamentAPI.Shared/MutationExamples/TournamentMutations.cs index 0beed58..48de2d6 100644 --- a/TournamentAPI.Shared/MutationExamples/TournamentMutations.cs +++ b/TournamentAPI.Shared/MutationExamples/TournamentMutations.cs @@ -26,6 +26,22 @@ mutation CreateTournament($input: CreateTournamentInput!) { } """; + public const string CreateTournamentWithFormatReturn = """ + mutation CreateTournament($input: CreateTournamentInput!) { + createTournament(input: $input) { + tournament { + id + name + ownerId + startDate + status + maxParticipants + format + } + } + } + """; + public const string CreateTournamentWithOwnerReturn = """ mutation CreateTournament($input: CreateTournamentInput!) { createTournament(input: $input) { @@ -92,5 +108,13 @@ mutation DeleteTournament($input: DeleteTournamentInput!) { } } """; + + public const string WithdrawParticipant = """ + mutation WithdrawParticipant($input: WithdrawParticipantInput!) { + withdrawParticipant(input: $input) { + boolean + } + } + """; } } diff --git a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs index 27d119a..baa044a 100644 --- a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs @@ -549,6 +549,31 @@ public static class Tournaments } """; + public const string GetByIdWithBracketStandings = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + status + championId + bracket { + id + tournamentId + standings { + participantId + wins + draws + losses + points + rank + matchesPlayed + matchesRemaining + } + } + } + } + """; + public const string GetByIdWithBracketAndMatches = """ query($id: Int!) { tournamentById(id: $id) { From 274c69bab028e68ab526e5b26b8ff16d93e71c8b Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:25:12 +0200 Subject: [PATCH 14/25] test: add unit tests for StandingsService computation logic Added StandingsServiceTests to verify standings calculation, including points, wins, draws, losses, matches played, matches remaining, and ranking. Covered tie-breaking by head-to-head, shared ranks for unresolved ties, and correct handling of bye matches as wins. Used diverse match scenarios to ensure ComputeStandings behaves as expected. --- .../Services/StandingsServiceTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 TournamentAPI.UnitTests/Services/StandingsServiceTests.cs diff --git a/TournamentAPI.UnitTests/Services/StandingsServiceTests.cs b/TournamentAPI.UnitTests/Services/StandingsServiceTests.cs new file mode 100644 index 0000000..efbfab5 --- /dev/null +++ b/TournamentAPI.UnitTests/Services/StandingsServiceTests.cs @@ -0,0 +1,121 @@ +using TournamentAPI.Data.Models; +using TournamentAPI.Standings; + +namespace TournamentAPI.UnitTests.Services; + +public class StandingsServiceTests +{ + private readonly StandingsService _sut = new(); + + [Fact] + public void ComputeStandings_ComputesPointsAndCountsCorrectly() + { + var matches = new List + { + new() { Player1Id = 1, Player2Id = 2, WinnerId = 1, Status = MatchStatus.Played }, + new() { Player1Id = 1, Player2Id = 3, WinnerId = null, Status = MatchStatus.Drawn }, + new() { Player1Id = 2, Player2Id = 3, WinnerId = null, Status = MatchStatus.Scheduled } + }; + + var standings = _sut.ComputeStandings(matches); + + var p1 = standings.Single(s => s.ParticipantId == 1); + var p2 = standings.Single(s => s.ParticipantId == 2); + var p3 = standings.Single(s => s.ParticipantId == 3); + + Assert.Equal(1, p1.Wins); + Assert.Equal(1, p1.Draws); + Assert.Equal(0, p1.Losses); + Assert.Equal(4, p1.Points); + Assert.Equal(2, p1.MatchesPlayed); + Assert.Equal(0, p1.MatchesRemaining); + + Assert.Equal(0, p2.Wins); + Assert.Equal(0, p2.Draws); + Assert.Equal(1, p2.Losses); + Assert.Equal(0, p2.Points); + Assert.Equal(1, p2.MatchesPlayed); + Assert.Equal(1, p2.MatchesRemaining); + + Assert.Equal(0, p3.Wins); + Assert.Equal(1, p3.Draws); + Assert.Equal(0, p3.Losses); + Assert.Equal(1, p3.Points); + Assert.Equal(1, p3.MatchesPlayed); + Assert.Equal(1, p3.MatchesRemaining); + + Assert.Equal(1, p1.Rank); + Assert.Equal(2, p3.Rank); + Assert.Equal(3, p2.Rank); + } + + [Fact] + public void ComputeStandings_WhenTwoParticipantsTieOnPoints_BreaksTieByHeadToHeadResult() + { + // P1 and P2 both finish on 3 points; P1 beat P2 directly, so P1 should rank above P2. + var matches = new List + { + new() { Player1Id = 1, Player2Id = 2, WinnerId = 1, Status = MatchStatus.Played }, + new() { Player1Id = 1, Player2Id = 4, WinnerId = 4, Status = MatchStatus.Played }, + new() { Player1Id = 2, Player2Id = 4, WinnerId = 2, Status = MatchStatus.Played }, + new() { Player1Id = 3, Player2Id = 4, WinnerId = 4, Status = MatchStatus.Played } + }; + + var standings = _sut.ComputeStandings(matches); + + var p1 = standings.Single(s => s.ParticipantId == 1); + var p2 = standings.Single(s => s.ParticipantId == 2); + var p3 = standings.Single(s => s.ParticipantId == 3); + var p4 = standings.Single(s => s.ParticipantId == 4); + + Assert.Equal(3, p1.Points); + Assert.Equal(3, p2.Points); + + Assert.Equal(1, p4.Rank); + Assert.Equal(2, p1.Rank); + Assert.Equal(3, p2.Rank); + Assert.Equal(4, p3.Rank); + } + + [Fact] + public void ComputeStandings_WhenHeadToHeadCannotResolveTie_ParticipantsShareRank() + { + // P1 and P2 both finish on 4 points and drew their head-to-head match, so neither outranks the other. + var matches = new List + { + new() { Player1Id = 1, Player2Id = 2, WinnerId = null, Status = MatchStatus.Drawn }, + new() { Player1Id = 1, Player2Id = 3, WinnerId = 1, Status = MatchStatus.Played }, + new() { Player1Id = 2, Player2Id = 3, WinnerId = 2, Status = MatchStatus.Played } + }; + + var standings = _sut.ComputeStandings(matches); + + var p1 = standings.Single(s => s.ParticipantId == 1); + var p2 = standings.Single(s => s.ParticipantId == 2); + var p3 = standings.Single(s => s.ParticipantId == 3); + + Assert.Equal(4, p1.Points); + Assert.Equal(4, p2.Points); + Assert.Equal(1, p1.Rank); + Assert.Equal(1, p2.Rank); + Assert.Equal(3, p3.Rank); + } + + [Fact] + public void ComputeStandings_ByeMatch_CountsAsAWinAndAsAMatchPlayed() + { + var matches = new List + { + new() { Player1Id = 1, Player2Id = null, WinnerId = 1, Status = MatchStatus.Played } + }; + + var standings = _sut.ComputeStandings(matches); + + var p1 = standings.Single(s => s.ParticipantId == 1); + + Assert.Equal(1, p1.Wins); + Assert.Equal(0, p1.Losses); + Assert.Equal(3, p1.Points); + Assert.Equal(1, p1.MatchesPlayed); + } +} From e9960c131eff824c23428126d581462d3d10063a Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:25:49 +0200 Subject: [PATCH 15/25] test: add unit tests for RoundRobinBracketStrategy Added RoundRobinBracketStrategyTests to verify bracket creation for even/odd participants, unique pairings, correct bye handling, and initial match status and references. --- .../RoundRobinBracketStrategyTests.cs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 TournamentAPI.UnitTests/Services/RoundRobinBracketStrategyTests.cs diff --git a/TournamentAPI.UnitTests/Services/RoundRobinBracketStrategyTests.cs b/TournamentAPI.UnitTests/Services/RoundRobinBracketStrategyTests.cs new file mode 100644 index 0000000..5e55832 --- /dev/null +++ b/TournamentAPI.UnitTests/Services/RoundRobinBracketStrategyTests.cs @@ -0,0 +1,132 @@ +using TournamentAPI.Brackets; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.UnitTests.Services; + +public class RoundRobinBracketStrategyTests +{ + private readonly RoundRobinBracketStrategy _sut = new(); + + [Fact] + public void CreateBracket_SetsTournamentId() + { + var bracket = _sut.CreateBracket(tournamentId: 42, participantIds: [1, 2]); + + Assert.Equal(42, bracket.TournamentId); + } + + [Fact] + public void CreateBracket_WithEvenParticipantCount_CreatesCorrectNumberOfMatches() + { + var participantIds = new List { 1, 2, 3, 4, 5, 6 }; + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: participantIds); + + Assert.Equal(participantIds.Count * (participantIds.Count - 1) / 2, bracket.Matches.Count); + } + + [Fact] + public void CreateBracket_WithEvenParticipantCount_EveryPairAppearsExactlyOnce() + { + var participantIds = new List { 1, 2, 3, 4, 5, 6 }; + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: participantIds); + + var pairs = bracket.Matches + .Select(m => (Math.Min(m.Player1Id, m.Player2Id!.Value), Math.Max(m.Player1Id, m.Player2Id!.Value))) + .ToList(); + + Assert.Equal(pairs.Count, pairs.Distinct().Count()); + + var expectedPairs = + from a in participantIds + from b in participantIds + where a < b + select (a, b); + + Assert.Equal(expectedPairs.OrderBy(p => p).ToList(), pairs.OrderBy(p => p).ToList()); + } + + [Fact] + public void CreateBracket_WithEvenParticipantCount_HasNoByes() + { + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4, 5, 6]); + + Assert.DoesNotContain(bracket.Matches, m => m.Player2Id == null); + } + + [Fact] + public void CreateBracket_WithOddParticipantCount_CreatesCorrectNumberOfMatches() + { + var participantIds = new List { 1, 2, 3, 4, 5 }; + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: participantIds); + + Assert.Equal(participantIds.Count * (participantIds.Count - 1) / 2, bracket.Matches.Count); + } + + [Fact] + public void CreateBracket_WithOddParticipantCount_EveryPairAppearsExactlyOnce() + { + var participantIds = new List { 1, 2, 3, 4, 5 }; + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: participantIds); + + var pairs = bracket.Matches + .Select(m => (Math.Min(m.Player1Id, m.Player2Id!.Value), Math.Max(m.Player1Id, m.Player2Id!.Value))) + .ToList(); + + Assert.Equal(pairs.Count, pairs.Distinct().Count()); + + var expectedPairs = + from a in participantIds + from b in participantIds + where a < b + select (a, b); + + Assert.Equal(expectedPairs.OrderBy(p => p).ToList(), pairs.OrderBy(p => p).ToList()); + } + + [Fact] + public void CreateBracket_WithOddParticipantCount_HasNoByeRows() + { + // Byes aren't persisted as Match rows: a participant simply has no match in their bye round. + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4, 5]); + + Assert.DoesNotContain(bracket.Matches, m => m.Player2Id == null); + } + + [Fact] + public void CreateBracket_WithOddParticipantCount_EveryParticipantIsMissingFromExactlyOneRound() + { + var participantIds = new List { 1, 2, 3, 4, 5 }; + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: participantIds); + + var rounds = bracket.Matches.Select(m => m.Round).Distinct().ToList(); + Assert.Equal(participantIds.Count, rounds.Count); + + foreach (var participantId in participantIds) + { + var roundsPlayed = bracket.Matches + .Count(m => m.Player1Id == participantId || m.Player2Id == participantId); + + Assert.Equal(participantIds.Count - 1, roundsPlayed); + } + } + + [Fact] + public void CreateBracket_ScheduledMatchesHaveNoWinner() + { + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4]); + + Assert.All(bracket.Matches, m => + { + Assert.Null(m.WinnerId); + Assert.Equal(MatchStatus.Scheduled, m.Status); + }); + } + + [Fact] + public void CreateBracket_SetsMatchBracketReference() + { + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2]); + + Assert.All(bracket.Matches, m => Assert.Same(bracket, m.Bracket)); + } +} From b812c4c803f8c47b6616e0f419144378f7fd6475 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:26:15 +0200 Subject: [PATCH 16/25] test: expand validation test coverage for tournaments Added unit tests to BracketMutationValidationsTests, MatchValidationsTests, and TournamentValidationsTests. Covered participant count validation, format support for round advancement and withdrawal, draw handling in matches, and authorization for participant withdrawal. Tests assert correct error codes and null results for valid and invalid cases across formats and match statuses. --- .../BracketMutationValidationsTests.cs | 34 ++++++++ .../Validations/MatchValidationsTests.cs | 77 +++++++++++++++++++ .../Validations/TournamentValidationsTests.cs | 63 +++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs b/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs index 7b36e2e..6e42b30 100644 --- a/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs @@ -222,4 +222,38 @@ public void ValidateNotFinalRound_WhenMultipleWinners_ReturnsNull() Assert.Null(error); } + + [Theory] + [InlineData(TournamentFormat.SingleElimination)] + [InlineData(TournamentFormat.RoundRobin)] + public void ValidateEnoughParticipants_WhenFewerThanTwo_ReturnsError_RegardlessOfFormat(TournamentFormat format) + { + var tournament = new Tournament { Id = 1, Format = format }; + + IError? error = BracketMutationValidations.ValidateEnoughParticipants(1, tournament.Id); + + Assert.NotNull(error); + Assert.Equal(BracketErrorCodes.NotEnoughParticipants, error.Code); + } + + [Fact] + public void ValidateFormatSupportsRoundAdvancement_WhenSingleElimination_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.SingleElimination }; + + IError? error = BracketMutationValidations.ValidateFormatSupportsRoundAdvancement(tournament); + + Assert.Null(error); + } + + [Fact] + public void ValidateFormatSupportsRoundAdvancement_WhenRoundRobin_ReturnsError() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.RoundRobin }; + + IError? error = BracketMutationValidations.ValidateFormatSupportsRoundAdvancement(tournament); + + Assert.NotNull(error); + Assert.Equal(BracketErrorCodes.RoundAdvancementNotSupportedForFormat, error.Code); + } } diff --git a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs index faaa8d3..3242f35 100644 --- a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs @@ -247,4 +247,81 @@ public void ValidateWinnerHasHigherScore_WhenPlayer2IsWinnerWithHigherScore_Retu Assert.Null(error); } + + [Fact] + public void ValidateMatchNotPlayed_WhenMatchIsDrawn_ReturnsError() + { + var match = new Match { Id = 1, WinnerId = null, Status = MatchStatus.Drawn }; + + IError? error = MatchValidations.ValidateMatchNotPlayed(match); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.MatchAlreadyPlayed, error.Code); + } + + [Fact] + public void ValidateMatchNotScheduled_WhenMatchIsDrawn_ReturnsNull() + { + var match = new Match { Id = 1, Status = MatchStatus.Drawn }; + + IError? error = MatchValidations.ValidateMatchNotScheduled(match); + + Assert.Null(error); + } + + [Fact] + public void ValidateDrawAllowedForFormat_WhenRoundRobinAndWinnerIsNull_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.RoundRobin }; + + IError? error = MatchValidations.ValidateDrawAllowedForFormat(tournament, matchId: 1, winnerId: null); + + Assert.Null(error); + } + + [Fact] + public void ValidateDrawAllowedForFormat_WhenSingleEliminationAndWinnerIsNull_ReturnsError() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.SingleElimination }; + + IError? error = MatchValidations.ValidateDrawAllowedForFormat(tournament, matchId: 1, winnerId: null); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.DrawNotAllowedForFormat, error.Code); + } + + [Fact] + public void ValidateDrawAllowedForFormat_WhenWinnerIsSet_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.SingleElimination }; + + IError? error = MatchValidations.ValidateDrawAllowedForFormat(tournament, matchId: 1, winnerId: 5); + + Assert.Null(error); + } + + [Fact] + public void ValidateDrawHasEqualScores_WhenWinnerIsNullAndScoresAreEqual_ReturnsNull() + { + IError? error = MatchValidations.ValidateDrawHasEqualScores(matchId: 1, winnerId: null, player1Score: 1, player2Score: 1); + + Assert.Null(error); + } + + [Fact] + public void ValidateDrawHasEqualScores_WhenWinnerIsNullAndScoresAreUnequal_ReturnsError() + { + IError? error = MatchValidations.ValidateDrawHasEqualScores(matchId: 1, winnerId: null, player1Score: 2, player2Score: 1); + + Assert.NotNull(error); + Assert.Equal(MatchErrorCodes.DrawRequiresEqualScores, error.Code); + } + + [Fact] + public void ValidateDrawHasEqualScores_WhenWinnerIsSetAndScoresAreUnequal_ReturnsNull() + { + IError? error = MatchValidations.ValidateDrawHasEqualScores(matchId: 1, winnerId: 2, player1Score: 2, player2Score: 1); + + Assert.Null(error); + } } diff --git a/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs b/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs index 7e657f6..1f7669e 100644 --- a/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs @@ -364,4 +364,67 @@ public void ValidateTournamentIsNotCompleted_WhenNotCompleted_ReturnsNull(Tourna Assert.Null(error); } + + [Fact] + public void ValidateCanWithdrawParticipant_WhenCallerIsOwner_ReturnsNull() + { + IError? error = TournamentValidations.ValidateCanWithdrawParticipant(ownerId: 1, callerId: 1, participantId: 2, tournamentId: 10); + + Assert.Null(error); + } + + [Fact] + public void ValidateCanWithdrawParticipant_WhenCallerIsTheParticipantBeingWithdrawn_ReturnsNull() + { + IError? error = TournamentValidations.ValidateCanWithdrawParticipant(ownerId: 1, callerId: 2, participantId: 2, tournamentId: 10); + + Assert.Null(error); + } + + [Fact] + public void ValidateCanWithdrawParticipant_WhenCallerIsUnrelatedThirdUser_ReturnsError() + { + IError? error = TournamentValidations.ValidateCanWithdrawParticipant(ownerId: 1, callerId: 3, participantId: 2, tournamentId: 10); + + Assert.NotNull(error); + Assert.Equal(TournamentErrorCodes.NotAuthorizedForWithdrawal, error.Code); + } + + [Fact] + public void ValidateFormatSupportsWithdrawal_WhenRoundRobin_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.RoundRobin }; + + IError? error = TournamentValidations.ValidateFormatSupportsWithdrawal(tournament); + + Assert.Null(error); + } + + [Fact] + public void ValidateFormatSupportsWithdrawal_WhenSingleElimination_ReturnsError() + { + var tournament = new Tournament { Id = 1, Format = TournamentFormat.SingleElimination }; + + IError? error = TournamentValidations.ValidateFormatSupportsWithdrawal(tournament); + + Assert.NotNull(error); + Assert.Equal(TournamentErrorCodes.WithdrawalNotSupportedForFormat, error.Code); + } + + [Fact] + public void ValidateBracketGeneratedForWithdrawal_WhenBracketExists_ReturnsNull() + { + IError? error = TournamentValidations.ValidateBracketGeneratedForWithdrawal(bracketExists: true, tournamentId: 1); + + Assert.Null(error); + } + + [Fact] + public void ValidateBracketGeneratedForWithdrawal_WhenBracketDoesNotExist_ReturnsError() + { + IError? error = TournamentValidations.ValidateBracketGeneratedForWithdrawal(bracketExists: false, tournamentId: 1); + + Assert.NotNull(error); + Assert.Equal(TournamentErrorCodes.WithdrawalRequiresBracket, error.Code); + } } From 6b8b0c0f7c6d208e70f6721429cad9e23d6adc8f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:28:25 +0200 Subject: [PATCH 17/25] feat: add withdrawal error codes and validations for tournament withdrawal, unsupported format, bracket requirements and participant not found --- .../Tournaments/TournamentErrorCodes.cs | 4 +++ TournamentAPI/Tournaments/TournamentErrors.cs | 31 +++++++++++++++++++ .../Tournaments/TournamentValidations.cs | 9 ++++++ 3 files changed, 44 insertions(+) diff --git a/TournamentAPI/Tournaments/TournamentErrorCodes.cs b/TournamentAPI/Tournaments/TournamentErrorCodes.cs index 68e5498..610e7ae 100644 --- a/TournamentAPI/Tournaments/TournamentErrorCodes.cs +++ b/TournamentAPI/Tournaments/TournamentErrorCodes.cs @@ -16,4 +16,8 @@ public static class TournamentErrorCodes public const string CannotDeleteTournamentWithBracket = "Tournament.CannotDeleteWithBracket"; public const string StatusCannotBeSetManually = "Tournament.StatusCannotBeSetManually"; public const string CannotChangeCompletedStatus = "Tournament.CannotChangeCompletedStatus"; + public const string NotAuthorizedForWithdrawal = "Tournament.NotAuthorizedForWithdrawal"; + public const string WithdrawalNotSupportedForFormat = "Tournament.WithdrawalNotSupportedForFormat"; + public const string WithdrawalRequiresBracket = "Tournament.WithdrawalRequiresBracket"; + public const string ParticipantNotFound = "Tournament.ParticipantNotFound"; } diff --git a/TournamentAPI/Tournaments/TournamentErrors.cs b/TournamentAPI/Tournaments/TournamentErrors.cs index 6bd7c25..e27a628 100644 --- a/TournamentAPI/Tournaments/TournamentErrors.cs +++ b/TournamentAPI/Tournaments/TournamentErrors.cs @@ -102,4 +102,35 @@ public static IError CannotChangeCompletedTournamentStatus(int tournamentId) => .SetCode(TournamentErrorCodes.CannotChangeCompletedStatus) .SetExtension("TournamentId", tournamentId) .Build(); + + public static IError NotAuthorizedForWithdrawal(int userId, int tournamentId, int participantId) => + ErrorBuilder.New() + .SetMessage("Only the tournament owner or the participant being withdrawn may perform this action.") + .SetCode(TournamentErrorCodes.NotAuthorizedForWithdrawal) + .SetExtension("UserId", userId) + .SetExtension("TournamentId", tournamentId) + .SetExtension("ParticipantId", participantId) + .Build(); + + public static IError WithdrawalNotSupportedForFormat(int tournamentId) => + ErrorBuilder.New() + .SetMessage("Participant withdrawal is only supported for Round Robin tournaments.") + .SetCode(TournamentErrorCodes.WithdrawalNotSupportedForFormat) + .SetExtension("TournamentId", tournamentId) + .Build(); + + public static IError WithdrawalRequiresBracket(int tournamentId) => + ErrorBuilder.New() + .SetMessage("Participant withdrawal requires the tournament's bracket to already be generated.") + .SetCode(TournamentErrorCodes.WithdrawalRequiresBracket) + .SetExtension("TournamentId", tournamentId) + .Build(); + + public static IError ParticipantNotFound(int tournamentId, int participantId) => + ErrorBuilder.New() + .SetMessage("Participant was not found in this tournament.") + .SetCode(TournamentErrorCodes.ParticipantNotFound) + .SetExtension("TournamentId", tournamentId) + .SetExtension("ParticipantId", participantId) + .Build(); } diff --git a/TournamentAPI/Tournaments/TournamentValidations.cs b/TournamentAPI/Tournaments/TournamentValidations.cs index 9076095..b791bad 100644 --- a/TournamentAPI/Tournaments/TournamentValidations.cs +++ b/TournamentAPI/Tournaments/TournamentValidations.cs @@ -57,4 +57,13 @@ public static class TournamentValidations return null; } + + public static IError? ValidateCanWithdrawParticipant(int ownerId, int callerId, int participantId, int tournamentId) + => callerId == ownerId || callerId == participantId ? null : TournamentErrors.NotAuthorizedForWithdrawal(callerId, tournamentId, participantId); + + public static IError? ValidateFormatSupportsWithdrawal(Tournament tournament) + => tournament.Format != TournamentFormat.RoundRobin ? TournamentErrors.WithdrawalNotSupportedForFormat(tournament.Id) : null; + + public static IError? ValidateBracketGeneratedForWithdrawal(bool bracketExists, int tournamentId) + => bracketExists ? null : TournamentErrors.WithdrawalRequiresBracket(tournamentId); } From fcc2e88c7567a160b246d6fc1fbaeb937d2f32ad Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:30:20 +0200 Subject: [PATCH 18/25] feat: add WithdrawParticipant mutation to TournamentMutations Added WithdrawParticipant mutation enabling authorized users to withdraw a participant from a tournament. Includes validation checks, updates scheduled matches, removes the participant, syncs bracket completion, and persists changes. Introduced WithdrawParticipantInput record for input parameters. --- .../Tournaments/TournamentMutations.cs | 55 +++++++++++++++++++ .../Tournaments/WithdrawParticipantInput.cs | 3 + 2 files changed, 58 insertions(+) create mode 100644 TournamentAPI/Tournaments/WithdrawParticipantInput.cs diff --git a/TournamentAPI/Tournaments/TournamentMutations.cs b/TournamentAPI/Tournaments/TournamentMutations.cs index a6826d3..c822bdb 100644 --- a/TournamentAPI/Tournaments/TournamentMutations.cs +++ b/TournamentAPI/Tournaments/TournamentMutations.cs @@ -262,4 +262,59 @@ public static partial class TournamentMutations return true; } + + [Authorize] + public static async Task WithdrawParticipant( + WithdrawParticipantInput input, + ClaimsPrincipal userClaims, + ApplicationDbContext context, + IResolverContext resolverContext, + IEnumerable completionStrategies, + CancellationToken token) + { + using var _ = resolverContext.PushEntityContext("Tournament", input.TournamentId); + + var userId = userClaims.GetUserId(); + + var tournament = await context.Tournaments + .Include(t => t.Bracket) + .ThenInclude(b => b!.Matches) + .Include(t => t.Participants) + .FirstOrDefaultAsync(t => t.Id == input.TournamentId, token); + + if (resolverContext.TryReportError(TournamentValidations.ValidateTournamentExists(tournament, input.TournamentId))) + return null; + + if (resolverContext.TryReportError(TournamentValidations.ValidateCanWithdrawParticipant(tournament!.OwnerId, userId, input.ParticipantId, input.TournamentId))) + return null; + + if (resolverContext.TryReportError(TournamentValidations.ValidateFormatSupportsWithdrawal(tournament))) + return null; + + if (resolverContext.TryReportError(TournamentValidations.ValidateBracketGeneratedForWithdrawal(tournament.Bracket != null, input.TournamentId))) + return null; + + var participant = tournament.Participants.FirstOrDefault(p => p.ParticipantId == input.ParticipantId); + + if (resolverContext.TryReportError(participant is null ? TournamentErrors.ParticipantNotFound(input.TournamentId, input.ParticipantId) : null)) + return null; + + var scheduledMatches = tournament.Bracket!.Matches + .Where(m => m.Status == MatchStatus.Scheduled && (m.Player1Id == input.ParticipantId || m.Player2Id == input.ParticipantId)); + + foreach (var match in scheduledMatches) + { + match.WinnerId = match.Player1Id == input.ParticipantId ? match.Player2Id!.Value : match.Player1Id; + match.Status = MatchStatus.Played; + } + + context.TournamentParticipants.Remove(participant!); + + var completionStrategy = completionStrategies.Single(s => s.Format == tournament.Format); + await completionStrategy.SyncCompletionAsync(context, tournament, tournament.Bracket!.Id, frontierRound: 0, token); + + await context.SaveChangesAsync(token); + + return true; + } } diff --git a/TournamentAPI/Tournaments/WithdrawParticipantInput.cs b/TournamentAPI/Tournaments/WithdrawParticipantInput.cs new file mode 100644 index 0000000..99b70f6 --- /dev/null +++ b/TournamentAPI/Tournaments/WithdrawParticipantInput.cs @@ -0,0 +1,3 @@ +namespace TournamentAPI.Tournaments; + +public record WithdrawParticipantInput(int TournamentId, int ParticipantId); From 5df875d9b7396e630500a44fe29d4984bed0b249 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:38:34 +0200 Subject: [PATCH 19/25] feat: set Tournament.Format with default in TournamentMutations Ensures Tournament.Format is assigned on creation, defaulting to SingleElimination if not provided --- TournamentAPI/Tournaments/TournamentMutations.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Tournaments/TournamentMutations.cs b/TournamentAPI/Tournaments/TournamentMutations.cs index c822bdb..bed8dd2 100644 --- a/TournamentAPI/Tournaments/TournamentMutations.cs +++ b/TournamentAPI/Tournaments/TournamentMutations.cs @@ -3,6 +3,7 @@ using HotChocolate.Resolvers; using Microsoft.EntityFrameworkCore; using System.Security.Claims; +using TournamentAPI.Brackets; using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; @@ -116,7 +117,8 @@ public static partial class TournamentMutations StartDate = input.StartDate, Status = input.Status, OwnerId = userId, - MaxParticipants = input.MaxParticipants + MaxParticipants = input.MaxParticipants, + Format = input.Format ?? TournamentFormat.SingleElimination }; context.Tournaments.Add(tournament); From 08a881f608d4d06357812971100ecf79e7c503b7 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:43:26 +0200 Subject: [PATCH 20/25] docs: add spec for Round Robin format with extensible bracket strategy and ADR --- docs/adr/0007_round_robin_format_strategy.md | 53 ++++ .../checklists/requirements.md | 36 +++ .../contracts/graphql-schema-changes.graphql | 101 ++++++++ .../data-model.md | 92 +++++++ specs/001-round-robin-bracket-format/plan.md | 118 +++++++++ .../quickstart.md | 56 +++++ .../research.md | 96 ++++++++ specs/001-round-robin-bracket-format/spec.md | 116 +++++++++ specs/001-round-robin-bracket-format/tasks.md | 231 ++++++++++++++++++ 9 files changed, 899 insertions(+) create mode 100644 docs/adr/0007_round_robin_format_strategy.md create mode 100644 specs/001-round-robin-bracket-format/checklists/requirements.md create mode 100644 specs/001-round-robin-bracket-format/contracts/graphql-schema-changes.graphql create mode 100644 specs/001-round-robin-bracket-format/data-model.md create mode 100644 specs/001-round-robin-bracket-format/plan.md create mode 100644 specs/001-round-robin-bracket-format/quickstart.md create mode 100644 specs/001-round-robin-bracket-format/research.md create mode 100644 specs/001-round-robin-bracket-format/spec.md create mode 100644 specs/001-round-robin-bracket-format/tasks.md diff --git a/docs/adr/0007_round_robin_format_strategy.md b/docs/adr/0007_round_robin_format_strategy.md new file mode 100644 index 0000000..60351d4 --- /dev/null +++ b/docs/adr/0007_round_robin_format_strategy.md @@ -0,0 +1,53 @@ +# Title +Bracket Format Extensibility: Generation/Completion Strategy Interfaces + +# Date +27/09/2026 + +## Status +Accepted + +## Context +`Tournament` supported exactly one bracket format, Single Elimination, and the code reflected that: +`BracketMutations.GenerateBracket` called `BracketService.CreateBracket` directly, and `MatchMutations` +called `BracketCompletionService.SyncChampionAsync` directly, with no notion of "format" anywhere in the +model. Adding Round Robin as a second format means both call sites now need to pick behavior based on +the tournament's format, and the feature's own requirement (FR-014) says a third format, whenever it +shows up, must not require re-editing the code that already works for Single Elimination or Round Robin. +That rules out solving this one branch at a time. + +## Considered Options +1. Inline `switch (tournament.Format)` in `BracketMutations.GenerateBracket` and `MatchMutations.Play`/`CorrectMatchResult` + - Pros: no new types; smallest possible diff for exactly two formats. + - Cons: every future format adds another `case` to these same mutation files, which is precisely + what FR-014 rules out; the switch would need to be duplicated (or centrally shared and threaded + through) anywhere else format-specific bracket behavior is needed, growing the same coupling over + time. +2. A generic rules-engine/plugin-loader for tournament formats + - Pros: maximally extensible; formats could in principle be registered without a recompile. + - Cons: no third format is planned or requested; this is speculative infrastructure for a + requirement (FR-014) that only asks for isolation between formats, not runtime pluggability, + violating Constitution Principle III (minimal abstraction). +3. `IBracketGenerationStrategy` / `IBracketCompletionStrategy`, one implementation per format, resolved + via `IEnumerable` + a `Format` property filtered by `tournament.Format` (chosen) + - Pros: the minimum indirection that actually satisfies FR-014, since a new format adds a new class + and one DI registration, never touching `BracketMutations`, `MatchMutations`, or either existing + format's implementation; `BracketService`/`BracketCompletionService` keep their exact existing + logic, just behind an interface, so Single Elimination's behavior is provably unchanged; each + interface stays scoped to exactly the one seam each format actually needs (initial schedule + generation; completion/champion detection), not a speculative do-everything format abstraction. + - Cons: a new interface + DI-based dispatch where a single concrete service existed before is a real + increase in indirection for a codebase that otherwise favors calling concrete services directly; + two small interfaces to maintain instead of zero. + +## Decision +Add `Tournament.Format` (`TournamentFormat` enum: `SingleElimination` = 0, default; `RoundRobin` = 1). +Introduce `IBracketGenerationStrategy` (generates a tournament's initial set of matches) and +`IBracketCompletionStrategy` (decides when a bracket is complete and who the champion is) in the +`Brackets` feature folder. `BracketService` and `BracketCompletionService` become the +`SingleElimination` implementations of these interfaces with their existing logic moved, not rewritten; +`RoundRobinBracketStrategy` and `RoundRobinCompletionStrategy` are the new `RoundRobin` implementations. +`BracketMutations.GenerateBracket` and `MatchMutations.Play`/`CorrectMatchResult` each resolve +`IEnumerable.Single(s => s.Format == tournament.Format)` instead of depending on a concrete +service, so neither mutation file needs to change again when a third format is added; only a new +strategy class and its DI registration would. diff --git a/specs/001-round-robin-bracket-format/checklists/requirements.md b/specs/001-round-robin-bracket-format/checklists/requirements.md new file mode 100644 index 0000000..5cde483 --- /dev/null +++ b/specs/001-round-robin-bracket-format/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Round Robin Tournament Format + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- 2026-09-27 clarification session resolved five open questions (round count, draws, standings tie-break, participant withdrawal handling, and the points system) — see spec.md's Clarifications section. Notably, the initial draft's default assumption of "no draws" was overturned: draws are allowed, which changed FR-005 through FR-010 and the Key Entities and Assumptions sections accordingly. +- 2026-09-27 `/speckit-analyze` pass merged the near-duplicate FR-003/FR-011 into a single FR-003 (renumbering FR-012–FR-015 down to FR-011–FR-014 across all artifacts) and reworded the three still-open Edge Cases bullets into resolved statements citing the FR that answers each, matching the withdrawal bullet's existing style. All checklist items still pass against the revised spec. +- All checklist items pass; no further spec updates required before proceeding to `/speckit-plan`/`/speckit-implement`. diff --git a/specs/001-round-robin-bracket-format/contracts/graphql-schema-changes.graphql b/specs/001-round-robin-bracket-format/contracts/graphql-schema-changes.graphql new file mode 100644 index 0000000..795a9f7 --- /dev/null +++ b/specs/001-round-robin-bracket-format/contracts/graphql-schema-changes.graphql @@ -0,0 +1,101 @@ +# GraphQL schema delta for Round Robin Tournament Format +# +# This project is implementation-first (HotChocolate derives schema.graphql from C#), +# so this file is not fed into a generator — it documents the contract this feature +# must produce, for review against the regenerated schema.graphql during implementation. +# Everything not listed here is unchanged. + +# --- New enum --- + +enum TournamentFormat { + SINGLE_ELIMINATION + ROUND_ROBIN +} + +# --- Changed enum: Match gains a dedicated DRAWN status --- + +enum MatchStatus { + SCHEDULED + PLAYED + NEEDS_REPLAY + DRAWN # NEW — appended last (not inserted after PLAYED) since MatchStatus has no HasConversion and is stored as a plain int; Round Robin only, a played match with no winner (research.md §2) +} + +# --- Changed: Tournament gains a Format field --- + +type Tournament { + format: TournamentFormat! + # ...all existing fields unchanged +} + +# --- Changed: format is choosable only at creation, defaults to SINGLE_ELIMINATION --- + +input CreateTournamentInput { + name: String! + startDate: DateTime! + status: TournamentStatus! + maxParticipants: Int! + format: TournamentFormat # optional; defaults to SINGLE_ELIMINATION when omitted (FR-002) +} + +# UpdateTournamentInput is UNCHANGED — format is deliberately not editable after creation (FR-012, research.md §3) + +# --- Changed: Play now allows a draw (Round Robin only) --- + +input PlayInput { + matchId: Int! + winnerId: Int # was Int! — null means a draw, which sets Match.status to DRAWN instead of PLAYED; rejected for Single Elimination matches (FR-005/FR-006) + player1Score: Int! + player2Score: Int! +} + +# CorrectMatchResultInput is UNCHANGED in shape; the same nullable-winnerId + format-aware +# validation rules from Play apply when correcting a Round Robin match's result, including +# setting/clearing DRAWN status as appropriate. + +# --- New mutation: withdraw a participant from an in-progress Round Robin tournament --- + +input WithdrawParticipantInput { + tournamentId: Int! + participantId: Int! +} + +type WithdrawParticipantPayload { + boolean: Boolean +} + +# Errors are reported the same way every other mutation in this API reports them — via the +# top-level GraphQL `errors` array (resolverContext.ReportError), NOT a typed field on the +# payload. No existing payload (PlayPayload, JoinTournamentPayload, AddParticipantPayload, ...) +# has an `errors` field, so this one doesn't either (fixed per /speckit-analyze finding F2). +# +# Callable by the tournament's owner OR the participant being withdrawn themselves — FR-010 +# covers both "a participant withdraws" (self-service) and "or is removed" (organizer-initiated) +# (research.md §6 Authorization). Rejected for anyone else, if the tournament's format is not +# ROUND_ROBIN, or if its bracket has not been generated yet — withdrawal before a schedule +# exists is unaffected; organizers/participants use the existing tournament participant +# management for that case. + +# --- New query: Round Robin standings --- + +type StandingEntry { + participantId: Int! + wins: Int! + draws: Int! + losses: Int! + points: Int! + rank: Int! + matchesPlayed: Int! + matchesRemaining: Int! +} + +type Bracket { + standings: [StandingEntry!] # null/empty for Single Elimination brackets; populated for Round Robin + # ...all existing fields unchanged +} + +# --- Changed: UpdateRound rejects Round Robin brackets --- + +# updateRound(input: UpdateRoundInput!): UpdateRoundPayload! — unchanged signature, but now +# returns a new "format not supported" error when the target bracket's tournament is +# ROUND_ROBIN, since a Round Robin schedule has no next-round-from-winners concept. diff --git a/specs/001-round-robin-bracket-format/data-model.md b/specs/001-round-robin-bracket-format/data-model.md new file mode 100644 index 0000000..961289c --- /dev/null +++ b/specs/001-round-robin-bracket-format/data-model.md @@ -0,0 +1,92 @@ +# Phase 1 Data Model: Round Robin Tournament Format + +## New: `TournamentFormat` (enum) + +`TournamentAPI/Data/Models/TournamentFormat.cs` + +| Value | Meaning | +|---|---| +| `SingleElimination` (0, default) | Existing bracket-and-advance format. | +| `RoundRobin` (1) | Every participant plays every other participant exactly once. | + +Stored as its underlying `int` on `Tournament`, matching how `TournamentStatus`/`MatchStatus` are already stored (no `HasConversion` in `ApplicationDbContext` today). + +## Changed: `Tournament` + +| Field | Change | +|---|---| +| `Format` | **New**, `TournamentFormat`, required, defaults to `SingleElimination`. Settable only at creation (`CreateTournamentInput.Format`, optional, defaults to `SingleElimination`); **not** present on `UpdateTournamentInput` — see research.md §3. | + +No other fields change. `Bracket`/`ChampionId`/`Status` are reused unchanged for Round Robin (see below). + +## Unchanged (reused as-is): `Bracket` + +Still `{ Id, TournamentId, Matches }`. Acts as the generic "this tournament's set of matches" container regardless of format — no format-specific fields needed since format-specific behavior lives in the strategy services (research.md §1), not the entity. + +## Changed: `MatchStatus` (enum) + +`TournamentAPI/Data/Models/MatchStatus.cs` + +| Value | Ordinal | Meaning | +|---|---|---| +| `Scheduled` (existing) | 0 | Not yet played. | +| `Played` (existing) | 1 | Decisive result — a win for one side, or a bye. `WinnerId` is always set. | +| `NeedsReplay` (existing) | 2 | Correction cascade flow, unchanged. | +| `Drawn` (**new**) | 3 | Round Robin only: match played with no winner. `WinnerId` is always null. | + +`Drawn` is declared **after** `NeedsReplay` (appended, not inserted between `Played` and `NeedsReplay`) — `MatchStatus` has no `HasConversion` in `ApplicationDbContext`, so it's stored as a plain `int`, and inserting a value in the middle would shift `NeedsReplay`'s stored integer (research.md §2). + +## Changed: `Match` + +Still `{ Id, Round, BracketId, Player1Id, Player2Id?, WinnerId?, Player1Score, Player2Score, Status, RowVersion }` — no new columns, just the new `Status` value above. Valid states, format-aware: + +| Status | WinnerId | Player2Id | Meaning | +|---|---|---|---| +| `Scheduled` | null | set | Not yet played (either format). | +| `Played` | set | null | Bye (either format). | +| `Played` | set | set | Decisive result (either format). | +| `Drawn` | **null** | set | **Draw — Round Robin only.** Single Elimination must never reach this state (FR-006). | +| `NeedsReplay` | (prior value) | set | Correction flow, unchanged. | + +`Status == Drawn` is treated as "already played" everywhere `Status == Played` currently is for that purpose — see research.md §2's downstream impact list (`ValidateMatchNotPlayed`, `ValidateMatchNotScheduled`, Round Robin completion). + +`Round` for Round Robin matches is the circle-method round number (research.md §4), not an elimination stage — same column, different meaning by format, no schema impact. + +A **forfeit** (participant withdrawal, FR-010) is stored exactly like an ordinary decisive result: `Status = Played`, `WinnerId` = the remaining opponent, scores left at their default (`0`/`0`). Nothing distinguishes it from a normally-played match in storage (research.md §6). + +## Changed: `TournamentParticipant` + +No new fields. A withdrawal is an ordinary soft delete: `IsDeleted = true` (existing `ISoftDeletable` pattern, existing global query filter already excludes it from `tournament.Participants`). + +## New (computed, not persisted): `StandingEntry` + +A plain GraphQL output type, not an EF entity — projected on demand from a bracket's `Match` rows. Exposed via the new `Standings` feature folder. + +| Field | Type | Notes | +|---|---|---| +| `ParticipantId` | `int` | | +| `Wins` | `int` | Includes forfeit wins. | +| `Draws` | `int` | | +| `Losses` | `int` | Includes forfeit losses. | +| `Points` | `int` | `Wins * 3 + Draws * 1` (FR-007). | +| `Rank` | `int` | 1-based; tied participants share a rank (FR-008) — e.g. two participants tied for 1st both get `Rank = 1`, next participant gets `Rank = 3`. | +| `MatchesPlayed` | `int` | Matches with `Status == Played` or `Status == Drawn` involving this participant. | +| `MatchesRemaining` | `int` | Matches with `Status == Scheduled` involving this participant. | + +### Ranking algorithm (FR-007/FR-008) + +1. Group by `Points` descending. +2. Within a points group of size > 1, order by head-to-head result: sum of points earned only from matches played among members of that same group. +3. If still tied after head-to-head, the tied participants share the same `Rank` (no further tie-break — matches the clarified answer). + +### Completion (FR-009) + +A Round Robin bracket is complete when every `Match` in it has `Status == Played` or `Status == Drawn` (i.e. no `Match` remains `Scheduled`). Whenever the completion strategy runs and this condition holds — including when it runs again because `CorrectMatchResult` changed an already-completed tournament's data — it (re)sets `Tournament.Status = Completed` and (re)computes `Tournament.ChampionId` from the *current* standings: the participant with `Rank == 1`, or `null` if more than one participant shares `Rank == 1` (mirrors Single Elimination's existing "no champion" fallback state rather than inventing a new one). The champion is never a one-time computation left stale after a later correction — it is recomputed from scratch every time completion is evaluated and the bracket is (still) complete. + +## Relationships (unchanged) + +`Tournament (1) —(0..1)→ Bracket (1) —(0..N)→ Match`, `Tournament (1) —(0..N)→ TournamentParticipant`. No new foreign keys. + +## Migration + +This project has no EF Core migrations at all — `Program.cs` and `TournamentAPI.IntegrationTests/BaseIntegrationTest.cs` both call `context.Database.EnsureCreatedAsync()`, which derives the schema directly from the current model on first creation. Adding `Tournament.Format` and `MatchStatus.Drawn` therefore requires no migration file — the next `EnsureCreatedAsync()` against a fresh database picks up both changes automatically. A developer with an existing local database from before this feature needs to drop it (or let their test/dev setup recreate it) to pick up the new `Format` column, since `EnsureCreatedAsync()` does not alter an already-created schema. diff --git a/specs/001-round-robin-bracket-format/plan.md b/specs/001-round-robin-bracket-format/plan.md new file mode 100644 index 0000000..89aca10 --- /dev/null +++ b/specs/001-round-robin-bracket-format/plan.md @@ -0,0 +1,118 @@ +# Implementation Plan: Round Robin Tournament Format + +**Branch**: `001-round-robin-bracket-format` | **Date**: 2026-09-27 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/001-round-robin-bracket-format/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. + +## Summary + +TournamentAPI supports only Single Elimination today. This feature adds a `Format` choice on `Tournament` (`SingleElimination` default, `RoundRobin`), lays a small strategy abstraction under the existing `Brackets` feature so a future format doesn't require touching Single Elimination or Round Robin code, and implements Round Robin: full round-robin scheduling (circle method, one bye per odd-count schedule), draws (a new `Drawn` `MatchStatus` value), a computed standings/points system (3/1/0, head-to-head tie-break, shared rank), and forfeit handling for mid-tournament withdrawal. Single Elimination's existing behavior is unchanged (verified by regression, not rewrite). + +## Technical Context + +**Language/Version**: .NET 9 / C# 13 (existing stack, no change) + +**Primary Dependencies**: HotChocolate v16 (GraphQL, implementation-first, type-extension), EF Core 9 + SQL Server, ASP.NET Core Identity + JWT bearer auth (existing stack — no new dependency needed for this feature) + +**Storage**: SQL Server via EF Core 9. No migrations exist in this repo — schema comes from `Database.EnsureCreatedAsync()` (`Program.cs`, `BaseIntegrationTest.cs`), so both model changes (`Tournament.Format`, `MatchStatus.Drawn`) take effect on the next fresh database creation with no migration file needed (see data-model.md) + +**Testing**: xUnit for `TournamentAPI.UnitTests` (schedule generator, standings ranking, format-aware validations), xUnit + TestContainers for `TournamentAPI.IntegrationTests` (GraphQL end-to-end, mirroring the existing `Brackets`/`Matches` test folders) + +**Target Platform**: ASP.NET Core web API (existing modular monolith deployable) + +**Project Type**: web-service (single backend, no frontend/mobile project in this repo) + +**Performance Goals**: None beyond what's already enforced. Round Robin's match count is `O(n²)` in participant count; reuses the existing `Tournament.MaxParticipants` bound rather than introducing a new one (research.md §9) + +**Constraints**: Format is immutable after tournament creation (research.md §3); Single Elimination must show zero behavioral change (FR-013/SC-003) + +**Scale/Scope**: Same organizer/participant scale as today's Single Elimination tournaments — no new scale target introduced by this feature + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Check | Status | +|---|---|---| +| I. GraphQL-First, Type-Extension | New `Standings` capability is its own `[ExtendObjectType]` feature folder; `Tournament`/`Bracket`/`Match` remain entities-as-types with `[GraphQLIgnore]` for non-schema fields. No new DTO/mapping layer. | PASS | +| II. Validation Without Exceptions | All new rules (format-aware draw validation, withdrawal eligibility, `updateRound` format rejection) are static functions returning `IError?`, composed via `TryReportError`, matching `TournamentValidations`/`MatchValidations`/`BracketMutationValidations` today. | PASS | +| III. Minimal Abstraction | The one new abstraction (`IBracketGenerationStrategy` / `IBracketCompletionStrategy`) is justified by an explicit requirement (FR-014) and two concrete implementations, not speculative. No repository layer, no AutoMapper — standings are a hand-written projection. | PASS — see Complexity Tracking (ADR required, not a violation) | +| IV. Optimistic Concurrency & Soft Delete | Withdrawal reuses `TournamentParticipant.IsDeleted` (no new delete path); `Match.RowVersion` concurrency handling in `Play`/`CorrectMatchResult` is unchanged. | PASS | +| V. Explicit Types & Dependencies | `winnerId` becomes nullable `int?` end-to-end (real nullability signal, not suppressed); a draw is a distinct `MatchStatus.Drawn` value rather than an overloaded null check; new files write explicit `using` directives per project convention. | PASS | +| VI. Documented Trade-offs | Format-strategy abstraction is a genuine trade-off → ADR required (tracked in Complexity Tracking below and `quickstart.md`'s checklist). | ACTION REQUIRED (ADR, not a gate failure) | + +No gate failures. One documented follow-up obligation (ADR) tracked below. + +**Post-Phase-1 re-check**: table above already reflects the full Phase 1 design (`Standings` feature folder, strategy interfaces, withdrawal mutation) produced in data-model.md/contracts/quickstart.md — no new principle concerns surfaced during design; re-check holds. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-round-robin-bracket-format/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + +```text +TournamentAPI/ +├── Data/Models/ +│ ├── Tournament.cs # + Format field +│ ├── TournamentFormat.cs # NEW enum +│ └── MatchStatus.cs # + Drawn value +├── Brackets/ +│ ├── BracketService.cs # → becomes SingleElimination strategy impl +│ ├── BracketCompletionService.cs # → becomes SingleElimination completion impl +│ ├── IBracketGenerationStrategy.cs # NEW +│ ├── IBracketCompletionStrategy.cs # NEW +│ ├── RoundRobinBracketStrategy.cs # NEW — circle-method schedule generation +│ ├── RoundRobinCompletionStrategy.cs # NEW — all-matches-played + standings-based champion +│ ├── BracketMutations.cs # generateBracket dispatches by Format; updateRound rejects RoundRobin +│ └── BracketMutationValidations.cs # + format-aware validation +├── Matches/ +│ ├── MatchMutations.cs # Play/CorrectMatchResult: nullable winnerId; skip correction cascade for Round Robin (research.md §10) +│ ├── MatchValidations.cs # + draw validation (format-aware) +│ └── MatchCorrectionService.cs # cascade propagation gated to Single Elimination only +├── Standings/ # NEW feature folder +│ ├── StandingEntry.cs # computed output type +│ ├── StandingsService.cs # ranking algorithm (points, head-to-head, shared rank) +│ ├── StandingsDataLoaders.cs # all-matches-by-bracket loader (non-paginated) +│ └── StandingsResolvers.cs # Bracket.standings field +├── Tournaments/ +│ ├── CreateTournamentInput.cs # + optional Format +│ ├── TournamentMutations.cs # NEW WithdrawParticipant mutation +│ └── TournamentValidations.cs # + withdrawal eligibility checks + +TournamentAPI.UnitTests/ +├── Services/ +│ ├── RoundRobinBracketStrategyTests.cs # NEW +│ └── StandingsServiceTests.cs # NEW +└── Validations/ + ├── MatchValidationsTests.cs # + draw cases + └── TournamentValidationsTests.cs # + withdrawal cases + +TournamentAPI.IntegrationTests/GraphQL/Tests/ +├── Brackets/BracketMutationTests.cs # + Round Robin generateBracket, updateRound-rejects cases +├── Matches/MatchMutationTests.cs # + draw cases (both formats) +├── Standings/StandingsQueryTests.cs # NEW +└── Tournaments/TournamentMutationTests.cs # + withdrawal cases +``` + +**Structure Decision**: Single web-service project (existing modular monolith). No new project is added to the solution — this feature extends the existing `Brackets`, `Matches`, and `Tournaments` feature folders and adds one new feature folder (`Standings`), consistent with the "one feature per folder" convention. + +## Complexity Tracking + +No Constitution gate failures. One documented obligation, not a violation: + +| Item | Why Needed | Simpler Alternative Rejected Because | +|------|------------|---------------------------------------| +| `IBracketGenerationStrategy` / `IBracketCompletionStrategy` (new abstraction, Principle VI requires an ADR) | FR-014 requires that a future third format not require changing Single Elimination or Round Robin code; a two-implementation strategy interface is the minimum indirection that achieves that. | An inline `switch (tournament.Format)` in `BracketMutations`/`MatchMutations` was considered simpler today, but every future format would then re-edit those same mutation files — directly violating FR-014. | diff --git a/specs/001-round-robin-bracket-format/quickstart.md b/specs/001-round-robin-bracket-format/quickstart.md new file mode 100644 index 0000000..e548e2f --- /dev/null +++ b/specs/001-round-robin-bracket-format/quickstart.md @@ -0,0 +1,56 @@ +# Quickstart: Validating Round Robin Tournament Format + +## Prerequisites + +- `dotnet build` succeeds. +- API running locally: `dotnet run --project TournamentAPI` (GraphQL endpoint at `/graphql`). +- An authenticated user (register/login via the existing `registerUser`/`loginUser` mutations) to act as the organizer, and 4+ additional users to act as participants — same setup as any existing Bracket/Match integration test. + +## Scenario 1 — Round Robin schedule generation (User Story 1, FR-003/FR-004) + +1. `createTournament` with `format: ROUND_ROBIN`, `maxParticipants: 5`. +2. `joinTournament` for 5 participants. +3. `generateBracket(input: { tournamentId })`. +4. **Expect**: the returned `Bracket.matches` contains exactly `5 * 4 / 2 = 10` matches, every pair of participants appears exactly once, and each participant appears in exactly 4 matches across the schedule with one round where they have no opponent (bye) — since 5 is odd. + +## Scenario 2 — Recording results, including a draw (User Story 1, FR-005/FR-006) + +1. Using the bracket from Scenario 1, call `play(input: { matchId, winnerId: , player1Score: 2, player2Score: 1 })` for one match. + - **Expect**: succeeds, match `status` becomes `PLAYED`. +2. Call `play` on a different match with `winnerId: null, player1Score: 1, player2Score: 1`. + - **Expect**: succeeds, match `status` becomes `DRAWN` (not `PLAYED`) and `winnerId` stays null. +3. Call `play` with `winnerId: null` but unequal scores. + - **Expect**: rejected with a validation error (draw requires equal scores). +4. Repeat step 2's null-`winnerId` call against a **Single Elimination** tournament's match. + - **Expect**: rejected — Single Elimination matches always require a winner (regression check for FR-006/FR-013). + +## Scenario 3 — Standings (User Story 2, FR-007/FR-008) + +1. Query the bracket's `standings` after only some matches are played. + - **Expect**: entries only reflect completed matches; `points = wins*3 + draws*1`; participants with no completed matches show `points: 0`. +2. Play all remaining matches to completion. + - **Expect**: `Tournament.status` becomes `COMPLETED`, `Tournament.champion` is set to the participant with `rank: 1` (or left unset if multiple participants share `rank: 1`), and `standings` reflects the final table with head-to-head tie-breaks applied per FR-008. + +## Scenario 4 — Withdrawal / forfeit (FR-010) + +1. With the Scenario 1 bracket in progress (some matches still `SCHEDULED`), call the new withdrawal mutation as the **organizer**, targeting one participant. +2. **Expect**: that participant's remaining `SCHEDULED` matches become `PLAYED` with their scheduled opponent as `winnerId`; `standings` shows the opponents' forfeit wins; the withdrawn participant no longer appears in `tournament.participants`. +3. Repeat against a different participant, this time calling the mutation **as that participant themselves** (not the organizer). + - **Expect**: succeeds identically to step 1 — FR-010 covers both a participant withdrawing themselves and the organizer removing them (research.md §6 Authorization). +4. Call the mutation as a third, unrelated user (neither the organizer nor the target participant). + - **Expect**: rejected — only the organizer or the participant being withdrawn may call it. + +## Scenario 5 — Single Elimination regression (User Story 3, FR-013, SC-003) + +1. Run the existing `generateBracket` → `play` → `updateRound` flow end-to-end on a tournament created **without** specifying `format` (or explicitly `SINGLE_ELIMINATION`). +2. **Expect**: byte-for-byte the same behavior as before this feature — champion determination via `BracketCompletionService`'s existing single-final-match logic, `updateRound` still advances winners. +3. Call `updateRound` against a **Round Robin** bracket instead. + - **Expect**: rejected with the new "format not supported" error (FR-013 boundary check). + +## Validation checklist + +- [x] All 5 scenarios pass manually via GraphQL (or as integration tests under `TournamentAPI.IntegrationTests/GraphQL/Tests/`). +- [x] `dotnet test TournamentAPI.UnitTests` passes (new: Round Robin schedule generator, standings ranking algorithm, format-aware `Play` validations, minimum-participant validation applies regardless of format). +- [x] `dotnet test TournamentAPI.IntegrationTests` passes (existing Single Elimination suite unmodified and green; new Round Robin suite added). +- [x] Postman collection updated for: `CreateTournamentInput.format`, `PlayInput.winnerId` nullability, new withdrawal mutation, new `Bracket.standings` query. +- [x] ADR added under `docs/adr/` for the format-strategy abstraction (research.md §1). diff --git a/specs/001-round-robin-bracket-format/research.md b/specs/001-round-robin-bracket-format/research.md new file mode 100644 index 0000000..63b4845 --- /dev/null +++ b/specs/001-round-robin-bracket-format/research.md @@ -0,0 +1,96 @@ +# Phase 0 Research: Round Robin Tournament Format + +## 1. Format extensibility mechanism + +**Decision**: Add a `Format` field (`TournamentFormat` enum: `SingleElimination` = 0 default, `RoundRobin` = 1) to `Tournament`. Introduce a small strategy abstraction in the `Brackets` feature folder — `IBracketGenerationStrategy` (generates the initial set of matches for a tournament's participants) and `IBracketCompletionStrategy` (decides when a bracket is complete and who the champion is) — with one implementation per format, resolved at the call site via `IEnumerable` + a `Format` property, filtered by `tournament.Format`. `BracketService`/`BracketCompletionService` become the `SingleElimination*` implementations (existing logic moved, not rewritten); `RoundRobinBracketStrategy`/`RoundRobinCompletionStrategy` are new. + +**Rationale**: This is the minimum abstraction that satisfies FR-014 (new formats added later without touching existing ones) without introducing a generic plugin system nobody asked for. It's a real, non-trivial trade-off (a new interface + DI-based dispatch where a single concrete service existed before), so per Constitution Principle VI this decision **requires an ADR** — flagged as an early task in `tasks.md`, not written here since `/speckit-plan` doesn't author ADRs itself. + +**Alternatives considered**: +- A `switch` on `tournament.Format` inline in `BracketMutations`/`MatchMutations` — rejected: it satisfies today's two formats but every future format re-touches the same mutation files, which is exactly what FR-014 rules out. +- A generic rules-engine/plugin-loader — rejected: massive over-engineering for two known formats; violates Constitution Principle III (minimal abstraction, no speculative infrastructure). + +## 2. Representing a draw + +**Decision**: Add a new `Drawn` value to the existing `MatchStatus` enum, declared **after** `NeedsReplay` (`Scheduled = 0, Played = 1, NeedsReplay = 2, Drawn = 3`) — appended at the end, not inserted between `Played` and `NeedsReplay`. A draw is `Status == Drawn`, `WinnerId == null`. `Played` continues to always mean a decisive result (`WinnerId` set — either a normal win or a bye). + +**Rationale**: Keeps "was this match decided, and how" fully explicit in `Status` rather than inferred from a `WinnerId` null-check that would otherwise also have to be disambiguated from the `Scheduled` state. Every place in the codebase that currently branches on `MatchStatus` (completion checks, `ValidateMatchNotPlayed`, `ValidateMatchNotScheduled`, correction eligibility) gets one clear new case instead of a secondary null-check, which is easier to reason about and to test. Costs one enum value addition; no new column. + +**Alternatives considered**: +- Nullable `WinnerId` + `Status == Played` meaning a draw (the original plan) — rejected per explicit direction: overloads one `Played` status to mean two different things (decisive result vs. draw), pushing the distinction into a second field-level check everywhere completion/validation logic reads `Status`. +- New `MatchOutcome` enum (`Win`, `Draw`) alongside `MatchStatus` — rejected: two enums tracking overlapping "is this match resolved" information is more state to keep consistent than one enum with an added value. +- New `bool IsDraw` column — rejected: redundant once `Status` already distinguishes it. +- Inserting `Drawn` between `Played` and `NeedsReplay` (matching its conceptual grouping) — rejected: `MatchStatus` has no `HasConversion` in `ApplicationDbContext`, so it's stored as a plain `int`; inserting a value in the middle silently shifts `NeedsReplay`'s underlying integer, which would corrupt any already-persisted `NeedsReplay` rows. Appending at the end costs nothing and avoids the risk entirely. + +**Downstream impact of this decision**: +- `ValidateMatchNotPlayed` (blocks re-playing an already-decided match) must also reject `Status == Drawn`. +- `ValidateMatchNotScheduled` (used by `CorrectMatchResult` to require the match has already been played) must treat `Drawn` as "already played" alongside `Played`. +- Round Robin completion (`RoundRobinCompletionStrategy`, see data-model.md's completion rule) is "every match has `Status == Played` or `Status == Drawn`", not just `Played`. +- `Play` sets `Status = MatchStatus.Drawn` (not `Played`) when `winnerId == null`; `CorrectMatchResult` follows the same rule when correcting a result into or out of a draw. +- The GraphQL `MatchStatus` enum gains `DRAWN` (see `contracts/graphql-schema-changes.graphql`). + +## 3. Format immutability + +**Decision**: `Format` is settable only in `CreateTournamentInput` (defaults to `SingleElimination` when omitted) and is never exposed on `UpdateTournamentInput`. There is no code path that changes it after creation, not just after bracket generation. + +**Rationale**: Trivially satisfies FR-012 ("MUST NOT allow a tournament's format to be changed once its schedule or bracket has been generated") with less validation code than allowing pre-generation edits. An organizer who picks the wrong format before generating a bracket can `deleteTournament` and recreate it — that mutation already exists. + +**Alternatives considered**: Allowing `Format` on `UpdateTournamentInput` guarded by a "bracket not yet generated" check — rejected: extra validation surface for a case the spec doesn't call out as a required capability (Assumptions in spec.md already treat format choice as a one-time decision). + +## 4. Round Robin schedule generation + +**Decision**: Use the standard circle/polygon rotation method: fix one participant, rotate the rest around it across rounds. With an odd participant count, add a placeholder "bye" seat to the rotation so each real participant sits out exactly one round. This produces exactly `N-1` (even N) or `N` (odd N) rounds, with every pair meeting exactly once and, for odd N, every participant getting exactly one bye — matching FR-003/FR-004 directly. + +**Rationale**: This is the standard, well-understood algorithm for round-robin scheduling; it satisfies the "exactly one bye each" requirement for free (a naive random-pairing-per-round approach doesn't guarantee even bye distribution). Round numbers are meaningful groupings (not elimination stages) and reuse the existing `Match.Round` column and the existing unique index `(BracketId, Round, Player1Id, Player2Id)` without changes. + +**Alternatives considered**: Emitting all pairings under a single `Round = 1` — rejected: still technically unique per the existing index (pairs are unique by construction), but throws away useful grouping information the standings/UX story implies ("what's left to be decided" reads naturally per round). + +## 5. Standings computation + +**Decision**: Compute standings on demand from `Match` rows (wins/draws/losses/points per FR-007, head-to-head tie-break per FR-008) in a new `Standings` feature folder — not a persisted, denormalized table. + +**Rationale**: Matches remain the single source of truth; nothing to keep in sync. A hand-written projection from `Match` to a `StandingEntry` output type is not a mapping library (Constitution Principle III is about banning generic mapping tools, not all projections) and follows the existing pattern of exposing a small, purpose-built type from the GraphQL layer. + +**Alternatives considered**: A persisted `Standing`/`StandingsSnapshot` entity updated on every match write — rejected: adds a second source of truth and invalidation logic for a value that's cheap to compute per tournament (bounded by `MaxParticipants`). + +## 6. Participant withdrawal / forfeit (FR-010) + +**Decision**: No participant-removal mutation exists today for either format. Add one new mutation, scoped to Round Robin tournaments whose bracket has already been generated: it soft-deletes the `TournamentParticipant` row (reusing the existing `IsDeleted` soft-delete pattern) and marks each of that participant's remaining `Scheduled` matches as `Played` with `WinnerId` set to their scheduled opponent (a forfeit win, worth standard win points per the Assumptions in spec.md). + +**Authorization**: FR-010 covers two distinct actors — a participant "withdraws" (self-service) "or is removed" (organizer-initiated). The mutation's caller MUST be either the tournament's owner or the participant being withdrawn themselves (`userId == tournament.OwnerId || userId == participantId`); anyone else is rejected. This was flagged during `/speckit-analyze` (finding F1) — an owner-only check would silently drop the "withdraws" half of FR-010's own wording. + +**Rationale**: Matches FR-010 exactly, reuses the existing soft-delete convention (Constitution Principle IV) instead of inventing a new "withdrawn" flag, and requires no new Match fields (a forfeit is indistinguishable in storage from a normally decisive match — acceptable since the spec doesn't require surfacing "this was a forfeit" separately in standings). + +**Alternatives considered**: Deleting the remaining scheduled matches outright — rejected explicitly by the clarification answer (forfeits, not removal). Generalizing this mutation to Single Elimination too — rejected: out of scope (spec's Edge Cases and FR-010 only cover Round Robin), and Single Elimination must show zero behavior change per FR-013. Owner-only authorization — rejected per the F1 analysis finding above, since it contradicts FR-010's own "withdraws or is removed" wording. + +## 7. `Play` mutation changes for draws + +**Decision**: Change `winnerId` from `Int!` to `Int` (nullable) on `PlayInput`/`Play`. Validation becomes format-aware: for Single Elimination, `winnerId == null` is rejected (existing behavior, unchanged); for Round Robin, `winnerId == null` is accepted only when `player1Score == player2Score`. + +**Rationale**: Directly implements FR-005/FR-006. This is a breaking change to an existing required GraphQL argument, so per the Definition of Done the Postman collection must be updated in the same change, and the schema/input change belongs in `data-model.md` + `contracts/`. + +**Alternatives considered**: A separate `PlayDraw` mutation — rejected: duplicates all the shared validation/concurrency/audit logic already in `Play` for no real benefit. + +## 8. `UpdateRound` restriction + +**Decision**: `UpdateRound` (advances a Single Elimination bracket to its next round from the current round's winners) gets one new validation: reject with a clear error if the bracket's tournament `Format != SingleElimination`. Round Robin brackets never call it — their full schedule is generated upfront. + +**Rationale**: Round Robin has no "advance to next round from winners" concept; a full schedule already exists after `GenerateBracket`. Rejecting explicitly (rather than leaving it to accidentally misbehave) keeps the format boundary explicit and testable, satisfying FR-013's "no change in behavior" guarantee for Single Elimination while making the Round Robin restriction discoverable. + +## 9. Performance / scale + +**Decision**: No new participant cap for Round Robin. The existing `Tournament.MaxParticipants` bound (already enforced for every tournament, min 2, no stated max) is reused as-is; Round Robin's `O(n²)` match count is accepted at whatever bound organizers already choose today. + +**Rationale**: The spec sets no explicit new performance/scale target (confirmed during `/speckit-clarify` — flagged Outstanding/low-impact), and introducing a Round-Robin-specific cap would be a new, unrequested configuration surface. Existing schedule generation is a single in-memory loop over participants (see `BracketService.CreateBracket`), so `O(n²)` match rows for realistic tournament sizes (tens of participants) is not a practical concern. + +**Alternatives considered**: Adding a lower `MaxParticipants` ceiling specifically for Round Robin — rejected: no requirement or evidence motivates a different limit than what organizers already work within today. + +## 10. Correction cascade must not run for Round Robin + +**Discovery**: `MatchCorrectionService.ApplyCorrectionAsync` unconditionally calls `PropagateAsync`, which uses `MatchCascadePositionCalculator.GetDownstreamMatchId` to find a "downstream match" in `Round + 1` purely by list position (`position / 2`) — it has no awareness of tournament format. A Round Robin bracket has multiple rounds too (research.md §4), so correcting a Round Robin match's result would spuriously compute a positional "downstream match" in the next round and overwrite its `Player1Id`/`Player2Id`/`Status` (potentially flipping it to `NeedsReplay`) even though Round Robin matches have no such advancement relationship. This is only latent today because `winnerId` was never nullable and Round Robin didn't exist; it becomes a real correctness bug the moment `CorrectMatchResult` is used on a Round Robin match. + +**Decision**: `MatchMutations.CorrectMatchResult` (and the `isReplay` correction path inside `Play`) must only invoke `MatchCorrectionService`'s cascade propagation when `tournament.Format == SingleElimination`. For Round Robin, applying a correction updates only the corrected match itself (audit row still written) — standings are computed on demand from `Match` rows, so no downstream match update is ever needed. + +**Rationale**: Prevents an existing single-elimination-only mechanism from corrupting unrelated Round Robin matches based on positional coincidence. This is a required correctness fix enabling Round Robin corrections at all, not an optional hardening step. + +**Alternatives considered**: Making `MatchCascadePositionCalculator` format-aware itself — rejected: the calculator has no access to `Tournament` and shouldn't need it; the cheaper, more legible fix is to not call it at all for Round Robin at the mutation call site. diff --git a/specs/001-round-robin-bracket-format/spec.md b/specs/001-round-robin-bracket-format/spec.md new file mode 100644 index 0000000..880e993 --- /dev/null +++ b/specs/001-round-robin-bracket-format/spec.md @@ -0,0 +1,116 @@ +# Feature Specification: Round Robin Tournament Format + +**Feature Branch**: `001-round-robin-bracket-format` + +**Created**: 2026-09-27 + +**Status**: Draft + +**Input**: User description: "Currently our TournamentAPI supports only Single Elimination Bracket. We need to lay the groundwork to easily add new tournament types in the future and implement round-robin format" + +## Clarifications + +### Session 2026-09-27 + +- Q: Should each pair of participants in a Round Robin tournament play each other once, or twice (e.g. home-and-away)? → A: Single round — each pair meets exactly once. +- Q: Can an individual Round Robin match end in a draw, or must every match produce a winner? → A: Draws are allowed — a match can end with no winner. +- Q: When two or more participants finish a Round Robin tournament with the same number of points, how should the tie in final standings be broken? → A: Head-to-head result between the tied participants first; if still tied, they share the same rank. +- Q: If a participant withdraws or is removed from a Round Robin tournament after its schedule has already been generated, what should happen to their remaining scheduled matches? → A: Remaining matches are recorded as forfeits (losses) for the withdrawn participant. +- Q: Now that draws are allowed, how many points should a win, a draw, and a loss each be worth for standings ranking? → A: Win = 3 points, draw = 1 point, loss = 0 points. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Organizer runs a Round Robin tournament (Priority: P1) + +An organizer creating a new tournament wants to run it as a Round Robin instead of Single Elimination, so that every participant gets to play every other participant rather than being knocked out after one loss. + +**Why this priority**: This is the core deliverable requested — without it, no new format exists and the feature has no user-facing value. + +**Independent Test**: Can be fully tested by creating a tournament with Round Robin selected and a set of participants, then verifying a complete match schedule is generated where every participant faces every other participant exactly once. + +**Acceptance Scenarios**: + +1. **Given** an organizer is creating a tournament with 6 participants, **When** they select Round Robin as the format and start the tournament, **Then** the system generates a schedule containing every possible pairing of participants exactly once. +2. **Given** an organizer is creating a tournament with an odd number of participants, **When** the Round Robin schedule is generated, **Then** each participant receives exactly one bye and the rest of the schedule still pairs every participant with every other participant exactly once. +3. **Given** a Round Robin tournament has started, **When** a participant reports or an organizer records the result of one of their matches (a win for either side, or a draw), **Then** that result is saved and reflected in the tournament's ongoing standings. + +--- + +### User Story 2 - Organizer and participants track Round Robin standings (Priority: P2) + +An organizer or participant wants to see up-to-date standings (ranking, record, points) for a Round Robin tournament at any point, so they know who is leading and what is left to be decided. + +**Why this priority**: Round Robin has no single elimination path to a winner; standings are how progress and the eventual result are communicated, so this is required to make the new format usable, but it builds on User Story 1 existing first. + +**Independent Test**: Can be fully tested by recording a mix of match results in a Round Robin tournament and verifying the standings view reflects the correct ranking, records, and remaining matches at that point. + +**Acceptance Scenarios**: + +1. **Given** a Round Robin tournament where only some matches have been played, **When** the standings are viewed, **Then** they show each participant's current record based only on completed matches, ranked according to the tournament's ranking rules. +2. **Given** a Round Robin tournament where every scheduled match has been played, **When** the standings are viewed, **Then** the tournament is marked complete and a final ranking (including the overall winner) is shown. + +--- + +### User Story 3 - Existing Single Elimination tournaments are unaffected (Priority: P3) + +An organizer who creates a tournament without thinking about format, or who is already running a Single Elimination tournament, wants everything to keep working exactly as it does today. + +**Why this priority**: Protects existing users and data; lower priority than delivering the new format itself, but a hard requirement before this feature can ship. + +**Independent Test**: Can be fully tested by running the existing Single Elimination creation and match-progression flows end-to-end after this feature ships and confirming behavior, results, and champion determination are unchanged. + +**Acceptance Scenarios**: + +1. **Given** an organizer creates a tournament without explicitly choosing a format, **When** the tournament is created, **Then** it behaves exactly as a Single Elimination tournament does today. +2. **Given** an existing Single Elimination tournament created before this feature shipped, **When** its matches are played to completion, **Then** it is decided and completed the same way it was before this feature shipped. + +--- + +### Edge Cases + +- Starting a Round Robin tournament with fewer than 2 participants is rejected, using the same minimum-participant rule already enforced for every tournament today (see FR-011). +- A participant who withdraws (themselves) or is removed (by the organizer) from a Round Robin tournament after the schedule has already been generated has their remaining unplayed matches recorded as forfeits (losses), so standings stay complete and comparable for everyone else (see FR-010). +- When two or more participants are mathematically tied and some of their matches haven't been played yet, standings show them at the same rank based on results so far, using the same ranking and tie-break rules applied to a completed tournament (see FR-008, User Story 2 Acceptance Scenario 1). +- Changing a tournament's format after its schedule or bracket has already been generated is not possible — format is fixed at creation (see FR-012). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST allow an organizer to choose a tournament format (Single Elimination or Round Robin) when creating a tournament. +- **FR-002**: System MUST default a tournament to Single Elimination when no format is explicitly chosen, so existing organizers and integrations see no change in behavior. +- **FR-003**: For a Round Robin tournament, system MUST generate a schedule in which every participant plays every other participant exactly once, in a single round (no rematches). +- **FR-004**: For a Round Robin tournament with an odd number of participants, system MUST give each participant exactly one bye across the schedule rather than leaving them unscheduled. +- **FR-005**: System MUST record the outcome of each Round Robin match individually as a win for one participant, a win for the other, or a draw. +- **FR-006**: System MUST allow a Round Robin match to end in a draw when neither participant wins. Single Elimination matches, in contrast, MUST always produce a winner, since advancing past a round depends on it. +- **FR-007**: System MUST calculate and expose standings for a Round Robin tournament at any point, ranking participants by total points earned: 3 points for a win, 1 point for a draw, 0 points for a loss (including a forfeit loss). +- **FR-008**: System MUST break ties in standings using head-to-head result between the tied participants first, and if the tie remains, by treating the participants as co-ranked (sharing the same position). +- **FR-009**: System MUST mark a Round Robin tournament as complete once every scheduled match has been played, and determine the final ranking and overall winner from the completed standings. +- **FR-010**: If a participant withdraws or is removed from a Round Robin tournament after its schedule has been generated, system MUST record each of that participant's remaining unplayed matches as a forfeit loss for them (and a corresponding win, worth the standard win points, for their scheduled opponent), so standings remain complete and comparable for everyone else. +- **FR-011**: System MUST prevent starting a Round Robin tournament with fewer than 2 participants, using the same minimum participant rule already enforced for tournaments today. +- **FR-012**: System MUST NOT allow a tournament's format to be changed once its schedule or bracket has been generated. +- **FR-013**: System MUST continue to support existing Single Elimination tournaments — creation, match progression, and champion determination — with no change in behavior after this feature ships. +- **FR-014**: System MUST be structured so that a new tournament format can be added in the future without requiring changes to the rules, data, or behavior of existing Single Elimination or Round Robin tournaments. + +### Key Entities + +- **Tournament**: Represents a competition run by an organizer; now additionally carries which format it uses (e.g., Single Elimination, Round Robin), decided at creation and fixed once the schedule/bracket is generated. +- **Format**: Represents the set of rules governing how a tournament's matches are generated, progressed, and resolved to a winner (e.g., Single Elimination's bracket-and-advance rules, Round Robin's play-everyone rules). Additional formats can be introduced over time. +- **Match**: A single contest between two participants (or a bye) within a tournament, with a round/grouping and a recorded outcome — a win for one side, or (for Round Robin only) a draw; used by both existing and new formats. +- **Standings**: A per-tournament, per-participant summary of results (wins, draws, losses, points, rank) used to communicate progress and determine the final outcome of formats — like Round Robin — that don't resolve via single-loss elimination. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An organizer can create a Round Robin tournament and see a complete, correct match schedule (every participant paired with every other participant exactly once) generated immediately upon starting the tournament. +- **SC-002**: At any point during a Round Robin tournament, standings shown to users are accurate as of the most recently recorded match result, with no stale or missing results. +- **SC-003**: 100% of existing Single Elimination tournament scenarios (creation, match progression, champion determination) continue to behave identically after this feature ships, with zero regressions. +- **SC-004**: A new tournament format can be added after this feature ships without modifying the rules, data, or behavior of previously created Single Elimination or Round Robin tournaments. +- **SC-005**: For a completed Round Robin tournament, the winner and full final ranking are unambiguous and derivable solely from recorded match results (including draws and forfeits), the published points system, and the tie-break rule. + +## Assumptions + +- The minimum participant count required to start a tournament (currently 2) applies equally to Round Robin tournaments; no additional Round Robin-specific minimum is introduced. +- A tournament's format is chosen once at creation time and cannot be changed after its schedule or bracket has been generated, consistent with how brackets already behave today. +- A forfeited match (from a participant withdrawal) counts as a standard win for the remaining opponent for standings purposes — the same points as a win achieved by playing the match. diff --git a/specs/001-round-robin-bracket-format/tasks.md b/specs/001-round-robin-bracket-format/tasks.md new file mode 100644 index 0000000..2467388 --- /dev/null +++ b/specs/001-round-robin-bracket-format/tasks.md @@ -0,0 +1,231 @@ +--- + +description: "Task list for Round Robin Tournament Format" +--- + +# Tasks: Round Robin Tournament Format + +**Input**: Design documents from `/specs/001-round-robin-bracket-format/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/graphql-schema-changes.graphql, quickstart.md + +**Tests**: Included. Not explicitly requested in the spec, but this codebase has an established per-service/per-validation xUnit test convention (`BracketServiceTests.cs`, `MatchValidationsTests.cs`, etc.) and the Definition of Done requires both test suites to pass — new scheduling/ranking/validation logic gets the same coverage as existing logic of the same kind. + +**Organization**: Tasks are grouped by user story (spec.md's US1/US2/US3) to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on an incomplete task) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) — omitted for Setup/Foundational/Polish +- File paths are exact and relative to the repository root + +--- + +## Phase 1: Setup + +- [x] T001 [P] Author an ADR at `docs/adr/0007_round_robin_format_strategy.md` (use the `scaffold-adr` skill/existing ADR template in `docs/adr/`) documenting the `IBracketGenerationStrategy`/`IBracketCompletionStrategy` decision: the decision itself, the rationale, and the two rejected alternatives (inline `switch` on format; a generic plugin/rules engine) from research.md §1. Required by Constitution Principle VI before this feature is considered done. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete — every story depends on `Tournament.Format` existing and the strategy dispatch mechanism being in place. + +- [x] T002 [P] Create `TournamentAPI/Data/Models/TournamentFormat.cs`: `public enum TournamentFormat { SingleElimination, RoundRobin }` — `SingleElimination` is ordinal `0` (the default) and `RoundRobin` is ordinal `1`, per data-model.md. +- [x] T003 [P] Add a `Drawn` value to `TournamentAPI/Data/Models/MatchStatus.cs`, **appended after `NeedsReplay`** (`Scheduled = 0, Played = 1, NeedsReplay = 2, Drawn = 3`) — do NOT insert it between `Played` and `NeedsReplay`, since `MatchStatus` has no `HasConversion` in `ApplicationDbContext` and is stored as a plain `int`; inserting in the middle would shift `NeedsReplay`'s stored value (research.md §2). +- [x] T004 Add `public TournamentFormat Format { get; set; } = TournamentFormat.SingleElimination;` to `Tournament` in `TournamentAPI/Data/Models/Tournament.cs`. Depends on: T002. +- [x] T005 In `TournamentAPI/Tournaments/CreateTournamentInput.cs`, change the record to `public record CreateTournamentInput(string Name, DateTime StartDate, TournamentStatus Status, int MaxParticipants, TournamentFormat? Format = null);`. In `TournamentMutations.CreateTournament` (`TournamentAPI/Tournaments/TournamentMutations.cs`), set `Format = input.Format ?? TournamentFormat.SingleElimination` when constructing the new `Tournament` (FR-002). Do **not** add `Format` to `TournamentAPI/Tournaments/UpdateTournamentInput.cs` — format is immutable after creation (FR-012, research.md §3). Depends on: T004. +- [x] T006 [P] Create `TournamentAPI/Brackets/IBracketGenerationStrategy.cs`: `public interface IBracketGenerationStrategy { TournamentFormat Format { get; } Bracket CreateBracket(int tournamentId, IList participantIds); }`. +- [x] T007 [P] Create `TournamentAPI/Brackets/IBracketCompletionStrategy.cs`: `public interface IBracketCompletionStrategy { TournamentFormat Format { get; } Task SyncCompletionAsync(ApplicationDbContext context, Tournament tournament, int bracketId, int frontierRound, CancellationToken token); }` (keeps the existing `frontierRound` parameter from `BracketCompletionService.SyncChampionAsync` so Single Elimination's call sites don't change shape; Round Robin's implementation ignores it). +- [x] T008 [P] Make `BracketService` (`TournamentAPI/Brackets/BracketService.cs`) implement `IBracketGenerationStrategy`, adding `public TournamentFormat Format => TournamentFormat.SingleElimination;`. No change to the existing `CreateBracket`/`CreateNextRoundMatches` logic. Depends on: T006. +- [x] T009 [P] Make `BracketCompletionService` (`TournamentAPI/Brackets/BracketCompletionService.cs`) implement `IBracketCompletionStrategy`: rename `SyncChampionAsync` to `SyncCompletionAsync` to match the interface signature, add `public TournamentFormat Format => TournamentFormat.SingleElimination;`. No change to the existing method body. Depends on: T007. +- [x] T010 [P] In `BracketMutations.GenerateBracket` (`TournamentAPI/Brackets/BracketMutations.cs`), replace the injected `BracketService bracketService` parameter with `IEnumerable generationStrategies`, and resolve `generationStrategies.Single(s => s.Format == tournament.Format)` before calling `.CreateBracket(tournamentId, participantIds)`. Depends on: T008. +- [x] T011 In `MatchMutations.Play` and `MatchMutations.CorrectMatchResult` (`TournamentAPI/Matches/MatchMutations.cs`), replace the injected `BracketCompletionService bracketCompletionService` parameter with `IEnumerable completionStrategies`, and resolve `completionStrategies.Single(s => s.Format == tournament.Format)` before calling `.SyncCompletionAsync(context, tournament, match.BracketId, frontierMatch.Round, token)` (`Play`) / `.SyncCompletionAsync(context, tournament, match.BracketId, frontierMatch.Round, token)` (`CorrectMatchResult`). Depends on: T009. +- [x] T012 Register both Single Elimination strategy implementations in `TournamentAPI/Program.cs`: `builder.Services.AddScoped();` and `builder.Services.AddScoped();` (alongside their existing concrete-type registrations, which stay as-is since `BracketMutations`/other call sites may still need the concrete types). Depends on: T008, T009. +- [x] T013 Gate the correction cascade to Single Elimination only (research.md §10 — this is a required correctness fix, not hardening: `MatchCascadePositionCalculator` is purely positional and will otherwise misfire on Round Robin's multi-round schedules). In `TournamentAPI/Matches/MatchCorrectionService.cs`, add a `bool allowCascade` parameter to `ApplyCorrectionAsync`; when `false`, write the audit row as today but return immediately instead of calling `PropagateAsync`. In `TournamentAPI/Matches/MatchMutations.cs`, pass `allowCascade: tournament.Format == TournamentFormat.SingleElimination` at both call sites (`Play`'s `isReplay` branch and `CorrectMatchResult`). Depends on: T004, T011. + +**Checkpoint**: Foundation ready — `Tournament.Format` exists, both strategy interfaces exist with Single Elimination implementations wired through DI and the mutation call sites, and the correction-cascade bug is fixed. User story work can now begin. + +--- + +## Phase 3: User Story 1 - Organizer runs a Round Robin tournament (Priority: P1) 🎯 MVP + +**Goal**: An organizer can create a Round Robin tournament, generate a full schedule (every pair exactly once, one bye per participant when the count is odd), and record match results — including draws. + +**Independent Test**: Create a tournament with `format: ROUND_ROBIN` and 5 participants, `generateBracket`, verify 10 matches covering every pair exactly once with one bye each; `play` one match decisively and one as a draw; confirm a Single Elimination match still rejects a null `winnerId`. + +### Tests for User Story 1 + +> Write these first; they should fail to compile/pass until the corresponding implementation task lands. + +- [x] T014 [P] [US1] Unit tests in `TournamentAPI.UnitTests/Services/RoundRobinBracketStrategyTests.cs`: even participant count (e.g. 6) → `N*(N-1)/2` matches, every pair appears exactly once, no byes; odd participant count (e.g. 5) → every pair appears exactly once AND every participant has exactly one bye across the schedule (FR-003/FR-004). +- [x] T015 [P] [US1] Unit tests in `TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs`: `winnerId: null` with equal scores succeeds for a Round Robin match; `winnerId: null` with unequal scores fails; `winnerId: null` fails outright for a Single Elimination match regardless of scores (FR-005/FR-006). +- [x] T016 [P] [US1] Unit test in `TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs` confirming `ValidateEnoughParticipants` rejects a `generateBracket` attempt with fewer than 2 participants **regardless of tournament format** — this existing, format-agnostic validation already satisfies FR-011 for Round Robin by reuse, but nothing currently verifies that (identified during `/speckit-analyze`, finding E1). + +### Implementation for User Story 1 + +- [x] T017 [US1] Create `TournamentAPI/Brackets/RoundRobinBracketStrategy.cs` implementing `IBracketGenerationStrategy` (`Format => TournamentFormat.RoundRobin`). Generate the schedule via the circle/polygon rotation method (research.md §4): fix one participant, rotate the rest across rounds; for an odd participant count, rotate a placeholder "bye" seat through the same rotation so every real participant sits out exactly one round. Each bye match sets `WinnerId` to the sole real participant and `Status = MatchStatus.Played`, matching `BracketService.CreateBracket`'s existing bye convention (FR-003/FR-004). Depends on: T006. +- [x] T018 [US1] Register the new strategy in `TournamentAPI/Program.cs`: `builder.Services.AddScoped();`. Depends on: T017, T012. +- [x] T019 [US1] In `TournamentAPI/Matches/MatchMutations.cs`, change the `winnerId` parameter of `Play` and `CorrectMatchResult` from `int` to `int?`. When `winnerId is null`, set `match.WinnerId = null` and `match.Status = MatchStatus.Drawn` (instead of `Played`); when `winnerId` is set, keep the existing `Played` behavior unchanged. Keep the existing `matchMetrics.MatchPlayed()` call firing in both cases (a draw is still "a match played", no new metric needed). Depends on: T003, T011, T013. +- [x] T020 [US1] Add `MatchErrorCodes.DrawNotAllowedForFormat` / `MatchErrors.DrawNotAllowedForFormat(int matchId)` and `MatchErrorCodes.DrawRequiresEqualScores` / `MatchErrors.DrawRequiresEqualScores(int matchId, int player1Score, int player2Score)` in `TournamentAPI/Matches/MatchErrorCodes.cs` and `TournamentAPI/Matches/MatchErrors.cs`, following the existing `ErrorBuilder.New().SetMessage(...).SetCode(...).SetExtension(...).Build()` pattern used by `MatchAlreadyPlayed`. +- [x] T021 [US1] In `TournamentAPI/Matches/MatchValidations.cs`, add `ValidateDrawAllowedForFormat(Tournament tournament, int? winnerId)` returning `MatchErrors.DrawNotAllowedForFormat(...)` when `winnerId is null && tournament.Format != TournamentFormat.RoundRobin` (FR-006), and `ValidateDrawHasEqualScores(int matchId, int? winnerId, int player1Score, int player2Score)` returning `MatchErrors.DrawRequiresEqualScores(...)` when `winnerId is null && player1Score != player2Score`. In `MatchMutations.Play`/`CorrectMatchResult`, call these two new validations, and only call the existing `ValidateWinnerIsParticipant`/`ValidateWinnerHasHigherScore` when `winnerId is not null`. Depends on: T019, T020. +- [x] T022 [US1] In `TournamentAPI/Matches/MatchValidations.cs`, update `ValidateMatchNotPlayed` and `ValidateMatchNotScheduled` to also treat `match.Status == MatchStatus.Drawn` the same as `MatchStatus.Played` (research.md §2: a drawn match is "already played" for both "don't replay it" and "it's eligible for correction" purposes). Depends on: T003. +- [x] T023 [US1] In `TournamentAPI/Brackets/BracketMutationValidations.cs`, add `ValidateFormatSupportsRoundAdvancement(Tournament tournament)` returning a new `BracketErrorCodes.RoundAdvancementNotSupportedForFormat` / `BracketErrors.RoundAdvancementNotSupportedForFormat(int tournamentId)` error when `tournament.Format != TournamentFormat.SingleElimination`. Wire it into `BracketMutations.UpdateRound` (`TournamentAPI/Brackets/BracketMutations.cs`) right after the existing bracket/owner checks — Round Robin's full schedule is generated upfront and has no next-round-from-winners concept (research.md §8). No change needed to `ValidateAllMatchesCompleted`/`ValidateNotFinalRound`, since Round Robin brackets never reach `UpdateRound`. Depends on: T010. +- [x] T024 [P] [US1] Create `TournamentAPI/Tournaments/WithdrawParticipantInput.cs`: `public record WithdrawParticipantInput(int TournamentId, int ParticipantId);`. +- [x] T025 [P] [US1] In `TournamentAPI/Tournaments/TournamentErrorCodes.cs` / `TournamentAPI/Tournaments/TournamentErrors.cs`, add error codes/builders for: caller is neither the tournament owner nor the participant being withdrawn (`NotAuthorizedForWithdrawal`), withdrawal attempted on a non-Round-Robin tournament, withdrawal attempted before a bracket exists, and the target participant not found — following the existing `ErrorBuilder` pattern in `TournamentErrors.cs`. +- [x] T026 [P] [US1] In `TournamentAPI/Tournaments/TournamentValidations.cs`, add `ValidateCanWithdrawParticipant(int ownerId, int callerId, int participantId, int tournamentId)` returning `TournamentErrors.NotAuthorizedForWithdrawal(...)` unless `callerId == ownerId || callerId == participantId` — FR-010 covers both a participant withdrawing themselves and the organizer removing them (research.md §6 Authorization; `/speckit-analyze` finding F1: an owner-only check would silently drop the "withdraws" half of FR-010). Also add `ValidateFormatSupportsWithdrawal(Tournament tournament)` (must be `TournamentFormat.RoundRobin`) and `ValidateBracketGeneratedForWithdrawal(bool bracketExists, int tournamentId)`. Uses the error builders from T025. +- [x] T027 [US1] Add a `WithdrawParticipant` mutation to `TournamentAPI/Tournaments/TournamentMutations.cs` (mirrors `JoinTournament`'s/`DeleteTournament`'s structure): load the tournament with its bracket, matches, and participants; validate it exists, `TournamentValidations.ValidateCanWithdrawParticipant` (owner OR the participant themselves — NOT `ValidateIsOwner`, which would wrongly exclude self-withdrawal), `ValidateFormatSupportsWithdrawal`, `ValidateBracketGeneratedForWithdrawal`, and that the target `TournamentParticipant` exists. For each of that participant's `Match` rows with `Status == MatchStatus.Scheduled`, set `Status = MatchStatus.Played` and `WinnerId` = whichever of `Player1Id`/`Player2Id` is **not** the withdrawing participant (FR-010 — a forfeit win for the opponent, standard win points per spec.md's Assumptions). Then call `context.TournamentParticipants.Remove(participant)` — `SoftDeleteInterceptor` converts this to `IsDeleted = true` automatically, exactly like `DeleteTournament`'s `context.Tournaments.Remove(tournament)` — and `SaveChangesAsync`. Depends on: T004, T024, T025, T026. +- [x] T028 [P] [US1] Unit tests in `TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs` for `ValidateCanWithdrawParticipant` (owner accepted, the withdrawing participant themselves accepted, an unrelated third user rejected), `ValidateFormatSupportsWithdrawal`, and `ValidateBracketGeneratedForWithdrawal` (all accept/reject cases). Depends on: T026. + +### Integration tests for User Story 1 + +- [x] T029 [US1] Integration test in `TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs`: `createTournament(format: ROUND_ROBIN)` → `joinTournament` × 5 → `generateBracket` → assert 10 matches, every pair exactly once, each participant appears in exactly one bye match (quickstart.md Scenario 1). +- [x] T030 [P] [US1] Integration test in `TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs`: on a Round Robin match, `play(winnerId: null, player1Score: 1, player2Score: 1)` succeeds with `status: DRAWN`; `play(winnerId: null, player1Score: 2, player2Score: 1)` is rejected (unequal scores) (quickstart.md Scenario 2, steps 2–3). +- [x] T031 [US1] Integration test in `TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs`: `updateRound` against a Round Robin bracket is rejected with the new format-not-supported error (quickstart.md Scenario 5, step 3). Same file as T029 — sequential with it, not parallel. +- [x] T032 [P] [US1] Integration tests in `TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs`: withdrawing a participant mid-Round-Robin-tournament (a) as the organizer and (b) as the participant themselves both turn their remaining `SCHEDULED` matches into forfeit wins for their opponents and remove them from `tournament.participants`; (c) calling it as an unrelated third user is rejected (quickstart.md Scenario 4). + +**Checkpoint**: User Story 1 is independently functional — Round Robin tournaments can be created, scheduled, played (including draws and withdrawals by either the organizer or the participant), and Single Elimination's `Play`/`UpdateRound` behavior is unchanged for decisive results. + +--- + +## Phase 4: User Story 2 - Organizer and participants track Round Robin standings (Priority: P2) + +**Goal**: Standings (wins/draws/losses/points/rank) are computable at any point for a Round Robin tournament, and the tournament auto-completes with a champion once every match is decided. + +**Independent Test**: Record a mix of results (including a draw and a forfeit) in a Round Robin tournament, query standings mid-tournament and confirm the ranking/points/tie-break are correct, then complete all matches and confirm the tournament closes out with the right champion (or no champion, if tied for first). + +### Tests for User Story 2 + +- [x] T033 [P] [US2] Unit tests in `TournamentAPI.UnitTests/Services/StandingsServiceTests.cs`: `Points = Wins * 3 + Draws * 1` (FR-007); a two-way points tie is broken by head-to-head result between the tied participants (FR-008); a tie that head-to-head cannot resolve leaves both participants sharing the same `Rank` (FR-008); `MatchesPlayed` counts both `Played` and `Drawn` matches, `MatchesRemaining` counts `Scheduled` matches (data-model.md's `StandingEntry` table). + +### Implementation for User Story 2 + +- [x] T034 [P] [US2] Create `TournamentAPI/Standings/StandingEntry.cs`: a plain class/output type with `ParticipantId`, `Wins`, `Draws`, `Losses`, `Points`, `Rank`, `MatchesPlayed`, `MatchesRemaining` (all `int`), per data-model.md's `StandingEntry` table. +- [x] T035 [US2] Create `TournamentAPI/Standings/StandingsService.cs` with `IReadOnlyList ComputeStandings(IReadOnlyList matches)`: derive `Wins`/`Draws`/`Losses`/`Points` per participant from `Match.WinnerId`/`Status` (a `Drawn` match adds one `Draws` to both sides; a `Played` match adds one `Wins` to the winner and one `Losses` to the other side, including forfeits), then rank per data-model.md's algorithm — group by `Points` descending, break ties within a group by summing points earned only from matches played among that group's members, and share `Rank` for any tie head-to-head cannot resolve. Depends on: T034. +- [x] T036 [P] [US2] Create `TournamentAPI/Standings/StandingsDataLoaders.cs` (`[DataLoaderGroup("StandingsBatchingContext")]`, mirroring `TournamentAPI/Brackets/BracketDataLoaders.cs`'s style): a `[DataLoader]` keyed by `BracketId` returning `Dictionary>` with **all** matches for each bracket (not paginated — unlike `MatchDataLoaders.GetMatchesByBracketAsync`, which pages and is unsuitable for computing a full standings table). +- [x] T037 [US2] Create `TournamentAPI/Standings/StandingsResolvers.cs` (`[ObjectType]`, mirroring `TournamentAPI/Brackets/BracketResolvers.cs`'s `[Parent]` pattern): add a `standings` field that loads the bracket's matches via T036's loader and returns `standingsService.ComputeStandings(matches)` when the bracket's tournament format is `RoundRobin`, or an empty list for Single Elimination brackets. Depends on: T035, T036. +- [x] T038 [P] [US2] Create `TournamentAPI/Brackets/RoundRobinCompletionStrategy.cs` implementing `IBracketCompletionStrategy` (`Format => TournamentFormat.RoundRobin`): the bracket is complete when no `Match` in it has `Status == MatchStatus.Scheduled` (data-model.md's completion rule). Every time this strategy runs and that condition holds — including when it runs again because `CorrectMatchResult` changed an already-`Completed` tournament's data — **(re)compute** `tournament.ChampionId` from the *current* standings and set `tournament.Status = TournamentStatus.Completed`: `ChampionId` = the `ParticipantId` of the `StandingEntry` with `Rank == 1`, or `null` if more than one entry shares `Rank == 1`. Do **not** guard this with `if (tournament.Status != Completed)` — the champion must never go stale after a post-completion correction changes the standings (`/speckit-analyze` finding C1). Depends on: T035, T007. +- [x] T039 [US2] Register the new strategy in `TournamentAPI/Program.cs`: `builder.Services.AddScoped();`. Depends on: T038, T012. + +### Integration tests for User Story 2 + +- [x] T040 [P] [US2] Integration tests in `TournamentAPI.IntegrationTests/GraphQL/Tests/Standings/StandingsQueryTests.cs` (new folder/file): querying `bracket.standings` mid-tournament reflects only completed matches; after all matches are played, `tournament.status` is `COMPLETED` and `tournament.champion` is the `Rank == 1` participant (or unset on a tie) (quickstart.md Scenario 3). + +**Checkpoint**: User Stories 1 and 2 are both independently functional — Round Robin tournaments can be played to completion with correct standings and champion determination. + +--- + +## Phase 5: User Story 3 - Existing Single Elimination tournaments are unaffected (Priority: P3) + +**Goal**: Confirm zero behavioral change for Single Elimination — this phase is regression verification, not new implementation (Foundational + US1 tasks already made the underlying code changes format-aware). + +**Independent Test**: Run the existing `generateBracket` → `play` → `updateRound` flow end-to-end on a tournament created without specifying a format, and confirm every outcome matches pre-feature behavior. + +### Tests for User Story 3 + +- [x] T041 [P] [US3] Integration test in `TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs`: `createTournament` without a `format` argument results in `tournament.format == SINGLE_ELIMINATION`, and the full existing `generateBracket` → `play` → `updateRound` flow on it behaves identically to before this feature (FR-002/FR-013, quickstart.md Scenario 5, steps 1–2). +- [x] T042 [P] [US3] Integration test in `TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchMutationTests.cs`: `play(winnerId: null, ...)` against a Single Elimination match is rejected (regression boundary check distinct from US1's Round Robin draw tests) (FR-006/FR-013). + +**Checkpoint**: All three user stories are independently functional. Existing Single Elimination test expectations are unmodified and green — any test that needed a behavior change to keep passing is a regression to fix, not a test to update (SC-003). + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [x] T043 [P] Update the team's Postman collection (via the Postman MCP tools) to add: `format` on `createTournament`'s input, a nullable-`winnerId` example on `play` (including a draw), the new `withdrawParticipant` mutation (callable as either the organizer or the participant), and `standings` on `bracket` queries — required by the Definition of Done for any input/payload/query-shape change. +- [x] T044 Run `dotnet build`, `dotnet test TournamentAPI.UnitTests`, and `dotnet test TournamentAPI.IntegrationTests` from the repository root; fix any failures before considering the feature done (Definition of Done). +- [x] T045 [P] Regenerate `schema.graphql` (`dotnet run --project TournamentAPI`, or the project's existing schema-export step) and diff it against `specs/001-round-robin-bracket-format/contracts/graphql-schema-changes.graphql` to confirm the actual schema delta matches what was planned (including that `WithdrawParticipantPayload` has no `errors` field, per that contract's corrected shape). +- [x] T046 Walk through `specs/001-round-robin-bracket-format/quickstart.md`'s 5 scenarios end-to-end and check off its validation checklist. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately, and can run any time before Phase 6. +- **Foundational (Phase 2)**: No dependency on Setup. **Blocks all of Phase 3–5.** +- **User Stories (Phase 3–5)**: All depend on Foundational (Phase 2) completion. + - US1 (P1) has no dependency on US2/US3. + - US2 (P2) depends on US1's `RoundRobinBracketStrategy` (T017) existing so there are matches to compute standings from, but not on US1's draw/withdrawal tasks specifically. + - US3 (P3) is pure regression verification of Foundational + US1's format-aware changes; it can run any time after US1's Phase 3 implementation tasks (T017–T028) land. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### Within Each Phase + +- Tests are listed before implementation and should be written first. +- Interfaces before implementations; implementations before DI registration; DI registration before the mutation that resolves the strategy at runtime. + +### Parallel Opportunities + +- Setup: T001 has no code dependency and can run any time. +- Foundational: T002+T003 in parallel; then T004 alone; then T006+T007 in parallel; then T008+T009 in parallel; then T010 (parallel with the T011→T013 chain, different files). +- US1: T014+T015+T016 in parallel; T024+T025+T026 in parallel (different files); T030+T032 in parallel with each other and with the T029→T031 chain (T029/T031 share a file). +- US2: T033+T034 in parallel; T036 in parallel with T035; T038 in parallel with T037 (both only depend on T035). +- US3: T041+T042 in parallel (different files). +- Polish: T043+T045 in parallel. + +--- + +## Parallel Example: Foundational Phase + +```bash +# Launch together: +Task: "Create TournamentAPI/Data/Models/TournamentFormat.cs" +Task: "Add Drawn value to TournamentAPI/Data/Models/MatchStatus.cs" + +# Then, once T004 (Tournament.Format) is done: +Task: "Create TournamentAPI/Brackets/IBracketGenerationStrategy.cs" +Task: "Create TournamentAPI/Brackets/IBracketCompletionStrategy.cs" +``` + +## Parallel Example: User Story 1 + +```bash +# Tests, launched together: +Task: "Unit tests for round-robin schedule generation in TournamentAPI.UnitTests/Services/RoundRobinBracketStrategyTests.cs" +Task: "Unit tests for format-aware draw validation in TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs" +Task: "Unit test for format-agnostic minimum-participant validation in TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs" + +# Withdrawal groundwork, launched together: +Task: "Create TournamentAPI/Tournaments/WithdrawParticipantInput.cs" +Task: "Add withdrawal error codes/errors in TournamentAPI/Tournaments/TournamentErrorCodes.cs and TournamentErrors.cs" +Task: "Add withdrawal validations (including owner-or-self authorization) in TournamentAPI/Tournaments/TournamentValidations.cs" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001 — can also be done later, it's documentation-only). +2. Complete Phase 2: Foundational (T002–T013) — **blocks everything else**. +3. Complete Phase 3: User Story 1 (T014–T032). +4. **STOP and VALIDATE**: run quickstart.md Scenarios 1, 2, and 4 manually or via the new integration tests. +5. This is a usable MVP: organizers (and participants themselves) can run a full Round Robin tournament (schedule, results, draws, withdrawal), even without a dedicated standings query (participants can still be told to tally `Match` results manually, or US2 follows immediately). + +### Incremental Delivery + +1. Setup + Foundational → foundation ready. +2. Add User Story 1 → test independently → Round Robin tournaments are playable (MVP). +3. Add User Story 2 → test independently → standings and auto-completion/champion determination work. +4. Add User Story 3 → regression-verify Single Elimination is untouched. +5. Polish (Postman, full test run, schema diff, quickstart walkthrough) → done. + +## Notes + +- [P] tasks touch different files and have no dependency on each other at the point they're ready to start. +- Every Foundational and US1/US2 implementation task cites the exact file(s) it touches and the research.md/data-model.md section backing its behavior — no task should require guessing a design decision that was already made in Phase 0/1. +- The correction-cascade fix (T013) is a **required correctness fix** uncovered during planning (research.md §10), not optional hardening — without it, correcting a Round Robin match's result can corrupt an unrelated match in the next round. +- The withdrawal mutation's owner-or-self authorization (T026/T027) and the always-recompute champion rule (T038) were both corrected during `/speckit-analyze` (findings F1 and C1) — the original drafts of these tasks were narrower than what FR-010/FR-009 actually require. +- Commit after each task or logical group; stop at either checkpoint to validate a story independently before moving on. + +--- + +## Phase 7: Convergence + +- [X] T047 [P] In the TournamentAPI Postman collection (via the Postman MCP tools), update the collection-level description's "Enums" and "Error codes" overview sections to list `MatchStatus.DRAWN`, the `TournamentFormat` enum, and the withdrawal error codes (`Tournament.NotAuthorizedForWithdrawal`, `Tournament.WithdrawalNotSupportedForFormat`, `Tournament.WithdrawalRequiresBracket`) and the draw error codes (`Match.DrawNotAllowedForFormat`, `Match.DrawRequiresEqualScores`) — the actual `format`/`withdrawParticipant`/draw/`standings` requests and their own descriptions are already correctly present (verified: "Withdraw Participant" request created 2026-09-27 with an accurate description, `createTournament`/`GetTournamentById` already include `format`/`standings`), only the collection's top-level overview blurb is stale (contradicts) +- [X] T048 In `TournamentAPI/Tournaments/TournamentMutations.cs`'s `WithdrawParticipant` mutation, after forfeiting the withdrawing participant's remaining `Scheduled` matches, resolve `IEnumerable` and call `.Single(s => s.Format == tournament.Format).SyncCompletionAsync(context, tournament, bracket.Id, frontierRound, token)` so that a withdrawal which clears the bracket's last `Scheduled` match correctly transitions `Tournament.Status` to `Completed` and (re)computes `ChampionId`, matching the always-recompute rule `RoundRobinCompletionStrategy` (T038) already applies from `Play`/`CorrectMatchResult` per FR-009 (missing) +- [X] T049 [P] Add a unit test in `TournamentAPI.UnitTests/Validations/BracketMutationValidationsTests.cs` for `ValidateFormatSupportsRoundAdvancement` (accepts Single Elimination, rejects Round Robin with `RoundAdvancementNotSupportedForFormat`), matching the per-validation unit test convention this feature's tests already apply to every other new validation (T016/T028/T033) per tasks.md's Tests section (partial) +- [X] T050 [P] Update `specs/001-round-robin-bracket-format/contracts/graphql-schema-changes.graphql`'s illustrated `MatchStatus` enum ordering from `SCHEDULED, PLAYED, DRAWN, NEEDS_REPLAY` to `SCHEDULED, PLAYED, NEEDS_REPLAY, DRAWN`, matching the actual (correct, append-only) implementation in `TournamentAPI/Data/Models/MatchStatus.cs` and `schema.graphql`, so the contract doc stays accurate (contradicts) From ac3494c40e13112c1d94d24914d2e10127a42ff2 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:45:35 +0200 Subject: [PATCH 21/25] feat: register standings services and update DI for bracket strategies Replaces direct BracketService/BracketCompletionService registration with specific strategy implementations for IBracketGenerationStrategy and IBracketCompletionStrategy, enabling multiple strategies via DI. Adds StandingsService and StandingsLookupService to the container for standings-related functionality. --- TournamentAPI/Program.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/TournamentAPI/Program.cs b/TournamentAPI/Program.cs index f44fd4a..3536989 100644 --- a/TournamentAPI/Program.cs +++ b/TournamentAPI/Program.cs @@ -11,6 +11,7 @@ using TournamentAPI.Matches; using TournamentAPI.Participants; using TournamentAPI.Services; +using TournamentAPI.Standings; using TournamentAPI.Tournaments; using TournamentAPI.Users; @@ -28,8 +29,14 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From 82dcf9eb8511d54b7c6adc87d90c7a19b2847a59 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:49:47 +0200 Subject: [PATCH 22/25] test: update DI to use strategy pattern for brackets in ApplicationUserSchemaTests Replaces direct BracketService registrations with strategy-based registrations for SingleElimination and RoundRobin bracket generation and completion. Adds StandingsService and StandingsLookupService to DI for testing. Supports extensibility and improved test coverage. --- .../Schema/ApplicationUserSchemaTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs b/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs index 91c6783..8cd7e69 100644 --- a/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs +++ b/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs @@ -8,6 +8,7 @@ using TournamentAPI.Matches; using TournamentAPI.Participants; using TournamentAPI.Services; +using TournamentAPI.Standings; using TournamentAPI.Tournaments; using TournamentAPI.Users; @@ -36,8 +37,14 @@ private static async Task BuildSchemaAsync() services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); From 2ea2eb1b91f07639dd5e2f37c0ffbddc91eaeb9c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:50:32 +0200 Subject: [PATCH 23/25] test: refactor BracketServiceTests to target SingleEliminationBracketStrategy BracketServiceTests now uses SingleEliminationBracketStrategy with a NullLogger, shifting the test focus from BracketService to the strategy class. --- TournamentAPI.UnitTests/Services/BracketServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TournamentAPI.UnitTests/Services/BracketServiceTests.cs b/TournamentAPI.UnitTests/Services/BracketServiceTests.cs index 645a8be..c6ff280 100644 --- a/TournamentAPI.UnitTests/Services/BracketServiceTests.cs +++ b/TournamentAPI.UnitTests/Services/BracketServiceTests.cs @@ -5,7 +5,7 @@ namespace TournamentAPI.UnitTests.Services; public class BracketServiceTests { - private readonly BracketService _sut = new(NullLogger.Instance); + private readonly SingleEliminationBracketStrategy _sut = new(NullLogger.Instance); [Fact] public void CreateBracket_SetsTournamentId() From 115519b42e840fee85d39cc6460685a6c66ef4f7 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:51:02 +0200 Subject: [PATCH 24/25] test: refactor concurrent logout test for idempotency Renamed the test to LogoutUser_ConcurrentRevoke_LeavesNoActiveTokens. Updated logic to: - Collect all successful responses and assert at least one exists. - Assert all successful responses indicate logout success. - For error responses, assert error code is RefreshTokenInvalid or RefreshTokenConflict. Removed the assertion that all responses must be successful, reflecting expected outcomes for concurrent logout attempts. --- .../GraphQL/Tests/Users/UserMutationTests.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs index 0d2ae02..d8cb5e8 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs @@ -655,7 +655,7 @@ public async Task LogoutUser_ReturnsError_WhenNotAuthenticated() } [Fact] - public async Task LogoutUser_TreatsConcurrentRevoke_AsIdempotentSuccess() + public async Task LogoutUser_ConcurrentRevoke_LeavesNoActiveTokens() { using var client1 = CreateClient(); using var client2 = CreateClient(); @@ -679,11 +679,17 @@ public async Task LogoutUser_TreatsConcurrentRevoke_AsIdempotentSuccess() var results = await Task.WhenAll(task1, task2); - Assert.All(results, r => + var successes = results.Where(r => !r.HasErrors).ToList(); + Assert.NotEmpty(successes); + Assert.All(successes, r => Assert.True(r.Data?.LogoutUser?.Boolean)); + + var allowedCodes = new[] { - Assert.False(r.HasErrors); - Assert.True(r.Data?.LogoutUser?.Boolean); - }); + UserErrors.RefreshTokenInvalid().Code, + UserErrors.RefreshTokenConflict().Code + }; + Assert.All(results.Where(r => r.HasErrors), r => + Assert.Contains(r.Errors!.First().Extensions!["code"]?.ToString(), allowedCodes)); var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); var activeTokenCount = await DbContext.RefreshTokens From 782159b40fd95763955c9c071dead1ebac0e35e1 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 28 Sep 2026 22:51:46 +0200 Subject: [PATCH 25/25] fix: skip dependent navigations in soft delete logic Removes unused System.Linq import and adds Microsoft.EntityFrameworkCore.Metadata. Updates the navigation loop to skip navigations where the metadata is an INavigation with IsOnDependent true, preventing processing of dependent-side navigations during soft delete operations. --- TournamentAPI/Data/SoftDeleteInterceptor.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Data/SoftDeleteInterceptor.cs b/TournamentAPI/Data/SoftDeleteInterceptor.cs index af9cc85..56984ca 100644 --- a/TournamentAPI/Data/SoftDeleteInterceptor.cs +++ b/TournamentAPI/Data/SoftDeleteInterceptor.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using System.Linq; +using Microsoft.EntityFrameworkCore.Metadata; using TournamentAPI.Data.Models; namespace TournamentAPI.Data; @@ -36,6 +36,8 @@ private static void CascadeSoftDelete(DbContext context, ISoftDeletable parentEn foreach (var navigation in parentEntry.Navigations) { + if (navigation.Metadata is INavigation { IsOnDependent: true }) continue; + if (navigation.CurrentValue is null) continue; if (navigation.CurrentValue is IEnumerable children)