From f2328968f439455847dde83184483b3fa7f3e46d Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:39:21 +0200 Subject: [PATCH 01/18] feat: add champion tracking to tournaments Added a new relationship in `ApplicationDbContext` to associate a `Tournament` with its `Champion`, including a foreign key (`ChampionId`) and `Restrict` delete behavior. Introduced a `WonTournaments` collection in `ApplicationUser` to track tournaments won by a user. Added a nullable `ChampionId` property and a `Champion` navigation property to the `Tournament` class to store and reference the champion of a tournament. Updated the `TournamentStatus` enum to include a new `Completed` status for concluded tournaments. --- TournamentAPI/Data/ApplicationDbContext.cs | 6 ++++++ TournamentAPI/Data/Models/ApplicationUser.cs | 2 ++ TournamentAPI/Data/Models/Tournament.cs | 3 +++ TournamentAPI/Data/Models/TournamentStatus.cs | 3 ++- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Data/ApplicationDbContext.cs b/TournamentAPI/Data/ApplicationDbContext.cs index 63993f9..0d180ab 100644 --- a/TournamentAPI/Data/ApplicationDbContext.cs +++ b/TournamentAPI/Data/ApplicationDbContext.cs @@ -46,6 +46,12 @@ protected override void OnModelCreating(ModelBuilder builder) .HasForeignKey(t => t.OwnerId) .OnDelete(DeleteBehavior.Restrict); + builder.Entity() + .HasOne(t => t.Champion) + .WithMany(u => u.WonTournaments) + .HasForeignKey(t => t.ChampionId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() .HasOne(b => b.Tournament) .WithOne(t => t.Bracket) diff --git a/TournamentAPI/Data/Models/ApplicationUser.cs b/TournamentAPI/Data/Models/ApplicationUser.cs index ae38c29..3696dc6 100644 --- a/TournamentAPI/Data/Models/ApplicationUser.cs +++ b/TournamentAPI/Data/Models/ApplicationUser.cs @@ -14,6 +14,8 @@ public class ApplicationUser : IdentityUser [GraphQLIgnore] public ICollection OwnedTournaments { get; set; } = new List(); [GraphQLIgnore] + public ICollection WonTournaments { get; set; } = new List(); + [GraphQLIgnore] public ICollection MatchesAsPlayer1 { get; set; } = new List(); [GraphQLIgnore] public ICollection MatchesAsPlayer2 { get; set; } = new List(); diff --git a/TournamentAPI/Data/Models/Tournament.cs b/TournamentAPI/Data/Models/Tournament.cs index b247700..e41d8d3 100644 --- a/TournamentAPI/Data/Models/Tournament.cs +++ b/TournamentAPI/Data/Models/Tournament.cs @@ -10,6 +10,7 @@ public class Tournament : ISoftDeletable public TournamentStatus Status { get; set; } public int OwnerId { get; set; } public int MaxParticipants { get; set; } + public int? ChampionId { get; set; } [GraphQLIgnore] public bool IsDeleted { get; set; } @@ -19,6 +20,8 @@ public class Tournament : ISoftDeletable [GraphQLIgnore] public ApplicationUser Owner { get; set; } = null!; [GraphQLIgnore] + public ApplicationUser? Champion { get; set; } + [GraphQLIgnore] public ICollection Participants { get; set; } = new List(); [GraphQLIgnore] diff --git a/TournamentAPI/Data/Models/TournamentStatus.cs b/TournamentAPI/Data/Models/TournamentStatus.cs index 2b085f2..c6262da 100644 --- a/TournamentAPI/Data/Models/TournamentStatus.cs +++ b/TournamentAPI/Data/Models/TournamentStatus.cs @@ -3,5 +3,6 @@ namespace TournamentAPI.Data.Models; public enum TournamentStatus { Closed, - Open + Open, + Completed } From afd6b62083c14108f93e555c44b22be82fc2063c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:41:57 +0200 Subject: [PATCH 02/18] feat: add BracketCompletionService for champion sync Introduce `BracketCompletionService` to handle tournament status and champion synchronization based on the final round of a bracket. The `SyncChampionAsync` method ensures the tournament's status and champion are updated when match scores are modified. Integrate `SyncChampionAsync` into `MatchMutations` to update tournament state after match updates. --- .../Brackets/BracketCompletionService.cs | 42 +++++++++++++++++++ TournamentAPI/Matches/MatchMutations.cs | 5 +++ 2 files changed, 47 insertions(+) create mode 100644 TournamentAPI/Brackets/BracketCompletionService.cs diff --git a/TournamentAPI/Brackets/BracketCompletionService.cs b/TournamentAPI/Brackets/BracketCompletionService.cs new file mode 100644 index 0000000..9c46309 --- /dev/null +++ b/TournamentAPI/Brackets/BracketCompletionService.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Brackets; + +public static class BracketCompletionService +{ + public static async Task SyncChampionAsync( + ApplicationDbContext context, + Tournament tournament, + int bracketId, + int frontierRound, + CancellationToken token) + { + var hasLaterRound = await context.Matches + .AnyAsync(m => m.BracketId == bracketId && m.Round > frontierRound, token); + + if (hasLaterRound) + return; + + var finalRoundMatches = await context.Matches + .Where(m => m.BracketId == bracketId && m.Round == frontierRound) + .ToListAsync(token); + + if (finalRoundMatches.Count != 1) + return; + + var finalMatch = finalRoundMatches[0]; + + if (finalMatch.Status == MatchStatus.Played) + { + tournament.Status = TournamentStatus.Completed; + tournament.ChampionId = finalMatch.WinnerId; + } + else if (tournament.Status == TournamentStatus.Completed) + { + tournament.Status = TournamentStatus.Closed; + tournament.ChampionId = null; + } + } +} diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index 1c42ae2..f744336 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -2,6 +2,7 @@ using HotChocolate.Resolvers; using Microsoft.EntityFrameworkCore; using System.Security.Claims; +using TournamentAPI.Brackets; using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; @@ -84,6 +85,8 @@ await MatchCorrectionService.ApplyCorrectionAsync( token); } + await BracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + await context.SaveChangesAsync(token); return true; @@ -175,6 +178,8 @@ await MatchCorrectionService.ApplyCorrectionAsync( Guid.NewGuid(), token); + await BracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + await context.SaveChangesAsync(token); return true; From 9d152692273f25a4c992ab4dae303b377f222b87 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:44:22 +0200 Subject: [PATCH 03/18] feat: return Match objects from correction methods Updated `ApplyCorrectionAsync` and `PropagateAsync` in `MatchCorrectionService` to return `Match` objects instead of `void`, enabling downstream operations to use the corrected or propagated match data. Modified `return` statements to align with the updated method signatures. In `MatchMutations`, captured the result of `ApplyCorrectionAsync` in a new variable `frontierMatch` to ensure the corrected match object is used in subsequent logic. Updated `isReplay` logic to utilize `frontierMatch`. --- TournamentAPI/Matches/MatchCorrectionService.cs | 14 +++++++------- TournamentAPI/Matches/MatchMutations.cs | 6 ++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/TournamentAPI/Matches/MatchCorrectionService.cs b/TournamentAPI/Matches/MatchCorrectionService.cs index 873e8cd..eb7ec7b 100644 --- a/TournamentAPI/Matches/MatchCorrectionService.cs +++ b/TournamentAPI/Matches/MatchCorrectionService.cs @@ -6,7 +6,7 @@ namespace TournamentAPI.Matches; public static class MatchCorrectionService { - public static async Task ApplyCorrectionAsync( + public static async Task ApplyCorrectionAsync( ApplicationDbContext context, Match match, MatchStatus previousStatus, @@ -25,9 +25,9 @@ public static async Task ApplyCorrectionAsync( correlationId, triggeredByMatchId: null, performedByUserId, notes: null)); if (previousWinnerId == match.WinnerId) - return; + return match; - await PropagateAsync(context, match, previousWinnerId, performedByUserId, correlationId, token); + return await PropagateAsync(context, match, previousWinnerId, performedByUserId, correlationId, token); } public static async Task RecordIdempotentDuplicateAsync( @@ -49,7 +49,7 @@ public static async Task RecordIdempotentDuplicateAsync( await context.SaveChangesAsync(token); } - private static async Task PropagateAsync( + private static async Task PropagateAsync( ApplicationDbContext context, Match sourceMatch, int? sourceMatchPreviousWinnerId, @@ -79,7 +79,7 @@ private static async Task PropagateAsync( currentRoundMatchIds, nextRoundMatchIds, upstreamMatch.Id); if (downstreamMatchId is null) - return; + return upstreamMatch; var downstream = nextRoundMatches.Single(m => m.Id == downstreamMatchId); @@ -121,7 +121,7 @@ private static async Task PropagateAsync( correlationId, upstreamMatch.Id, performedByUserId, notes: "Participant swapped after upstream correction; match not yet played.")); - return; + return downstream; } downstream.Status = MatchStatus.NeedsReplay; @@ -132,7 +132,7 @@ private static async Task PropagateAsync( correlationId, upstreamMatch.Id, performedByUserId, notes: "Invalidated by upstream correction.")); - return; + return downstream; } } diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index f744336..0ef1a74 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -69,9 +69,11 @@ public static partial class MatchMutations try { + var frontierMatch = match; + if (isReplay) { - await MatchCorrectionService.ApplyCorrectionAsync( + frontierMatch = await MatchCorrectionService.ApplyCorrectionAsync( context, match, previousStatus, @@ -165,7 +167,7 @@ await MatchCorrectionService.ApplyCorrectionAsync( try { - await MatchCorrectionService.ApplyCorrectionAsync( + var frontierMatch = await MatchCorrectionService.ApplyCorrectionAsync( context, match, previousStatus, From b82a3b88ee15e60ec999717942e1bd4fc1845fb4 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:48:39 +0200 Subject: [PATCH 04/18] feat: expand tournament validation conditions Updated `ValidateTournamentIsClosed` to consider a tournament valid if its status is either `TournamentStatus.Closed` or `TournamentStatus.Completed`. Previously, only `TournamentStatus.Closed` was accepted. This change ensures broader validation for completed tournaments. --- TournamentAPI/Matches/MatchValidations.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TournamentAPI/Matches/MatchValidations.cs b/TournamentAPI/Matches/MatchValidations.cs index 91114cf..25dc961 100644 --- a/TournamentAPI/Matches/MatchValidations.cs +++ b/TournamentAPI/Matches/MatchValidations.cs @@ -8,7 +8,7 @@ public static class MatchValidations => match == null ? MatchErrors.MatchNotFound(matchId) : null; public static IError? ValidateTournamentIsClosed(Tournament tournament) - => tournament.Status != TournamentStatus.Closed ? MatchErrors.TournamentNotClosed(tournament.Id) : null; + => 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; From a7893da4572d093eef0b30089d0c62021fe8355b Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:53:05 +0200 Subject: [PATCH 05/18] feat: add support for fetching user-won matches Added a new `DefaultMatchOrder` extension method for default sorting of `Match` entities. Updated `MatchService` to include new dependencies (`IUserWonMatchIdsBatchingContext` and `ApplicationDbContext`) and added the `GetWonMatchesByUserAsync` method to retrieve paginated matches won by a specific user. Enhanced `ApplicationUserResolvers` with a new `GetWonMatches` resolver for fetching user-won matches via GraphQL, with support for pagination, filtering, and sorting. Introduced `UserWonMatchIdsDataLoaders` for efficient batch loading of match IDs grouped by `WinnerId`. --- .../Extensions/SortDefinitionExtensions.cs | 3 +++ TournamentAPI/Matches/MatchService.cs | 25 ++++++++++++++++++- .../Users/ApplicationUserResolvers.cs | 12 +++++++++ .../Users/UserWonMatchIdsDataLoaders.cs | 24 ++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 TournamentAPI/Users/UserWonMatchIdsDataLoaders.cs diff --git a/TournamentAPI/Extensions/SortDefinitionExtensions.cs b/TournamentAPI/Extensions/SortDefinitionExtensions.cs index cfde988..3ae2cb8 100644 --- a/TournamentAPI/Extensions/SortDefinitionExtensions.cs +++ b/TournamentAPI/Extensions/SortDefinitionExtensions.cs @@ -7,4 +7,7 @@ public static class SortDefinitionExtensions { public static SortDefinition DefaultTournamentOrder(this SortDefinition sort) => sort.IfEmpty(o => o.AddAscending(t => t.Id)).AddAscending(t => t.Id); + + public static SortDefinition DefaultMatchOrder(this SortDefinition sort) + => sort.IfEmpty(o => o.AddAscending(m => m.Id)).AddAscending(m => m.Id); } diff --git a/TournamentAPI/Matches/MatchService.cs b/TournamentAPI/Matches/MatchService.cs index 2b6ccc0..254fbc9 100644 --- a/TournamentAPI/Matches/MatchService.cs +++ b/TournamentAPI/Matches/MatchService.cs @@ -1,9 +1,16 @@ using GreenDonut.Data; +using HotChocolate.Types.Pagination; +using TournamentAPI.Data; using TournamentAPI.Data.Models; +using TournamentAPI.Extensions; +using TournamentAPI.Users; namespace TournamentAPI.Matches; -public class MatchService(IMatchBatchingContext batchingContext) +public class MatchService( + IMatchBatchingContext batchingContext, + IUserWonMatchIdsBatchingContext wonMatchIdsBatchingContext, + ApplicationDbContext context) { public async Task> GetMatchesByBracketAsync( int bracketId, @@ -17,4 +24,20 @@ public async Task> GetMatchesByBracketAsync( .LoadAsync(bracketId, cancellationToken) ?? Page.Empty; } + + public async Task> GetWonMatchesByUserAsync( + int userId, + PagingArguments pagingArgs, + QueryContext? query, + CancellationToken cancellationToken) + { + var ids = await wonMatchIdsBatchingContext.WonMatchIdsByWinnerId.LoadAsync(userId, cancellationToken) ?? []; + + var page = await context.Matches + .Where(m => ids.Contains(m.Id)) + .With(query, s => s.DefaultMatchOrder()) + .ToPageAsync(pagingArgs, cancellationToken); + + return new PageConnection(page); + } } diff --git a/TournamentAPI/Users/ApplicationUserResolvers.cs b/TournamentAPI/Users/ApplicationUserResolvers.cs index d891a0c..8f37671 100644 --- a/TournamentAPI/Users/ApplicationUserResolvers.cs +++ b/TournamentAPI/Users/ApplicationUserResolvers.cs @@ -2,6 +2,7 @@ using HotChocolate.Resolvers; using HotChocolate.Types.Pagination; using TournamentAPI.Data.Models; +using TournamentAPI.Matches; using TournamentAPI.Tournaments; namespace TournamentAPI.Users; @@ -53,6 +54,17 @@ public static async Task> GetWonTournaments( CancellationToken cancellationToken) => await userTournamentsService.GetWonTournamentsAsync(user.Id, pagingArgs, query, cancellationToken); + [UseConnection(IncludeTotalCount = true)] + [UseFiltering] + [UseSorting] + public static async Task> GetWonMatches( + [Parent(requires: nameof(ApplicationUser.Id))] ApplicationUser user, + PagingArguments pagingArgs, + QueryContext query, + MatchService matchService, + CancellationToken cancellationToken) + => await matchService.GetWonMatchesByUserAsync(user.Id, pagingArgs, query, cancellationToken); + private static bool IsViewingOwnAccount(IResolverContext ctx, ApplicationUser user) { var viewerId = ctx.GetGlobalStateOrDefault("userId"); diff --git a/TournamentAPI/Users/UserWonMatchIdsDataLoaders.cs b/TournamentAPI/Users/UserWonMatchIdsDataLoaders.cs new file mode 100644 index 0000000..18db462 --- /dev/null +++ b/TournamentAPI/Users/UserWonMatchIdsDataLoaders.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Users; + +[DataLoaderGroup("UserWonMatchIdsBatchingContext")] +internal static class UserWonMatchIdsDataLoaders +{ + [DataLoader] + public static async Task> GetWonMatchIdsByWinnerIdAsync( + IReadOnlyList winnerIds, + ApplicationDbContext context, + CancellationToken cancellationToken + ) + { + winnerIds = [.. winnerIds.OrderBy(x => x)]; + return await context.Matches + .Where(m => m.WinnerId.HasValue && winnerIds.Contains(m.WinnerId.Value) && m.Status == MatchStatus.Played) + .GroupBy(m => m.WinnerId!.Value) + .Select(g => new { g.Key, MatchIds = g.Select(m => m.Id).ToArray() }) + .ToDictionaryAsync(g => g.Key, g => g.MatchIds, cancellationToken); + } +} From 620e96404fd4c77f6f90c6fd0692341a176119c7 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:54:13 +0200 Subject: [PATCH 06/18] refactor: optimize tournament winner data retrieval Refactor `UserWonTournamentIdsDataLoaders` to query the `Tournaments` table directly for completed tournaments with defined champions. This replaces the previous approach of analyzing match-level data from the `Matches` table, reducing complexity and improving performance by leveraging tournament- level data. --- TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs b/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs index 1922045..d73c26f 100644 --- a/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs +++ b/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs @@ -15,12 +15,10 @@ CancellationToken cancellationToken ) { winnerIds = [.. winnerIds.OrderBy(x => x)]; - return await context.Matches - .Where(m => m.WinnerId.HasValue && winnerIds.Contains(m.WinnerId.Value) && m.Status == MatchStatus.Played) - .Where(m => m.Round == m.Bracket.Matches.Max(x => x.Round) - && m.Bracket.Matches.Count(o => o.Round == m.Round) == 1) - .GroupBy(m => m.WinnerId!.Value) - .Select(g => new { g.Key, TournamentIds = g.Select(m => m.Bracket.TournamentId).ToArray() }) + return await context.Tournaments + .Where(t => t.Status == TournamentStatus.Completed && t.ChampionId.HasValue && winnerIds.Contains(t.ChampionId.Value)) + .GroupBy(t => t.ChampionId!.Value) + .Select(g => new { g.Key, TournamentIds = g.Select(t => t.Id).ToArray() }) .ToDictionaryAsync(g => g.Key, g => g.TournamentIds, cancellationToken); } } From 03b6493d4454b26a7d1c05906983f9e35fc08eb2 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:56:34 +0200 Subject: [PATCH 07/18] feat: add validations for tournament "Completed" status Added two new error codes to `TournamentErrorCodes`: - `StatusCannotBeSetManually` - `CannotChangeCompletedStatus` Introduced corresponding error methods in `TournamentErrors`: - `TournamentStatusCannotBeSetManually` - `CannotChangeCompletedTournamentStatus` Enhanced `TournamentMutations` with new validation checks: - Prevent manually setting status to "Completed". - Block modifications to tournaments already "Completed". Updated `TournamentValidations`: - Added `ValidateStatusIsNotCompleted` and `ValidateTournamentIsNotCompleted`. - Updated `ValidateTournamentCanBeDeleted` to handle "Completed" status. --- TournamentAPI/Tournaments/TournamentErrorCodes.cs | 2 ++ TournamentAPI/Tournaments/TournamentErrors.cs | 13 +++++++++++++ TournamentAPI/Tournaments/TournamentMutations.cs | 9 +++++++++ TournamentAPI/Tournaments/TournamentValidations.cs | 8 +++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Tournaments/TournamentErrorCodes.cs b/TournamentAPI/Tournaments/TournamentErrorCodes.cs index 0e81937..68e5498 100644 --- a/TournamentAPI/Tournaments/TournamentErrorCodes.cs +++ b/TournamentAPI/Tournaments/TournamentErrorCodes.cs @@ -14,4 +14,6 @@ public static class TournamentErrorCodes public const string CannotReopenTournamentWithBracket = "Tournament.CannotReopenWithBracket"; public const string CannotReopenTournamentAfterStartDate = "Tournament.CannotReopenAfterStartDate"; public const string CannotDeleteTournamentWithBracket = "Tournament.CannotDeleteWithBracket"; + public const string StatusCannotBeSetManually = "Tournament.StatusCannotBeSetManually"; + public const string CannotChangeCompletedStatus = "Tournament.CannotChangeCompletedStatus"; } diff --git a/TournamentAPI/Tournaments/TournamentErrors.cs b/TournamentAPI/Tournaments/TournamentErrors.cs index 344b307..6bd7c25 100644 --- a/TournamentAPI/Tournaments/TournamentErrors.cs +++ b/TournamentAPI/Tournaments/TournamentErrors.cs @@ -89,4 +89,17 @@ public static IError CannotDeleteTournamentWithBracket(int tournamentId) => .SetCode(TournamentErrorCodes.CannotDeleteTournamentWithBracket) .SetExtension("TournamentId", tournamentId) .Build(); + + public static IError TournamentStatusCannotBeSetManually() => + ErrorBuilder.New() + .SetMessage("Tournament status cannot be manually set to Completed; it is set automatically once the final match is decided.") + .SetCode(TournamentErrorCodes.StatusCannotBeSetManually) + .Build(); + + public static IError CannotChangeCompletedTournamentStatus(int tournamentId) => + ErrorBuilder.New() + .SetMessage("Tournament status cannot be changed once the tournament is Completed.") + .SetCode(TournamentErrorCodes.CannotChangeCompletedStatus) + .SetExtension("TournamentId", tournamentId) + .Build(); } diff --git a/TournamentAPI/Tournaments/TournamentMutations.cs b/TournamentAPI/Tournaments/TournamentMutations.cs index e2e5e4d..21503b2 100644 --- a/TournamentAPI/Tournaments/TournamentMutations.cs +++ b/TournamentAPI/Tournaments/TournamentMutations.cs @@ -91,6 +91,9 @@ public static partial class TournamentMutations if (resolverContext.TryReportError(TournamentValidations.ValidateStartDateHasMinimumLeadTime(input.StartDate, DateTime.UtcNow))) return null; + if (resolverContext.TryReportError(TournamentValidations.ValidateStatusIsNotCompleted(input.Status))) + return null; + var tournament = new Tournament { Name = input.Name, @@ -170,6 +173,12 @@ public static partial class TournamentMutations if (input.Status != null) { + if (resolverContext.TryReportError(TournamentValidations.ValidateTournamentIsNotCompleted(tournament))) + return null; + + if (resolverContext.TryReportError(TournamentValidations.ValidateStatusIsNotCompleted(input.Status.Value))) + return null; + var bracketExists = await context.Brackets.AnyAsync(b => b.TournamentId == tournament.Id, token); if (resolverContext.TryReportError(TournamentValidations.ValidateTournamentCanBeReopened(tournament.Id, bracketExists, input.Status.Value, tournament.StartDate, DateTime.UtcNow))) diff --git a/TournamentAPI/Tournaments/TournamentValidations.cs b/TournamentAPI/Tournaments/TournamentValidations.cs index cea2a59..9076095 100644 --- a/TournamentAPI/Tournaments/TournamentValidations.cs +++ b/TournamentAPI/Tournaments/TournamentValidations.cs @@ -34,10 +34,16 @@ public static class TournamentValidations => startDate < now.Add(MinimumStartDateLeadTime) ? TournamentErrors.StartDateTooSoon(startDate) : null; public static IError? ValidateTournamentCanBeDeleted(Tournament tournament) - => tournament.Status == TournamentStatus.Closed && tournament.Bracket != null + => (tournament.Status == TournamentStatus.Closed || tournament.Status == TournamentStatus.Completed) && tournament.Bracket != null ? TournamentErrors.CannotDeleteTournamentWithBracket(tournament.Id) : null; + public static IError? ValidateStatusIsNotCompleted(TournamentStatus requestedStatus) + => requestedStatus == TournamentStatus.Completed ? TournamentErrors.TournamentStatusCannotBeSetManually() : null; + + public static IError? ValidateTournamentIsNotCompleted(Tournament tournament) + => tournament.Status == TournamentStatus.Completed ? TournamentErrors.CannotChangeCompletedTournamentStatus(tournament.Id) : null; + public static IError? ValidateTournamentCanBeReopened(int tournamentId, bool bracketExists, TournamentStatus newStatus, DateTime startDate, DateTime now) { if (newStatus != TournamentStatus.Open) From e2cb667c4e7797c8bd92e125a87e958c3a1f5e8a Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:57:25 +0200 Subject: [PATCH 08/18] feat: add GetChampion resolver to TournamentResolvers A new asynchronous method `GetChampion` was added to the `TournamentResolvers` class. This method retrieves the champion of a tournament based on the `ChampionId` property of the `Tournament` object. If `ChampionId` is null, the method returns null; otherwise, it fetches the champion's details using the `ApplicationUserService.GetApplicationUserByIdAsync` method. --- TournamentAPI/Tournaments/TournamentResolvers.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/TournamentAPI/Tournaments/TournamentResolvers.cs b/TournamentAPI/Tournaments/TournamentResolvers.cs index 543a4f9..c5558e6 100644 --- a/TournamentAPI/Tournaments/TournamentResolvers.cs +++ b/TournamentAPI/Tournaments/TournamentResolvers.cs @@ -19,6 +19,14 @@ public static bool GetIsActive([Parent] Tournament tournament) CancellationToken cancellationToken) => await applicationUserService.GetApplicationUserByIdAsync(tournament.OwnerId, cancellationToken); + public static async Task GetChampion( + [Parent(requires: nameof(Tournament.ChampionId))] Tournament tournament, + ApplicationUserService applicationUserService, + CancellationToken cancellationToken) + => tournament.ChampionId is null + ? null + : await applicationUserService.GetApplicationUserByIdAsync(tournament.ChampionId.Value, cancellationToken); + public static async Task GetBracket( [Parent(requires: nameof(Tournament.Id))] Tournament tournament, BracketLookupService bracketLookupService, From 1323ade4c8248ef14d1fdecd0b02366b78dbe966 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 20:58:29 +0200 Subject: [PATCH 09/18] feat: update seeded tournaments and add new fixture Updated `tournament3` and `tournament16` to reflect their completed status and added champion details. Introduced `tournament17`, a new "Bracket Validation Fixture" with a two-round bracket for testing purposes. Updated the `AddRangeAsync` call to include the new tournament. --- TournamentAPI/Data/DatabaseSeeder.cs | 45 ++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/TournamentAPI/Data/DatabaseSeeder.cs b/TournamentAPI/Data/DatabaseSeeder.cs index 2b72ee4..c5f9d05 100644 --- a/TournamentAPI/Data/DatabaseSeeder.cs +++ b/TournamentAPI/Data/DatabaseSeeder.cs @@ -75,9 +75,11 @@ public static async Task SeedAsync( { Name = "Winter Championship 2024", StartDate = DateTime.UtcNow.AddDays(-30), - Status = TournamentStatus.Closed, + Status = TournamentStatus.Completed, OwnerId = user1.Id, Owner = user1, + ChampionId = user1.Id, + Champion = user1, MaxParticipants = 8, Participants = new List(), Bracket = new Bracket @@ -359,9 +361,11 @@ public static async Task SeedAsync( { Name = "Champions Cup", StartDate = DateTime.UtcNow.AddDays(-10), - Status = TournamentStatus.Closed, + Status = TournamentStatus.Completed, OwnerId = user2.Id, Owner = user2, + ChampionId = user6.Id, + Champion = user6, MaxParticipants = 4, Participants = new List(), Bracket = new Bracket() @@ -383,7 +387,42 @@ public static async Task SeedAsync( tournament16.Bracket.Matches.Add(match20); tournament16.Bracket.Matches.Add(match21); - await context.Tournaments.AddRangeAsync(tournament1, tournament2, tournament3, tournament4, tournament5, tournament6, tournament7, tournament8, tournament9, tournament10, tournament11, tournament12, tournament13, tournament14, tournament15, tournament16); + // Tournament 17: Closed tournament with 4 participants and a two-round bracket where round 1 + // is fully played and round 2 (the final) has already been generated but not yet played. + // Used as a fixture for bracket-validation tests that need an existing, in-progress bracket + // without the tournament being Completed. + var tournament17 = new Tournament + { + Name = "Bracket Validation Fixture", + StartDate = DateTime.UtcNow.AddDays(-5), + Status = TournamentStatus.Closed, + OwnerId = user4.Id, + Owner = user4, + MaxParticipants = 4, + Participants = new List(), + Bracket = new Bracket + { + Matches = new List() + } + }; + + tournament17.Participants.Add(new TournamentParticipant { Tournament = tournament17, Participant = user1, SlotNumber = 1 }); + tournament17.Participants.Add(new TournamentParticipant { Tournament = tournament17, Participant = user2, SlotNumber = 2 }); + tournament17.Participants.Add(new TournamentParticipant { Tournament = tournament17, Participant = user3, SlotNumber = 3 }); + tournament17.Participants.Add(new TournamentParticipant { Tournament = tournament17, Participant = user4, SlotNumber = 4 }); + + // Round 1 (2 matches, both played) + var match22 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user2.Id, WinnerId = user1.Id, Status = MatchStatus.Played, Bracket = tournament17.Bracket }; + var match23 = new Match { Round = 1, Player1Id = user3.Id, Player2Id = user4.Id, WinnerId = user3.Id, Status = MatchStatus.Played, Bracket = tournament17.Bracket }; + + // Round 2 - Final (already generated, not yet played) + var match24 = new Match { Round = 2, Player1Id = user1.Id, Player2Id = user3.Id, WinnerId = null, Status = MatchStatus.Scheduled, Bracket = tournament17.Bracket }; + + tournament17.Bracket.Matches.Add(match22); + tournament17.Bracket.Matches.Add(match23); + tournament17.Bracket.Matches.Add(match24); + + await context.Tournaments.AddRangeAsync(tournament1, tournament2, tournament3, tournament4, tournament5, tournament6, tournament7, tournament8, tournament9, tournament10, tournament11, tournament12, tournament13, tournament14, tournament15, tournament16, tournament17); await context.SaveChangesAsync(); } } From b8118576568027945e6aecd63d199202fd31d134 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:00:39 +0200 Subject: [PATCH 10/18] test: add validation tests for tournament status changes Added new test methods to validate error handling for scenarios where tournament status is set to `Completed` manually or modified improperly: - `CreateTournament_ReturnsStatusCannotBeSetManuallyError_WhenStatusIsCompleted`: Ensures creating a tournament with `Completed` status fails. - `UpdateTournament_ReturnsStatusCannotBeSetManuallyError_WhenSettingStatusToCompleted`: Ensures updating a tournament's status to `Completed` manually fails. - `UpdateTournament_ReturnsCannotChangeCompletedStatusError_WhenTournamentIsAlreadyCompleted`: Ensures modifying the status of an already `Completed` tournament fails. - `DeleteTournament_ReturnsCannotDeleteWithBracketError_WhenTournamentIsCompleted`: Ensures deleting a `Completed` tournament with a bracket fails. --- .../Tournaments/TournamentMutationTests.cs | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs index d426cc6..9bee63d 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs @@ -1764,4 +1764,244 @@ public async Task JoinTournament_HandlesDbUpdateException_WhenRaceConditionFills Assert.Equal(3, participantsInDb.Count); Assert.Equal(3, participantsInDb.Select(tp => tp.SlotNumber).Distinct().Count()); } + + [Fact] + public async Task CreateTournament_ReturnsStatusCannotBeSetManuallyError_WhenStatusIsCompleted() + { + // 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 = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var variables = new + { + input = new + { + name = "Should Not Be Created", + startDate = DateTime.UtcNow.AddDays(7).ToString("o"), + status = TournamentStatus.Completed.ToString().ToUpper(), + maxParticipants = 8 + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithOwnerReturn, + variables); + + // Assert + Assert.True(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.CreateTournament); + Assert.Null(response.Data.CreateTournament.Tournament); + Assert.NotNull(response.Errors); + + var error = response.Errors.First(); + Assert.NotNull(error); + Assert.NotNull(error.Extensions); + Assert.True(error.Extensions.ContainsKey("code")); + Assert.NotNull(error.Message); + + var expectedError = TournamentErrors.TournamentStatusCannotBeSetManually(); + Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + + var tournamentInDb = await DbContext.Tournaments + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == "Should Not Be Created"); + + Assert.Null(tournamentInDb); + } + + [Fact] + public async Task UpdateTournament_ReturnsStatusCannotBeSetManuallyError_WhenSettingStatusToCompleted() + { + // Arrange + var email = "alice@example.com"; + var password = "Password123!"; + var tournamentToUpdateId = 1; // Open tournament, not yet Completed + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var variables = new + { + input = new + { + tournamentId = tournamentToUpdateId, + status = TournamentStatus.Completed.ToString().ToUpper() + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + variables); + + // Assert + Assert.True(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.UpdateTournament); + Assert.Null(response.Data.UpdateTournament.Tournament); + Assert.NotNull(response.Errors); + + var error = response.Errors.First(); + Assert.NotNull(error); + Assert.NotNull(error.Extensions); + Assert.True(error.Extensions.ContainsKey("code")); + Assert.NotNull(error.Message); + + var expectedError = TournamentErrors.TournamentStatusCannotBeSetManually(); + Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + + var tournamentInDb = await DbContext.Tournaments + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tournamentToUpdateId); + + Assert.NotNull(tournamentInDb); + Assert.Equal(TournamentStatus.Open, tournamentInDb.Status); + } + + [Fact] + public async Task UpdateTournament_ReturnsCannotChangeCompletedStatusError_WhenTournamentIsAlreadyCompleted() + { + // Arrange + var email = "alice@example.com"; + var password = "Password123!"; + var tournamentToUpdateId = 3; // already Completed + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var variables = new + { + input = new + { + tournamentId = tournamentToUpdateId, + status = TournamentStatus.Closed.ToString().ToUpper() + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + variables); + + // Assert + Assert.True(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.UpdateTournament); + Assert.Null(response.Data.UpdateTournament.Tournament); + Assert.NotNull(response.Errors); + + var error = response.Errors.First(); + Assert.NotNull(error); + Assert.NotNull(error.Extensions); + Assert.True(error.Extensions.ContainsKey("code")); + Assert.NotNull(error.Message); + + var expectedError = TournamentErrors.CannotChangeCompletedTournamentStatus(tournamentToUpdateId); + Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + Assert.Equal(expectedError.Extensions!["TournamentId"]?.ToString(), error.Extensions["TournamentId"]?.ToString()); + + var tournamentInDb = await DbContext.Tournaments + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tournamentToUpdateId); + + Assert.NotNull(tournamentInDb); + Assert.Equal(TournamentStatus.Completed, tournamentInDb.Status); + } + + [Fact] + public async Task DeleteTournament_ReturnsCannotDeleteWithBracketError_WhenTournamentIsCompleted() + { + // Arrange + var email = "alice@example.com"; + var password = "Password123!"; + var tournamentToDeleteId = 3; // Completed tournament with an existing bracket + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var variables = new + { + input = new + { + tournamentId = tournamentToDeleteId + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.DeleteTournament, + variables); + + // Assert + Assert.True(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.DeleteTournament); + Assert.Null(response.Data.DeleteTournament.Boolean); + Assert.NotNull(response.Errors); + + var error = response.Errors.First(); + Assert.NotNull(error); + Assert.NotNull(error.Extensions); + Assert.True(error.Extensions.ContainsKey("code")); + Assert.NotNull(error.Message); + + var expectedError = TournamentErrors.CannotDeleteTournamentWithBracket(tournamentToDeleteId); + Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + Assert.Equal(expectedError.Extensions!["TournamentId"]?.ToString(), error.Extensions["TournamentId"]?.ToString()); + + var tournamentInDb = await DbContext.Tournaments + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tournamentToDeleteId); + + Assert.NotNull(tournamentInDb); + } } From 971975de5c8f05eb42bd51b1201a8270cf433eff Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:02:07 +0200 Subject: [PATCH 11/18] test: add and update bracket mutation tests Updated existing tests to reflect new scenarios: - `GenerateBracket_ReturnsBracketAlreadyExistsError_WhenBracketAlreadyExists`: Adjusted setup for a closed tournament with an unplayed final. - `UpdateRound_ReturnsNoMatchesInRoundError_WhenNoMatchesInRound`: Adjusted setup for a round with no matches. - `UpdateRound_ReturnsNextRoundAlreadyExistsError_WhenNextRoundAlreadyExists`: Adjusted setup for a next round already generated. - Renamed and updated `UpdateRound_ReturnsBracketAlreadyHasWinnerError_WhenBracketAlreadyHasWinner` to `UpdateRound_ReturnsRoundUpdateNotAllowedError_WhenTournamentIsAlreadyCompleted`. Added two new tests: - `Play_MarksTournamentCompletedAndSetsChampion_WhenFinalMatchIsPlayed`: Verifies tournament completion and champion assignment after the final match. - `Play_DoesNotChangeTournamentCompletionState_WhenMatchIsNotTheFinal`: Verifies no state change for non-final matches. --- .../Tests/Brackets/BracketMutationTests.cs | 141 ++++++++++++++++-- 1 file changed, 125 insertions(+), 16 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs index 0927c34..c2e277c 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Brackets/BracketMutationTests.cs @@ -6,6 +6,7 @@ using TournamentAPI.Tournaments; namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Brackets; + public class BracketMutationTests : BaseIntegrationTest { public BracketMutationTests(IntegrationTestWebAppFactory factory) : base(factory) @@ -258,11 +259,11 @@ public async Task GenerateBracket_ReturnsBracketGenerationNotAllowedError_WhenTo [Fact] public async Task GenerateBracket_ReturnsBracketAlreadyExistsError_WhenBracketAlreadyExists() { - // Arrange - var email = "alice@example.com"; + // Arrange: tournament 17 (bracket 7) is Closed and already has a bracket, but its final + // hasn't been played yet, so it stays Closed rather than Completed. + var email = "david@example.com"; var password = "Password123!"; - var tournamentCreateBracketId = 3; - var bracketId = 1; + var tournamentCreateBracketId = 17; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -811,11 +812,11 @@ public async Task UpdateRound_ReturnsRoundUpdateNotAllowedError_WhenTournamentIs [Fact] public async Task UpdateRound_ReturnsNoMatchesInRoundError_WhenNoMatchesInRound() { - // Arrange - var email = "alice@example.com"; + // Arrange: tournament 17 (bracket 7) only has rounds 1 and 2 generated, so round 3 has no matches. + var email = "david@example.com"; var password = "Password123!"; - var bracketId = 1; - var roundNumber = 4; + var bracketId = 7; + var roundNumber = 3; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -1037,10 +1038,10 @@ public async Task UpdateRound_CorrectlyAdvancesWinners_WhenOddNumberOfWinners() [Fact] public async Task UpdateRound_ReturnsNextRoundAlreadyExistsError_WhenNextRoundAlreadyExists() { - // Arrange - var email = "alice@example.com"; + // Arrange: tournament 17 (bracket 7) already has round 2 generated from round 1's winners. + var email = "david@example.com"; var password = "Password123!"; - var bracketId = 1; + var bracketId = 7; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -1092,17 +1093,17 @@ public async Task UpdateRound_ReturnsNextRoundAlreadyExistsError_WhenNextRoundAl .Where(m => m.BracketId == bracketId && m.Round == 2) .ToListAsync(); - Assert.NotEmpty(matchesForNextRound); - Assert.Equal(2, matchesForNextRound.Count); + Assert.Single(matchesForNextRound); } [Fact] - public async Task UpdateRound_ReturnsBracketAlreadyHasWinnerError_WhenBracketAlreadyHasWinner() + public async Task UpdateRound_ReturnsRoundUpdateNotAllowedError_WhenTournamentIsAlreadyCompleted() { // Arrange var email = "alice@example.com"; var password = "Password123!"; var bracketId = 1; + var tournamentId = 3; using var client = CreateClient(); var tokenResponse = await client.ExecuteMutationAsync( @@ -1144,10 +1145,10 @@ public async Task UpdateRound_ReturnsBracketAlreadyHasWinnerError_WhenBracketAlr Assert.True(error.Extensions.ContainsKey("code")); Assert.NotNull(error.Message); - var expectedError = BracketErrors.BracketAlreadyHasWinner(bracketId); + var expectedError = BracketErrors.RoundUpdateNotAllowed(tournamentId); Assert.Equal(expectedError.Code, error.Extensions["code"]?.ToString()); Assert.Equal(expectedError.Message, error.Message); - Assert.Equal(expectedError.Extensions!["BracketId"]?.ToString(), error.Extensions["BracketId"]?.ToString()); + Assert.Equal(expectedError.Extensions!["TournamentId"]?.ToString(), error.Extensions["TournamentId"]?.ToString()); var matchesForNextRound = await DbContext.Matches .AsNoTracking() @@ -1156,4 +1157,112 @@ public async Task UpdateRound_ReturnsBracketAlreadyHasWinnerError_WhenBracketAlr Assert.Empty(matchesForNextRound); } + + [Fact] + public async Task Play_MarksTournamentCompletedAndSetsChampion_WhenFinalMatchIsPlayed() + { + // Arrange: tournament 8 ("Event") has exactly 2 participants and no bracket yet, so generating + // it produces a single round-1 match, which is by definition the final. + var email = "bob@example.com"; + var password = "Password123!"; + var tournamentId = 8; + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var finalMatch = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Bracket.TournamentId == tournamentId); + + var variables = new + { + input = new + { + matchId = finalMatch.Id, + winnerId = finalMatch.Player1Id, + player1Score = 3, + player2Score = 1 + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + variables); + + // Assert + Assert.False(response.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 Play_DoesNotChangeTournamentCompletionState_WhenMatchIsNotTheFinal() + { + // Arrange: tournament 10 ("Trio Tournament") has 3 participants, so generating it produces a + // round of 2 matches (one real, one auto-resolved bye) - neither one is the final on its own. + 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 = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId } }); + + var realMatch = await DbContext.Matches + .AsNoTracking() + .SingleAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled); + + var variables = new + { + input = new + { + matchId = realMatch.Id, + winnerId = realMatch.Player1Id, + player1Score = 3, + player2Score = 1 + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Match.Play, + variables); + + // Assert + Assert.False(response.HasErrors); + + var tournamentAfter = await DbContext.Tournaments.AsNoTracking().SingleAsync(t => t.Id == tournamentId); + + Assert.Equal(TournamentStatus.Closed, tournamentAfter.Status); + Assert.Null(tournamentAfter.ChampionId); + } } From 6e9940e77fe3abe456ba8fd17b032cfc6feb4576 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:03:27 +0200 Subject: [PATCH 12/18] test: add tests for GetTournamentById champion details query behavior Added two new test methods to the `TournamentQueryTests` class: - `GetTournamentById_WithChampion_ReturnsCompletedStatusAndChampion_ForCompletedTournament` verifies that a "COMPLETED" tournament returns the champion's details, including ID and first name. - `GetTournamentById_WithChampion_ReturnsNullChampion_ForInProgressTournament` ensures that a "CLOSED" tournament with an in-progress bracket does not return champion details. --- .../Tests/Tournaments/TournamentQueryTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs index d5a6496..f1149d6 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs @@ -511,4 +511,48 @@ public async Task GetTournamentById_ReturnsIsActiveFalse_WhenStartDateHasPassedB Assert.Equal("OPEN", response.Data.TournamentById.Status); Assert.False(response.Data.TournamentById.IsActive); } + + [Fact] + public async Task GetTournamentById_WithChampion_ReturnsCompletedStatusAndChampion_ForCompletedTournament() + { + // Arrange: tournament 3 ("Winter Championship 2024") is seeded as Completed with alice as champion. + var tournamentId = 3; + + // Act + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithChampion, + new { id = tournamentId }); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById); + Assert.Equal("COMPLETED", response.Data.TournamentById.Status); + Assert.NotNull(response.Data.TournamentById.ChampionId); + Assert.NotNull(response.Data.TournamentById.Champion); + Assert.Equal(response.Data.TournamentById.ChampionId, response.Data.TournamentById.Champion.Id); + Assert.Equal("Alice", response.Data.TournamentById.Champion.FirstName); + } + + [Fact] + public async Task GetTournamentById_WithChampion_ReturnsNullChampion_ForInProgressTournament() + { + // Arrange: tournament 4 ("Autumn Battle") is Closed with a bracket in progress, not Completed. + var tournamentId = 4; + + // Act + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithChampion, + new { id = tournamentId }); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById); + Assert.Equal("CLOSED", response.Data.TournamentById.Status); + Assert.Null(response.Data.TournamentById.ChampionId); + Assert.Null(response.Data.TournamentById.Champion); + } } From 2d3a023fbf1e40d858d1d08d1ce1c0843625dc47 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:04:03 +0200 Subject: [PATCH 13/18] test: update TournamentQueryTests for new total count Updated the `TournamentQueryTests.cs` file to reflect the increase in the total count of tournaments from 15 to 16. Adjusted the expected `TotalCount` value in the following test cases: - `GetAllWithTotalCount` - `GetAllWithParticipants` - `GetAllWithBracketAndMatches` - `GetAllWithOwner` - `GetAllWithSorting` - Test ensuring "Cancelled Spring Event" is not present. --- .../Tests/Tournaments/TournamentQueryTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs index f1149d6..ebeed78 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs @@ -167,7 +167,7 @@ public async Task GetTournaments_ReturnsAllTournamentsWithTotalCount() // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(15, response.Data.Tournaments.TotalCount); + Assert.Equal(16, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var tournamentNames = response.Data.Tournaments.Nodes?.Select(t => t.Name).ToList(); @@ -240,7 +240,7 @@ public async Task GetTournaments_WithParticipants_ReturnsTournamentsWithParticip // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(15, response.Data.Tournaments.TotalCount); + Assert.Equal(16, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var springTournament = response.Data.Tournaments.Nodes?.FirstOrDefault(t => t.Name == "Spring Invitational"); @@ -266,7 +266,7 @@ public async Task GetTournaments_WithBracketAndMatches_ReturnsTournamentsWithBra // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(15, response.Data.Tournaments.TotalCount); + Assert.Equal(16, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var springTournament = response.Data.Tournaments.Nodes?.FirstOrDefault(t => t.Name == "Spring Invitational"); @@ -296,7 +296,7 @@ public async Task GetTournaments_WithOwner_ReturnsTournamentsWithOwnerDetails() // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(15, response.Data.Tournaments.TotalCount); + Assert.Equal(16, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); foreach (var tournament in response.Data.Tournaments.Nodes!) @@ -320,7 +320,7 @@ public async Task GetTournaments_WithDescendingNameSorting_ReturnsTournamentsInD // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(15, response.Data.Tournaments.TotalCount); + Assert.Equal(16, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var tournamentNames = response.Data.Tournaments.Nodes?.Select(t => t.Name).ToList(); @@ -341,7 +341,7 @@ public async Task GetTournaments_ExcludesSoftDeletedTournaments() Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(15, response.Data.Tournaments.TotalCount); + Assert.Equal(16, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var tournamentNames = response.Data.Tournaments.Nodes?.Select(t => t.Name).ToList(); From 1cda48d36140cafe77e78f56715e7a0f6ba33551 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:05:42 +0200 Subject: [PATCH 14/18] test: add tests for match win and replay scenarios in UserQueryTests Added two new test methods in `UserQueryTests`: 1. `GetMe_WonMatches_IncludesMatchWinsRegardlessOfTournamentCompletion`: - Ensures match wins are included in the response even if the tournament is undecided. - Validates that the match win appears in `WonMatches` but not in `WonTournaments`. 2. `GetMe_WonMatches_ExcludesMatchesNeedingReplay`: - Ensures matches marked as `NeedsReplay` are excluded from the `WonMatches` response. --- .../GraphQL/Tests/Users/UserQueryTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs index ae8762e..720e4a2 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; using TournamentAPI.Shared.Models; namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Users; @@ -169,4 +171,80 @@ public async Task GetMe_WonTournaments_ExcludesIncompleteBrackets() var wonTournamentNames = response.Data.Me.WonTournaments.Nodes?.Select(t => t.Name).ToList(); Assert.DoesNotContain("Doubles Tournament", wonTournamentNames); } + + [Fact] + public async Task GetMe_WonMatches_IncludesMatchWinsRegardlessOfTournamentCompletion() + { + // Arrange - bob won Round 1 of Tournament 12 ("Doubles Tournament"), which isn't decided + // yet (no final round generated), so it doesn't show under wonTournaments, but the match + // win itself should still show under wonMatches. + var email = "bob@example.com"; + using var client = CreateClient(); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = "Password123!" + } + }); + client.SetAuthToken(token.Data.LoginUser.String); + + var wonMatchInDb = await DbContext.Matches + .AsNoTracking() + .SingleAsync(m => m.WinnerId == 2 && m.Bracket.Tournament.Name == "Doubles Tournament"); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Me?.WonMatches); + + var wonMatchIds = response.Data.Me.WonMatches.Nodes?.Select(m => m.Id).ToList(); + Assert.Contains(wonMatchInDb.Id, wonMatchIds); + + var wonTournamentNames = response.Data.Me.WonTournaments!.Nodes?.Select(t => t.Name).ToList(); + Assert.DoesNotContain("Doubles Tournament", wonTournamentNames); + } + + [Fact] + public async Task GetMe_WonMatches_ExcludesMatchesNeedingReplay() + { + // Arrange - flip carol's already-played win in Tournament 4 to NeedsReplay; its stale + // WinnerId should no longer count as a win until it's replayed. + var email = "carol@example.com"; + using var client = CreateClient(); + + var match = await DbContext.Matches.FirstAsync(m => m.Bracket.Tournament.Name == "Autumn Battle" && m.WinnerId == 3); + match.Status = MatchStatus.NeedsReplay; + await DbContext.SaveChangesAsync(); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = "Password123!" + } + }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Me?.WonMatches); + + var wonMatchIds = response.Data.Me.WonMatches.Nodes?.Select(m => m.Id).ToList(); + Assert.DoesNotContain(match.Id, wonMatchIds); + } } From 0f148a25ac72af3302b89fb9fddd3b4d50638745 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:07:26 +0200 Subject: [PATCH 15/18] test: add test to ensure completed tournaments are untouched when auto closing A new test method, `RunAsync_LeavesCompletedTournamentsUntouched_EvenPastStartDate`, was added to the `TournamentAutoCloseJobTests` class. This test verifies that tournaments with a status of `Completed` remain unaffected by the `TournamentAutoCloseJob`, even if their `StartDate` is in the past. --- .../Jobs/TournamentAutoCloseJobTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/TournamentAPI.IntegrationTests/Jobs/TournamentAutoCloseJobTests.cs b/TournamentAPI.IntegrationTests/Jobs/TournamentAutoCloseJobTests.cs index 347ed78..2eda9c1 100644 --- a/TournamentAPI.IntegrationTests/Jobs/TournamentAutoCloseJobTests.cs +++ b/TournamentAPI.IntegrationTests/Jobs/TournamentAutoCloseJobTests.cs @@ -40,6 +40,32 @@ public async Task RunAsync_ClosesOpenTournamentsPastStartDate_AndLeavesOthersUnt Assert.Equal(TournamentStatus.Open, untouchedTournament.Status); } + [Fact] + public async Task RunAsync_LeavesCompletedTournamentsUntouched_EvenPastStartDate() + { + // Arrange: tournament 3 is seeded as Completed; the job's filter only ever targets Open + // tournaments, so a Completed one past its StartDate must never be touched by it. + var completedTournamentId = 3; + + var tournament = await DbContext.Tournaments.FirstAsync(t => t.Id == completedTournamentId); + tournament.StartDate = DateTime.UtcNow.AddMinutes(-10); + await DbContext.SaveChangesAsync(); + + using var scope = Factory.Services.CreateScope(); + var job = scope.ServiceProvider.GetRequiredService(); + + // Act + await job.RunAsync(CancellationToken.None); + + // Assert + var tournamentAfter = await DbContext.Tournaments + .AsNoTracking() + .FirstAsync(t => t.Id == completedTournamentId); + + Assert.Equal(TournamentStatus.Completed, tournamentAfter.Status); + Assert.NotNull(tournamentAfter.ChampionId); + } + [Fact] public async Task RunAsync_WhenNothingIsDue_IsANoOp() { From 6b0e80651a76a9553e816bfdfc64836f6ea2dc98 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:09:20 +0200 Subject: [PATCH 16/18] test: add champion and won matches support to response models and query examples --- TournamentAPI.Shared/Models/ResponseModels.cs | 3 +++ .../QueryExamples/TournamentQueries.cs | 16 ++++++++++++++++ .../QueryExamples/UserQueries.cs | 10 ++++++++++ 3 files changed, 29 insertions(+) diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index 724fbd2..dbf43d6 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -34,7 +34,9 @@ public class TournamentNode public int OwnerId { get; set; } public int MaxParticipants { get; set; } public bool IsActive { get; set; } + public int? ChampionId { get; set; } public ApplicationUserNode? Owner { get; set; } + public ApplicationUserNode? Champion { get; set; } public BracketNode? Bracket { get; set; } public ParticipantsConnection? Participants { get; set; } } @@ -271,6 +273,7 @@ public class UserNode public bool IsEmailPublic { get; set; } public TournamentsConnection? WonTournaments { get; set; } public TournamentsConnection? PlayedTournaments { get; set; } + public MatchesByBracketConnection? WonMatches { get; set; } } public class UpdateEmailVisibilityResponse diff --git a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs index 9ad6172..d54bc3c 100644 --- a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs @@ -344,6 +344,22 @@ public static class Tournaments } """; + public const string GetByIdWithChampion = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + status + championId + champion { + id + firstName + lastName + } + } + } + """; + public const string GetByIdWithBracketAndMatches = """ query($id: Int!) { tournamentById(id: $id) { diff --git a/TournamentAPI.Shared/QueryExamples/UserQueries.cs b/TournamentAPI.Shared/QueryExamples/UserQueries.cs index 89944e7..824c8a2 100644 --- a/TournamentAPI.Shared/QueryExamples/UserQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/UserQueries.cs @@ -41,6 +41,16 @@ public static class Users } } } + wonMatches(first: 10) { + totalCount + edges { + node { + id + round + bracketId + } + } + } } } """; From 0c6db2e31b55ce4199d949cefbe46efebd8d154c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:10:14 +0200 Subject: [PATCH 17/18] test: add unit test for ValidateTournamentIsClosed This test ensures that the `ValidateTournamentIsClosed` method correctly returns `null` when the tournament's status is set to `Completed`. The test creates a `Tournament` object with `Id = 1` and `Status = TournamentStatus.Completed`, calls the method, and asserts the result is `null`. --- .../Validations/MatchValidationsTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs index be07ed3..faaa8d3 100644 --- a/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/MatchValidationsTests.cs @@ -46,6 +46,16 @@ public void ValidateTournamentIsClosed_WhenTournamentIsClosed_ReturnsNull() Assert.Null(error); } + [Fact] + public void ValidateTournamentIsClosed_WhenTournamentIsCompleted_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Status = TournamentStatus.Completed }; + + IError? error = MatchValidations.ValidateTournamentIsClosed(tournament); + + Assert.Null(error); + } + [Fact] public void ValidateMatchNotPlayed_WhenMatchIsPlayed_ReturnsError() { From 7f482b886a1b621981623894d14c54c8bc0b4ac7 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 16 Sep 2026 21:10:46 +0200 Subject: [PATCH 18/18] test: add validation tests for tournament operations Added tests to validate tournament deletion behavior: - Ensure error is returned when deleting tournaments with brackets in `Closed` or `Completed` statuses. - Ensure no error is returned when deleting tournaments with `Open` status and a bracket or `Closed` status without a bracket. Added tests to validate status change restrictions: - Ensure error is returned when setting status to `Completed` manually or modifying tournaments with `Completed` status. - Ensure no error is returned for statuses other than `Completed`. --- .../Validations/TournamentValidationsTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs b/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs index dee09f5..7e657f6 100644 --- a/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs +++ b/TournamentAPI.UnitTests/Validations/TournamentValidationsTests.cs @@ -289,4 +289,79 @@ public void ValidateTournamentCanBeReopened_WhenReopeningAfterStartDateHasPassed Assert.NotNull(error); Assert.Equal(TournamentErrorCodes.CannotReopenTournamentAfterStartDate, error.Code); } + + [Theory] + [InlineData(TournamentStatus.Closed)] + [InlineData(TournamentStatus.Completed)] + public void ValidateTournamentCanBeDeleted_WhenStatusHasBracket_ReturnsError(TournamentStatus status) + { + var tournament = new Tournament { Id = 1, Status = status, Bracket = new Bracket { Id = 1 } }; + + IError? error = TournamentValidations.ValidateTournamentCanBeDeleted(tournament); + + Assert.NotNull(error); + Assert.Equal(TournamentErrorCodes.CannotDeleteTournamentWithBracket, error.Code); + } + + [Fact] + public void ValidateTournamentCanBeDeleted_WhenOpenWithBracket_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Status = TournamentStatus.Open, Bracket = new Bracket { Id = 1 } }; + + IError? error = TournamentValidations.ValidateTournamentCanBeDeleted(tournament); + + Assert.Null(error); + } + + [Fact] + public void ValidateTournamentCanBeDeleted_WhenClosedWithoutBracket_ReturnsNull() + { + var tournament = new Tournament { Id = 1, Status = TournamentStatus.Closed, Bracket = null }; + + IError? error = TournamentValidations.ValidateTournamentCanBeDeleted(tournament); + + Assert.Null(error); + } + + [Fact] + public void ValidateStatusIsNotCompleted_WhenCompleted_ReturnsError() + { + IError? error = TournamentValidations.ValidateStatusIsNotCompleted(TournamentStatus.Completed); + + Assert.NotNull(error); + Assert.Equal(TournamentErrorCodes.StatusCannotBeSetManually, error.Code); + } + + [Theory] + [InlineData(TournamentStatus.Open)] + [InlineData(TournamentStatus.Closed)] + public void ValidateStatusIsNotCompleted_WhenNotCompleted_ReturnsNull(TournamentStatus status) + { + IError? error = TournamentValidations.ValidateStatusIsNotCompleted(status); + + Assert.Null(error); + } + + [Fact] + public void ValidateTournamentIsNotCompleted_WhenCompleted_ReturnsError() + { + var tournament = new Tournament { Id = 1, Status = TournamentStatus.Completed }; + + IError? error = TournamentValidations.ValidateTournamentIsNotCompleted(tournament); + + Assert.NotNull(error); + Assert.Equal(TournamentErrorCodes.CannotChangeCompletedStatus, error.Code); + } + + [Theory] + [InlineData(TournamentStatus.Open)] + [InlineData(TournamentStatus.Closed)] + public void ValidateTournamentIsNotCompleted_WhenNotCompleted_ReturnsNull(TournamentStatus status) + { + var tournament = new Tournament { Id = 1, Status = status }; + + IError? error = TournamentValidations.ValidateTournamentIsNotCompleted(tournament); + + Assert.Null(error); + } }