From 61d27022a58acd0893f09cf52694a14c7dc73256 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:05:15 +0200 Subject: [PATCH 01/12] test: add JWT validation integration tests and token factory Added JwtValidationTests for parameterized JWT auth tests in GraphQL, covering expired, tampered, malformed, and missing-claim tokens. Introduced JwtTestTokenFactory to generate test tokens with specific properties. Ensured protected operations return correct errors --- .../Authentication/JwtValidationTests.cs | 140 ++++++++++++++++++ .../Infrastructure/JwtTestTokenFactory.cs | 82 ++++++++++ 2 files changed, 222 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs create mode 100644 TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs new file mode 100644 index 0000000..a373418 --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TournamentAPI.Configuration; +using TournamentAPI.IntegrationTests.Infrastructure; +using TournamentAPI.Shared.MutationExamples; +using TournamentAPI.Shared.QueryExamples; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Authentication; + +public class JwtValidationTests : BaseIntegrationTest +{ + public JwtValidationTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + private static readonly (string Name, string Query, object? Variables, bool CallsGetUserId)[] Operations = + [ + ("GetMe", Queries.Users.GetMe, null, true), + ("LogoutUser", Mutations.Users.LogoutUser, null, false), + ("AddParticipant", Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { userId = 1, tournamentId = 1 } }, true), + ("JoinTournament", Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = 1 } }, true), + ("CreateTournament", Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new { input = new { name = "Auth Test Tournament", startDate = DateTime.UtcNow.AddDays(1), status = "OPEN", maxParticipants = 4 } }, true), + ("UpdateTournament", Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId = 1 } }, true), + ("DeleteTournament", Mutations.Tournaments.DeleteTournament, + new { input = new { tournamentId = 999999 } }, true), + ("GenerateBracket", Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId = 1 } }, true), + ("UpdateRound", Mutations.Bracket.UpdateRound, + new { input = new { bracketId = 1, roundNumber = 1 } }, true), + ("Play", Mutations.Match.Play, + new { input = new { matchId = 1, winnerId = 1, player1Score = 1, player2Score = 0 } }, true), + ("CorrectMatchResult", Mutations.Match.CorrectMatchResult, + new { input = new { matchId = 1, winnerId = 1, player1Score = 1, player2Score = 0, version = "" } }, true), + ("UpdateEmailVisibility", Mutations.Users.UpdateEmailVisibility, + new { input = new { isEmailPublic = true } }, true), + ]; + + public static TheoryData AllProtectedOperations() + { + var data = new TheoryData(); + foreach (var op in Operations) + data.Add(op.Name, op.Query, op.Variables); + return data; + } + + public static TheoryData OperationsRequiringUserIdClaim() + { + var data = new TheoryData(); + foreach (var op in Operations.Where(o => o.CallsGetUserId)) + data.Add(op.Name, op.Query, op.Variables); + return data; + } + + private JwtOptions GetJwtOptions() => Factory.Services.GetRequiredService>().Value; + + [Theory] + [MemberData(nameof(AllProtectedOperations))] + public async Task ProtectedOperation_WithExpiredToken_ReturnsAuthenticationError(string name, string query, object? variables) + { + using var client = CreateClient(); + var token = JwtTestTokenFactory.CreateExpiredToken(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail with an expired token."); + var error = response.Errors!.First(); + Assert.Equal("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Theory] + [MemberData(nameof(AllProtectedOperations))] + public async Task ProtectedOperation_WithTamperedSignature_ReturnsAuthenticationError(string name, string query, object? variables) + { + using var client = CreateClient(); + var token = JwtTestTokenFactory.CreateTokenWithWrongSignature(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail with a tampered signature."); + var error = response.Errors!.First(); + Assert.Equal("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Theory] + [MemberData(nameof(AllProtectedOperations))] + public async Task ProtectedOperation_WithMalformedTokenString_ReturnsAuthenticationError(string name, string query, object? variables) + { + using var client = CreateClient(); + client.SetAuthToken(JwtTestTokenFactory.CreateMalformedTokenString()); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail with a malformed token."); + var error = response.Errors!.First(); + Assert.Equal("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Theory] + [MemberData(nameof(OperationsRequiringUserIdClaim))] + public async Task ProtectedOperation_WithTokenMissingNameIdentifierClaim_ReturnsDifferentErrorThanAuthenticationFailure(string name, string query, object? variables) + { + using var client = CreateClient(); + var token = JwtTestTokenFactory.CreateTokenMissingNameIdentifierClaim(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail without a NameIdentifier claim."); + var error = response.Errors!.First(); + Assert.NotEqual("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Fact] + public async Task LogoutUser_WithTokenMissingNameIdentifierClaim_Succeeds() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + var rawRefreshToken = client.GetRefreshTokenCookie(); + Assert.NotNull(rawRefreshToken); + client.SetRefreshTokenCookie(rawRefreshToken); + + var token = JwtTestTokenFactory.CreateTokenMissingNameIdentifierClaim(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteMutationAsync( + Mutations.Users.LogoutUser, new { }); + + Assert.False(response.HasErrors); + Assert.True(response.Data?.LogoutUser?.Boolean); + } +} diff --git a/TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs b/TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs new file mode 100644 index 0000000..3b996bc --- /dev/null +++ b/TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs @@ -0,0 +1,82 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using TournamentAPI.Configuration; + +namespace TournamentAPI.IntegrationTests.Infrastructure; + +public static class JwtTestTokenFactory +{ + public static string CreateExpiredToken(JwtOptions options, int userId, string userName, string email) + { + var token = new JwtSecurityToken( + options.Issuer, + options.Audience, + CreateClaims(userId, userName, email), + expires: DateTime.UtcNow.AddMinutes(-10), + signingCredentials: CreateSigningCredentials(options.Key)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string CreateTokenWithWrongSignature(JwtOptions options, int userId, string userName, string email) + { + var wrongKey = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + + var token = new JwtSecurityToken( + options.Issuer, + options.Audience, + CreateClaims(userId, userName, email), + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: CreateSigningCredentials(wrongKey)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string CreateTokenMissingNameIdentifierClaim(JwtOptions options, int userId, string userName, string email) + { + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64), + new Claim(ClaimTypes.Name, userName), + new Claim(ClaimTypes.Email, email) + }; + + var token = new JwtSecurityToken( + options.Issuer, + options.Audience, + claims, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: CreateSigningCredentials(options.Key)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string CreateMalformedTokenString() => "not.a.jwt"; + + private static Claim[] CreateClaims(int userId, string userName, string email) + { + return + [ + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64), + new Claim(ClaimTypes.Name, userName), + new Claim(ClaimTypes.Email, email), + new Claim(ClaimTypes.NameIdentifier, userId.ToString()) + ]; + } + + private static SigningCredentials CreateSigningCredentials(string key) + { + return new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), + SecurityAlgorithms.HmacSha256); + } +} From 78cfe6efd334db2543f7810be34dd62c0e80295a Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:05:54 +0200 Subject: [PATCH 02/12] test: add integration tests for GraphQL data loaders behavior --- .../Tests/DataLoaders/BatchingTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs new file mode 100644 index 0000000..d313d5e --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs @@ -0,0 +1,146 @@ +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.DataLoaders; + +public class BatchingTests : BaseIntegrationTest +{ + public BatchingTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetTournamentById_WithManyMatchesReferencingManyUsers_BatchesApplicationUserLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithAllMatchesAndPlayers, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById?.Bracket?.MatchesByBracket); + Assert.Equal(7, response.Data.TournamentById.Bracket.MatchesByBracket.TotalCount); + + var userTableCommands = CountTableCommands("AspNetUsers"); + Assert.Equal(1, userTableCommands); + } + + [Fact] + public async Task GetTournamentById_WithManyParticipants_BatchesParticipantAndBackReferenceLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsAndTournamentBackReference, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById?.Participants); + Assert.Equal(8, response.Data.TournamentById.Participants.TotalCount); + + var userTableCommands = CountTableCommands("AspNetUsers"); + var tournamentTableCommands = CountTableCommands("Tournaments"); + + Assert.Equal(1, userTableCommands); + Assert.Equal(2, tournamentTableCommands); + } + + [Fact] + public async Task GetTournaments_WithManyBrackets_BatchesBracketLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetAllWithBracketAndMatches); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Tournaments); + + var bracketTableCommands = CountTableCommands("Brackets"); + Assert.Equal(1, bracketTableCommands); + } + + [Fact] + public async Task GetTournamentParticipants_WithManyParticipants_BatchesWonTournamentIdLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsWonTournaments, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.Equal(8, response.Data?.TournamentById?.Participants?.TotalCount); + + var tournamentTableCommands = CountTableCommands("Tournaments"); + // 16, not 9: 1 root tournamentById fetch + 1 batched id-lookup covering all 8 + // participants (id resolution IS batched) + 8 per-participant page-fetch queries to + // materialize the actual won-tournament entities + 6 extra count probes for the + // participants with no won tournaments. See memory: history-connections-not-fully-batched. + Assert.Equal(16, tournamentTableCommands); + } + + [Fact] + public async Task GetTournamentParticipants_WithManyParticipants_BatchesWonMatchIdLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsWonMatches, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.Equal(8, response.Data?.TournamentById?.Participants?.TotalCount); + + var matchTableCommands = CountTableCommands("Matches"); + // 9 = 1 batched id-lookup covering all 8 participants + 1 page-fetch query per + // participant to materialize the actual won-match entities. + Assert.Equal(9, matchTableCommands); + } + + [Fact] + public async Task GetTournamentParticipants_WithManyParticipants_BatchesPlayedTournamentIdLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsPlayedTournaments, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.Equal(8, response.Data?.TournamentById?.Participants?.TotalCount); + + var participantTableCommands = CountTableCommands("TournamentParticipants"); + // 3, not 1: 2 baseline commands the participants connection itself always issues + // (its own totalCount and item fetch) + 1 batched id-lookup covering all 8 participants + // for playedTournaments - the id-lookup step really is a single batched query. + Assert.Equal(3, participantTableCommands); + } + + private int CountTableCommands(string tableName) => + Factory.CommandRecorder.Commands.Count(c => c.Contains($"[{tableName}]") || c.Contains($" {tableName} ")); +} From f8aaf9ba1d5f14c3a31d6b32b280e756b9a3cad7 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:06:37 +0200 Subject: [PATCH 03/12] test: add tests for round filtering in MatchQueryTests Added tests to verify that querying matches by round returns only matches from the specified round and that querying a non-existent round returns an empty result set. --- .../GraphQL/Tests/Matches/MatchQueryTests.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs index 4ce6485..f489430 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs @@ -59,4 +59,46 @@ public async Task GetMatchesForRound_WithPlayerDetails_ReturnsMatchesWithPlayers Assert.NotNull(match.Winner); } } + + [Fact] + public async Task GetMatchesForRound_WithRoundFilter_ExcludesOtherRounds() + { + // Act + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Match.GetMatchesForRoundWithBasicFields, + new + { + tournamentId = 3, + roundNumber = 1 + }); + + // Assert + Assert.False(response.HasErrors); + var matches = response.Data!.TournamentById!.Bracket!.MatchesByBracket!; + Assert.Equal(4, matches.TotalCount); + Assert.All(matches.Nodes!, m => Assert.Equal(1, m.Round)); + } + + [Fact] + public async Task GetMatchesForRound_WithNonExistentRound_ReturnsEmptyConnection() + { + // Act + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Match.GetMatchesForRoundWithBasicFields, + new + { + tournamentId = 3, + roundNumber = 99 + }); + + // Assert + Assert.False(response.HasErrors); + var matches = response.Data!.TournamentById!.Bracket!.MatchesByBracket!; + Assert.Equal(0, matches.TotalCount); + Assert.Empty(matches.Nodes!); + } } From 7236349823d60899286b4fa083eccb50546f7b47 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:07:08 +0200 Subject: [PATCH 04/12] test: add integration tests for participant GraphQL queries Added ParticipantQueryTests covering: - Sorting by slot number (asc/desc) - Filtering by min slot number - Filtering by participant ID --- .../Participants/ParticipantQueryTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs new file mode 100644 index 0000000..a565859 --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs @@ -0,0 +1,68 @@ +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Participants; + +public class ParticipantQueryTests : BaseIntegrationTest +{ + public ParticipantQueryTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetTournamentParticipants_WithDescendingSlotNumberSort_ReturnsInDescendingOrder() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsSortedBySlotNumberDescending, + new { id = 3 }); + + Assert.False(response.HasErrors); + var participantIds = response.Data!.TournamentById!.Participants!.Nodes!.Select(n => n.ParticipantId).ToList(); + Assert.Equal([8, 7, 6, 5, 4, 3, 2, 1], participantIds); + } + + [Fact] + public async Task GetTournamentParticipants_WithAscendingSlotNumberSort_ReturnsInAscendingOrder() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsSortedBySlotNumberAscending, + new { id = 3 }); + + Assert.False(response.HasErrors); + var participantIds = response.Data!.TournamentById!.Participants!.Nodes!.Select(n => n.ParticipantId).ToList(); + Assert.Equal([1, 2, 3, 4, 5, 6, 7, 8], participantIds); + } + + [Fact] + public async Task GetTournamentParticipants_WithSlotNumberFilter_ReturnsOnlyMatchingParticipants() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsFilteredBySlotNumberGreaterThan, + new { id = 3, minSlot = 5 }); + + Assert.False(response.HasErrors); + var participants = response.Data!.TournamentById!.Participants!; + Assert.Equal(3, participants.TotalCount); + Assert.Equal([6, 7, 8], participants.Nodes!.Select(n => n.ParticipantId).OrderBy(id => id)); + } + + [Fact] + public async Task GetTournamentParticipants_WithParticipantIdFilter_ReturnsOnlyThatParticipant() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsFilteredByParticipantId, + new { id = 3, participantId = 3 }); + + Assert.False(response.HasErrors); + var participants = response.Data!.TournamentById!.Participants!; + Assert.Equal(1, participants.TotalCount); + Assert.Equal(3, participants.Nodes!.Single().ParticipantId); + } +} From 36451318dce43d29af1a87e254f22d44d82d136f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:08:51 +0200 Subject: [PATCH 05/12] test: add integration tests for participant slot assignment Added SlotNumberAssignmentTests to verify unique, strictly increasing slot numbers for tournament participants. Tests cover sequential adds, joins, interleaved operations, and race conditions to ensure no gaps or duplicates in slot assignments. --- .../Participants/SlotNumberAssignmentTests.cs | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs new file mode 100644 index 0000000..b3d5229 --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs @@ -0,0 +1,226 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; +using TournamentAPI.Shared.Helpers; +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Participants; + +public class SlotNumberAssignmentTests : BaseIntegrationTest +{ + public SlotNumberAssignmentTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + private async Task LoginAsync(string email, string password = "Password123!") + { + var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password } }); + client.SetAuthToken(tokenResponse.Data!.LoginUser!.String!); + + return client; + } + + private async Task CreateOpenTournamentAsync(int ownerId, int maxParticipants) + { + var tournament = new Tournament + { + Name = "Slot Number Test Tournament", + StartDate = DateTime.UtcNow.AddDays(7), + Status = TournamentStatus.Open, + OwnerId = ownerId, + MaxParticipants = maxParticipants + }; + + DbContext.Tournaments.Add(tournament); + await DbContext.SaveChangesAsync(); + + return tournament; + } + + [Fact] + public async Task AddParticipant_SequentialAdds_AssignStrictlyIncreasingUniqueSlotNumbers() + { + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var tournament = await CreateOpenTournamentAsync(alice.Id, maxParticipants: 5); + using var owner = await LoginAsync("alice@example.com"); + + var participantUserNames = new[] { "bob", "carol", "david", "emma" }; + var expectedSlot = 1; + + foreach (var userName in participantUserNames) + { + var user = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == userName); + + var response = await owner.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId = tournament.Id, userId = user.Id } }); + + Assert.False(response.HasErrors); + + var slot = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id && tp.ParticipantId == user.Id) + .Select(tp => tp.SlotNumber) + .SingleAsync(); + Assert.Equal(expectedSlot, slot); + expectedSlot++; + } + + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + Assert.Equal(Enumerable.Range(1, participantUserNames.Length), finalSlots); + } + + [Fact] + public async Task JoinTournament_SequentialJoins_AssignStrictlyIncreasingUniqueSlotNumbers() + { + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var tournament = await CreateOpenTournamentAsync(alice.Id, maxParticipants: 5); + + var joinerEmails = new[] { "bob@example.com", "carol@example.com", "david@example.com", "emma@example.com" }; + var expectedSlot = 1; + + foreach (var email in joinerEmails) + { + using var client = await LoginAsync(email); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = tournament.Id } }); + + Assert.False(response.HasErrors); + + var user = await DbContext.Users.AsNoTracking().FirstAsync(u => u.Email == email); + var slot = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id && tp.ParticipantId == user.Id) + .Select(tp => tp.SlotNumber) + .SingleAsync(); + Assert.Equal(expectedSlot, slot); + expectedSlot++; + } + + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + Assert.Equal(Enumerable.Range(1, joinerEmails.Length), finalSlots); + } + + [Fact] + public async Task AddParticipant_And_JoinTournament_Interleaved_ProduceNoGapsOrDuplicates() + { + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var tournament = await CreateOpenTournamentAsync(alice.Id, maxParticipants: 5); + using var owner = await LoginAsync("alice@example.com"); + + var bob = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "bob"); + var david = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "david"); + + var addBobResponse = await owner.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId = tournament.Id, userId = bob.Id } }); + Assert.False(addBobResponse.HasErrors); + + using (var carolClient = await LoginAsync("carol@example.com")) + { + var joinCarolResponse = await carolClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = tournament.Id } }); + Assert.False(joinCarolResponse.HasErrors); + } + + var addDavidResponse = await owner.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId = tournament.Id, userId = david.Id } }); + Assert.False(addDavidResponse.HasErrors); + + using (var emmaClient = await LoginAsync("emma@example.com")) + { + var joinEmmaResponse = await emmaClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = tournament.Id } }); + Assert.False(joinEmmaResponse.HasErrors); + } + + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + Assert.Equal([1, 2, 3, 4], finalSlots); + } + + [Fact] + public async Task JoinTournament_AfterRaceCondition_WinningRequestHasCorrectSlotNumberAndNoGapExists() + { + var tournamentId = 14; + using var client = await LoginAsync("carol@example.com"); + using var client2 = await LoginAsync("david@example.com"); + + var variables = new { input = new { tournamentId } }; + + var task1 = client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, variables); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, variables); + var results = await Task.WhenAll(task1, task2); + + Assert.Single(results, r => !r.HasErrors); + Assert.Single(results, r => r.HasErrors); + + var tournament = await DbContext.Tournaments.AsNoTracking().FirstAsync(t => t.Id == tournamentId); + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournamentId) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + + Assert.Equal(Enumerable.Range(1, tournament.MaxParticipants), finalSlots); + Assert.Equal(tournament.MaxParticipants, finalSlots.Max()); + } + + [Fact] + public async Task AddParticipant_AfterRaceCondition_WinningRequestHasCorrectSlotNumberAndNoGapExists() + { + var tournamentId = 14; + var carol = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "carol"); + var david = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "david"); + using var client = await LoginAsync("emma@example.com"); + using var client2 = await LoginAsync("emma@example.com"); + + var variables1 = new { input = new { tournamentId, userId = carol.Id } }; + var variables2 = new { input = new { tournamentId, userId = david.Id } }; + + var task1 = client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, variables1); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, variables2); + var results = await Task.WhenAll(task1, task2); + + Assert.Single(results, r => !r.HasErrors); + Assert.Single(results, r => r.HasErrors); + + var tournament = await DbContext.Tournaments.AsNoTracking().FirstAsync(t => t.Id == tournamentId); + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournamentId) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + + Assert.Equal(Enumerable.Range(1, tournament.MaxParticipants), finalSlots); + Assert.Equal(tournament.MaxParticipants, finalSlots.Max()); + } +} From 3e429a3fe23f59ad37e3004028856e20cbcc2e48 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:09:24 +0200 Subject: [PATCH 06/12] test: add tests for participant email privacy in queries Added tests to verify participant email visibility in tournament queries for anonymous users, unrelated users, and private email owners, ensuring correct privacy handling in all scenarios. --- .../Tests/Tournaments/TournamentQueryTests.cs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs index ebeed78..e258fce 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; using TournamentAPI.Shared.Models; namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Tournaments; @@ -81,6 +82,152 @@ public async Task GetTournamentById_WithParticipants_HidesPrivateParticipantEmai Assert.Null(henry!.Email); } + [Fact] + public async Task GetTournamentById_Participants_AsAnonymousViewer_ShowsAllPublicEmailsAndHidesOnlyHenrys() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var expectedEmails = new Dictionary + { + [1] = "alice@example.com", + [2] = "bob@example.com", + [3] = "carol@example.com", + [4] = "david@example.com", + [5] = "emma@example.com", + [6] = "frank@example.com", + [7] = "grace@example.com", + [8] = null, + }; + + var participants = response.Data!.TournamentById!.Participants!.Nodes!.Select(p => p.Participant!); + Assert.Equal(expectedEmails.Count, participants.Count()); + foreach (var participant in participants) + { + Assert.Equal(expectedEmails[participant.Id], participant.Email); + } + } + + [Fact] + public async Task GetTournamentById_Participants_AsUnrelatedAuthenticatedViewer_ShowsAllPublicEmailsAndHidesOnlyHenrys() + { + using var client = CreateClient(); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RegisterUser, + new { input = new { email = "ivan@example.com", userName = "ivan", password = "Password123!" } }); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "ivan@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var expectedEmails = new Dictionary + { + [1] = "alice@example.com", + [2] = "bob@example.com", + [3] = "carol@example.com", + [4] = "david@example.com", + [5] = "emma@example.com", + [6] = "frank@example.com", + [7] = "grace@example.com", + [8] = null, + }; + + var participants = response.Data!.TournamentById!.Participants!.Nodes!.Select(p => p.Participant!); + Assert.Equal(expectedEmails.Count, participants.Count()); + foreach (var participant in participants) + { + Assert.Equal(expectedEmails[participant.Id], participant.Email); + } + } + + [Fact] + public async Task GetTournamentById_Participants_AsHenryViewingHimself_ShowsHenrysOwnEmailButLeavesOthersUnaffected() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "henry@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var expectedEmails = new Dictionary + { + [1] = "alice@example.com", + [2] = "bob@example.com", + [3] = "carol@example.com", + [4] = "david@example.com", + [5] = "emma@example.com", + [6] = "frank@example.com", + [7] = "grace@example.com", + [8] = "henry@example.com", + }; + + var participants = response.Data!.TournamentById!.Participants!.Nodes!.Select(p => p.Participant!); + Assert.Equal(expectedEmails.Count, participants.Count()); + foreach (var participant in participants) + { + Assert.Equal(expectedEmails[participant.Id], participant.Email); + } + } + + [Fact] + public async Task GetTournamentById_Participants_WithTwoPrivateEmailParticipants_EachOnlyRevealsOwnEmailToSelf() + { + var ivan = new ApplicationUser + { + UserName = "ivan", + Email = "ivan@example.com", + FirstName = "Ivan", + LastName = "Ivanov", + IsEmailPublic = false + }; + DbContext.Users.Add(ivan); + await DbContext.SaveChangesAsync(); + + DbContext.TournamentParticipants.Add(new TournamentParticipant + { + TournamentId = 3, + ParticipantId = ivan.Id, + SlotNumber = 9 + }); + await DbContext.SaveChangesAsync(); + + using var client = CreateClient(); + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "henry@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var nodes = response.Data!.TournamentById!.Participants!.Nodes!; + + var henryNode = nodes.Single(n => n.Participant!.Id == 8); + Assert.Equal("henry@example.com", henryNode.Participant!.Email); + + var ivanNode = nodes.Single(n => n.Participant!.Id == ivan.Id); + Assert.Null(ivanNode.Participant!.Email); + } + [Fact] public async Task GetTournamentById_WithOwner_ShowsPublicEmail_EvenWhenIsEmailPublicNotRequested() { From c8506505cb4dfe7cf5b1dbd7822eb471a1dcb399 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:10:42 +0200 Subject: [PATCH 07/12] test: add tests for filtering and sorting in UserQueryTests Added tests to verify filtering played tournaments by name, filtering won matches by round, and sorting played tournaments by name in descending order. --- .../GraphQL/Tests/Users/UserQueryTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs index 720e4a2..5bac75e 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs @@ -247,4 +247,79 @@ public async Task GetMe_WonMatches_ExcludesMatchesNeedingReplay() var wonMatchIds = response.Data.Me.WonMatches.Nodes?.Select(m => m.Id).ToList(); Assert.DoesNotContain(match.Id, wonMatchIds); } + + [Fact] + public async Task GetMe_PlayedTournaments_WithNameFilter_ReturnsOnlyMatchingTournaments() + { + // Arrange + var email = "alice@example.com"; + using var client = CreateClient(); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password = "Password123!" } }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMePlayedTournamentsWithNameFilter, + new { nameFilter = "Cup" }); + + // Assert + Assert.False(response.HasErrors); + var playedTournaments = response.Data!.Me!.PlayedTournaments!; + Assert.Equal(1, playedTournaments.TotalCount); + Assert.Equal(["Champions Cup"], playedTournaments.Nodes!.Select(t => t.Name)); + } + + [Fact] + public async Task GetMe_WonMatches_WithRoundFilter_ReturnsOnlyMatchingRoundMatches() + { + // Arrange + var email = "alice@example.com"; + using var client = CreateClient(); + + var expectedMatch = await DbContext.Matches + .AsNoTracking() + .SingleAsync(m => m.WinnerId == 1 && m.Round == 2 && m.Bracket.Tournament.Name == "Winter Championship 2024"); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password = "Password123!" } }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWonMatchesWithRoundFilter, + new { roundNumber = 2 }); + + // Assert + Assert.False(response.HasErrors); + var wonMatches = response.Data!.Me!.WonMatches!; + Assert.Equal(1, wonMatches.TotalCount); + Assert.Equal(expectedMatch.Id, wonMatches.Nodes!.Single().Id); + } + + [Fact] + public async Task GetMe_PlayedTournaments_WithSorting_ReturnsSortedResults() + { + // Arrange + var email = "alice@example.com"; + using var client = CreateClient(); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password = "Password123!" } }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMePlayedTournamentsSortedByNameDescending); + + // Assert + Assert.False(response.HasErrors); + var names = response.Data!.Me!.PlayedTournaments!.Nodes!.Select(t => t.Name).ToList(); + Assert.True(names.Count > 1); + Assert.Equal(names.OrderByDescending(n => n, StringComparer.Ordinal), names); + } } From 1e7ba9f940e21cc39a09507b0949fc258b11397e Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:13:23 +0200 Subject: [PATCH 08/12] test: add QueryCommandRecorder for SQL capture in data loader tests --- .../Infrastructure/QueryCommandRecorder.cs | 56 +++++++++++++++++++ .../IntegrationTestWebAppFactory.cs | 4 ++ 2 files changed, 60 insertions(+) create mode 100644 TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs diff --git a/TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs b/TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs new file mode 100644 index 0000000..bad0c6b --- /dev/null +++ b/TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs @@ -0,0 +1,56 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace TournamentAPI.IntegrationTests.Infrastructure; + +public class QueryCommandRecorder : DbCommandInterceptor +{ + private readonly List _commands = new(); + private readonly object _lock = new(); + + public IReadOnlyList Commands + { + get + { + lock (_lock) + { + return _commands.ToList(); + } + } + } + + public void Clear() + { + lock (_lock) + { + _commands.Clear(); + } + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Record(command); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Record(command); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + private void Record(DbCommand command) + { + lock (_lock) + { + _commands.Add(command.CommandText); + } + } +} diff --git a/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs b/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs index 16ee838..9ae63ff 100644 --- a/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs +++ b/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using Testcontainers.MsSql; using TournamentAPI.Data; +using TournamentAPI.IntegrationTests.Infrastructure; namespace TournamentAPI.IntegrationTests; @@ -18,6 +19,8 @@ public class IntegrationTestWebAppFactory : WebApplicationFactory, IAsy .WithPassword("Your_password123") .Build(); + public QueryCommandRecorder CommandRecorder { get; } = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureTestServices(services => @@ -29,6 +32,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddDbContextFactory(options => { options.UseSqlServer(connectionString); + options.AddInterceptors(CommandRecorder); }); services.AddHangfire(config => config.UseSqlServerStorage(connectionString)); From 2ee9d360cd128d6e2c58f478cbbd4622c00c10d5 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:18:07 +0200 Subject: [PATCH 09/12] test: expand example GraphQL queries for tournaments and users Expanded TournamentQueries and UserQueries with new GraphQL query constants for advanced filtering, sorting, and data retrieval. Includes queries for participants by slot or ID, participants' won tournaments and matches, all matches and players in a tournament, and user-played tournaments with name filters and sorting. --- .../QueryExamples/TournamentQueries.cs | 189 ++++++++++++++++++ .../QueryExamples/UserQueries.cs | 48 +++++ 2 files changed, 237 insertions(+) diff --git a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs index d54bc3c..27d119a 100644 --- a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs @@ -314,6 +314,137 @@ public static class Tournaments } """; + public const string GetByIdWithParticipantsSortedBySlotNumberDescending = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, order: { slotNumber: DESC }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsSortedBySlotNumberAscending = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, order: { slotNumber: ASC }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsFilteredBySlotNumberGreaterThan = """ + query($id: Int!, $minSlot: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, where: { slotNumber: { gt: $minSlot } }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsFilteredByParticipantId = """ + query($id: Int!, $participantId: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, where: { participantId: { eq: $participantId } }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsWonTournaments = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20) { + totalCount + edges { + node { + participant { + id + wonTournaments(first: 5) { + totalCount + } + } + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsWonMatches = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20) { + totalCount + edges { + node { + participant { + id + wonMatches(first: 5) { + totalCount + } + } + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsPlayedTournaments = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20) { + totalCount + edges { + node { + participant { + id + playedTournaments(first: 5) { + totalCount + } + } + } + } + } + } + } + """; + public const string GetByIdWithOwnerTournamentHistory = """ query($id: Int!) { tournamentById(id: $id) { @@ -360,6 +491,64 @@ public static class Tournaments } """; + public const string GetByIdWithAllMatchesAndPlayers = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + bracket { + id + tournamentId + matchesByBracket(first: 20) { + totalCount + edges { + node { + id + round + player1Id + player2Id + winnerId + player1 { + id + } + player2 { + id + } + winner { + id + } + } + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsAndTournamentBackReference = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + participants(first: 20) { + totalCount + edges { + node { + participantId + tournamentId + participant { + id + } + tournament { + id + } + } + } + } + } + } + """; + 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 824c8a2..6e411d3 100644 --- a/TournamentAPI.Shared/QueryExamples/UserQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/UserQueries.cs @@ -54,5 +54,53 @@ public static class Users } } """; + + public const string GetMePlayedTournamentsWithNameFilter = """ + query($nameFilter: String!) { + me { + playedTournaments(first: 10, where: { name: { contains: $nameFilter } }) { + totalCount + edges { + node { + id + name + } + } + } + } + } + """; + + public const string GetMeWonMatchesWithRoundFilter = """ + query($roundNumber: Int!) { + me { + wonMatches(first: 10, where: { round: { eq: $roundNumber } }) { + totalCount + edges { + node { + id + round + } + } + } + } + } + """; + + public const string GetMePlayedTournamentsSortedByNameDescending = """ + query { + me { + playedTournaments(first: 20, order: { name: DESC }) { + totalCount + edges { + node { + id + name + } + } + } + } + } + """; } } From 2e2bc2209fc546cee79c35a9b33670924a944fe9 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:22:22 +0200 Subject: [PATCH 10/12] test: add tests for ApplicationUser GraphQL schema fields Added ApplicationUserSchemaTests to verify the GraphQL ApplicationUser type. Ensured only allow-listed fields are exposed and internal identity fields are not present in the schema. --- .../Schema/ApplicationUserSchemaTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs diff --git a/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs b/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs new file mode 100644 index 0000000..91c6783 --- /dev/null +++ b/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs @@ -0,0 +1,99 @@ +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TournamentAPI.Brackets; +using TournamentAPI.Configuration.Extensions; +using TournamentAPI.Matches; +using TournamentAPI.Participants; +using TournamentAPI.Services; +using TournamentAPI.Tournaments; +using TournamentAPI.Users; + +namespace TournamentAPI.UnitTests.Schema; + +public class ApplicationUserSchemaTests +{ + private static async Task BuildSchemaAsync() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "unit-test-signing-key-unit-test-signing-key", + ["Jwt:Issuer"] = "unit-test-issuer", + ["Jwt:Audience"] = "unit-test-audience", + ["ConnectionStrings:DefaultConnection"] = "Server=.;Database=schema-test;TrustServerCertificate=True;", + ["HealthCheck:ApiKey"] = "unit-test-health-check-key" + }) + .Build(); + + var services = new ServiceCollection(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddMetrics(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddApplicationOptions(); + services.AddApplicationDatabase(); + services.AddApplicationAuthentication(); + services.AddApplicationAuthorization(); + services.AddApplicationMetrics(); + services.AddApplicationGraphQL(isDevelopment: true); + + var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + var executor = await manager.GetExecutorAsync(); + + return executor.Schema; + } + + [Fact] + public async Task ApplicationUserType_ExposesExactlyAllowListedFields() + { + var schema = await BuildSchemaAsync(); + var type = schema.Types.GetType("ApplicationUser"); + + var fieldNames = type.Fields + .Where(f => !f.IsIntrospectionField) + .Select(f => f.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + var expected = new[] + { + "email", "firstName", "id", "isEmailPublic", "lastName", + "playedTournaments", "wonMatches", "wonTournaments" + } + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(expected, fieldNames); + } + + [Fact] + public async Task ApplicationUserType_DoesNotExposeIdentityInternalFields() + { + var schema = await BuildSchemaAsync(); + var type = schema.Types.GetType("ApplicationUser"); + + var fieldNames = type.Fields.Select(f => f.Name).ToHashSet(); + + var identityInternalFields = new[] { "userName", "passwordHash", "securityStamp", "phoneNumber" }; + + foreach (var field in identityInternalFields) + { + Assert.DoesNotContain(field, fieldNames); + } + } +} From 9a4ce5259ecb12a6c2e8dcc27690d7df1fcdb5f1 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:24:20 +0200 Subject: [PATCH 11/12] build: expose internals to unit tests in TournamentAPI.csproj Added to allow TournamentAPI.UnitTests project to access internal types for improved unit testing coverage. --- TournamentAPI/TournamentAPI.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TournamentAPI/TournamentAPI.csproj b/TournamentAPI/TournamentAPI.csproj index e81b93a..57614b6 100644 --- a/TournamentAPI/TournamentAPI.csproj +++ b/TournamentAPI/TournamentAPI.csproj @@ -7,6 +7,10 @@ true + + + + From 1be13954a000a6f055a1030fe5a2dcd45918ef99 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 24 Sep 2026 17:25:11 +0200 Subject: [PATCH 12/12] test: add WonMatches property to ApplicationUserNode response model Added WonMatches of type MatchesByBracketConnection? to ApplicationUserNode in ResponseModels.cs to represent user's won matches by bracket. --- TournamentAPI.Shared/Models/ResponseModels.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index dbf43d6..1f6a81d 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -73,6 +73,7 @@ public class ApplicationUserNode public bool IsEmailPublic { get; set; } public TournamentsConnection? WonTournaments { get; set; } public TournamentsConnection? PlayedTournaments { get; set; } + public MatchesByBracketConnection? WonMatches { get; set; } } public class BracketNode