using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; using System.Text.Json; using Tiku.Application.Commerce; using Tiku.Application.Auth; using Tiku.Application.Growth; using Tiku.Application.Storage; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Api; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Content; using Tiku.Domain.Tenancy; using Tiku.IntegrationTests.Infrastructure; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class ApiTestFactory( IWechatOAuthClient? wechatOAuthClient = null, IObjectStorageService? objectStorageService = null, IReferralQrcodeGenerator? referralQrcodeGenerator = null, IPaymentProviderGateway? paymentProviderGateway = null, IDomainOwnershipVerifier? domainOwnershipVerifier = null, IDomainGatewayProvisioner? domainGatewayProvisioner = null, ISmsProvider? smsProvider = null, IReadOnlyDictionary? configurationOverrides = null) : WebApplicationFactory { private readonly PostgresTestDatabase database = PostgresTestDatabase.Create(); public string DatabaseConnectionString => database.ConnectionString; protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { if (configurationOverrides is not null) { foreach (var pair in configurationOverrides.Where(pair => pair.Value is not null)) { builder.UseSetting(pair.Key, pair.Value); } } builder.ConfigureAppConfiguration((_, configuration) => { var values = new Dictionary { ["Security:Jwt:KeyId"] = TestJwtKeys.KeyId, ["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem, ["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api" }; if (configurationOverrides is not null) { foreach (var pair in configurationOverrides) { values[pair.Key] = pair.Value; } } configuration.AddInMemoryCollection(values); }); builder.ConfigureServices(services => { foreach (var descriptor in services .Where(descriptor => descriptor.ServiceType == typeof(NpgsqlDataSource) || descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) || descriptor.ServiceType == typeof(DbContextOptions) || descriptor.ServiceType == typeof(TikuDbContext)) .ToArray()) { services.Remove(descriptor); } services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString)); services.AddScoped(); services.AddDbContext((serviceProvider, options) => { var dataSource = serviceProvider.GetRequiredService(); options.UseNpgsql(dataSource, npgsql => npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)); options.AddInterceptors(serviceProvider.GetRequiredService()); }); services.RemoveAll(); services.AddSingleton(); if (wechatOAuthClient is not null) { services.AddSingleton(wechatOAuthClient); } if (objectStorageService is not null) { services.AddSingleton(objectStorageService); } if (referralQrcodeGenerator is not null) { services.AddSingleton(referralQrcodeGenerator); } if (paymentProviderGateway is not null) { services.AddSingleton(paymentProviderGateway); } if (domainOwnershipVerifier is not null) { services.RemoveAll(); services.AddSingleton(domainOwnershipVerifier); } if (domainGatewayProvisioner is not null) { services.RemoveAll(); services.AddSingleton(domainGatewayProvisioner); } if (smsProvider is not null) { services.RemoveAll(); services.AddSingleton(smsProvider); } }); } public async Task SeedAsync(params object[] entities) { using var scope = Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .InitializeSystem(null, "Integration test fixture seeding"); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.AddRange(entities); await dbContext.SaveChangesAsync(); var backendMembers = entities .OfType() .Where(membership => membership.Status == MembershipStatus.Active && membership.Role is TenantRole.TenantOwner or TenantRole.TenantAdmin) .Select(membership => (membership.TenantId, membership.UserId)) .Distinct() .ToArray(); if (backendMembers.Length > 0) { await EnsureTenantBackendAccessAsync(dbContext, backendMembers); } } private static async Task EnsureTenantBackendAccessAsync( TikuDbContext dbContext, IEnumerable<(Guid TenantId, Guid UserId)> members) { var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray(); var existingPermissionCodes = await dbContext.BackendPermissions .Where(permission => permissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToListAsync(); foreach (var permissionCode in permissionCodes.Except(existingPermissionCodes, StringComparer.Ordinal)) { dbContext.BackendPermissions.Add(new BackendPermission { Code = permissionCode, Name = permissionCode, Area = BackendPermissionArea.Tenant, Module = permissionCode.Split(':')[1], IsSystem = true }); } foreach (var tenantGroup in members.GroupBy(member => member.TenantId)) { var tenantId = tenantGroup.Key; var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Code == "integration_test_admin"); if (role is null) { role = new TenantBackendRole { TenantId = tenantId, Code = "integration_test_admin", Name = "Integration Test Administrator", Status = BackendRoleStatus.Active, IsSystem = true, DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) }; dbContext.TenantBackendRoles.Add(role); } var assignedPermissionCodes = await dbContext.TenantBackendRolePermissions .Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id) .Select(entity => entity.PermissionCode) .ToListAsync(); foreach (var permissionCode in permissionCodes.Except(assignedPermissionCodes, StringComparer.Ordinal)) { dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission { TenantId = tenantId, RoleId = role.Id, PermissionCode = permissionCode }); } var assignedUserIds = await dbContext.TenantBackendUserRoles .Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id) .Select(entity => entity.UserId) .ToListAsync(); foreach (var member in tenantGroup.Where(member => !assignedUserIds.Contains(member.UserId))) { dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = member.UserId, RoleId = role.Id }); } } await dbContext.SaveChangesAsync(); } public IServiceScope CreateSystemScope(string reason = "Integration test verification") { var scope = Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .InitializeSystem(null, reason); return scope; } public IServiceScope CreateTenantScope(Guid tenantId, string? tenantCode = null) { var scope = Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .Initialize(tenantId, tenantCode, TenantResolutionSource.TenantCode); return scope; } public async Task SeedQuestionWithVersionAsync(Question question, QuestionVersion version) { question.CurrentVersionId = null; await SeedAsync(question); await SeedAsync(version); using var scope = Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .InitializeSystem(question.TenantId, "Integration test question version linking"); var dbContext = scope.ServiceProvider.GetRequiredService(); var persistedQuestion = await dbContext.Questions.SingleAsync(item => item.TenantId == question.TenantId && item.Id == question.Id); persistedQuestion.CurrentVersionId = version.Id; await dbContext.SaveChangesAsync(); } public async Task SeedQuestionReferenceAsync( Guid tenantId, Guid questionOwnerTenantId, Guid questionId, QuestionSource source = QuestionSource.Tenant) { var reference = new TenantQuestionReference { Id = Guid.NewGuid(), TenantId = tenantId, QuestionOwnerTenantId = questionOwnerTenantId, QuestionId = questionId, Source = source }; await SeedAsync(reference); return reference; } public async Task SeedActiveSessionAsync( Guid userId, Guid? tenantId = null, string tokenHash = "integration-test-token-hash", bool includeMembership = false) { var resolvedTenantId = tenantId ?? Guid.NewGuid(); var user = new User { Id = userId, Phone = "13800000000" }; var session = new AuthSession { Id = Guid.NewGuid(), Realm = AuthRealm.Tenant, TenantId = resolvedTenantId, UserId = userId, TokenFamilyId = Guid.NewGuid(), TokenHash = tokenHash, SecurityStamp = user.SecurityStamp ?? string.Empty, Provider = "test", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) }; var entities = new List { new Tenant { Id = resolvedTenantId, Slug = resolvedTenantId.ToString("N"), Name = "Test Tenant" }, user, session }; if (includeMembership) { entities.Add(new TenantMembership { TenantId = resolvedTenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active }); } await SeedAsync([.. entities]); return session.Id; } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing) { database.Dispose(); } } }