Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4417962
feat: add Drawn status to MatchStatus enum
Dejmenek Sep 28, 2026
e4e927a
feat: add TournamentFormat enum and format property to Tournament
Dejmenek Sep 28, 2026
3f8dbb7
feat: add optional Format to CreateTournamentInput
Dejmenek Sep 28, 2026
c3f1f97
feat: support draws in matches with validation and error codes
Dejmenek Sep 28, 2026
27afcc0
feat: support multiple tournament formats in MatchMutations
Dejmenek Sep 28, 2026
b81e6e8
feat: add Standings feature with round-robin computation
Dejmenek Sep 28, 2026
8a1f811
feat: enable support for multiple tournament formats in BracketMutations
Dejmenek Sep 28, 2026
884ae13
feat: add validation for round advancement by tournament format
Dejmenek Sep 28, 2026
ac43d35
test: add integration tests for round-robin bracket behavior
Dejmenek Sep 28, 2026
e489aaa
test: add integration tests for null winnerId in match play
Dejmenek Sep 28, 2026
679df7f
test: add integration tests for tournament standings logic
Dejmenek Sep 28, 2026
1dbce8e
test: add integration tests for tournament defaults and withdrawals
Dejmenek Sep 28, 2026
33b7864
test: add format, standings, and withdrawal support to tournament res…
Dejmenek Sep 28, 2026
274c69b
test: add unit tests for StandingsService computation logic
Dejmenek Sep 28, 2026
e9960c1
test: add unit tests for RoundRobinBracketStrategy
Dejmenek Sep 28, 2026
b812c4c
test: expand validation test coverage for tournaments
Dejmenek Sep 28, 2026
6b8b0c0
feat: add withdrawal error codes and validations for tournament withd…
Dejmenek Sep 28, 2026
fcc2e88
feat: add WithdrawParticipant mutation to TournamentMutations
Dejmenek Sep 28, 2026
5df875d
feat: set Tournament.Format with default in TournamentMutations
Dejmenek Sep 28, 2026
08a881f
docs: add spec for Round Robin format with extensible bracket strateg…
Dejmenek Sep 28, 2026
ac3494c
feat: register standings services and update DI for bracket strategies
Dejmenek Sep 28, 2026
82dcf9e
test: update DI to use strategy pattern for brackets in ApplicationUs…
Dejmenek Sep 28, 2026
2ea2eb1
test: refactor BracketServiceTests to target SingleEliminationBracket…
Dejmenek Sep 28, 2026
115519b
test: refactor concurrent logout test for idempotency
Dejmenek Sep 28, 2026
782159b
fix: skip dependent navigations in soft delete logic
Dejmenek Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1265,4 +1265,145 @@ await client.ExecuteMutationAsync<GenerateBracketResponse>(
Assert.Equal(TournamentStatus.Closed, tournamentAfter.Status);
Assert.Null(tournamentAfter.ChampionId);
}

[Fact]
public async Task GenerateBracket_RoundRobin_WithOddParticipantCount_CreatesFullScheduleWithOneByeEach()
{
// Arrange: 5 participants (odd) → 5*4/2 = 10 matches, every pair exactly once, one bye each.
var email = "alice@example.com";
var password = "Password123!";
using var client = CreateClient();

var tokenResponse = await client.ExecuteMutationAsync<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new { input = new { email, password } });
client.SetAuthToken(tokenResponse.Data.LoginUser.String);

var createResponse = await client.ExecuteMutationAsync<CreateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn,
new
{
input = new
{
name = "Round Robin Fixture",
startDate = DateTime.UtcNow.AddDays(1),
status = "OPEN",
maxParticipants = 5,
format = "ROUND_ROBIN"
}
});

Assert.False(createResponse.HasErrors);
var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id;

var participantIds = new[] { 1, 2, 3, 4, 5 };
foreach (var participantId in participantIds)
{
var addResponse = await client.ExecuteMutationAsync<AddParticipantResponse>(
Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn,
new { input = new { tournamentId, userId = participantId } });
Assert.False(addResponse.HasErrors);
}

var closeResponse = await client.ExecuteMutationAsync<UpdateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn,
new { input = new { tournamentId, status = "CLOSED" } });
Assert.False(closeResponse.HasErrors);

// Act
var response = await client.ExecuteMutationAsync<GenerateBracketResponse>(
Shared.MutationExamples.Mutations.Bracket.GenerateBracket,
new { input = new { tournamentId } });

