Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
148 changes: 148 additions & 0 deletions TournamentAPI.LoadTests/AuthenticationLoadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Text.Json;
using NBomber.CSharp;
using UserMutations = TournamentAPI.Shared.MutationExamples.Mutations.Users;
using UserQueries = TournamentAPI.Shared.QueryExamples.Queries.Users;

namespace TournamentAPI.LoadTests;

[Trait("Category", "Load")]
public class AuthenticationLoadTests : BaseLoadTest, IClassFixture<MediumDatasetWebAppFactory>
{
private const string RefreshTokenCookieName = "refreshToken=";

private readonly MediumDatasetWebAppFactory _factory;

public AuthenticationLoadTests(MediumDatasetWebAppFactory factory) : base(factory)
{
_factory = factory;
}

[Fact]
public void AuthenticationFlow_ShouldMeetBudgets_UnderSteadyLogins()
{
// Arrange
using var client = CreateClient();
var http = client.HttpClient;
var emails = _factory.UserEmails;
var password = _factory.UserPassword;

var scenario = Scenario.Create("authenticate_and_refresh_session", async context =>
{
var email = emails[(int)(context.InvocationNumber % emails.Count)];
string? accessToken = null;
string? refreshCookie = null;

var login = await GraphQLLoadStep.RunAsync(
StepNames.Login,
context,
http,
UserMutations.LoginUser,
new { input = new { email, password } },
HasStringResult("loginUser"),
onResponse: (response, body) =>
{
accessToken = ReadStringResult(body, "loginUser");
refreshCookie = ReadRefreshTokenCookie(response);
});

if (login.IsError)
{
return login;
}

var profile = await GraphQLLoadStep.RunAsync(
StepNames.ViewProfile,
context,
http,
UserQueries.GetMe,
bodyCheck: GraphQLLoadStep.NonNullField("me"),
bearerToken: accessToken);

if (profile.IsError)
{
return profile;
}

var refresh = await GraphQLLoadStep.RunAsync(
StepNames.RefreshToken,
context,
http,
UserMutations.RefreshToken,
bodyCheck: HasStringResult("refreshToken"),
refreshTokenCookie: refreshCookie,
onResponse: (response, body) =>
{
accessToken = ReadStringResult(body, "refreshToken");
refreshCookie = ReadRefreshTokenCookie(response);
});

if (refresh.IsError)
{
return refresh;
}

return await GraphQLLoadStep.RunAsync(
StepNames.Logout,
context,
http,
UserMutations.LogoutUser,
bodyCheck: HasTrueResult("logoutUser"),
bearerToken: accessToken,
refreshTokenCookie: refreshCookie);
})
.WithWarmUpDuration(TimeSpan.FromSeconds(10))
.WithLoadSimulations(Simulation.Inject(
rate: LoadTestBudgets.Rates.AuthFlowPerSecond,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30)));

// Act & Assert
LoadTestRun.Execute(
scenario,
LoadThresholds.ForSteps(
LoadTestDataSize.Medium,
"200",
StepNames.Login,
StepNames.ViewProfile,
StepNames.RefreshToken,
StepNames.Logout));
}

private static Func<JsonElement, bool> HasStringResult(string mutation)
{
return data => data.TryGetProperty(mutation, out var result)
&& result.TryGetProperty("string", out var value)
&& value.ValueKind == JsonValueKind.String;
}

private static Func<JsonElement, bool> HasTrueResult(string mutation)
{
return data => data.TryGetProperty(mutation, out var result)
&& result.TryGetProperty("boolean", out var value)
&& value.ValueKind == JsonValueKind.True;
}

private static string? ReadStringResult(byte[] body, string mutation)
{
using var document = JsonDocument.Parse(body);

return document.RootElement.TryGetProperty("data", out var data)
&& data.TryGetProperty(mutation, out var result)
&& result.TryGetProperty("string", out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}

private static string? ReadRefreshTokenCookie(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var cookies))
{
return null;
}

var cookie = cookies.FirstOrDefault(c => c.StartsWith(RefreshTokenCookieName, StringComparison.Ordinal));

return cookie?.Split(';')[0][RefreshTokenCookieName.Length..];
}
}
2 changes: 1 addition & 1 deletion TournamentAPI.LoadTests/BaseLoadTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ protected BaseLoadTest(WebApplicationFactory<Program> factory)

protected TestClient CreateClient()
{
var httpClient = _factory.CreateClient();
var httpClient = _factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false });
var testClient = new TestClient(httpClient);
testClient.SetApiKey(LoadTestWebAppFactory.ApiKey);

Expand Down
27 changes: 27 additions & 0 deletions TournamentAPI.LoadTests/DatasetWebAppFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Identity;
using TournamentAPI.Data;
using TournamentAPI.Data.Models;

namespace TournamentAPI.LoadTests;

public abstract class DatasetWebAppFactory : LatencyWebAppFactory
{
protected abstract LoadTestDataSize Size { get; }

public override string UserPassword => LoadTestDataSeeder.UserPassword;

public override string TournamentSearchTerm => LoadTestDataSeeder.TournamentNamePrefix;

protected override Task SeedAsync(ApplicationDbContext context, UserManager<ApplicationUser> userManager)
=> LoadTestDataSeeder.SeedAsync(context, userManager, Size);
}

