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); + } } 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); + } } diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs index d5a6496..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(); @@ -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); + } } 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); + } } 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() { 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 + } + } + } } } """; 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() { 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); + } } 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/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/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(); } } 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 } 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/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 1c42ae2..0ef1a74 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; @@ -68,9 +69,11 @@ public static partial class MatchMutations try { + var frontierMatch = match; + if (isReplay) { - await MatchCorrectionService.ApplyCorrectionAsync( + frontierMatch = await MatchCorrectionService.ApplyCorrectionAsync( context, match, previousStatus, @@ -84,6 +87,8 @@ await MatchCorrectionService.ApplyCorrectionAsync( token); } + await BracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + await context.SaveChangesAsync(token); return true; @@ -162,7 +167,7 @@ await MatchCorrectionService.ApplyCorrectionAsync( try { - await MatchCorrectionService.ApplyCorrectionAsync( + var frontierMatch = await MatchCorrectionService.ApplyCorrectionAsync( context, match, previousStatus, @@ -175,6 +180,8 @@ await MatchCorrectionService.ApplyCorrectionAsync( Guid.NewGuid(), token); + await BracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + await context.SaveChangesAsync(token); return true; 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/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; 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/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, 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) 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); + } +} 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); } }