// Assert
Assert.False(response.HasErrors);

var matches = await DbContext.Matches
.AsNoTracking()
.Where(m => m.Bracket.TournamentId == tournamentId)
.ToListAsync();

Assert.Equal(10, matches.Count);

var pairs = matches
.Where(m => m.Player2Id != null)
.Select(m => (Math.Min(m.Player1Id, m.Player2Id!.Value), Math.Max(m.Player1Id, m.Player2Id!.Value)))
.ToList();
Assert.Equal(pairs.Count, pairs.Distinct().Count());

var expectedPairs =
(from a in participantIds
from b in participantIds
where a < b
select (a, b))
.OrderBy(p => p)
.ToList();
Assert.Equal(expectedPairs, pairs.OrderBy(p => p).ToList());

// Byes aren't persisted as Match rows: each participant is simply missing from one round.
Assert.DoesNotContain(matches, m => m.Player2Id == null);

foreach (var participantId in participantIds)
{
var matchesPlayed = matches.Count(m => m.Player1Id == participantId || m.Player2Id == participantId);
Assert.Equal(participantIds.Length - 1, matchesPlayed);
}
}

[Fact]
public async Task UpdateRound_ReturnsFormatNotSupportedError_WhenBracketIsRoundRobin()
{
// Arrange: a Round Robin tournament with a generated bracket has no next-round-from-winners concept.
var email = "alice@example.com";
var password = "Password123!";
using var client = CreateClient();

var tokenResponse = await client.ExecuteMutationAsync<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new { input = new { email, password } });
client.SetAuthToken(tokenResponse.Data.LoginUser.String);

var createResponse = await client.ExecuteMutationAsync<CreateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn,
new
{
input = new
{
name = "Round Robin UpdateRound Fixture",
startDate = DateTime.UtcNow.AddDays(1),
status = "OPEN",
maxParticipants = 4,
format = "ROUND_ROBIN"
}
});
var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id;

foreach (var participantId in new[] { 1, 2, 3, 4 })
{
await client.ExecuteMutationAsync<AddParticipantResponse>(
Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn,
new { input = new { tournamentId, userId = participantId } });
}

await client.ExecuteMutationAsync<UpdateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn,
new { input = new { tournamentId, status = "CLOSED" } });

await client.ExecuteMutationAsync<GenerateBracketResponse>(
Shared.MutationExamples.Mutations.Bracket.GenerateBracket,
new { input = new { tournamentId } });

var bracketId = await DbContext.Brackets.AsNoTracking().Where(b => b.TournamentId == tournamentId).Select(b => b.Id).SingleAsync();

// Act
var response = await client.ExecuteMutationAsync<UpdateRoundResponse>(
Shared.MutationExamples.Mutations.Bracket.UpdateRound,
new { input = new { bracketId, roundNumber = 1 } });

// Assert
Assert.True(response.HasErrors);
var error = response.Errors!.First();
var expectedError = BracketErrors.RoundAdvancementNotSupportedForFormat(tournamentId);
Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using TournamentAPI.Tournaments;

namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Matches;

public class MatchMutationTests : BaseIntegrationTest
{
public MatchMutationTests(IntegrationTestWebAppFactory factory) : base(factory)
Expand Down Expand Up @@ -573,4 +574,160 @@ public async Task Play_ReturnsWinnerScoreMismatchError_WhenWinnerScoreIsNotHighe
Assert.NotNull(match);
Assert.Null(match.WinnerId);
}

[Fact]
public async Task Play_RoundRobin_WithNullWinnerIdAndEqualScores_SucceedsAsADraw()
{
// Arrange
var email = "alice@example.com";
var password = "Password123!";
using var client = CreateClient();

var tokenResponse = await client.ExecuteMutationAsync<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new { input = new { email, password } });
client.SetAuthToken(tokenResponse.Data.LoginUser.String);

var createResponse = await client.ExecuteMutationAsync<CreateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn,
new
{
input = new
{
name = "Round Robin Draw Fixture",
startDate = DateTime.UtcNow.AddDays(1),
status = "OPEN",
maxParticipants = 4,
format = "ROUND_ROBIN"
}
});
var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id;

foreach (var participantId in new[] { 1, 2, 3, 4 })
{
await client.ExecuteMutationAsync<AddParticipantResponse>(
Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn,
new { input = new { tournamentId, userId = participantId } });
}

