Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Bogus" Version="35.6.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" />
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="NBomber" Version="6.3.0" />
<PackageVersion Include="NBomber.Http" Version="6.2.0" />
<!-- Value Objects -->
<PackageVersion Include="Vogen" Version="8.0.5" />
<!-- Azure -->
Expand Down
2 changes: 2 additions & 0 deletions SimpleModule.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
<Project Path="tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj" />
<Project Path="tests/SimpleModule.DevTools.Tests/SimpleModule.DevTools.Tests.csproj" />
<Project Path="tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj" />
<Project Path="tests/SimpleModule.Benchmarks/SimpleModule.Benchmarks.csproj" />
<Project Path="tests/SimpleModule.LoadTests/SimpleModule.LoadTests.csproj" />
</Folder>
<Project Path="SimpleModule.AppHost/SimpleModule.AppHost.csproj" />
<Project Path="SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj" />
Expand Down
84 changes: 84 additions & 0 deletions tests/SimpleModule.Benchmarks/Benchmarks/AdminBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> CreateRole()
{
using var form = new FormUrlEncodedContent([
new KeyValuePair<string, string>("name", $"BenchRole-{Guid.NewGuid():N}"),
new KeyValuePair<string, string>("description", "Benchmark test role"),
]);
return await _client.PostAsync("/admin/roles/", form);
}

[Benchmark]
public async Task CreateAndDeleteRole()
{
using var form = new FormUrlEncodedContent([
new KeyValuePair<string, string>("name", $"BenchRole-{Guid.NewGuid():N}"),
new KeyValuePair<string, string>("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<HttpResponseMessage> CreateUser()
{
var email = $"bench-{Guid.NewGuid():N}@test.dev";
using var form = new FormUrlEncodedContent([
new KeyValuePair<string, string>("email", email),
new KeyValuePair<string, string>("displayName", "Benchmark User"),
new KeyValuePair<string, string>("password", "BenchPass123!"),
new KeyValuePair<string, string>("emailConfirmed", "true"),
]);
return await _client.PostAsync("/admin/users/", form);
}
}
49 changes: 49 additions & 0 deletions tests/SimpleModule.Benchmarks/Benchmarks/AuditLogsBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> GetAllAuditLogs() =>
await _client.GetAsync("/api/audit-logs");

[Benchmark]
public async Task<HttpResponseMessage> GetAuditLogStats() =>
await _client.GetAsync("/api/audit-logs/stats");

[Benchmark]
public async Task<HttpResponseMessage> ExportAuditLogs() =>
await _client.GetAsync("/api/audit-logs/export");

[Benchmark]
public async Task<HttpResponseMessage> GetAuditLogById() =>
await _client.GetAsync("/api/audit-logs/1");
}
42 changes: 42 additions & 0 deletions tests/SimpleModule.Benchmarks/Benchmarks/FileStorageBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> GetAllFiles() =>
await _client.GetAsync("/api/files");

[Benchmark]
public async Task<HttpResponseMessage> ListFolders() =>
await _client.GetAsync("/api/files/folders");
}
76 changes: 76 additions & 0 deletions tests/SimpleModule.Benchmarks/Benchmarks/OrdersBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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<Order>();
_seedOrderId = order!.Id.Value;
}

[GlobalCleanup]
public void Cleanup() => Dispose();

public void Dispose()
{
_client?.Dispose();
_factory?.Dispose();
GC.SuppressFinalize(this);
}

[Benchmark]
public async Task<HttpResponseMessage> GetAllOrders() =>
await _client.GetAsync("/api/orders");

[Benchmark]
public async Task<HttpResponseMessage> GetOrderById() =>
await _client.GetAsync($"/api/orders/{_seedOrderId}");

[Benchmark]
public async Task<HttpResponseMessage> CreateOrder()
{
var request = FakeDataGenerators.CreateOrderRequestFaker.Generate();
return await _client.PostAsJsonAsync("/api/orders", request);
}

[Benchmark]
public async Task<HttpResponseMessage> 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<Order>();
await _client.DeleteAsync($"/api/orders/{order!.Id}");
}
}
65 changes: 65 additions & 0 deletions tests/SimpleModule.Benchmarks/Benchmarks/PageBuilderBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> GetAllPages() =>
await _client.GetAsync("/api/pagebuilder");

[Benchmark]
public async Task<HttpResponseMessage> GetAllTags() =>
await _client.GetAsync("/api/pagebuilder/tags");

[Benchmark]
public async Task<HttpResponseMessage> GetAllTemplates() =>
await _client.GetAsync("/api/pagebuilder/templates");

[Benchmark]
public async Task<HttpResponseMessage> 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);
}
}
}
Loading