forked from xiongyuxing/tiku-backend.net
perf: remove backoffice catalog query amplification
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
72
Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs
Normal file
72
Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Bootstrap;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tiku.IntegrationTests.Bootstrap;
|
||||
|
||||
public sealed class BuiltinBackofficeCatalogSeederTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Seed_populates_complete_catalog_and_is_idempotent()
|
||||
{
|
||||
using var database = PostgresTestDatabase.Create();
|
||||
await using var dbContext = CreateDbContext(database.ConnectionString);
|
||||
var seeder = new BuiltinBackofficeCatalogSeeder(dbContext);
|
||||
|
||||
await seeder.SeedAsync();
|
||||
dbContext.ChangeTracker.Clear();
|
||||
await seeder.SeedAsync();
|
||||
|
||||
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
|
||||
Assert.Equal(26, await dbContext.PermissionModules.CountAsync());
|
||||
Assert.Equal(35, await dbContext.BackendPermissions.CountAsync());
|
||||
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
|
||||
Assert.Equal(15, await dbContext.SaasFeatures.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(26, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(35, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(21, await dbContext.BackendMenus.Select(item => item.Code).Distinct().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_preserves_existing_values_and_fills_partial_catalog()
|
||||
{
|
||||
using var database = PostgresTestDatabase.Create();
|
||||
await using var dbContext = CreateDbContext(database.ConnectionString);
|
||||
dbContext.SaasFeatures.Add(new SaasFeature
|
||||
{
|
||||
Code = SaasFeatureCatalog.CoreBackoffice,
|
||||
Name = "Custom feature name",
|
||||
Category = "custom",
|
||||
IsCore = true,
|
||||
Status = SaasFeatureStatus.Active
|
||||
});
|
||||
dbContext.PermissionModules.Add(new PermissionModule
|
||||
{
|
||||
Code = "tenant_dashboard",
|
||||
Name = "Custom module name",
|
||||
Area = BackendPermissionArea.Tenant
|
||||
});
|
||||
dbContext.BackendPermissions.Add(new BackendPermission
|
||||
{
|
||||
Code = BackendPermissions.TenantDashboardView,
|
||||
Name = "Custom permission name",
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
PermissionModuleCode = "tenant_dashboard",
|
||||
IsSystem = true
|
||||
});
|
||||
dbContext.BackendMenus.Add(new BackendMenu
|
||||
{
|
||||
Code = "tenant.dashboard",
|
||||
Title = "Custom menu title",
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Path = "/custom-dashboard",
|
||||
PermissionCode = BackendPermissions.TenantDashboardView,
|
||||
IsActive = true
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync();
|
||||
|
||||
Assert.Equal("Custom feature name", (await dbContext.SaasFeatures.SingleAsync(
|
||||
item => item.Code == SaasFeatureCatalog.CoreBackoffice)).Name);
|
||||
Assert.Equal("Custom module name", (await dbContext.PermissionModules.SingleAsync(
|
||||
item => item.Code == "tenant_dashboard")).Name);
|
||||
Assert.Equal("Custom permission name", (await dbContext.BackendPermissions.SingleAsync(
|
||||
item => item.Code == BackendPermissions.TenantDashboardView)).Name);
|
||||
Assert.Equal("Custom menu title", (await dbContext.BackendMenus.SingleAsync(
|
||||
item => item.Code == "tenant.dashboard")).Title);
|
||||
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
|
||||
Assert.Equal(26, await dbContext.PermissionModules.CountAsync());
|
||||
Assert.Equal(35, await dbContext.BackendPermissions.CountAsync());
|
||||
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
|
||||
Assert.False(await dbContext.PermissionModules.AnyAsync(module =>
|
||||
module.RequiredFeatureCode != null &&
|
||||
!dbContext.SaasFeatures.Any(feature => feature.Code == module.RequiredFeatureCode)));
|
||||
Assert.False(await dbContext.BackendPermissions.AnyAsync(permission =>
|
||||
!dbContext.PermissionModules.Any(module => module.Code == permission.PermissionModuleCode)));
|
||||
Assert.False(await dbContext.BackendMenus.AnyAsync(menu =>
|
||||
menu.PermissionCode != null &&
|
||||
!dbContext.BackendPermissions.Any(permission => permission.Code == menu.PermissionCode)));
|
||||
}
|
||||
|
||||
private static TikuDbContext CreateDbContext(string connectionString)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
|
||||
.Options;
|
||||
return new TikuDbContext(options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user