public class MediumDatasetWebAppFactory : DatasetWebAppFactory
{
protected override LoadTestDataSize Size => LoadTestDataSize.Medium;
}

public class LargeDatasetWebAppFactory : DatasetWebAppFactory
{
protected override LoadTestDataSize Size => LoadTestDataSize.Large;
}
52 changes: 41 additions & 11 deletions TournamentAPI.LoadTests/GraphQLLoadStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ public static Task<Response<object>> RunAsync(
object? variables = null,
Func<JsonElement, bool>? bodyCheck = null,
IReadOnlySet<string>? expectedRejections = null,
string? bearerToken = null)
string? bearerToken = null,
string? refreshTokenCookie = null,
Action<HttpResponseMessage, byte[]>? onResponse = null,
Func<JsonElement, string?>? classifyRejection = null)
{
return Step.Run(stepName, context, async () =>
{
Expand All @@ -34,9 +37,15 @@ public static Task<Response<object>> RunAsync(
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
}

if (refreshTokenCookie is not null)
{
request.Headers.Add("Cookie", $"refreshToken={refreshTokenCookie}");
}

using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsByteArrayAsync();
var (code, isSuccess) = Classify(response.IsSuccessStatusCode, (int)response.StatusCode, body, bodyCheck);
onResponse?.Invoke(response, body);
var (code, isSuccess) = Classify(response.IsSuccessStatusCode, (int)response.StatusCode, body, bodyCheck, classifyRejection);

return isSuccess || expectedRejections?.Contains(code) == true
? Response.Ok(statusCode: code, sizeBytes: body.Length)
Expand All @@ -52,27 +61,48 @@ public static Func<JsonElement, bool> NonEmptyConnection(string field)
&& edges.GetArrayLength() > 0;
}

public static Func<JsonElement, bool> NonNullField(string field)
{
return data => data.TryGetProperty(field, out var value) && value.ValueKind == JsonValueKind.Object;
}

public static Func<JsonElement, string?> RejectionWhenErrorHasExtension(string extensionName, string statusCode)
{
return error => error.TryGetProperty("extensions", out var extensions)
&& extensions.TryGetProperty(extensionName, out _)
? statusCode
: null;
}

private static (string Code, bool IsSuccess) Classify(
bool isSuccessStatusCode,
int statusCode,
byte[] body,
Func<JsonElement, bool>? bodyCheck)
Func<JsonElement, bool>? bodyCheck,
Func<JsonElement, string?>? classifyRejection)
{
var httpCode = statusCode.ToString();

if (!isSuccessStatusCode)
{
return (httpCode, false);
}

try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;

if (root.TryGetProperty("errors", out var errors)
var hasErrors = root.TryGetProperty("errors", out var errors)
&& errors.ValueKind == JsonValueKind.Array
&& errors.GetArrayLength() > 0)
&& errors.GetArrayLength() > 0;

if (hasErrors && classifyRejection?.Invoke(errors[0]) is { } rejection)
{
return (rejection, true);
}

if (!isSuccessStatusCode)
{
return (httpCode, false);
}

if (hasErrors)
{
return (ReadErrorCode(errors[0]), false);
}
Expand All @@ -87,7 +117,7 @@ private static (string Code, bool IsSuccess) Classify(
}
catch (JsonException)
{
return (InvalidJsonCode, false);
return isSuccessStatusCode ? (InvalidJsonCode, false) : (httpCode, false);
}
}

Expand Down
104 changes: 104 additions & 0 deletions TournamentAPI.LoadTests/LoadTestBudgets.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,111 @@
namespace TournamentAPI.LoadTests;

internal sealed record StepBudget(
string Step,
double P95Ms,
double P99Ms,
double MaxMs,
double? MinMeanBytes = null,
double? MaxMeanBytes = null);

internal sealed record MeasuredBaseline(double P95Ms, double P99Ms, double MaxMs, double MeanKilobytes);

internal static class StepNames
{
public const string ListTournaments = "list_tournaments";
public const string ViewTournament = "view_tournament";
public const string SearchTournamentsByName = "search_tournaments_by_name";
public const string SortTournaments = "sort_tournaments";
public const string ViewOwnerHistory = "view_owner_history";
public const string ViewParticipantsWonTournaments = "view_participants_won_tournaments";
public const string ViewParticipantsPlayedTournaments = "view_participants_played_tournaments";
public const string ViewParticipantsWonMatches = "view_participants_won_matches";
public const string ViewBracketStandings = "view_bracket_standings";
public const string ViewOwnTournamentHistory = "view_own_tournament_history";
public const string ListTournamentsWithOversizedPage = "list_tournaments_with_oversized_page";
public const string Login = "login";
public const string ViewProfile = "view_profile";
public const string RefreshToken = "refresh_token";
public const string Logout = "logout";
}

internal static class LoadTestBudgets
{
public static class Rates
{
public const int ListTournamentsPerSecond = 15;
public const int ViewTournamentPerSecond = 6;
public const int DiscoverTournamentsPerSecond = 4;
public const int InsightPerSecond = 5;
public const int AuthFlowPerSecond = 5;
}

public static class Steps
{
private const double P95Headroom = 3.5;
private const double P99Headroom = 3.5;
private const double MaxHeadroom = 5;
private const double PayloadFloorRatio = 0.6;
private const double PayloadCeilingRatio = 1.6;
private const double BytesPerKilobyte = 1_000;

private const double P95FloorMs = 100;
private const double P99FloorMs = 250;
private const double MaxFloorMs = 1_000;

private static readonly Dictionary<(LoadTestDataSize Size, string Step), MeasuredBaseline> Measured = new()
{
[(LoadTestDataSize.Default, StepNames.ListTournaments)] = new(7.8, 13.46, 48.05, 1.388),
[(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.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),
[(LoadTestDataSize.Medium, StepNames.SearchTournamentsByName)] = new(8.49, 14.45, 35.30, 1.410),
[(LoadTestDataSize.Medium, StepNames.SortTournaments)] = new(25.81, 48.42, 61.24, 6.173),
[(LoadTestDataSize.Medium, StepNames.ViewOwnerHistory)] = new(124.61, 215.81, 241.48, 0.415),
[(LoadTestDataSize.Medium, StepNames.ViewParticipantsWonTournaments)] = new(220.67, 260.99, 263.62, 0.614),
[(LoadTestDataSize.Medium, StepNames.ViewParticipantsPlayedTournaments)] = new(193.15, 265.73, 324.24, 0.638),
[(LoadTestDataSize.Medium, StepNames.ViewParticipantsWonMatches)] = new(236.42, 321.02, 321.11, 0.583),
[(LoadTestDataSize.Medium, StepNames.ViewBracketStandings)] = new(72.58, 211.46, 249.80, 1.007),
[(LoadTestDataSize.Medium, StepNames.ViewOwnTournamentHistory)] = new(94.66, 208.77, 208.91, 0.487),
[(LoadTestDataSize.Medium, StepNames.ListTournamentsWithOversizedPage)] = new(14.54, 48.19, 147.34, 0.213),
[(LoadTestDataSize.Medium, StepNames.Login)] = new(537.60, 824.83, 970.28, 0.653),
[(LoadTestDataSize.Medium, StepNames.ViewProfile)] = new(18.18, 39.23, 47.57, 0.131),
[(LoadTestDataSize.Medium, StepNames.RefreshToken)] = new(84.42, 170.62, 215.82, 0.656),
[(LoadTestDataSize.Medium, StepNames.Logout)] = new(46.69, 83.07, 83.73, 0.039),

[(LoadTestDataSize.Large, StepNames.ListTournaments)] = new(9.35, 12.01, 41.25, 1.416),
[(LoadTestDataSize.Large, StepNames.ViewTournament)] = new(30.34, 45.41, 51.63, 1.770),
[(LoadTestDataSize.Large, StepNames.SearchTournamentsByName)] = new(10.14, 22.27, 41.25, 1.416),
[(LoadTestDataSize.Large, StepNames.SortTournaments)] = new(42.40, 54.37, 74.04, 8.631),
[(LoadTestDataSize.Large, StepNames.ViewOwnerHistory)] = new(144.77, 237.18, 288.05, 0.571),
[(LoadTestDataSize.Large, StepNames.ViewParticipantsWonTournaments)] = new(244.74, 369.92, 486.91, 1.162),
[(LoadTestDataSize.Large, StepNames.ViewParticipantsPlayedTournaments)] = new(240.26, 408.32, 430.80, 1.216),
[(LoadTestDataSize.Large, StepNames.ViewParticipantsWonMatches)] = new(253.18, 411.39, 472.05, 1.110),
[(LoadTestDataSize.Large, StepNames.ViewBracketStandings)] = new(117.38, 237.82, 262.43, 1.910),
[(LoadTestDataSize.Large, StepNames.ViewOwnTournamentHistory)] = new(130.69, 231.04, 233.95, 0.940),
[(LoadTestDataSize.Large, StepNames.ListTournamentsWithOversizedPage)] = new(8.34, 29.39, 43.05, 0.213)
};

public static StepBudget For(LoadTestDataSize size, string step)
{
if (!Measured.TryGetValue((size, step), out var baseline))
{
throw new InvalidOperationException($"No measured baseline for step '{step}' on the {size} dataset.");
}

return new StepBudget(
step,
Math.Max(P95FloorMs, baseline.P95Ms * P95Headroom),
Math.Max(P99FloorMs, baseline.P99Ms * P99Headroom),
Math.Max(MaxFloorMs, baseline.MaxMs * MaxHeadroom),
baseline.MeanKilobytes * BytesPerKilobyte * PayloadFloorRatio,
baseline.MeanKilobytes * BytesPerKilobyte * PayloadCeilingRatio);
}
}

public static class ListTournaments
{
public const string Step = "list_tournaments";
Expand Down
Loading
Loading