perf: remove backoffice catalog query amplification

This commit is contained in:
2026-07-30 09:50:05 +08:00
parent bedc77bffd
commit 98585aa521
8 changed files with 560 additions and 228 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -21,6 +22,7 @@ using Tiku.Domain.Commerce;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.IntegrationTests.Infrastructure;
using Tiku.Infrastructure.Bootstrap;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
@@ -34,7 +36,8 @@ public sealed class ApiTestFactory(
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
ISmsProvider? smsProvider = null,
IReadOnlyDictionary<string, string?>? configurationOverrides = null) : WebApplicationFactory<ApiProgramMarker>
IReadOnlyDictionary<string, string?>? configurationOverrides = null,
DbCommandInterceptor? dbCommandInterceptor = null) : WebApplicationFactory<ApiProgramMarker>
{
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
@@ -88,6 +91,10 @@ public sealed class ApiTestFactory(
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
if (dbCommandInterceptor is not null)
{
options.AddInterceptors(dbCommandInterceptor);
}
});
services.RemoveAll<IJwtKeyRing>();
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
@@ -315,6 +322,15 @@ public sealed class ApiTestFactory(
}
}
public async Task SeedBuiltinBackofficeCatalogAsync()
{
using var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(null, "Integration test built-in backoffice catalog seeding");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync();
}
private static async Task EnsureTenantBackendAccessAsync(
TikuDbContext dbContext,
IEnumerable<(Guid TenantId, Guid UserId)> members)

View File

@@ -15,7 +15,9 @@ public sealed class BackofficeUiBootstrapTests
[Fact]
public async Task TenantUiBootstrap_ReturnsOnlyMenusAllowedByEffectivePermissions()
{
await using var factory = new ApiTestFactory();
var commandRecorder = new RecordingDbCommandInterceptor();
await using var factory = new ApiTestFactory(dbCommandInterceptor: commandRecorder);
await factory.SeedBuiltinBackofficeCatalogAsync();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
@@ -42,14 +44,6 @@ public sealed class BackofficeUiBootstrapTests
Role = TenantRole.Student,
Status = MembershipStatus.Active
},
new BackendPermission
{
Code = BackendPermissions.TenantDashboardView,
Name = "Tenant dashboard",
Area = BackendPermissionArea.Tenant,
PermissionModuleCode = "tenant_dashboard",
IsSystem = true
},
new TenantBackendRole
{
Id = roleId,
@@ -75,17 +69,100 @@ public sealed class BackofficeUiBootstrapTests
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
commandRecorder.Reset();
using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap");
using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var firstRequestCommands = commandRecorder.Snapshot();
commandRecorder.Reset();
using var repeatedResponse = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap");
var repeatedRequestCommands = commandRecorder.Snapshot();
using var roleManagementResponse = await client.GetAsync("/api/backoffice/tenant/bootstrap");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode);
Assert.Equal(
[BackendPermissions.TenantDashboardView],
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
Assert.Equal(
["tenant.dashboard"],
bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString()));
Assert.True(
firstRequestCommands.Count is >= 1 and <= 10,
$"Expected at most 10 SQL commands, but captured {firstRequestCommands.Count}:{Environment.NewLine}{string.Join($"{Environment.NewLine}---{Environment.NewLine}", firstRequestCommands)}");
Assert.InRange(repeatedRequestCommands.Count, 1, firstRequestCommands.Count);
AssertNoPerCodeCatalogExistenceQueries(firstRequestCommands);
AssertNoPerCodeCatalogExistenceQueries(repeatedRequestCommands);
Assert.Equal(HttpStatusCode.Forbidden, roleManagementResponse.StatusCode);
}
[Fact]
public async Task PlatformUiBootstrap_DoesNotProbeCatalogPerCode()
{
var commandRecorder = new RecordingDbCommandInterceptor();
await using var factory = new ApiTestFactory(
configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
},
dbCommandInterceptor: commandRecorder);
await factory.SeedBuiltinBackofficeCatalogAsync();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"platform-{Guid.NewGuid():N}@example.test";
await factory.SeedAsync(
new User
{
Id = userId,
Email = email,
NormalizedEmail = email.ToUpperInvariant(),
UserName = email,
NormalizedUserName = email.ToUpperInvariant(),
Name = "Platform UI Operator",
PrimaryRole = "platform_admin",
RawProfile = JsonDefaults.Object()
}.WithTestPassword(),
new PlatformBackendRole
{
Id = roleId,
Code = $"platform_ui_{Guid.NewGuid():N}",
Name = "Platform UI Operator",
Status = BackendRoleStatus.Active
},
new PlatformBackendRolePermission
{
RoleId = roleId,
PermissionCode = BackendPermissions.PlatformDashboardView
},
new PlatformBackendUserRole
{
UserId = userId,
RoleId = roleId
});
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(email));
commandRecorder.Reset();
using var response = await client.GetAsync("/api/backoffice/platform/ui-bootstrap");
using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var commands = commandRecorder.Snapshot();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(
[BackendPermissions.PlatformDashboardView],
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
Assert.Equal(
["platform.dashboard"],
bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString()));
Assert.InRange(commands.Count, 1, 10);
AssertNoPerCodeCatalogExistenceQueries(commands);
}
private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable<string> commands)
{
string[] catalogTables = ["saas_features", "permission_modules", "backend_permissions", "backend_menus"];
Assert.DoesNotContain(commands, command =>
command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) &&
catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase)));
}
}

View File

@@ -0,0 +1,72 @@
using System.Collections.Concurrent;
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Tiku.IntegrationTests.Api;
internal sealed class RecordingDbCommandInterceptor : DbCommandInterceptor
{
private readonly ConcurrentQueue<string> commandTexts = new();
public IReadOnlyList<string> Snapshot() => commandTexts.ToArray();
public void Reset()
{
while (commandTexts.TryDequeue(out _))
{
}
}
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
Record(command);
return result;
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
Record(command);
return ValueTask.FromResult(result);
}
public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
{
Record(command);
return result;
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
Record(command);
return ValueTask.FromResult(result);
}
public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
{
Record(command);
return result;
}
public override ValueTask<object?> ScalarExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
object? result,
CancellationToken cancellationToken = default)
{
Record(command);
return ValueTask.FromResult(result);
}
private void Record(DbCommand command) => commandTexts.Enqueue(command.CommandText);
}