await client.ExecuteMutationAsync<UpdateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn,
new { input = new { tournamentId, status = "CLOSED" } });

await client.ExecuteMutationAsync<GenerateBracketResponse>(
Shared.MutationExamples.Mutations.Bracket.GenerateBracket,
new { input = new { tournamentId } });

var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled);

// Act
var response = await client.ExecuteMutationAsync<PlayMatchResponse>(
Shared.MutationExamples.Mutations.Match.Play,
new { input = new { matchId = match.Id, winnerId = (int?)null, player1Score = 1, player2Score = 1 } });

// Assert
Assert.False(response.HasErrors);

var matchAfter = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == match.Id);
Assert.Equal(MatchStatus.Drawn, matchAfter.Status);
Assert.Null(matchAfter.WinnerId);
}

[Fact]
public async Task Play_RoundRobin_WithNullWinnerIdAndUnequalScores_IsRejected()
{
// Arrange
var email = "alice@example.com";
var password = "Password123!";
using var client = CreateClient();

var tokenResponse = await client.ExecuteMutationAsync<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new { input = new { email, password } });
client.SetAuthToken(tokenResponse.Data.LoginUser.String);

var createResponse = await client.ExecuteMutationAsync<CreateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn,
new
{
input = new
{
name = "Round Robin Bad Draw Fixture",
startDate = DateTime.UtcNow.AddDays(1),
status = "OPEN",
maxParticipants = 4,
format = "ROUND_ROBIN"
}
});
var tournamentId = createResponse.Data!.CreateTournament!.Tournament!.Id;

foreach (var participantId in new[] { 1, 2, 3, 4 })
{
await client.ExecuteMutationAsync<AddParticipantResponse>(
Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn,
new { input = new { tournamentId, userId = participantId } });
}

await client.ExecuteMutationAsync<UpdateTournamentResponse>(
Shared.MutationExamples.Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn,
new { input = new { tournamentId, status = "CLOSED" } });

await client.ExecuteMutationAsync<GenerateBracketResponse>(
Shared.MutationExamples.Mutations.Bracket.GenerateBracket,
new { input = new { tournamentId } });

var match = await DbContext.Matches.AsNoTracking().FirstAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled);

// Act
var response = await client.ExecuteMutationAsync<PlayMatchResponse>(
Shared.MutationExamples.Mutations.Match.Play,
new { input = new { matchId = match.Id, winnerId = (int?)null, player1Score = 2, player2Score = 1 } });

// Assert
Assert.True(response.HasErrors);
var error = response.Errors!.First();
var expectedError = MatchErrors.DrawRequiresEqualScores(match.Id, 2, 1);
Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString());

var matchAfter = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == match.Id);
Assert.Equal(MatchStatus.Scheduled, matchAfter.Status);
}

[Fact]
public async Task Play_SingleElimination_WithNullWinnerId_IsRejected()
{
// Arrange: tournament 10 ("Trio Tournament") is Single Elimination; regression boundary check
// distinct from the Round Robin draw tests above.
var email = "bob@example.com";
var password = "Password123!";
var tournamentId = 10;
using var client = CreateClient();

var tokenResponse = await client.ExecuteMutationAsync<LoginResponse>(
Shared.MutationExamples.Mutations.Users.LoginUser,
new { input = new { email, password } });
client.SetAuthToken(tokenResponse.Data.LoginUser.String);

await client.ExecuteMutationAsync<GenerateBracketResponse>(
Shared.MutationExamples.Mutations.Bracket.GenerateBracket,
new { input = new { tournamentId } });

var match = await DbContext.Matches
.AsNoTracking()
.FirstAsync(m => m.Bracket.TournamentId == tournamentId && m.Status == MatchStatus.Scheduled);

// Act
var response = await client.ExecuteMutationAsync<PlayMatchResponse>(
Shared.MutationExamples.Mutations.Match.Play,
new { input = new { matchId = match.Id, winnerId = (int?)null, player1Score = 1, player2Score = 1 } });

// Assert
Assert.True(response.HasErrors);
var error = response.Errors!.First();
var expectedError = MatchErrors.DrawNotAllowedForFormat(match.Id);
Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString());

var matchAfter = await DbContext.Matches.AsNoTracking().SingleAsync(m => m.Id == match.Id);
Assert.Equal(MatchStatus.Scheduled, matchAfter.Status);
}
}
Loading
Loading