diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs index 89cccec..d5a6496 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs @@ -137,6 +137,24 @@ await client.ExecuteMutationAsync( Assert.Equal("alice@example.com", response.Data!.TournamentById!.Owner!.Email); } + [Fact] + public async Task GetTournamentById_WithOwnerTournamentHistory_IsReachableViaOwnerField() + { + // wonTournaments/playedTournaments live on the shared ApplicationUser type, so they + // should be reachable through tournament.owner, not just through getMe. + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithOwnerTournamentHistory, + new { id = 3 }); + + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById?.Owner?.WonTournaments); + + var wonTournamentNames = response.Data.TournamentById.Owner.WonTournaments.Nodes?.Select(t => t.Name).ToList(); + Assert.Contains("Winter Championship 2024", wonTournamentNames); + } + [Fact] public async Task GetTournaments_ReturnsAllTournamentsWithTotalCount() { @@ -149,7 +167,7 @@ public async Task GetTournaments_ReturnsAllTournamentsWithTotalCount() // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(14, response.Data.Tournaments.TotalCount); + Assert.Equal(15, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var tournamentNames = response.Data.Tournaments.Nodes?.Select(t => t.Name).ToList(); @@ -222,7 +240,7 @@ public async Task GetTournaments_WithParticipants_ReturnsTournamentsWithParticip // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(14, response.Data.Tournaments.TotalCount); + Assert.Equal(15, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var springTournament = response.Data.Tournaments.Nodes?.FirstOrDefault(t => t.Name == "Spring Invitational"); @@ -248,7 +266,7 @@ public async Task GetTournaments_WithBracketAndMatches_ReturnsTournamentsWithBra // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(14, response.Data.Tournaments.TotalCount); + Assert.Equal(15, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var springTournament = response.Data.Tournaments.Nodes?.FirstOrDefault(t => t.Name == "Spring Invitational"); @@ -278,7 +296,7 @@ public async Task GetTournaments_WithOwner_ReturnsTournamentsWithOwnerDetails() // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(14, response.Data.Tournaments.TotalCount); + Assert.Equal(15, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); foreach (var tournament in response.Data.Tournaments.Nodes!) @@ -302,7 +320,7 @@ public async Task GetTournaments_WithDescendingNameSorting_ReturnsTournamentsInD // Assert Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(14, response.Data.Tournaments.TotalCount); + Assert.Equal(15, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var tournamentNames = response.Data.Tournaments.Nodes?.Select(t => t.Name).ToList(); @@ -323,7 +341,7 @@ public async Task GetTournaments_ExcludesSoftDeletedTournaments() Assert.False(response.HasErrors); Assert.NotNull(response.Data?.Tournaments?.Edges); - Assert.Equal(14, response.Data.Tournaments.TotalCount); + Assert.Equal(15, response.Data.Tournaments.TotalCount); Assert.Equal(10, response.Data.Tournaments.Edges.Count); var tournamentNames = response.Data.Tournaments.Nodes?.Select(t => t.Name).ToList(); diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs index fb1058c..ae8762e 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs @@ -67,4 +67,106 @@ public async Task GetMe_ReturnsOwnEmail_EvenWhenPrivate() Assert.False(response.Data.Me.IsEmailPublic); Assert.Equal(email, response.Data.Me.Email); } + + [Fact] + public async Task GetMe_WonTournaments_ReturnsOnlyTournamentsUserWon() + { + // Arrange + var email = "alice@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); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Me?.WonTournaments); + + var wonTournamentNames = response.Data.Me.WonTournaments.Nodes?.Select(t => t.Name).ToList(); + Assert.Equal(["Winter Championship 2024"], wonTournamentNames); + } + + [Fact] + public async Task GetMe_PlayedTournaments_ReturnsParticipatedTournaments_ButWonTournamentsOnlyTheWonOne() + { + // Arrange - alice won Tournament 3 ("Winter Championship 2024") but lost Round 1 + // of Tournament 16 ("Champions Cup"), so she should show up as having played both + // but only won the first. + var email = "alice@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); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Me?.PlayedTournaments); + Assert.NotNull(response.Data.Me.WonTournaments); + + var playedTournamentNames = response.Data.Me.PlayedTournaments.Nodes?.Select(t => t.Name).ToList(); + Assert.Contains("Winter Championship 2024", playedTournamentNames); + Assert.Contains("Champions Cup", playedTournamentNames); + + var wonTournamentNames = response.Data.Me.WonTournaments.Nodes?.Select(t => t.Name).ToList(); + Assert.Equal(["Winter Championship 2024"], wonTournamentNames); + Assert.DoesNotContain("Champions Cup", wonTournamentNames); + } + + [Fact] + public async Task GetMe_WonTournaments_ExcludesIncompleteBrackets() + { + // Arrange - bob won Round 1 of Tournament 12 ("Doubles Tournament"), but no final + // round has been generated yet, so the tournament isn't decided and shouldn't count as won. + 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); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWithTournamentHistory); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Me?.WonTournaments); + + var wonTournamentNames = response.Data.Me.WonTournaments.Nodes?.Select(t => t.Name).ToList(); + Assert.DoesNotContain("Doubles Tournament", wonTournamentNames); + } } diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index 66c0599..4f39d17 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -69,6 +69,8 @@ public class ApplicationUserNode public string LastName { get; set; } = string.Empty; public string? Email { get; set; } public bool IsEmailPublic { get; set; } + public TournamentsConnection? WonTournaments { get; set; } + public TournamentsConnection? PlayedTournaments { get; set; } } public class BracketNode @@ -243,6 +245,8 @@ public class UserNode public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public bool IsEmailPublic { get; set; } + public TournamentsConnection? WonTournaments { get; set; } + public TournamentsConnection? PlayedTournaments { get; set; } } public class UpdateEmailVisibilityResponse diff --git a/TournamentAPI.Shared/MutationExamples/BracketMutations.cs b/TournamentAPI.Shared/MutationExamples/BracketMutations.cs index f1e219c..6bc9ea0 100644 --- a/TournamentAPI.Shared/MutationExamples/BracketMutations.cs +++ b/TournamentAPI.Shared/MutationExamples/BracketMutations.cs @@ -20,14 +20,18 @@ mutation GenerateBracketWithMatches($input: GenerateBracketInput!) { bracket { id tournamentId - matches { - id - round - participantAId - participantBId - scoreA - scoreB - winnerId + matchesByBracket(first: 10) { + totalCount + edges { + node { + id + bracketId + round + player1Id + player2Id + winnerId + } + } } } } diff --git a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs index f6c8536..9ad6172 100644 --- a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs @@ -314,6 +314,36 @@ public static class Tournaments } """; + public const string GetByIdWithOwnerTournamentHistory = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + owner { + id + wonTournaments(first: 10) { + totalCount + edges { + node { + id + name + } + } + } + playedTournaments(first: 10) { + totalCount + edges { + node { + id + name + } + } + } + } + } + } + """; + 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 d20b2f5..89944e7 100644 --- a/TournamentAPI.Shared/QueryExamples/UserQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/UserQueries.cs @@ -14,5 +14,35 @@ public static class Users } } """; + + public const string GetMeWithTournamentHistory = """ + query { + me { + id + firstName + lastName + email + isEmailPublic + wonTournaments(first: 10) { + totalCount + edges { + node { + id + name + } + } + } + playedTournaments(first: 10) { + totalCount + edges { + node { + id + name + } + } + } + } + } + """; } } diff --git a/TournamentAPI/Data/DatabaseSeeder.cs b/TournamentAPI/Data/DatabaseSeeder.cs index aad2d3e..5e47f92 100644 --- a/TournamentAPI/Data/DatabaseSeeder.cs +++ b/TournamentAPI/Data/DatabaseSeeder.cs @@ -352,7 +352,38 @@ public static async Task SeedAsync( tournament15.Participants.Add(new TournamentParticipant { Tournament = tournament15, Participant = user3, SlotNumber = 2 }); tournament15.Participants.Add(new TournamentParticipant { Tournament = tournament15, Participant = user4, SlotNumber = 3 }); - await context.Tournaments.AddRangeAsync(tournament1, tournament2, tournament3, tournament4, tournament5, tournament6, tournament7, tournament8, tournament9, tournament10, tournament11, tournament12, tournament13, tournament14, tournament15); + // Tournament 16: Closed tournament with 4 participants and a full, completed bracket + // with a different champion than Tournament 3 (used to prove wonTournaments/playedTournaments + // are filtered per user, not shared across every participant of a shared tournament). + var tournament16 = new Tournament + { + Name = "Champions Cup", + StartDate = DateTime.UtcNow.AddDays(-10), + Status = TournamentStatus.Closed, + OwnerId = user2.Id, + Owner = user2, + MaxParticipants = 4, + Participants = new List(), + Bracket = new Bracket() + }; + + tournament16.Participants.Add(new TournamentParticipant { Tournament = tournament16, Participant = user1, SlotNumber = 1 }); + tournament16.Participants.Add(new TournamentParticipant { Tournament = tournament16, Participant = user3, SlotNumber = 2 }); + tournament16.Participants.Add(new TournamentParticipant { Tournament = tournament16, Participant = user5, SlotNumber = 3 }); + tournament16.Participants.Add(new TournamentParticipant { Tournament = tournament16, Participant = user6, SlotNumber = 4 }); + + // Round 1 - Semi Finals (2 matches) + var match19 = new Match { Round = 1, Player1Id = user1.Id, Player2Id = user3.Id, WinnerId = user3.Id, Bracket = tournament16.Bracket }; + var match20 = new Match { Round = 1, Player1Id = user5.Id, Player2Id = user6.Id, WinnerId = user6.Id, Bracket = tournament16.Bracket }; + + // Round 2 - Final + var match21 = new Match { Round = 2, Player1Id = user3.Id, Player2Id = user6.Id, WinnerId = user6.Id, Bracket = tournament16.Bracket }; + + tournament16.Bracket.Matches.Add(match19); + 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); await context.SaveChangesAsync(); } } diff --git a/TournamentAPI/Extensions/SortDefinitionExtensions.cs b/TournamentAPI/Extensions/SortDefinitionExtensions.cs new file mode 100644 index 0000000..cfde988 --- /dev/null +++ b/TournamentAPI/Extensions/SortDefinitionExtensions.cs @@ -0,0 +1,10 @@ +using GreenDonut.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.Extensions; + +public static class SortDefinitionExtensions +{ + public static SortDefinition DefaultTournamentOrder(this SortDefinition sort) + => sort.IfEmpty(o => o.AddAscending(t => t.Id)).AddAscending(t => t.Id); +} diff --git a/TournamentAPI/Program.cs b/TournamentAPI/Program.cs index a88f2c4..ec2295b 100644 --- a/TournamentAPI/Program.cs +++ b/TournamentAPI/Program.cs @@ -30,6 +30,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddApplicationDatabase(); builder.Services.AddApplicationRateLimiting(); builder.Services.AddApplicationTelemetry(); diff --git a/TournamentAPI/Tournaments/TournamentQueries.cs b/TournamentAPI/Tournaments/TournamentQueries.cs index d4fcda6..5c42135 100644 --- a/TournamentAPI/Tournaments/TournamentQueries.cs +++ b/TournamentAPI/Tournaments/TournamentQueries.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using TournamentAPI.Data; using TournamentAPI.Data.Models; +using TournamentAPI.Extensions; namespace TournamentAPI.Tournaments; @@ -24,15 +25,12 @@ public static async Task> GetTournaments( { var page = await context.Tournaments .AsNoTracking() - .With(query, DefaultOrder) + .With(query, s => s.DefaultTournamentOrder()) .ToPageAsync(pagingArgs, cancellationToken); return page; } - private static SortDefinition DefaultOrder(SortDefinition sort) - => sort.IfEmpty(o => o.AddAscending(t => t.Id)).AddAscending(t => t.Id); - [UseFirstOrDefault] public static IQueryable? GetTournamentById( int id, diff --git a/TournamentAPI/Users/ApplicationUserResolvers.cs b/TournamentAPI/Users/ApplicationUserResolvers.cs index e434973..d891a0c 100644 --- a/TournamentAPI/Users/ApplicationUserResolvers.cs +++ b/TournamentAPI/Users/ApplicationUserResolvers.cs @@ -1,5 +1,8 @@ +using GreenDonut.Data; using HotChocolate.Resolvers; +using HotChocolate.Types.Pagination; using TournamentAPI.Data.Models; +using TournamentAPI.Tournaments; namespace TournamentAPI.Users; @@ -28,6 +31,28 @@ static partial void Configure(IObjectTypeDescriptor descriptor) public static string? GetEmail([Parent] ApplicationUser user, IResolverContext ctx) => user.IsEmailPublic || IsViewingOwnAccount(ctx, user) ? user.Email : null; + [UseConnection(IncludeTotalCount = true)] + [UseFiltering] + [UseSorting] + public static async Task> GetPlayedTournaments( + [Parent(requires: nameof(ApplicationUser.Id))] ApplicationUser user, + PagingArguments pagingArgs, + QueryContext query, + UserTournamentsService userTournamentsService, + CancellationToken cancellationToken) + => await userTournamentsService.GetPlayedTournamentsAsync(user.Id, pagingArgs, query, cancellationToken); + + [UseConnection(IncludeTotalCount = true)] + [UseFiltering] + [UseSorting] + public static async Task> GetWonTournaments( + [Parent(requires: nameof(ApplicationUser.Id))] ApplicationUser user, + PagingArguments pagingArgs, + QueryContext query, + UserTournamentsService userTournamentsService, + CancellationToken cancellationToken) + => await userTournamentsService.GetWonTournamentsAsync(user.Id, pagingArgs, query, cancellationToken); + private static bool IsViewingOwnAccount(IResolverContext ctx, ApplicationUser user) { var viewerId = ctx.GetGlobalStateOrDefault("userId"); diff --git a/TournamentAPI/Users/UserPlayedTournamentIdsDataLoaders.cs b/TournamentAPI/Users/UserPlayedTournamentIdsDataLoaders.cs new file mode 100644 index 0000000..04427c5 --- /dev/null +++ b/TournamentAPI/Users/UserPlayedTournamentIdsDataLoaders.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; + +namespace TournamentAPI.Users; + +[DataLoaderGroup("UserPlayedTournamentIdsBatchingContext")] +internal static class UserPlayedTournamentIdsDataLoaders +{ + [DataLoader] + public static async Task> GetPlayedTournamentIdsByParticipantIdAsync( + IReadOnlyList participantIds, + ApplicationDbContext context, + CancellationToken cancellationToken + ) + { + participantIds = [.. participantIds.OrderBy(x => x)]; + return await context.TournamentParticipants + .Where(tp => participantIds.Contains(tp.ParticipantId)) + .GroupBy(tp => tp.ParticipantId) + .Select(g => new { g.Key, Ids = g.Select(tp => tp.TournamentId).ToArray() }) + .ToDictionaryAsync(g => g.Key, g => g.Ids, cancellationToken); + } +} diff --git a/TournamentAPI/Users/UserTournamentsService.cs b/TournamentAPI/Users/UserTournamentsService.cs new file mode 100644 index 0000000..e1fe733 --- /dev/null +++ b/TournamentAPI/Users/UserTournamentsService.cs @@ -0,0 +1,49 @@ +using GreenDonut.Data; +using HotChocolate.Types.Pagination; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; +using TournamentAPI.Extensions; + +namespace TournamentAPI.Users; + +public class UserTournamentsService( + IUserPlayedTournamentIdsBatchingContext playedTournamentIdsBatchingContext, + IUserWonTournamentIdsBatchingContext wonTournamentIdsBatchingContext, + ApplicationDbContext context) +{ + public async Task> GetPlayedTournamentsAsync( + int userId, + PagingArguments pagingArgs, + QueryContext? query, + CancellationToken cancellationToken) + { + var ids = await playedTournamentIdsBatchingContext.PlayedTournamentIdsByParticipantId.LoadAsync(userId, cancellationToken); + return await GetPageAsync(ids, pagingArgs, query, cancellationToken); + } + + public async Task> GetWonTournamentsAsync( + int userId, + PagingArguments pagingArgs, + QueryContext? query, + CancellationToken cancellationToken) + { + var ids = await wonTournamentIdsBatchingContext.WonTournamentIdsByWinnerId.LoadAsync(userId, cancellationToken); + return await GetPageAsync(ids, pagingArgs, query, cancellationToken); + } + + private async Task> GetPageAsync( + int[]? tournamentIds, + PagingArguments pagingArgs, + QueryContext? query, + CancellationToken cancellationToken) + { + tournamentIds ??= []; + + var page = await context.Tournaments + .Where(t => tournamentIds.Contains(t.Id)) + .With(query, s => s.DefaultTournamentOrder()) + .ToPageAsync(pagingArgs, cancellationToken); + + return new PageConnection(page); + } +} diff --git a/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs b/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs new file mode 100644 index 0000000..c0d9f0a --- /dev/null +++ b/TournamentAPI/Users/UserWonTournamentIdsDataLoaders.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data; + +namespace TournamentAPI.Users; + +[DataLoaderGroup("UserWonTournamentIdsBatchingContext")] +internal static class UserWonTournamentIdsDataLoaders +{ + [DataLoader] + public static async Task> GetWonTournamentIdsByWinnerIdAsync( + 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)) + .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() }) + .ToDictionaryAsync(g => g.Key, g => g.TournamentIds, cancellationToken); + } +}