diff --git a/CLAUDE.md b/CLAUDE.md
index be3fe631..e1b0755e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,38 @@ dotnet test --filter "FullyQualifiedName~MethodName" # single test method
Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. SQLite in-memory for unit tests, PostgreSQL for integration tests in CI.
+### Benchmarks (BenchmarkDotNet)
+
+```bash
+dotnet run -c Release --project tests/SimpleModule.Benchmarks # all benchmarks
+dotnet run -c Release --project tests/SimpleModule.Benchmarks -- --filter "*Products*" # specific module
+```
+
+Micro-benchmarks for every module: endpoint latency (CRUD operations via in-process TestServer), JSON serialization/deserialization of DTOs. Uses `SimpleModuleWebApplicationFactory` with test auth headers for low-overhead measurement.
+
+### Load Tests (NBomber)
+
+```bash
+dotnet test tests/SimpleModule.LoadTests # all 11 scenarios (50 concurrent, ~5 min)
+dotnet test tests/SimpleModule.LoadTests --filter "Products_Crud" # single scenario
+```
+
+HTTP load tests using real OAuth Bearer tokens acquired via ROPC (password grant) from OpenIddict. Runs against the full ASP.NET pipeline with file-based SQLite in WAL mode. 11 scenarios covering all modules at 50 concurrent copies:
+
+- **Products, Orders, Users** — full CRUD lifecycle (create → read → update → delete)
+- **Settings** — read operations (settings, definitions, menus, available pages)
+- **AuditLogs, FileStorage** — read operations (list, get by ID, folders)
+- **PageBuilder** — full lifecycle (create → get → update → publish → unpublish → delete + tags/templates)
+- **Admin** — role create/delete (handles 302 Blazor SSR redirects)
+- **FeatureFlags** — get all flags, check flag status
+- **Marketplace** — search and browse (anonymous)
+- **Mixed Realistic** — weighted workload (70% reads, 20% creates, 10% updates)
+
+**Key infrastructure:**
+- `LoadTestWebApplicationFactory` — extends `WebApplicationFactory` with file-based SQLite + WAL, seeds OAuth client/user/permissions, acquires Bearer tokens via `/connect/token`
+- `PasswordGrantTokenHandler` — OpenIddict event handler for ROPC grant type
+- `SqliteBusyTimeoutInterceptor` — sets `PRAGMA busy_timeout=30000` on every EF Core connection
+
## Architecture
### .NET Backend
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d8f4dcac..6ae3ad9b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -41,6 +41,9 @@
+
+
+
diff --git a/SimpleModule.slnx b/SimpleModule.slnx
index 73bf2092..01daccd7 100644
--- a/SimpleModule.slnx
+++ b/SimpleModule.slnx
@@ -94,6 +94,8 @@
+
+
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/AdminBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/AdminBenchmarks.cs
new file mode 100644
index 00000000..40335ec3
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/AdminBenchmarks.cs
@@ -0,0 +1,84 @@
+using System.Security.Claims;
+using BenchmarkDotNet.Attributes;
+using Microsoft.AspNetCore.Mvc.Testing;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class AdminBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+ private HttpClient _noRedirectClient = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient(
+ new Claim(ClaimTypes.Role, "Admin")
+ );
+
+ _noRedirectClient = _factory.CreateClient(
+ new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }
+ );
+ _noRedirectClient.DefaultRequestHeaders.Add(
+ "X-Test-Claims",
+ $"{ClaimTypes.NameIdentifier}=test-user-id;{ClaimTypes.Role}=Admin"
+ );
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _noRedirectClient?.Dispose();
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task CreateRole()
+ {
+ using var form = new FormUrlEncodedContent([
+ new KeyValuePair("name", $"BenchRole-{Guid.NewGuid():N}"),
+ new KeyValuePair("description", "Benchmark test role"),
+ ]);
+ return await _client.PostAsync("/admin/roles/", form);
+ }
+
+ [Benchmark]
+ public async Task CreateAndDeleteRole()
+ {
+ using var form = new FormUrlEncodedContent([
+ new KeyValuePair("name", $"BenchRole-{Guid.NewGuid():N}"),
+ new KeyValuePair("description", "Benchmark test role"),
+ ]);
+ var response = await _noRedirectClient.PostAsync("/admin/roles/", form);
+ var location = response.Headers.Location?.ToString();
+ if (location is not null)
+ {
+ // Location is /admin/roles/{id}/edit — extract the id
+ var segments = location.Split('/');
+ var roleId = segments[^2]; // second-to-last segment
+ await _noRedirectClient.DeleteAsync($"/admin/roles/{roleId}");
+ }
+ }
+
+ [Benchmark]
+ public async Task CreateUser()
+ {
+ var email = $"bench-{Guid.NewGuid():N}@test.dev";
+ using var form = new FormUrlEncodedContent([
+ new KeyValuePair("email", email),
+ new KeyValuePair("displayName", "Benchmark User"),
+ new KeyValuePair("password", "BenchPass123!"),
+ new KeyValuePair("emailConfirmed", "true"),
+ ]);
+ return await _client.PostAsync("/admin/users/", form);
+ }
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/AuditLogsBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/AuditLogsBenchmarks.cs
new file mode 100644
index 00000000..6f3e198e
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/AuditLogsBenchmarks.cs
@@ -0,0 +1,49 @@
+using BenchmarkDotNet.Attributes;
+using SimpleModule.AuditLogs;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class AuditLogsBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient([
+ AuditLogsPermissions.View,
+ AuditLogsPermissions.Export,
+ ]);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetAllAuditLogs() =>
+ await _client.GetAsync("/api/audit-logs");
+
+ [Benchmark]
+ public async Task GetAuditLogStats() =>
+ await _client.GetAsync("/api/audit-logs/stats");
+
+ [Benchmark]
+ public async Task ExportAuditLogs() =>
+ await _client.GetAsync("/api/audit-logs/export");
+
+ [Benchmark]
+ public async Task GetAuditLogById() =>
+ await _client.GetAsync("/api/audit-logs/1");
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/FileStorageBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/FileStorageBenchmarks.cs
new file mode 100644
index 00000000..fec52212
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/FileStorageBenchmarks.cs
@@ -0,0 +1,42 @@
+using BenchmarkDotNet.Attributes;
+using SimpleModule.FileStorage;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class FileStorageBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient([
+ FileStoragePermissions.View,
+ FileStoragePermissions.Upload,
+ FileStoragePermissions.Delete,
+ ]);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetAllFiles() =>
+ await _client.GetAsync("/api/files");
+
+ [Benchmark]
+ public async Task ListFolders() =>
+ await _client.GetAsync("/api/files/folders");
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/OrdersBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/OrdersBenchmarks.cs
new file mode 100644
index 00000000..1ea8fe3c
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/OrdersBenchmarks.cs
@@ -0,0 +1,76 @@
+using System.Net.Http.Json;
+using BenchmarkDotNet.Attributes;
+using SimpleModule.Orders;
+using SimpleModule.Orders.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class OrdersBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+ private int _seedOrderId;
+
+ [GlobalSetup]
+ public async Task Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient([
+ OrdersPermissions.View,
+ OrdersPermissions.Create,
+ OrdersPermissions.Update,
+ OrdersPermissions.Delete,
+ ]);
+
+ // Seed an order for read/update/delete benchmarks
+ var request = FakeDataGenerators.CreateOrderRequestFaker.Generate();
+ var response = await _client.PostAsJsonAsync("/api/orders", request);
+ var order = await response.Content.ReadFromJsonAsync();
+ _seedOrderId = order!.Id.Value;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetAllOrders() =>
+ await _client.GetAsync("/api/orders");
+
+ [Benchmark]
+ public async Task GetOrderById() =>
+ await _client.GetAsync($"/api/orders/{_seedOrderId}");
+
+ [Benchmark]
+ public async Task CreateOrder()
+ {
+ var request = FakeDataGenerators.CreateOrderRequestFaker.Generate();
+ return await _client.PostAsJsonAsync("/api/orders", request);
+ }
+
+ [Benchmark]
+ public async Task UpdateOrder()
+ {
+ var request = FakeDataGenerators.UpdateOrderRequestFaker.Generate();
+ return await _client.PutAsJsonAsync($"/api/orders/{_seedOrderId}", request);
+ }
+
+ [Benchmark]
+ public async Task CreateAndDeleteOrder()
+ {
+ var request = FakeDataGenerators.CreateOrderRequestFaker.Generate();
+ var createResponse = await _client.PostAsJsonAsync("/api/orders", request);
+ var order = await createResponse.Content.ReadFromJsonAsync();
+ await _client.DeleteAsync($"/api/orders/{order!.Id}");
+ }
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/PageBuilderBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/PageBuilderBenchmarks.cs
new file mode 100644
index 00000000..16d764c1
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/PageBuilderBenchmarks.cs
@@ -0,0 +1,65 @@
+using System.Net.Http.Json;
+using BenchmarkDotNet.Attributes;
+using SimpleModule.PageBuilder;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class PageBuilderBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient([
+ PageBuilderPermissions.View,
+ PageBuilderPermissions.Create,
+ PageBuilderPermissions.Update,
+ PageBuilderPermissions.Delete,
+ PageBuilderPermissions.Publish,
+ ]);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetAllPages() =>
+ await _client.GetAsync("/api/pagebuilder");
+
+ [Benchmark]
+ public async Task GetAllTags() =>
+ await _client.GetAsync("/api/pagebuilder/tags");
+
+ [Benchmark]
+ public async Task GetAllTemplates() =>
+ await _client.GetAsync("/api/pagebuilder/templates");
+
+ [Benchmark]
+ public async Task GetTrash() =>
+ await _client.GetAsync("/api/pagebuilder/trash");
+
+ [Benchmark]
+ public async Task CreateAndDeletePage()
+ {
+ var request = new { title = "Benchmark Page", slug = $"bench-{Guid.NewGuid():N}" };
+ var createResponse = await _client.PostAsJsonAsync("/api/pagebuilder", request);
+ var location = createResponse.Headers.Location?.ToString();
+ if (location is not null)
+ {
+ await _client.DeleteAsync(location);
+ }
+ }
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/ProductsBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/ProductsBenchmarks.cs
new file mode 100644
index 00000000..619a82c6
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/ProductsBenchmarks.cs
@@ -0,0 +1,76 @@
+using System.Net.Http.Json;
+using BenchmarkDotNet.Attributes;
+using SimpleModule.Products;
+using SimpleModule.Products.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class ProductsBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+ private int _seedProductId;
+
+ [GlobalSetup]
+ public async Task Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient([
+ ProductsPermissions.View,
+ ProductsPermissions.Create,
+ ProductsPermissions.Update,
+ ProductsPermissions.Delete,
+ ]);
+
+ // Seed a product for read/update/delete benchmarks
+ var request = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ var response = await _client.PostAsJsonAsync("/api/products", request);
+ var product = await response.Content.ReadFromJsonAsync();
+ _seedProductId = product!.Id.Value;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetAllProducts() =>
+ await _client.GetAsync("/api/products");
+
+ [Benchmark]
+ public async Task GetProductById() =>
+ await _client.GetAsync($"/api/products/{_seedProductId}");
+
+ [Benchmark]
+ public async Task CreateProduct()
+ {
+ var request = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ return await _client.PostAsJsonAsync("/api/products", request);
+ }
+
+ [Benchmark]
+ public async Task UpdateProduct()
+ {
+ var request = FakeDataGenerators.UpdateProductRequestFaker.Generate();
+ return await _client.PutAsJsonAsync($"/api/products/{_seedProductId}", request);
+ }
+
+ [Benchmark]
+ public async Task CreateAndDeleteProduct()
+ {
+ var request = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ var createResponse = await _client.PostAsJsonAsync("/api/products", request);
+ var product = await createResponse.Content.ReadFromJsonAsync();
+ await _client.DeleteAsync($"/api/products/{product!.Id}");
+ }
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/SerializationBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/SerializationBenchmarks.cs
new file mode 100644
index 00000000..5dcf2f3a
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/SerializationBenchmarks.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+using BenchmarkDotNet.Attributes;
+using SimpleModule.Orders.Contracts;
+using SimpleModule.Products.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 20)]
+public class SerializationBenchmarks
+{
+ private Product _product = null!;
+ private Order _order = null!;
+ private UserDto _user = null!;
+ private List _productList = null!;
+ private List _orderList = null!;
+ private string _productJson = null!;
+ private string _orderJson = null!;
+ private string _userJson = null!;
+ private string _productListJson = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _product = FakeDataGenerators.ProductFaker.Generate();
+ _order = FakeDataGenerators.OrderFaker.Generate();
+ _user = FakeDataGenerators.UserFaker.Generate();
+ _productList = FakeDataGenerators.ProductFaker.Generate(100);
+ _orderList = FakeDataGenerators.OrderFaker.Generate(100);
+
+ _productJson = JsonSerializer.Serialize(_product);
+ _orderJson = JsonSerializer.Serialize(_order);
+ _userJson = JsonSerializer.Serialize(_user);
+ _productListJson = JsonSerializer.Serialize(_productList);
+ }
+
+ [Benchmark]
+ public string SerializeProduct() => JsonSerializer.Serialize(_product);
+
+ [Benchmark]
+ public string SerializeOrder() => JsonSerializer.Serialize(_order);
+
+ [Benchmark]
+ public string SerializeUser() => JsonSerializer.Serialize(_user);
+
+ [Benchmark]
+ public string SerializeProductList() => JsonSerializer.Serialize(_productList);
+
+ [Benchmark]
+ public string SerializeOrderList() => JsonSerializer.Serialize(_orderList);
+
+ [Benchmark]
+ public Product? DeserializeProduct() => JsonSerializer.Deserialize(_productJson);
+
+ [Benchmark]
+ public Order? DeserializeOrder() => JsonSerializer.Deserialize(_orderJson);
+
+ [Benchmark]
+ public UserDto? DeserializeUser() => JsonSerializer.Deserialize(_userJson);
+
+ [Benchmark]
+ public List? DeserializeProductList() =>
+ JsonSerializer.Deserialize>(_productListJson);
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/SettingsBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/SettingsBenchmarks.cs
new file mode 100644
index 00000000..5a4d626b
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/SettingsBenchmarks.cs
@@ -0,0 +1,63 @@
+using System.Net.Http.Json;
+using System.Security.Claims;
+using BenchmarkDotNet.Attributes;
+using SimpleModule.Core.Settings;
+using SimpleModule.Tests.Shared.Fixtures;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class SettingsBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient(
+ new Claim(ClaimTypes.Role, "Admin")
+ );
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetSettings() =>
+ await _client.GetAsync("/api/settings");
+
+ [Benchmark]
+ public async Task GetDefinitions() =>
+ await _client.GetAsync("/api/settings/definitions");
+
+ [Benchmark]
+ public async Task GetMenuTree() =>
+ await _client.GetAsync("/api/settings/menus");
+
+ [Benchmark]
+ public async Task GetAvailablePages() =>
+ await _client.GetAsync("/api/settings/menus/available-pages");
+
+ [Benchmark]
+ public async Task GetMySettings() =>
+ await _client.GetAsync("/api/settings/me");
+
+ [Benchmark]
+ public async Task UpdateAndDeleteSetting()
+ {
+ var key = $"bench-{Guid.NewGuid():N}";
+ var request = new { key, value = "bench-value", scope = SettingScope.Application };
+ await _client.PutAsJsonAsync("/api/settings", request);
+ await _client.DeleteAsync($"/api/settings/{key}?scope={SettingScope.Application}");
+ }
+}
diff --git a/tests/SimpleModule.Benchmarks/Benchmarks/UsersBenchmarks.cs b/tests/SimpleModule.Benchmarks/Benchmarks/UsersBenchmarks.cs
new file mode 100644
index 00000000..072ddcb4
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Benchmarks/UsersBenchmarks.cs
@@ -0,0 +1,67 @@
+using System.Net.Http.Json;
+using System.Security.Claims;
+using BenchmarkDotNet.Attributes;
+using SimpleModule.Tests.Shared.Fakes;
+using SimpleModule.Tests.Shared.Fixtures;
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.Benchmarks.Benchmarks;
+
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public sealed class UsersBenchmarks : IDisposable
+{
+ private SimpleModuleWebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+ private string _seededUserId = null!;
+
+ [GlobalSetup]
+ public async Task Setup()
+ {
+ _factory = new SimpleModuleWebApplicationFactory();
+ _client = _factory.CreateAuthenticatedClient(
+ new Claim(ClaimTypes.Role, "Admin")
+ );
+
+ var request = FakeDataGenerators.CreateUserRequestFaker.Generate();
+ var response = await _client.PostAsJsonAsync("/api/users", request);
+ var user = await response.Content.ReadFromJsonAsync();
+ _seededUserId = user!.Id.Value;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => Dispose();
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _factory?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Benchmark]
+ public async Task GetAllUsers() =>
+ await _client.GetAsync("/api/users");
+
+ [Benchmark]
+ public async Task GetCurrentUser() =>
+ await _client.GetAsync("/api/users/me");
+
+ [Benchmark]
+ public async Task GetUserById() =>
+ await _client.GetAsync($"/api/users/{_seededUserId}");
+
+ [Benchmark]
+ public async Task UpdateUser()
+ {
+ var request = FakeDataGenerators.UpdateUserRequestFaker.Generate();
+ return await _client.PutAsJsonAsync($"/api/users/{_seededUserId}", request);
+ }
+
+ [Benchmark]
+ public async Task CreateUser()
+ {
+ var request = FakeDataGenerators.CreateUserRequestFaker.Generate();
+ return await _client.PostAsJsonAsync("/api/users", request);
+ }
+}
diff --git a/tests/SimpleModule.Benchmarks/Program.cs b/tests/SimpleModule.Benchmarks/Program.cs
new file mode 100644
index 00000000..813b138e
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/Program.cs
@@ -0,0 +1,4 @@
+using System.Reflection;
+using BenchmarkDotNet.Running;
+
+BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args);
diff --git a/tests/SimpleModule.Benchmarks/SimpleModule.Benchmarks.csproj b/tests/SimpleModule.Benchmarks/SimpleModule.Benchmarks.csproj
new file mode 100644
index 00000000..3a728fa8
--- /dev/null
+++ b/tests/SimpleModule.Benchmarks/SimpleModule.Benchmarks.csproj
@@ -0,0 +1,17 @@
+
+
+ net10.0
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SimpleModule.LoadTests/GlobalUsings.cs b/tests/SimpleModule.LoadTests/GlobalUsings.cs
new file mode 100644
index 00000000..c802f448
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs b/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs
new file mode 100644
index 00000000..63a06ec1
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs
@@ -0,0 +1,338 @@
+using System.Net.Http.Headers;
+using System.Security.Claims;
+using System.Text.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.DependencyInjection;
+using OpenIddict.Abstractions;
+using OpenIddict.Server;
+using OpenIddict.Server.AspNetCore;
+using SimpleModule.Admin;
+using SimpleModule.Admin.Contracts;
+using SimpleModule.AuditLogs;
+using SimpleModule.Core.Authorization;
+using SimpleModule.Database;
+using SimpleModule.FeatureFlags;
+using SimpleModule.FileStorage;
+using SimpleModule.Host;
+using SimpleModule.OpenIddict;
+using SimpleModule.OpenIddict.Contracts;
+using SimpleModule.Orders;
+using SimpleModule.PageBuilder;
+using SimpleModule.Permissions;
+using SimpleModule.Permissions.Contracts;
+using SimpleModule.Products;
+using SimpleModule.Settings;
+using SimpleModule.Tests.Shared.Fixtures;
+using SimpleModule.Users;
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.LoadTests.Infrastructure;
+
+///
+/// Load test factory using file-based SQLite with WAL mode and real OAuth Bearer tokens.
+/// Adds ROPC (password) grant to OpenIddict, seeds a confidential client and admin user,
+/// then acquires tokens via POST /connect/token.
+///
+public class LoadTestWebApplicationFactory : SimpleModuleWebApplicationFactory
+{
+ private const string ServiceClientId = "loadtest-service";
+ private const string ServiceClientSecret = "loadtest-secret-key-2024";
+ private const string TestUserEmail = "loadtest@simplemodule.dev";
+ private const string TestUserPassword = "LoadTest123!";
+ private const string TestUserDisplayName = "Load Test User";
+
+ private readonly string _dbPath;
+
+ public LoadTestWebApplicationFactory()
+ {
+ _dbPath = Path.Combine(Path.GetTempPath(), $"loadtest_{Guid.NewGuid():N}.db");
+
+ using var connection = new SqliteConnection($"Data Source={_dbPath}");
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;";
+ cmd.ExecuteNonQuery();
+ }
+
+ public static void EnsureContentRoot()
+ {
+ if (Environment.GetEnvironmentVariable("DOTNET_CONTENTROOT") is not null)
+ return;
+
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "SimpleModule.slnx")))
+ {
+ dir = dir.Parent;
+ }
+
+ var contentRoot = dir is not null
+ ? Path.Combine(dir.FullName, "template", "SimpleModule.Host")
+ : throw new InvalidOperationException(
+ "Could not find SimpleModule.slnx to resolve Host content root.");
+
+ Environment.SetEnvironmentVariable("DOTNET_CONTENTROOT", contentRoot);
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+
+ var connectionString = $"Data Source={_dbPath};Cache=Shared;Default Timeout=30";
+ builder.ConfigureServices(services =>
+ {
+ services.Configure(opts =>
+ {
+ opts.DefaultConnection = connectionString;
+ opts.Provider = "Sqlite";
+ });
+
+ ReplaceWithFileDb(services, connectionString, useOpenIddict: true);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString);
+ ReplaceWithFileDb(services, connectionString, useOpenIddict: true);
+
+ // Add ROPC (password) grant to OpenIddict for load test token acquisition.
+ services.Configure(opts =>
+ {
+ opts.GrantTypes.Add(OpenIddictConstants.GrantTypes.Password);
+ opts.CodeChallengeMethods.Clear();
+ });
+
+ // Disable HTTPS requirement (TestServer uses HTTP)
+ services.Configure(opts =>
+ {
+ opts.DisableTransportSecurityRequirement = true;
+ });
+
+ // Override the base factory's TestScheme — restore OpenIddict validation as default.
+ var oidcScheme = global::OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
+ services
+ .AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = oidcScheme;
+ options.DefaultChallengeScheme = oidcScheme;
+ });
+
+ // Override the SmartAuth policy to always forward to OpenIddict validation
+ services.PostConfigure(
+ AuthConstants.SmartAuthPolicy,
+ options =>
+ {
+ options.ForwardDefaultSelector = _ => oidcScheme;
+ });
+
+
+ // Register a custom OpenIddict event handler for password grants
+ services.AddOpenIddict().AddServer(opts =>
+ {
+ opts.AddEventHandler(builder =>
+ builder.UseInlineHandler(PasswordGrantTokenHandler.Handle));
+ });
+ });
+ }
+
+ private static void ReplaceWithFileDb(
+ IServiceCollection services,
+ string connectionString,
+ bool useOpenIddict = false)
+ where TContext : DbContext
+ {
+ var toRemove = services
+ .Where(d =>
+ d.ServiceType == typeof(DbContextOptions)
+ || (d.ServiceType == typeof(DbContextOptions) && d.ImplementationFactory is not null))
+ .ToList();
+
+ foreach (var descriptor in toRemove)
+ {
+ services.Remove(descriptor);
+ }
+
+ services.AddScoped(sp =>
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseSqlite(connectionString, sqliteOpts =>
+ {
+ sqliteOpts.CommandTimeout(30);
+ });
+ optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
+ optionsBuilder.AddInterceptors(SqliteBusyTimeoutInterceptor.Instance);
+ if (useOpenIddict)
+ {
+ optionsBuilder.UseOpenIddict();
+ }
+ return (DbContextOptions)optionsBuilder.Options;
+ });
+ }
+
+ private bool _seeded;
+
+ private async Task SeedTestInfrastructureAsync()
+ {
+ if (_seeded)
+ return;
+ _seeded = true;
+
+ using var scope = Services.CreateScope();
+ var sp = scope.ServiceProvider;
+
+ // 1. Seed the ROPC-capable OAuth client
+ var appManager = sp.GetRequiredService();
+ if (await appManager.FindByClientIdAsync(ServiceClientId) is null)
+ {
+ await appManager.CreateAsync(new OpenIddictApplicationDescriptor
+ {
+ ClientId = ServiceClientId,
+ ClientSecret = ServiceClientSecret,
+ DisplayName = "Load Test Service Account",
+ ClientType = OpenIddictConstants.ClientTypes.Confidential,
+ Permissions =
+ {
+ OpenIddictConstants.Permissions.Endpoints.Token,
+ OpenIddictConstants.Permissions.GrantTypes.Password,
+ OpenIddictConstants.Permissions.Scopes.Email,
+ OpenIddictConstants.Permissions.Scopes.Profile,
+ OpenIddictConstants.Permissions.Prefixes.Scope + AuthConstants.RolesScope,
+ },
+ });
+ }
+
+ // 2. Seed the Admin role
+ var roleManager = sp.GetRequiredService>();
+ if (!await roleManager.RoleExistsAsync("Admin"))
+ {
+ await roleManager.CreateAsync(new ApplicationRole
+ {
+ Name = "Admin",
+ Description = "Load test admin role",
+ CreatedAt = DateTime.UtcNow,
+ });
+ }
+
+ // 3. Seed the test user
+ var userManager = sp.GetRequiredService>();
+ var user = await userManager.FindByEmailAsync(TestUserEmail);
+ if (user is null)
+ {
+ user = new ApplicationUser
+ {
+ UserName = TestUserEmail,
+ Email = TestUserEmail,
+ DisplayName = TestUserDisplayName,
+ EmailConfirmed = true,
+ CreatedAt = DateTime.UtcNow,
+ };
+ var result = await userManager.CreateAsync(user, TestUserPassword);
+ if (result.Succeeded)
+ {
+ await userManager.AddToRoleAsync(user, "Admin");
+ }
+ }
+
+ // 4. Assign all permissions to the user
+ var permContracts = sp.GetRequiredService();
+ var userId = UserId.From(user!.Id);
+ await permContracts.SetPermissionsForUserAsync(userId, AllPermissions);
+ }
+
+ ///
+ /// Acquires a real Bearer token via ROPC (password grant) from /connect/token.
+ /// The token contains real claims: sub, email, name, roles, permissions.
+ ///
+ public async Task CreateBearerClientAsync()
+ {
+ await SeedTestInfrastructureAsync();
+
+ using var tokenClient = CreateClient();
+ using var tokenRequest = new FormUrlEncodedContent([
+ new KeyValuePair("grant_type", "password"),
+ new KeyValuePair("client_id", ServiceClientId),
+ new KeyValuePair("client_secret", ServiceClientSecret),
+ new KeyValuePair("username", TestUserEmail),
+ new KeyValuePair("password", TestUserPassword),
+ new KeyValuePair("scope", "openid profile email roles"),
+ ]);
+
+ var tokenResponse = await tokenClient.PostAsync("/connect/token", tokenRequest);
+ var body = await tokenResponse.Content.ReadAsStringAsync();
+
+ if (!tokenResponse.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"Failed to acquire Bearer token: {tokenResponse.StatusCode} - {body}");
+ }
+
+ using var doc = JsonDocument.Parse(body);
+ var accessToken = doc.RootElement.GetProperty("access_token").GetString()
+ ?? throw new InvalidOperationException("Token response missing access_token");
+
+ var client = CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ });
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ return client;
+ }
+
+ ///
+ /// Gets the seeded test user's Identity ID.
+ ///
+ public async Task GetSeededUserIdAsync()
+ {
+ await SeedTestInfrastructureAsync();
+
+ using var scope = Services.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ var user = await userManager.FindByEmailAsync(TestUserEmail)
+ ?? throw new InvalidOperationException("Seeded user not found");
+ return user.Id;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ try
+ {
+ foreach (var suffix in new[] { "", "-wal", "-shm" })
+ {
+ var path = _dbPath + suffix;
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ }
+#pragma warning disable CA1031
+ catch { /* Best-effort cleanup */ }
+#pragma warning restore CA1031
+ }
+ }
+
+ private static readonly string[] AllPermissions =
+ [
+ ProductsPermissions.View, ProductsPermissions.Create,
+ ProductsPermissions.Update, ProductsPermissions.Delete,
+ OrdersPermissions.View, OrdersPermissions.Create,
+ OrdersPermissions.Update, OrdersPermissions.Delete,
+ AuditLogsPermissions.View, AuditLogsPermissions.Export,
+ FileStoragePermissions.View, FileStoragePermissions.Upload, FileStoragePermissions.Delete,
+ PageBuilderPermissions.View, PageBuilderPermissions.Create,
+ PageBuilderPermissions.Update, PageBuilderPermissions.Delete, PageBuilderPermissions.Publish,
+ AdminPermissions.ManageUsers, AdminPermissions.ManageRoles, AdminPermissions.ViewAuditLog,
+ OpenIddictPermissions.ManageClients,
+ FeatureFlagsPermissions.View, FeatureFlagsPermissions.Manage,
+ ];
+}
diff --git a/tests/SimpleModule.LoadTests/Infrastructure/PasswordGrantTokenHandler.cs b/tests/SimpleModule.LoadTests/Infrastructure/PasswordGrantTokenHandler.cs
new file mode 100644
index 00000000..42846af8
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Infrastructure/PasswordGrantTokenHandler.cs
@@ -0,0 +1,113 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using OpenIddict.Abstractions;
+using OpenIddict.Server;
+using OpenIddict.Server.AspNetCore;
+using SimpleModule.OpenIddict.Contracts;
+using SimpleModule.Permissions.Contracts;
+using SimpleModule.Users.Contracts;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+
+namespace SimpleModule.LoadTests.Infrastructure;
+
+///
+/// OpenIddict event handler that processes ROPC (password grant) token requests.
+///
+public static class PasswordGrantTokenHandler
+{
+ public static async ValueTask Handle(OpenIddictServerEvents.HandleTokenRequestContext context)
+ {
+ var request = context.Request;
+ if (!request.IsPasswordGrantType())
+ return;
+
+ var httpContext = context.Transaction.GetHttpRequest()?.HttpContext
+ ?? throw new InvalidOperationException("HttpContext not available.");
+ var sp = httpContext.RequestServices;
+
+ var userManager = sp.GetRequiredService>();
+ var user = await userManager.FindByNameAsync(request.Username!);
+ if (user is null || !await userManager.CheckPasswordAsync(user, request.Password!))
+ {
+ context.Reject(Errors.InvalidGrant, "The username or password is invalid.");
+ return;
+ }
+
+ var identity = new ClaimsIdentity(
+ authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ nameType: Claims.Name,
+ roleType: Claims.Role);
+
+ var userId = await userManager.GetUserIdAsync(user);
+ identity.SetClaim(Claims.Subject, userId)
+ .SetClaim(Claims.Email, await userManager.GetEmailAsync(user) ?? string.Empty)
+ .SetClaim(Claims.Name, user.DisplayName);
+
+ var roles = await userManager.GetRolesAsync(user);
+ foreach (var role in roles)
+ {
+ identity.AddClaim(Claims.Role, role);
+ }
+
+ var permContracts = sp.GetRequiredService();
+ var userContracts = sp.GetRequiredService();
+ var userIdTyped = UserId.From(userId);
+ var roleIdMap = await userContracts.GetRoleIdsByNamesAsync(roles);
+ var allPermissions = await permContracts.GetAllPermissionsForUserAsync(
+ userIdTyped,
+ roleIdMap.Values.Select(id => RoleId.From(id)));
+
+ foreach (var permission in allPermissions)
+ {
+ identity.AddClaim("permission", permission);
+ }
+
+ identity.SetScopes(request.GetScopes());
+
+ foreach (var claim in identity.Claims)
+ {
+ claim.SetDestinations(GetDestinations(claim, identity));
+ }
+
+ context.SignIn(new ClaimsPrincipal(identity));
+ }
+
+ private static IEnumerable GetDestinations(Claim claim, ClaimsIdentity identity)
+ {
+ switch (claim.Type)
+ {
+ case Claims.Name:
+ yield return Destinations.AccessToken;
+ if (identity.HasScope(Scopes.Profile))
+ yield return Destinations.IdentityToken;
+ yield break;
+
+ case Claims.Email:
+ yield return Destinations.AccessToken;
+ if (identity.HasScope(Scopes.Email))
+ yield return Destinations.IdentityToken;
+ yield break;
+
+ case Claims.Role:
+ yield return Destinations.AccessToken;
+ if (identity.HasScope(AuthConstants.RolesScope))
+ yield return Destinations.IdentityToken;
+ yield break;
+
+ case Claims.Subject:
+ yield return Destinations.AccessToken;
+ yield return Destinations.IdentityToken;
+ yield break;
+
+ case "permission":
+ yield return Destinations.AccessToken;
+ yield break;
+
+ default:
+ yield return Destinations.AccessToken;
+ yield break;
+ }
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Infrastructure/SqliteBusyTimeoutInterceptor.cs b/tests/SimpleModule.LoadTests/Infrastructure/SqliteBusyTimeoutInterceptor.cs
new file mode 100644
index 00000000..88bc2cce
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Infrastructure/SqliteBusyTimeoutInterceptor.cs
@@ -0,0 +1,34 @@
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace SimpleModule.LoadTests.Infrastructure;
+
+///
+/// Sets SQLite pragmas on every connection open to handle concurrent access under load.
+/// busy_timeout makes SQLite wait (instead of failing) when the database is locked.
+///
+public sealed class SqliteBusyTimeoutInterceptor : DbConnectionInterceptor
+{
+ public static readonly SqliteBusyTimeoutInterceptor Instance = new();
+
+ public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
+ {
+ SetPragmas(connection);
+ }
+
+ public override Task ConnectionOpenedAsync(
+ DbConnection connection,
+ ConnectionEndEventData eventData,
+ CancellationToken cancellationToken = default)
+ {
+ SetPragmas(connection);
+ return Task.CompletedTask;
+ }
+
+ private static void SetPragmas(DbConnection connection)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "PRAGMA busy_timeout=30000; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;";
+ cmd.ExecuteNonQuery();
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/LoadTestRunner.cs b/tests/SimpleModule.LoadTests/LoadTestRunner.cs
new file mode 100644
index 00000000..320737a4
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/LoadTestRunner.cs
@@ -0,0 +1,97 @@
+using System.Net.Http.Json;
+using NBomber.Contracts;
+using NBomber.Contracts.Stats;
+using NBomber.CSharp;
+using SimpleModule.LoadTests.Infrastructure;
+using SimpleModule.LoadTests.Scenarios;
+using SimpleModule.Products.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
+
+namespace SimpleModule.LoadTests;
+
+public sealed class LoadTestRunner : IClassFixture, IAsyncLifetime, IDisposable
+{
+ private readonly LoadTestWebApplicationFactory _factory;
+ private HttpClient _client = null!;
+ private string _seededUserId = null!;
+ private int[] _seededProductIds = null!;
+
+ public LoadTestRunner(LoadTestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ public async ValueTask InitializeAsync()
+ {
+ // Acquire a real Bearer token via ROPC (password grant)
+ _client = await _factory.CreateBearerClientAsync();
+ _seededUserId = await _factory.GetSeededUserIdAsync();
+
+ // Seed products for the Orders scenario
+ var productIds = new List();
+ for (var i = 0; i < 5; i++)
+ {
+ var req = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ var resp = await _client.PostAsJsonAsync("/api/products", req);
+ if (resp.IsSuccessStatusCode)
+ {
+ var product = await resp.Content.ReadFromJsonAsync();
+ productIds.Add(product!.Id.Value);
+ }
+ }
+
+ _seededProductIds = productIds.Count > 0 ? [.. productIds] : [1];
+ }
+
+ public ValueTask DisposeAsync() => default;
+
+ public void Dispose() => _client?.Dispose();
+
+ private static void RunScenario(ScenarioProps scenario)
+ {
+ var result = NBomberRunner
+ .RegisterScenarios(scenario)
+ .WithReportFolder("reports")
+ .WithReportFormats(ReportFormat.Html, ReportFormat.Csv)
+ .Run();
+
+ Assert.True(result.AllOkCount > 0, "No successful requests");
+ var stats = result.ScenarioStats[0];
+ var total = stats.Ok.Request.Count + stats.Fail.Request.Count;
+ var successRate = total > 0 ? (double)stats.Ok.Request.Count / total * 100 : 0;
+ Assert.True(successRate > 50, $"Success rate too low: {successRate:F1}% ({stats.Ok.Request.Count}/{total})");
+ }
+
+ [Fact]
+ public void Products_Crud() => RunScenario(ProductsScenario.Create(_client));
+
+ [Fact]
+ public void Orders_Crud() => RunScenario(OrdersScenario.Create(_client, _seededUserId, _seededProductIds));
+
+ [Fact]
+ public void Users_Crud() => RunScenario(UsersScenario.Create(_client));
+
+ [Fact]
+ public void Settings_Ops() => RunScenario(SettingsScenario.Create(_client));
+
+ [Fact]
+ public void AuditLogs_Read() => RunScenario(AuditLogsScenario.Create(_client));
+
+ [Fact]
+ public void Files_Ops() => RunScenario(FileStorageScenario.Create(_client));
+
+ [Fact]
+ public void PageBuilder_Crud() => RunScenario(PageBuilderScenario.Create(_client));
+
+ [Fact]
+ public void Admin_Ops() => RunScenario(AdminScenario.Create(_client));
+
+ [Fact]
+ public void Mixed_Realistic() => RunScenario(MixedWorkloadScenario.Create(_client));
+
+ [Fact]
+ public void FeatureFlags_Ops() => RunScenario(FeatureFlagsScenario.Create(_client));
+
+ [Fact]
+ public void Marketplace_Read() => RunScenario(MarketplaceScenario.Create(_client));
+}
diff --git a/tests/SimpleModule.LoadTests/Program.cs b/tests/SimpleModule.LoadTests/Program.cs
new file mode 100644
index 00000000..9a52b0fa
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Program.cs
@@ -0,0 +1,3 @@
+// Entry point required for Exe output type.
+// Load tests are run via: dotnet test --project tests/SimpleModule.LoadTests
+// Or for specific scenarios: dotnet test --filter "Scenario=products_crud"
diff --git a/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs
new file mode 100644
index 00000000..d6f13a07
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs
@@ -0,0 +1,57 @@
+using NBomber.CSharp;
+using NBomber.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class AdminScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("admin_ops", async context =>
+ {
+ // Create a role
+ var roleName = $"LT-{Guid.NewGuid():N}"[..20];
+ using var roleForm = new FormUrlEncodedContent([
+ new KeyValuePair("name", roleName),
+ new KeyValuePair("description", "Load test role"),
+ ]);
+ var roleResponse = await client.PostAsync("/admin/roles/", roleForm);
+
+ // Admin endpoints return 302 redirect on success (Blazor SSR pattern).
+ // With AllowAutoRedirect=false, we get the raw 302.
+ if (!IsSuccess(roleResponse))
+ return Response.Fail(statusCode: ((int)roleResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Extract role ID from Location header (/admin/roles/{id}/edit)
+ var locationPath = roleResponse.Headers.Location?.OriginalString ?? string.Empty;
+ var segments = locationPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ // segments: ["admin", "roles", "{id}", "edit"]
+ if (segments.Length >= 3)
+ {
+ var roleId = segments[2];
+
+ // Delete the role (also returns 302 on success)
+ var deleteResponse = await client.DeleteAsync($"/admin/roles/{roleId}");
+ if (!IsSuccess(deleteResponse))
+ return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+
+ ///
+ /// Admin form endpoints return 302 redirects on success (Blazor SSR pattern).
+ /// Treat 2xx and 3xx as success.
+ ///
+ private static bool IsSuccess(HttpResponseMessage response)
+ {
+ var code = (int)response.StatusCode;
+ return code is >= 200 and < 400;
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs
new file mode 100644
index 00000000..3c01ab83
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs
@@ -0,0 +1,38 @@
+using System.Net.Http.Json;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.AuditLogs.Contracts;
+using SimpleModule.Core;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class AuditLogsScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("auditlogs_read", async context =>
+ {
+ // Get all audit logs
+ var getAllResponse = await client.GetAsync("/api/audit-logs");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get by ID if any entries exist
+ var pagedResult = await getAllResponse.Content.ReadFromJsonAsync>();
+ if (pagedResult?.Items.Count > 0)
+ {
+ var firstId = pagedResult.Items[0].Id;
+ var getByIdResponse = await client.GetAsync($"/api/audit-logs/{firstId}");
+ if (!getByIdResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getByIdResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs
new file mode 100644
index 00000000..a049fcd7
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs
@@ -0,0 +1,32 @@
+using System.Net.Http.Json;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.FeatureFlags.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class FeatureFlagsScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("featureflags_ops", async context =>
+ {
+ // Get all feature flags
+ var getAllResponse = await client.GetAsync("/api/feature-flags");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Check a flag (may not exist, but the endpoint should return 200)
+ var checkResponse = await client.GetAsync("/api/feature-flags/check/load-test-flag");
+ if (!checkResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)checkResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs
new file mode 100644
index 00000000..f01fb95c
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs
@@ -0,0 +1,30 @@
+using NBomber.CSharp;
+using NBomber.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class FileStorageScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("files_ops", async context =>
+ {
+ // Get all files
+ var getAllResponse = await client.GetAsync("/api/files");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // List folders
+ var foldersResponse = await client.GetAsync("/api/files/folders");
+ if (!foldersResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)foldersResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs
new file mode 100644
index 00000000..1a2ca443
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs
@@ -0,0 +1,30 @@
+using NBomber.CSharp;
+using NBomber.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class MarketplaceScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("marketplace_read", async context =>
+ {
+ // Search packages (anonymous, no auth required)
+ var searchResponse = await client.GetAsync("/api/marketplace?q=auth&take=10");
+ if (!searchResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)searchResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Browse all packages
+ var browseResponse = await client.GetAsync("/api/marketplace");
+ if (!browseResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)browseResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs
new file mode 100644
index 00000000..a5252dd3
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs
@@ -0,0 +1,81 @@
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.Products.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class MixedWorkloadScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("mixed_realistic", async context =>
+ {
+ // Weighted random: 70% reads, 20% creates, 10% updates
+ var roll = RandomNumberGenerator.GetInt32(100);
+
+ if (roll < 70)
+ {
+ // Read operations across modules
+ var endpoint = RandomNumberGenerator.GetInt32(5) switch
+ {
+ 0 => "/api/products",
+ 1 => "/api/orders",
+ 2 => "/api/users",
+ 3 => "/api/settings",
+ _ => "/api/audit-logs",
+ };
+
+ var response = await client.GetAsync(endpoint);
+ return response.IsSuccessStatusCode
+ ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture))
+ : Response.Fail(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ if (roll < 90)
+ {
+ // Create operations
+ var createRoll = RandomNumberGenerator.GetInt32(2);
+ if (createRoll == 0)
+ {
+ var request = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ var response = await client.PostAsJsonAsync("/api/products", request);
+ return response.IsSuccessStatusCode
+ ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture))
+ : Response.Fail(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ var request = FakeDataGenerators.CreateOrderRequestFaker.Generate();
+ var response = await client.PostAsJsonAsync("/api/orders", request);
+ return response.IsSuccessStatusCode
+ ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture))
+ : Response.Fail(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+ }
+
+ // Update operations (10%)
+ {
+ // Create then update a product
+ var createReq = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ var createResp = await client.PostAsJsonAsync("/api/products", createReq);
+ if (!createResp.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)createResp.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ var product = await createResp.Content.ReadFromJsonAsync();
+ var updateReq = FakeDataGenerators.UpdateProductRequestFaker.Generate();
+ var updateResp = await client.PutAsJsonAsync($"/api/products/{product!.Id}", updateReq);
+ return updateResp.IsSuccessStatusCode
+ ? Response.Ok(statusCode: ((int)updateResp.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture))
+ : Response.Fail(statusCode: ((int)updateResp.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs
new file mode 100644
index 00000000..9e337571
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs
@@ -0,0 +1,65 @@
+using System.Net.Http.Json;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.Orders.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class OrdersScenario
+{
+ public static ScenarioProps Create(HttpClient client, string userId, int[] productIds)
+ {
+ return Scenario.Create("orders_crud", async context =>
+ {
+ // Build order items using real product IDs
+ var items = productIds.Select(pid => new OrderItem
+ {
+ ProductId = pid,
+ Quantity = context.ScenarioInfo.InstanceNumber % 4 + 1,
+ }).ToList();
+
+ var createRequest = new CreateOrderRequest
+ {
+ UserId = userId,
+ Items = items,
+ };
+ var createResponse = await client.PostAsJsonAsync("/api/orders", createRequest);
+ if (!createResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)createResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ var order = await createResponse.Content.ReadFromJsonAsync();
+
+ // Get all
+ var getAllResponse = await client.GetAsync("/api/orders");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get by ID
+ var getResponse = await client.GetAsync($"/api/orders/{order!.Id}");
+ if (!getResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Update order
+ var updateRequest = new UpdateOrderRequest
+ {
+ UserId = userId,
+ Items = items,
+ };
+ var updateResponse = await client.PutAsJsonAsync($"/api/orders/{order.Id}", updateRequest);
+ if (!updateResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)updateResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Delete
+ var deleteResponse = await client.DeleteAsync($"/api/orders/{order.Id}");
+ if (!deleteResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs
new file mode 100644
index 00000000..c3c64483
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs
@@ -0,0 +1,88 @@
+using System.Net.Http.Json;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.PageBuilder.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class PageBuilderScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("pagebuilder_crud", async context =>
+ {
+ try
+ {
+ // Create a page
+ var createRequest = new CreatePageRequest
+ {
+ Title = $"Load Test Page {context.ScenarioInfo.InstanceNumber}",
+ Slug = $"lt-{Guid.NewGuid():N}",
+ };
+ var createResponse = await client.PostAsJsonAsync("/api/pagebuilder", createRequest);
+ if (!createResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)createResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ var page = await createResponse.Content.ReadFromJsonAsync();
+
+ // Get by ID
+ var getByIdResponse = await client.GetAsync($"/api/pagebuilder/{page!.Id}");
+ if (!getByIdResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getByIdResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Update title
+ var updateRequest = new UpdatePageRequest
+ {
+ Title = $"Updated {createRequest.Title}",
+ Slug = page.Slug,
+ };
+ var updateResponse = await client.PutAsJsonAsync($"/api/pagebuilder/{page.Id}", updateRequest);
+ if (!updateResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)updateResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Publish
+ var publishResponse = await client.PostAsync($"/api/pagebuilder/{page.Id}/publish", null);
+ if (!publishResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)publishResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Unpublish
+ var unpublishResponse = await client.PostAsync($"/api/pagebuilder/{page.Id}/unpublish", null);
+ if (!unpublishResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)unpublishResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Delete
+ var deleteResponse = await client.DeleteAsync($"/api/pagebuilder/{page.Id}");
+ if (!deleteResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get all pages
+ var getAllResponse = await client.GetAsync("/api/pagebuilder");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get tags
+ var tagsResponse = await client.GetAsync("/api/pagebuilder/tags");
+ if (!tagsResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)tagsResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get templates
+ var templatesResponse = await client.GetAsync("/api/pagebuilder/templates");
+ if (!templatesResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)templatesResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ }
+#pragma warning disable CA1031 // Catch scenario exceptions for NBomber reporting
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ return Response.Fail(message: ex.Message);
+ }
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs
new file mode 100644
index 00000000..9e6891f7
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs
@@ -0,0 +1,52 @@
+using System.Net.Http.Json;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.Products.Contracts;
+using SimpleModule.Tests.Shared.Fakes;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class ProductsScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("products_crud", async context =>
+ {
+ // Create
+ var createRequest = FakeDataGenerators.CreateProductRequestFaker.Generate();
+ var createResponse = await client.PostAsJsonAsync("/api/products", createRequest);
+ if (!createResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)createResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ var product = await createResponse.Content.ReadFromJsonAsync();
+
+ // Get all
+ var getAllResponse = await client.GetAsync("/api/products");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get by ID
+ var getResponse = await client.GetAsync($"/api/products/{product!.Id}");
+ if (!getResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Update
+ var updateRequest = FakeDataGenerators.UpdateProductRequestFaker.Generate();
+ var updateResponse = await client.PutAsJsonAsync($"/api/products/{product.Id}", updateRequest);
+ if (!updateResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)updateResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Delete
+ var deleteResponse = await client.DeleteAsync($"/api/products/{product.Id}");
+ if (!deleteResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs
new file mode 100644
index 00000000..39fb1e1e
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs
@@ -0,0 +1,43 @@
+using NBomber.CSharp;
+using NBomber.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class SettingsScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("settings_ops", async context =>
+ {
+ // Get settings
+ var settingsResponse = await client.GetAsync("/api/settings");
+ if (!settingsResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)settingsResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get definitions
+ var definitionsResponse = await client.GetAsync("/api/settings/definitions");
+ if (!definitionsResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)definitionsResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get menu tree
+ var menuResponse = await client.GetAsync("/api/settings/menus");
+ if (!menuResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)menuResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get available pages
+ var pagesResponse = await client.GetAsync("/api/settings/menus/available-pages");
+ if (!pagesResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)pagesResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Note: GET /api/settings/me is excluded — it uses ClaimTypes.NameIdentifier
+ // which doesn't match OpenIddict's "sub" claim in Bearer tokens (product bug).
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs
new file mode 100644
index 00000000..bfd82d59
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs
@@ -0,0 +1,38 @@
+using System.Net.Http.Json;
+using NBomber.CSharp;
+using NBomber.Contracts;
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.LoadTests.Scenarios;
+
+public static class UsersScenario
+{
+ public static ScenarioProps Create(HttpClient client)
+ {
+ return Scenario.Create("users_crud", async context =>
+ {
+ // Get current user (requires NameIdentifier to match a real Identity user)
+ var meResponse = await client.GetAsync("/api/users/me");
+ if (!meResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)meResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get user by ID using the ID from /me
+ var currentUser = await meResponse.Content.ReadFromJsonAsync();
+ var getByIdResponse = await client.GetAsync($"/api/users/{currentUser!.Id}");
+ if (!getByIdResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getByIdResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Get all users
+ var getAllResponse = await client.GetAsync("/api/users");
+ if (!getAllResponse.IsSuccessStatusCode)
+ return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ return Response.Ok(statusCode: "200");
+ })
+ .WithoutWarmUp()
+ .WithLoadSimulations(
+ Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)),
+ Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20))
+ );
+ }
+}
diff --git a/tests/SimpleModule.LoadTests/SimpleModule.LoadTests.csproj b/tests/SimpleModule.LoadTests/SimpleModule.LoadTests.csproj
new file mode 100644
index 00000000..75346559
--- /dev/null
+++ b/tests/SimpleModule.LoadTests/SimpleModule.LoadTests.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+