From 572fc08c644f39d215b702522350ff51e263864d Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 29 Sep 2026 18:23:19 +0200 Subject: [PATCH 1/6] test: return a bare scenario result from load test scenarios Returning a step's Response from the scenario delegate makes NBomber count its status code and payload a second time at scenario level, so scenario status codes were doubled. Scenarios now go through GraphQLLoadScenario.Create, which returns a plain Ok or Fail based on the last step. A failing step still counts against the error budget. The status mix check now takes both sides of its ratio from status code counts. Multi-step scenarios aggregate the code of every step, while the request count only counts the scenario once. Co-Authored-By: Claude Sonnet 5.5 --- .../AuthenticationLoadTests.cs | 2 +- .../ConcurrencyLimiterTests.cs | 2 +- .../GraphQLLoadScenario.cs | 21 +++++++++++++++++++ TournamentAPI.LoadTests/LatencyTests.cs | 4 ++-- TournamentAPI.LoadTests/LoadThresholds.cs | 2 +- TournamentAPI.LoadTests/TokenBucketTests.cs | 2 +- .../TournamentBrowseLoadTests.cs | 6 +++--- .../TournamentInsightsLoadTests.cs | 6 +++--- 8 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 TournamentAPI.LoadTests/GraphQLLoadScenario.cs diff --git a/TournamentAPI.LoadTests/AuthenticationLoadTests.cs b/TournamentAPI.LoadTests/AuthenticationLoadTests.cs index 672102d..68d49af 100644 --- a/TournamentAPI.LoadTests/AuthenticationLoadTests.cs +++ b/TournamentAPI.LoadTests/AuthenticationLoadTests.cs @@ -26,7 +26,7 @@ public void AuthenticationFlow_ShouldMeetBudgets_UnderSteadyLogins() var emails = _factory.UserEmails; var password = _factory.UserPassword; - var scenario = Scenario.Create("authenticate_and_refresh_session", async context => + var scenario = GraphQLLoadScenario.Create("authenticate_and_refresh_session", async context => { var email = emails[(int)(context.InvocationNumber % emails.Count)]; string? accessToken = null; diff --git a/TournamentAPI.LoadTests/ConcurrencyLimiterTests.cs b/TournamentAPI.LoadTests/ConcurrencyLimiterTests.cs index 42c8559..9130242 100644 --- a/TournamentAPI.LoadTests/ConcurrencyLimiterTests.cs +++ b/TournamentAPI.LoadTests/ConcurrencyLimiterTests.cs @@ -16,7 +16,7 @@ public void ConcurrencyLimiter_Should_RejectRequests_AboveThePermitLimit() // Arrange using var client = CreateClient(); - var scenario = Scenario.Create("concurrent_tournament_browsing", async context => + var scenario = GraphQLLoadScenario.Create("concurrent_tournament_browsing", async context => await GraphQLLoadStep.RunAsync( LoadTestBudgets.ListTournaments.Step, context, diff --git a/TournamentAPI.LoadTests/GraphQLLoadScenario.cs b/TournamentAPI.LoadTests/GraphQLLoadScenario.cs new file mode 100644 index 0000000..5bb79a9 --- /dev/null +++ b/TournamentAPI.LoadTests/GraphQLLoadScenario.cs @@ -0,0 +1,21 @@ +using NBomber.Contracts; +using NBomber.CSharp; + +namespace TournamentAPI.LoadTests; + +internal static class GraphQLLoadScenario +{ + /// + /// Creates a scenario whose own result only reports success or failure of the last step. + /// Returning the step response directly makes NBomber count its status code and payload a second time at scenario level. + /// + public static ScenarioProps Create(string name, Func>> run) + { + return Scenario.Create(name, async context => + { + var last = await run(context); + + return last.IsError ? Response.Fail() : Response.Ok(); + }); + } +} diff --git a/TournamentAPI.LoadTests/LatencyTests.cs b/TournamentAPI.LoadTests/LatencyTests.cs index e37f6bc..875b3c7 100644 --- a/TournamentAPI.LoadTests/LatencyTests.cs +++ b/TournamentAPI.LoadTests/LatencyTests.cs @@ -34,7 +34,7 @@ public void Api_ShouldMeetLatencyBudget_UnderNormalLoad() { // Arrange using var client = CreateClient(); - var scenario = Scenario.Create("browse_tournaments", async context => + var scenario = GraphQLLoadScenario.Create("browse_tournaments", async context => await GraphQLLoadStep.RunAsync( ListBudget.Step, context, @@ -62,7 +62,7 @@ public void Api_ShouldDegradeGracefully_AsLoadIncreases_AndRecover_AsItDrops() { // Arrange using var client = CreateClient(); - var scenario = Scenario.Create("browse_tournaments_ramp", async context => + var scenario = GraphQLLoadScenario.Create("browse_tournaments_ramp", async context => await GraphQLLoadStep.RunAsync( PhaseAt(context.GetScenarioTimerTime()), context, diff --git a/TournamentAPI.LoadTests/LoadThresholds.cs b/TournamentAPI.LoadTests/LoadThresholds.cs index 8ade61a..4e9e280 100644 --- a/TournamentAPI.LoadTests/LoadThresholds.cs +++ b/TournamentAPI.LoadTests/LoadThresholds.cs @@ -121,7 +121,7 @@ public static bool IsStatusMixSatisfied( double minOkPercent, string[] allowedFailCodes) { - var total = stats.Ok.Request.Count + stats.Fail.Request.Count; + var total = stats.Ok.StatusCodes.Sum(c => c.Count) + stats.Fail.StatusCodes.Sum(c => c.Count); if (total == 0) { return false; diff --git a/TournamentAPI.LoadTests/TokenBucketTests.cs b/TournamentAPI.LoadTests/TokenBucketTests.cs index 1fbfd76..2d9cfc9 100644 --- a/TournamentAPI.LoadTests/TokenBucketTests.cs +++ b/TournamentAPI.LoadTests/TokenBucketTests.cs @@ -16,7 +16,7 @@ public void TokenBucket_Should_AllowBurst_Then_Reject() // Arrange using var client = CreateClient(); - var scenario = Scenario.Create("tournament_browsing_burst", async context => + var scenario = GraphQLLoadScenario.Create("tournament_browsing_burst", async context => await GraphQLLoadStep.RunAsync( LoadTestBudgets.ListTournaments.Step, context, diff --git a/TournamentAPI.LoadTests/TournamentBrowseLoadTests.cs b/TournamentAPI.LoadTests/TournamentBrowseLoadTests.cs index b33bdca..87d1bbd 100644 --- a/TournamentAPI.LoadTests/TournamentBrowseLoadTests.cs +++ b/TournamentAPI.LoadTests/TournamentBrowseLoadTests.cs @@ -26,7 +26,7 @@ public void Api_ShouldMeetBudgets_UnderMixedBrowsingTraffic() var tournamentIds = _factory.TournamentIds; var searchTerm = _factory.TournamentSearchTerm; - var browseList = Scenario.Create("browse_tournament_list", async context => + var browseList = GraphQLLoadScenario.Create("browse_tournament_list", async context => await GraphQLLoadStep.RunAsync( StepNames.ListTournaments, context, @@ -39,7 +39,7 @@ await GraphQLLoadStep.RunAsync( interval: TimeSpan.FromSeconds(1), during: Duration)); - var viewDetails = Scenario.Create("view_tournament_details", async context => + var viewDetails = GraphQLLoadScenario.Create("view_tournament_details", async context => await GraphQLLoadStep.RunAsync( StepNames.ViewTournament, context, @@ -53,7 +53,7 @@ await GraphQLLoadStep.RunAsync( interval: TimeSpan.FromSeconds(1), during: Duration)); - var discover = Scenario.Create("discover_tournaments", async context => + var discover = GraphQLLoadScenario.Create("discover_tournaments", async context => { var search = await GraphQLLoadStep.RunAsync( StepNames.SearchTournamentsByName, diff --git a/TournamentAPI.LoadTests/TournamentInsightsLoadTests.cs b/TournamentAPI.LoadTests/TournamentInsightsLoadTests.cs index b896f83..d8785a9 100644 --- a/TournamentAPI.LoadTests/TournamentInsightsLoadTests.cs +++ b/TournamentAPI.LoadTests/TournamentInsightsLoadTests.cs @@ -50,7 +50,7 @@ public void Api_ShouldMeetBudgets_ForExpensiveQueryShapes() VirtualUserSession[] sessions = []; - var ownHistory = Scenario.Create("inspect_own_tournament_history", async context => + var ownHistory = GraphQLLoadScenario.Create("inspect_own_tournament_history", async context => { var session = VirtualUserSessions.Pick(sessions, context.InvocationNumber); @@ -69,7 +69,7 @@ public void Api_ShouldMeetBudgets_ForExpensiveQueryShapes() .WithWarmUpDuration(WarmUp) .WithLoadSimulations(Steady()); - var oversizedPage = Scenario.Create("request_oversized_page", async context => + var oversizedPage = GraphQLLoadScenario.Create("request_oversized_page", async context => await GraphQLLoadStep.RunAsync( StepNames.ListTournamentsWithOversizedPage, context, @@ -98,7 +98,7 @@ private static ScenarioProps InspectTournament( IReadOnlyList tournamentIds, Func? bodyCheck = null) { - return Scenario.Create(scenarioName, async context => + return GraphQLLoadScenario.Create(scenarioName, async context => await GraphQLLoadStep.RunAsync( stepName, context, From a9e7dd2bcc115167bec5678b04e4f2e2fa470974 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 29 Sep 2026 18:23:19 +0200 Subject: [PATCH 2/6] test: add provisioning helpers and budgets for write load tests Write scenarios provision users, open tournaments, closed tournaments and matches through DbContext before the measured run, so the loop only exercises the mutation under test. The budgets hold the step names, rates and baselines for every write scenario. Baselines are the worst value over three runs, since run to run variance on this machine is large. GraphQLLoadStep gets a TrueResult body check for mutations that return a boolean. Co-Authored-By: Claude Sonnet 5.5 --- TournamentAPI.LoadTests/GraphQLLoadStep.cs | 7 ++ TournamentAPI.LoadTests/LoadTestBudgets.cs | 27 ++++++ .../WriteScenarioProvisioner.Brackets.cs | 56 +++++++++++++ .../WriteScenarioProvisioner.Matches.cs | 83 +++++++++++++++++++ .../WriteScenarioProvisioner.Tournaments.cs | 46 ++++++++++ .../WriteScenarioProvisioner.cs | 42 ++++++++++ 6 files changed, 261 insertions(+) create mode 100644 TournamentAPI.LoadTests/WriteScenarioProvisioner.Brackets.cs create mode 100644 TournamentAPI.LoadTests/WriteScenarioProvisioner.Matches.cs create mode 100644 TournamentAPI.LoadTests/WriteScenarioProvisioner.Tournaments.cs create mode 100644 TournamentAPI.LoadTests/WriteScenarioProvisioner.cs diff --git a/TournamentAPI.LoadTests/GraphQLLoadStep.cs b/TournamentAPI.LoadTests/GraphQLLoadStep.cs index 146b0fc..a1d5674 100644 --- a/TournamentAPI.LoadTests/GraphQLLoadStep.cs +++ b/TournamentAPI.LoadTests/GraphQLLoadStep.cs @@ -66,6 +66,13 @@ public static Func NonNullField(string field) return data => data.TryGetProperty(field, out var value) && value.ValueKind == JsonValueKind.Object; } + public static Func TrueResult(string mutation) + { + return data => data.TryGetProperty(mutation, out var result) + && result.TryGetProperty("boolean", out var value) + && value.ValueKind == JsonValueKind.True; + } + public static Func RejectionWhenErrorHasExtension(string extensionName, string statusCode) { return error => error.TryGetProperty("extensions", out var extensions) diff --git a/TournamentAPI.LoadTests/LoadTestBudgets.cs b/TournamentAPI.LoadTests/LoadTestBudgets.cs index 0184795..0c45749 100644 --- a/TournamentAPI.LoadTests/LoadTestBudgets.cs +++ b/TournamentAPI.LoadTests/LoadTestBudgets.cs @@ -1,3 +1,5 @@ +using TournamentAPI.Data.Models; + namespace TournamentAPI.LoadTests; internal sealed record StepBudget( @@ -27,6 +29,17 @@ internal static class StepNames public const string ViewProfile = "view_profile"; public const string RefreshToken = "refresh_token"; public const string Logout = "logout"; + public const string CreateTournament = "create_tournament"; + public const string JoinTournament = "join_tournament"; + public const string PlayMatch = "play_match"; + public const string CorrectMatchResult = "correct_match_result"; + + public static string GenerateBracket(TournamentFormat format, int participants) + { + var formatName = format == TournamentFormat.RoundRobin ? "round_robin" : "single_elimination"; + + return $"generate_bracket_{formatName}_{participants}"; + } } internal static class LoadTestBudgets @@ -38,6 +51,10 @@ public static class Rates public const int DiscoverTournamentsPerSecond = 4; public const int InsightPerSecond = 5; public const int AuthFlowPerSecond = 5; + public const int CreateTournamentPerSecond = 10; + public const int JoinTournamentPerSecond = 40; + public const int MatchContentionPerSecond = 40; + public const int GenerateBracketPerSecond = 4; } public static class Steps @@ -59,6 +76,16 @@ public static class Steps [(LoadTestDataSize.Default, StepNames.ViewTournament)] = new(18.96, 23.97, 30.28, 0.592), [(LoadTestDataSize.Default, StepNames.SearchTournamentsByName)] = new(8.26, 11.89, 12.15, 0.314), [(LoadTestDataSize.Default, StepNames.SortTournaments)] = new(19.54, 21.01, 22.43, 2.678), + [(LoadTestDataSize.Default, StepNames.CreateTournament)] = new(25.09, 426.75, 469.95, 0.177), + [(LoadTestDataSize.Default, StepNames.JoinTournament)] = new(24.59, 46.24, 1141.50, 0.198), + [(LoadTestDataSize.Default, StepNames.PlayMatch)] = new(171.39, 741.38, 963.21, 0.138), + [(LoadTestDataSize.Default, StepNames.CorrectMatchResult)] = new(87.68, 164.22, 206.74, 0.186), + [(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.SingleElimination, 8))] = new(203.65, 503.04, 502.94, 0.130), + [(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.SingleElimination, 16))] = new(218.88, 412.93, 412.90, 0.130), + [(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.SingleElimination, 32))] = new(235.52, 398.34, 398.16, 0.130), + [(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.RoundRobin, 8))] = new(300.80, 386.30, 386.08, 0.130), + [(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.RoundRobin, 16))] = new(530.94, 849.92, 849.89, 0.131), + [(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.RoundRobin, 32))] = new(1304.58, 2053.12, 2052.77, 0.131), [(LoadTestDataSize.Medium, StepNames.ListTournaments)] = new(8.56, 13.29, 35.31, 1.410), [(LoadTestDataSize.Medium, StepNames.ViewTournament)] = new(23.52, 47.84, 60.71, 1.425), diff --git a/TournamentAPI.LoadTests/WriteScenarioProvisioner.Brackets.cs b/TournamentAPI.LoadTests/WriteScenarioProvisioner.Brackets.cs new file mode 100644 index 0000000..f710cdf --- /dev/null +++ b/TournamentAPI.LoadTests/WriteScenarioProvisioner.Brackets.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.LoadTests; + +internal static partial class WriteScenarioProvisioner +{ + public static async Task> CreateClosedTournamentsAsync( + IServiceProvider services, + string ownerEmail, + IReadOnlyList playerEmails, + string namePrefix, + int count, + int participantCount, + TournamentFormat format) + { + using var scope = services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var owner = await context.Users.SingleAsync(u => u.Email == ownerEmail); + var players = await context.Users + .Where(u => playerEmails.Contains(u.Email!)) + .OrderBy(u => u.Id) + .Take(participantCount) + .ToListAsync(); + + if (players.Count < participantCount) + { + throw new ArgumentException($"Only {players.Count} of the {participantCount} required players exist.", nameof(playerEmails)); + } + + var tournaments = new List(count); + + for (var i = 0; i < count; i++) + { + tournaments.Add(new Tournament + { + Name = $"{namePrefix} {i}", + StartDate = DateTime.UtcNow.AddDays(7), + OwnerId = owner.Id, + MaxParticipants = participantCount, + Status = TournamentStatus.Closed, + Format = format, + Participants = players + .Select((player, slot) => new TournamentParticipant { ParticipantId = player.Id, SlotNumber = slot + 1 }) + .ToList() + }); + } + + context.Tournaments.AddRange(tournaments); + await context.SaveChangesAsync(); + + return tournaments.Select(t => t.Id).ToList(); + } +} diff --git a/TournamentAPI.LoadTests/WriteScenarioProvisioner.Matches.cs b/TournamentAPI.LoadTests/WriteScenarioProvisioner.Matches.cs new file mode 100644 index 0000000..55f705d --- /dev/null +++ b/TournamentAPI.LoadTests/WriteScenarioProvisioner.Matches.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; +using TournamentAPI.Matches; + +namespace TournamentAPI.LoadTests; + +internal sealed record ProvisionedMatch(int Id, int Player1Id, int Player2Id, string Version); + +internal static partial class WriteScenarioProvisioner +{ + public const int PlayedMatchPlayer1Score = 1; + + public static async Task> CreateClosedTournamentMatchesAsync( + IServiceProvider services, + string ownerEmail, + IReadOnlyList playerEmails, + string namePrefix, + int matchCount, + bool played) + { + if (playerEmails.Count < 2 || playerEmails.Count % 2 != 0) + { + throw new ArgumentException("An even number of at least two players is required.", nameof(playerEmails)); + } + + using var scope = services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var owner = await context.Users.SingleAsync(u => u.Email == ownerEmail); + var players = await context.Users + .Where(u => playerEmails.Contains(u.Email!)) + .OrderBy(u => u.Id) + .ToListAsync(); + + var matchesPerTournament = players.Count / 2; + var tournamentCount = (matchCount + matchesPerTournament - 1) / matchesPerTournament; + var matches = new List(); + + for (var i = 0; i < tournamentCount; i++) + { + var tournament = new Tournament + { + Name = $"{namePrefix} {i}", + StartDate = DateTime.UtcNow.AddDays(7), + OwnerId = owner.Id, + MaxParticipants = players.Count, + Status = TournamentStatus.Closed, + Format = TournamentFormat.SingleElimination, + Participants = players + .Select((player, slot) => new TournamentParticipant { ParticipantId = player.Id, SlotNumber = slot + 1 }) + .ToList(), + Bracket = new Bracket { Matches = new List() } + }; + + for (var pair = 0; pair < matchesPerTournament; pair++) + { + var match = new Match + { + Round = 1, + Player1Id = players[pair * 2].Id, + Player2Id = players[pair * 2 + 1].Id, + Status = played ? MatchStatus.Played : MatchStatus.Scheduled, + WinnerId = played ? players[pair * 2].Id : null, + Player1Score = played ? PlayedMatchPlayer1Score : 0, + Player2Score = 0 + }; + + tournament.Bracket.Matches.Add(match); + matches.Add(match); + } + + context.Tournaments.Add(tournament); + } + + await context.SaveChangesAsync(); + + return matches + .Take(matchCount) + .Select(m => new ProvisionedMatch(m.Id, m.Player1Id, m.Player2Id!.Value, MatchVersionCodec.Encode(m.RowVersion))) + .ToList(); + } +} diff --git a/TournamentAPI.LoadTests/WriteScenarioProvisioner.Tournaments.cs b/TournamentAPI.LoadTests/WriteScenarioProvisioner.Tournaments.cs new file mode 100644 index 0000000..e3cbb63 --- /dev/null +++ b/TournamentAPI.LoadTests/WriteScenarioProvisioner.Tournaments.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.LoadTests; + +internal static partial class WriteScenarioProvisioner +{ + public static async Task> CreateOpenTournamentsAsync( + IServiceProvider services, + string ownerEmail, + string namePrefix, + int count, + int maxParticipants) + { + using var scope = services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var owner = await context.Users.SingleAsync(u => u.Email == ownerEmail); + var tournaments = new List(count); + + for (var i = 0; i < count; i++) + { + var tournament = new Tournament + { + Name = $"{namePrefix} {i}", + StartDate = DateTime.UtcNow.AddDays(7), + OwnerId = owner.Id, + MaxParticipants = maxParticipants, + Status = TournamentStatus.Open, + Format = TournamentFormat.SingleElimination, + Participants = new List + { + new() { ParticipantId = owner.Id, SlotNumber = 1 } + } + }; + + tournaments.Add(tournament); + } + + context.Tournaments.AddRange(tournaments); + await context.SaveChangesAsync(); + + return tournaments.Select(t => t.Id).ToList(); + } +} diff --git a/TournamentAPI.LoadTests/WriteScenarioProvisioner.cs b/TournamentAPI.LoadTests/WriteScenarioProvisioner.cs new file mode 100644 index 0000000..abb6005 --- /dev/null +++ b/TournamentAPI.LoadTests/WriteScenarioProvisioner.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using TournamentAPI.Data.Models; + +namespace TournamentAPI.LoadTests; + +internal static partial class WriteScenarioProvisioner +{ + public static async Task> CreateUsersAsync( + IServiceProvider services, + string prefix, + int count, + string password) + { + using var scope = services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var emails = new List(count); + + for (var i = 0; i < count; i++) + { + var user = new ApplicationUser + { + UserName = $"{prefix}{i}", + Email = $"{prefix}{i}@load.test", + FirstName = $"{prefix}FirstName{i}", + LastName = $"{prefix}LastName{i}", + IsEmailPublic = true + }; + + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + throw new InvalidOperationException($"Failed to create user {user.UserName}: {errors}"); + } + + emails.Add(user.Email); + } + + return emails; + } +} From 303639918bed9b8ad2e73d78c4968c54933774ea Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 29 Sep 2026 18:23:19 +0200 Subject: [PATCH 3/6] test: add create tournament load test Ten users create open tournaments at 10 requests per second for 30 seconds. The test checks the error budget, tail latency and payload size of the create_tournament step. Co-Authored-By: Claude Sonnet 5.5 --- .../CreateTournamentLoadTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 TournamentAPI.LoadTests/CreateTournamentLoadTests.cs diff --git a/TournamentAPI.LoadTests/CreateTournamentLoadTests.cs b/TournamentAPI.LoadTests/CreateTournamentLoadTests.cs new file mode 100644 index 0000000..8fe1203 --- /dev/null +++ b/TournamentAPI.LoadTests/CreateTournamentLoadTests.cs @@ -0,0 +1,65 @@ +using NBomber.CSharp; +using TournamentMutations = TournamentAPI.Shared.MutationExamples.Mutations.Tournaments; + +namespace TournamentAPI.LoadTests; + +[Trait("Category", "Load")] +public class CreateTournamentLoadTests : BaseLoadTest, IClassFixture +{ + private const string UserPrefix = "creator"; + private const int Creators = 10; + + private readonly LatencyWebAppFactory _factory; + + public CreateTournamentLoadTests(LatencyWebAppFactory factory) : base(factory) + { + _factory = factory; + } + + [Fact] + public void CreateTournament_ShouldMeetBudgets_UnderSteadyWrites() + { + // Arrange + VirtualUserSession[] sessions = []; + + var scenario = GraphQLLoadScenario.Create("create_tournament", async context => + { + var session = VirtualUserSessions.Pick(sessions, context.InvocationNumber); + + return await GraphQLLoadStep.RunAsync( + StepNames.CreateTournament, + context, + session.Client, + TournamentMutations.CreateTournamentWithBasicFieldsReturn, + new + { + input = new + { + name = $"Write Load Tournament {context.InvocationNumber}", + startDate = DateTime.UtcNow.AddDays(7).ToString("o"), + status = "OPEN", + maxParticipants = 8 + } + }, + GraphQLLoadStep.NonNullField("createTournament"), + bearerToken: session.AccessToken); + }) + .WithInit(async _ => + { + var emails = await WriteScenarioProvisioner.CreateUsersAsync( + _factory.Services, UserPrefix, Creators, _factory.UserPassword); + + sessions = await VirtualUserSessions.LoginAsync(CreateClient, emails, _factory.UserPassword, Creators); + }) + .WithWarmUpDuration(TimeSpan.FromSeconds(10)) + .WithLoadSimulations(Simulation.Inject( + rate: LoadTestBudgets.Rates.CreateTournamentPerSecond, + interval: TimeSpan.FromSeconds(1), + during: TimeSpan.FromSeconds(30))); + + // Act & Assert + LoadTestRun.Execute( + scenario, + LoadThresholds.ForSteps(LoadTestDataSize.Default, "200", StepNames.CreateTournament)); + } +} From 9ee0942c6cc361ce20a4f36c7133185984db2f88 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 29 Sep 2026 18:23:19 +0200 Subject: [PATCH 4/6] test: add join tournament contention load test Eight users at a time try to take the two free slots of the same tournament. Tournament.Full and Tournament.UserAlreadyParticipant count as expected rejections. After the run the test checks that no tournament holds more participants than MaxParticipants and that at least one join succeeded. Losers of a slot race get Tournament.Full even when slots remain, as ADR 0004 describes, so only part of the free slots get filled. Co-Authored-By: Claude Sonnet 5.5 --- .../JoinTournamentContentionLoadTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 TournamentAPI.LoadTests/JoinTournamentContentionLoadTests.cs diff --git a/TournamentAPI.LoadTests/JoinTournamentContentionLoadTests.cs b/TournamentAPI.LoadTests/JoinTournamentContentionLoadTests.cs new file mode 100644 index 0000000..439e0f0 --- /dev/null +++ b/TournamentAPI.LoadTests/JoinTournamentContentionLoadTests.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NBomber.CSharp; +using TournamentAPI.Data; +using TournamentAPI.Tournaments; +using TournamentMutations = TournamentAPI.Shared.MutationExamples.Mutations.Tournaments; + +namespace TournamentAPI.LoadTests; + +[Trait("Category", "Load")] +public class JoinTournamentContentionLoadTests : BaseLoadTest, IClassFixture +{ + private const string OwnerPrefix = "joinowner"; + private const string JoinerPrefix = "joiner"; + private const string TournamentNamePrefix = "Join Contention Tournament"; + private const int Joiners = 40; + private const int Tournaments = 120; + private const int MaxParticipants = 3; + private const int ContendersPerTournament = 8; + private const double MinSuccessfulJoinPercent = 5; + + private static readonly string[] ExpectedRejections = + [ + TournamentErrorCodes.TournamentFull, + TournamentErrorCodes.UserAlreadyParticipant + ]; + + private readonly LatencyWebAppFactory _factory; + + public JoinTournamentContentionLoadTests(LatencyWebAppFactory factory) : base(factory) + { + _factory = factory; + } + + [Fact] + public async Task JoinTournament_ShouldNeverOverfillATournament_UnderContention() + { + // Arrange + VirtualUserSession[] sessions = []; + IReadOnlyList tournamentIds = []; + + var scenario = GraphQLLoadScenario.Create("join_tournament_contention", async context => + { + var session = VirtualUserSessions.Pick(sessions, context.InvocationNumber); + var tournamentId = tournamentIds[(int)(context.InvocationNumber / ContendersPerTournament % tournamentIds.Count)]; + + return await GraphQLLoadStep.RunAsync( + StepNames.JoinTournament, + context, + session.Client, + TournamentMutations.JoinTournament, + new { input = new { tournamentId } }, + GraphQLLoadStep.TrueResult("joinTournament"), + expectedRejections: ExpectedRejections.ToHashSet(), + bearerToken: session.AccessToken); + }) + .WithInit(async _ => + { + var owners = await WriteScenarioProvisioner.CreateUsersAsync( + _factory.Services, OwnerPrefix, 1, _factory.UserPassword); + + var joiners = await WriteScenarioProvisioner.CreateUsersAsync( + _factory.Services, JoinerPrefix, Joiners, _factory.UserPassword); + + tournamentIds = await WriteScenarioProvisioner.CreateOpenTournamentsAsync( + _factory.Services, owners[0], TournamentNamePrefix, Tournaments, MaxParticipants); + + sessions = await VirtualUserSessions.LoginAsync(CreateClient, joiners, _factory.UserPassword, Joiners); + }) + .WithWarmUpDuration(TimeSpan.FromSeconds(5)) + .WithLoadSimulations(Simulation.Inject( + rate: LoadTestBudgets.Rates.JoinTournamentPerSecond, + interval: TimeSpan.FromSeconds(1), + during: TimeSpan.FromSeconds(15))); + + var thresholds = new List + { + LoadThresholds.ErrorBudget(0), + LoadThresholds.StatusMix("200", MinSuccessfulJoinPercent, []) + }; + thresholds.AddRange(LoadThresholds.ForBudget(LoadTestBudgets.Steps.For(LoadTestDataSize.Default, StepNames.JoinTournament))); + + // Act + LoadTestRun.Execute(scenario, thresholds.ToArray()); + + // Assert + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var overfilled = await context.Tournaments + .AsNoTracking() + .Where(t => tournamentIds.Contains(t.Id) && t.Participants.Count > t.MaxParticipants) + .Select(t => t.Id) + .ToListAsync(); + + Assert.Empty(overfilled); + + var joined = await context.Tournaments + .AsNoTracking() + .Where(t => tournamentIds.Contains(t.Id)) + .SumAsync(t => t.Participants.Count - 1); + + Assert.True(joined > 0, "No join succeeded, so the contention run proved nothing."); + } +} From 8a6a01918b4db7eedbd123795072cfe073fa4618 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 29 Sep 2026 18:23:20 +0200 Subject: [PATCH 5/6] test: add match play and correction contention load tests Six requests at a time target the same match. Concurrent plays must produce one success and Match.AlreadyPlayed for the rest. Concurrent corrections that share one stale version and send different scores must produce one success and Match.VersionConflict for the rest. Both tests compare the number of successful requests with the number of matches that changed in the database. They run without warm-up because NBomber warm-up runs the mutations but leaves them out of the stats. Co-Authored-By: Claude Sonnet 5.5 --- .../MatchContentionLoadTests.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 TournamentAPI.LoadTests/MatchContentionLoadTests.cs diff --git a/TournamentAPI.LoadTests/MatchContentionLoadTests.cs b/TournamentAPI.LoadTests/MatchContentionLoadTests.cs new file mode 100644 index 0000000..989fb97 --- /dev/null +++ b/TournamentAPI.LoadTests/MatchContentionLoadTests.cs @@ -0,0 +1,174 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NBomber.Contracts; +using NBomber.Contracts.Stats; +using NBomber.CSharp; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; +using TournamentAPI.Matches; +using MatchMutations = TournamentAPI.Shared.MutationExamples.Mutations.Match; + +namespace TournamentAPI.LoadTests; + +[Trait("Category", "Load")] +public class MatchContentionLoadTests : BaseLoadTest, IClassFixture +{ + private const int Players = 8; + private const int Matches = 120; + private const int ContendersPerMatch = 6; + + private readonly LatencyWebAppFactory _factory; + + public MatchContentionLoadTests(LatencyWebAppFactory factory) : base(factory) + { + _factory = factory; + } + + [Fact] + public async Task PlayMatch_ShouldAcceptExactlyOneResult_UnderContention() + { + // Arrange + VirtualUserSession[] owner = []; + IReadOnlyList matches = []; + + var scenario = GraphQLLoadScenario.Create("play_match_contention", async context => + { + var target = matches[(int)(context.InvocationNumber / ContendersPerMatch % matches.Count)]; + var contender = (int)(context.InvocationNumber % ContendersPerMatch); + + return await GraphQLLoadStep.RunAsync( + StepNames.PlayMatch, + context, + owner[0].Client, + MatchMutations.Play, + new + { + input = new + { + matchId = target.Id, + winnerId = target.Player1Id, + player1Score = 2 + contender, + player2Score = 0 + } + }, + GraphQLLoadStep.TrueResult("play"), + expectedRejections: new HashSet { MatchErrorCodes.MatchAlreadyPlayed }, + bearerToken: owner[0].AccessToken); + }) + .WithInit(async _ => + { + var (ownerEmail, playerEmails) = await CreateOwnerAndPlayersAsync("playowner", "playplayer"); + + matches = await WriteScenarioProvisioner.CreateClosedTournamentMatchesAsync( + _factory.Services, ownerEmail, playerEmails, "Play Contention Tournament", Matches, played: false); + + owner = await VirtualUserSessions.LoginAsync(CreateClient, [ownerEmail], _factory.UserPassword, 1); + }) + .WithLoadSimulations(ContentionRate()); + + // Act + var stats = LoadTestRun.Execute(scenario, ContentionThresholds(StepNames.PlayMatch)); + + // Assert + var matchIds = matches.Select(m => m.Id).ToList(); + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var played = await context.Matches + .AsNoTracking() + .CountAsync(m => matchIds.Contains(m.Id) && m.Status == MatchStatus.Played); + + Assert.Equal(SuccessfulRequests(stats, StepNames.PlayMatch), played); + } + + [Fact] + public async Task CorrectMatchResult_ShouldAcceptExactlyOneCorrection_PerStaleVersion() + { + // Arrange + VirtualUserSession[] owner = []; + IReadOnlyList matches = []; + + var scenario = GraphQLLoadScenario.Create("correct_match_result_contention", async context => + { + var target = matches[(int)(context.InvocationNumber / ContendersPerMatch % matches.Count)]; + var contender = (int)(context.InvocationNumber % ContendersPerMatch); + + return await GraphQLLoadStep.RunAsync( + StepNames.CorrectMatchResult, + context, + owner[0].Client, + MatchMutations.CorrectMatchResult, + new + { + input = new + { + matchId = target.Id, + winnerId = target.Player1Id, + player1Score = WriteScenarioProvisioner.PlayedMatchPlayer1Score + 1 + contender, + player2Score = 0, + version = target.Version + } + }, + GraphQLLoadStep.TrueResult("correctMatchResult"), + expectedRejections: new HashSet { MatchErrorCodes.MatchVersionConflict }, + bearerToken: owner[0].AccessToken); + }) + .WithInit(async _ => + { + var (ownerEmail, playerEmails) = await CreateOwnerAndPlayersAsync("correctowner", "correctplayer"); + + matches = await WriteScenarioProvisioner.CreateClosedTournamentMatchesAsync( + _factory.Services, ownerEmail, playerEmails, "Correction Contention Tournament", Matches, played: true); + + owner = await VirtualUserSessions.LoginAsync(CreateClient, [ownerEmail], _factory.UserPassword, 1); + }) + .WithLoadSimulations(ContentionRate()); + + // Act + var stats = LoadTestRun.Execute(scenario, ContentionThresholds(StepNames.CorrectMatchResult)); + + // Assert + var matchIds = matches.Select(m => m.Id).ToList(); + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var corrected = await context.Matches + .AsNoTracking() + .CountAsync(m => matchIds.Contains(m.Id) && m.Player1Score != WriteScenarioProvisioner.PlayedMatchPlayer1Score); + + Assert.Equal(SuccessfulRequests(stats, StepNames.CorrectMatchResult), corrected); + } + + private async Task<(string OwnerEmail, IReadOnlyList PlayerEmails)> CreateOwnerAndPlayersAsync( + string ownerPrefix, + string playerPrefix) + { + var owners = await WriteScenarioProvisioner.CreateUsersAsync(_factory.Services, ownerPrefix, 1, _factory.UserPassword); + var players = await WriteScenarioProvisioner.CreateUsersAsync(_factory.Services, playerPrefix, Players, _factory.UserPassword); + + return (owners[0], players); + } + + private static LoadSimulation ContentionRate() + { + return Simulation.Inject( + rate: LoadTestBudgets.Rates.MatchContentionPerSecond, + interval: TimeSpan.FromSeconds(1), + during: TimeSpan.FromSeconds(15)); + } + + private static LoadThreshold[] ContentionThresholds(string step) + { + var thresholds = new List + { + LoadThresholds.ErrorBudget(0), + LoadThresholds.StatusMix("200", 100.0 / ContendersPerMatch - 1, []) + }; + thresholds.AddRange(LoadThresholds.ForBudget(LoadTestBudgets.Steps.For(LoadTestDataSize.Default, step))); + + return thresholds.ToArray(); + } + + private static int SuccessfulRequests(ScenarioStats stats, string step) + => stats.StepStats.Get(step).Ok.StatusCodes.Find("200")?.Count ?? 0; +} From 1b0b3e9b970c32d0f25315cc526e90fa57da4516 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 29 Sep 2026 18:23:20 +0200 Subject: [PATCH 6/6] test: add bracket generation load tests Single elimination and round robin brackets are generated for 8, 16 and 32 participants. Each tournament gets two calls, so the second one must return Bracket.AlreadyExists. After the run the number of successful requests must equal the number of tournaments that have a bracket. Co-Authored-By: Claude Sonnet 5.5 --- .../BracketGenerationLoadTests.cs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 TournamentAPI.LoadTests/BracketGenerationLoadTests.cs diff --git a/TournamentAPI.LoadTests/BracketGenerationLoadTests.cs b/TournamentAPI.LoadTests/BracketGenerationLoadTests.cs new file mode 100644 index 0000000..517420d --- /dev/null +++ b/TournamentAPI.LoadTests/BracketGenerationLoadTests.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NBomber.Contracts; +using NBomber.Contracts.Stats; +using NBomber.CSharp; +using TournamentAPI.Brackets; +using TournamentAPI.Data; +using TournamentAPI.Data.Models; +using BracketMutations = TournamentAPI.Shared.MutationExamples.Mutations.Bracket; + +namespace TournamentAPI.LoadTests; + +[Trait("Category", "Load")] +public class BracketGenerationLoadTests : BaseLoadTest, IClassFixture +{ + private const int TournamentsPerScenario = 24; + private const int CallsPerTournament = 2; + + private static readonly int[] ParticipantCounts = [8, 16, 32]; + private static readonly TournamentFormat[] Formats = [TournamentFormat.SingleElimination, TournamentFormat.RoundRobin]; + + private readonly LatencyWebAppFactory _factory; + + public BracketGenerationLoadTests(LatencyWebAppFactory factory) : base(factory) + { + _factory = factory; + } + + [Fact] + public async Task GenerateBracket_ShouldMeetBudgets_AndCreateOneBracketPerTournament() + { + // Arrange + var owners = await WriteScenarioProvisioner.CreateUsersAsync(_factory.Services, "bracketowner", 1, _factory.UserPassword); + var players = await WriteScenarioProvisioner.CreateUsersAsync(_factory.Services, "bracketplayer", ParticipantCounts.Max(), _factory.UserPassword); + var owner = (await VirtualUserSessions.LoginAsync(CreateClient, owners, _factory.UserPassword, 1))[0]; + + var runs = new List(); + var tournamentIds = new List(); + + foreach (var format in Formats) + { + foreach (var participantCount in ParticipantCounts) + { + var stepName = StepNames.GenerateBracket(format, participantCount); + + var ids = await WriteScenarioProvisioner.CreateClosedTournamentsAsync( + _factory.Services, owners[0], players, $"Bracket {stepName}", TournamentsPerScenario, participantCount, format); + + tournamentIds.AddRange(ids); + + var scenario = GraphQLLoadScenario.Create(stepName, async context => + await GraphQLLoadStep.RunAsync( + stepName, + context, + owner.Client, + BracketMutations.GenerateBracket, + new { input = new { tournamentId = ids[(int)(context.InvocationNumber / CallsPerTournament % ids.Count)] } }, + GraphQLLoadStep.NonNullField("generateBracket"), + expectedRejections: new HashSet { BracketErrorCodes.BracketAlreadyExists }, + bearerToken: owner.AccessToken)) + .WithLoadSimulations(Simulation.Inject( + rate: LoadTestBudgets.Rates.GenerateBracketPerSecond, + interval: TimeSpan.FromSeconds(1), + during: TimeSpan.FromSeconds(10))); + + var thresholds = new List + { + LoadThresholds.ErrorBudget(0), + LoadThresholds.StatusMix("200", 100.0 / CallsPerTournament - 1, []) + }; + thresholds.AddRange(LoadThresholds.ForBudget(LoadTestBudgets.Steps.For(LoadTestDataSize.Default, stepName))); + + runs.Add(new ScenarioRun(scenario, thresholds.ToArray())); + } + } + + // Act + var stats = LoadTestRun.ExecuteAll(runs.ToArray()); + + // Assert + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var withBracket = await context.Tournaments + .AsNoTracking() + .CountAsync(t => tournamentIds.Contains(t.Id) && t.Bracket != null); + + var successful = stats.Sum(s => SuccessfulRequests(s.Value, s.Key)); + + Assert.Equal(successful, withBracket); + } + + private static int SuccessfulRequests(ScenarioStats stats, string step) + => stats.StepStats.Get(step).Ok.StatusCodes.Find("200")?.Count ?? 0; +}