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 + + + + + + + + + + + + + + + +