Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ await client.ExecuteMutationAsync<UpdateEmailVisibilityResponse>(
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<TournamentByIdResponse>(
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()
{
Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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!)
Expand All @@ -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();
Expand All @@ -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();
Expand Down
102 changes: 102 additions & 0 deletions TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
password = "Password123!"
}
});
client.SetAuthToken(token.Data.LoginUser.String);

Check warning on line 27 in TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs

View workflow job for this annotation

GitHub Actions / build-test

Possible null reference argument for parameter 'token' in 'void TestClient.SetAuthToken(string token)'.

Check warning on line 27 in TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.

Check warning on line 27 in TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.

// Act
var response = await client.ExecuteQueryAsync<MeResponse>(
Expand Down Expand Up @@ -54,7 +54,7 @@
password = "Password123!"
}
});
client.SetAuthToken(token.Data.LoginUser.String);

Check warning on line 57 in TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs

View workflow job for this annotation

GitHub Actions / build-test

Possible null reference argument for parameter 'token' in 'void TestClient.SetAuthToken(string token)'.

Check warning on line 57 in TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.

Check warning on line 57 in TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.

// Act
var response = await client.ExecuteQueryAsync<MeResponse>(
Expand All @@ -67,4 +67,106 @@
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<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new
{
input = new
{
email = email,
password = "Password123!"
}
});
client.SetAuthToken(token.Data.LoginUser.String);

// Act
var response = await client.ExecuteQueryAsync<MeResponse>(
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<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new
{
input = new
{
email = email,
password = "Password123!"
}
});
client.SetAuthToken(token.Data.LoginUser.String);

// Act
var response = await client.ExecuteQueryAsync<MeResponse>(
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<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new
{
input = new
{
email = email,
password = "Password123!"
}
});
client.SetAuthToken(token.Data.LoginUser.String);

// Act
var response = await client.ExecuteQueryAsync<MeResponse>(
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);
}
}
4 changes: 4 additions & 0 deletions TournamentAPI.Shared/Models/ResponseModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions TournamentAPI.Shared/MutationExamples/BracketMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions TournamentAPI.Shared/QueryExamples/TournamentQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions TournamentAPI.Shared/QueryExamples/UserQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
""";
}
}
33 changes: 32 additions & 1 deletion TournamentAPI/Data/DatabaseSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TournamentParticipant>(),
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();
}
}
Expand Down
10 changes: 10 additions & 0 deletions TournamentAPI/Extensions/SortDefinitionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using GreenDonut.Data;
using TournamentAPI.Data.Models;

namespace TournamentAPI.Extensions;

public static class SortDefinitionExtensions
{
public static SortDefinition<Tournament> DefaultTournamentOrder(this SortDefinition<Tournament> sort)
=> sort.IfEmpty(o => o.AddAscending(t => t.Id)).AddAscending(t => t.Id);
}
1 change: 1 addition & 0 deletions TournamentAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
builder.Services.AddScoped<BracketLookupService>();
builder.Services.AddScoped<ParticipantsService>();
builder.Services.AddScoped<TournamentLookupService>();
builder.Services.AddScoped<UserTournamentsService>();
builder.Services.AddApplicationDatabase();
builder.Services.AddApplicationRateLimiting();
builder.Services.AddApplicationTelemetry();
Expand Down
6 changes: 2 additions & 4 deletions TournamentAPI/Tournaments/TournamentQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.EntityFrameworkCore;
using TournamentAPI.Data;
using TournamentAPI.Data.Models;
using TournamentAPI.Extensions;

namespace TournamentAPI.Tournaments;

Expand All @@ -24,15 +25,12 @@ public static async Task<PageConnection<Tournament>> GetTournaments(
{
var page = await context.Tournaments
.AsNoTracking()
.With(query, DefaultOrder)
.With(query, s => s.DefaultTournamentOrder())
.ToPageAsync(pagingArgs, cancellationToken);

return page;
}

private static SortDefinition<Tournament> DefaultOrder(SortDefinition<Tournament> sort)
=> sort.IfEmpty(o => o.AddAscending(t => t.Id)).AddAscending(t => t.Id);

[UseFirstOrDefault]
public static IQueryable<Tournament>? GetTournamentById(
int id,
Expand Down
Loading
Loading