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/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; +} 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/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)); + } +} 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/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/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."); + } +} 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/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/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/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; +} 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, 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; + } +}