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; using Npgsql; using System.Text.Json; using Tiku.Application.Commerce; using Tiku.Application.PlatformBilling; 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.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; public sealed class ApiTestFactory( IWechatOAuthClient? wechatOAuthClient = null, IObjectStorageService? objectStorageService = null, IReferralQrcodeGenerator? referralQrcodeGenerator = null, IPaymentProviderGateway? paymentProviderGateway = null, IPlatformBillingPaymentGateway? platformBillingPaymentGateway = null, IDomainOwnershipVerifier? domainOwnershipVerifier = null, IDomainGatewayProvisioner? domainGatewayProvisioner = null, ISmsProvider? smsProvider = null, IReadOnlyDictionary? configurationOverrides = null, DbCommandInterceptor? dbCommandInterceptor = 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 { ["BackgroundProcessing:Enabled"] = "false", ["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()); if (dbCommandInterceptor is not null) { options.AddInterceptors(dbCommandInterceptor); } }); 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 (platformBillingPaymentGateway is not null) { services.RemoveAll(); services.AddSingleton(platformBillingPaymentGateway); } 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(); var tenants = entities.OfType().Where(tenant => tenant.Mode == TenantMode.Saas).ToArray(); var hasExplicitCapabilitySetup = entities.Any(entity => entity is SaasOffering or SaasOfferingVersion or TenantSaasSubscription); if (tenants.Length > 0 && !hasExplicitCapabilitySetup) { const string integrationOfferingCode = "integration-full-access"; var existingFeatures = await dbContext.SaasFeatures.Select(feature => feature.Code).ToArrayAsync(); dbContext.SaasFeatures.AddRange(SaasFeatureCatalog.All .Except(existingFeatures, StringComparer.Ordinal) .Select((code, index) => new SaasFeature { Code = code, Name = code, Category = code.Split('.')[0], IsCore = code == SaasFeatureCatalog.CoreBackoffice, Status = SaasFeatureStatus.Active, SortOrder = index * 10 })); var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Code == integrationOfferingCode); if (offering is null) { offering = new SaasOffering { Code = integrationOfferingCode, Name = "Integration Full Access", Type = SaasOfferingType.BasePlan, Status = SaasOfferingStatus.Active }; dbContext.SaasOfferings.Add(offering); } var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.OfferingId == offering.Id && value.Version == 1); if (version is null) { version = new SaasOfferingVersion { OfferingId = offering.Id, Version = 1, Status = SaasOfferingVersionStatus.Draft, OriginalAmountCents = 100, AmountCents = 100, EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1) }; dbContext.SaasOfferingVersions.Add(version); } await dbContext.SaveChangesAsync(); var entitledFeatures = await dbContext.SaasOfferingVersionFeatures .Where(entitlement => entitlement.OfferingVersionId == version.Id) .Select(entitlement => entitlement.FeatureCode) .ToArrayAsync(); dbContext.SaasOfferingVersionFeatures.AddRange(SaasFeatureCatalog.All .Where(code => code != SaasFeatureCatalog.CoreBackoffice) .Except(entitledFeatures, StringComparer.Ordinal) .Select(code => new SaasOfferingVersionFeature { OfferingVersionId = version.Id, FeatureCode = code })); await dbContext.SaveChangesAsync(); if (version.Status == SaasOfferingVersionStatus.Draft) { version.Status = SaasOfferingVersionStatus.Published; version.PublishedAt = DateTimeOffset.UtcNow.AddDays(-1); await dbContext.SaveChangesAsync(); } var now = DateTimeOffset.UtcNow; var subscriptionGraphs = tenants.SelectMany(tenant => { var subscription = new TenantSaasSubscription { TenantId = tenant.Id, BaseOfferingVersionId = version.Id, Status = TenantSaasSubscriptionStatus.Active, StartsAt = now.AddDays(-1), CurrentPeriodStart = now.AddDays(-1), CurrentPeriodEnd = now.AddYears(1) }; return new object[] { subscription, new TenantSaasSubscriptionItem { TenantId = tenant.Id, SubscriptionId = subscription.Id, OfferingVersionId = version.Id, ItemType = TenantSaasSubscriptionItemType.BasePlan, Status = TenantSaasSubscriptionItemStatus.Active, StartsAt = subscription.CurrentPeriodStart, EndsAt = subscription.CurrentPeriodEnd } }; }); entities = entities.Concat(subscriptionGraphs).ToArray(); } var explicitPermissions = entities.OfType().ToArray(); if (explicitPermissions.Length > 0) { var moduleCodes = explicitPermissions.Select(value => value.PermissionModuleCode) .Distinct(StringComparer.Ordinal) .ToArray(); var existingModuleCodes = await dbContext.PermissionModules .Where(value => moduleCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(); var suppliedModuleCodes = entities.OfType().Select(value => value.Code).ToArray(); var modules = moduleCodes .Except(existingModuleCodes, StringComparer.Ordinal) .Except(suppliedModuleCodes, StringComparer.Ordinal) .Select(code => new PermissionModule { Code = code, Name = code, Area = explicitPermissions.First(value => value.PermissionModuleCode == code).Area, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures.GetValueOrDefault(code) }) .ToArray(); var requiredFeatureCodes = modules.Select(value => value.RequiredFeatureCode) .Where(value => value is not null) .Cast() .Distinct(StringComparer.Ordinal) .ToArray(); var existingRequiredFeatures = await dbContext.SaasFeatures .Where(value => requiredFeatureCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(); var suppliedFeatureCodes = entities.OfType().Select(value => value.Code).ToArray(); dbContext.SaasFeatures.AddRange(requiredFeatureCodes .Except(existingRequiredFeatures, StringComparer.Ordinal) .Except(suppliedFeatureCodes, StringComparer.Ordinal) .Select(code => new SaasFeature { Code = code, Name = code, Category = code.Split('.')[0], Status = SaasFeatureStatus.Active })); dbContext.PermissionModules.AddRange(modules); await dbContext.SaveChangesAsync(); } var offeringVersionsToFinalize = entities.OfType() .Where(value => value.Status != SaasOfferingVersionStatus.Draft) .Select(value => new { Version = value, TargetStatus = value.Status }) .ToArray(); foreach (var item in offeringVersionsToFinalize) { item.Version.Status = SaasOfferingVersionStatus.Draft; } dbContext.AddRange(entities); await dbContext.SaveChangesAsync(); foreach (var item in offeringVersionsToFinalize) { item.Version.Status = item.TargetStatus; } 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); } } public async Task SeedBuiltinBackofficeCatalogAsync() { using var scope = Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .InitializeSystem(null, "Integration test built-in backoffice catalog seeding"); var dbContext = scope.ServiceProvider.GetRequiredService(); await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync(); } private static async Task EnsureTenantBackendAccessAsync( TikuDbContext dbContext, IEnumerable<(Guid TenantId, Guid UserId)> members) { var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray(); var featureCodes = PermissionModuleCatalog.RequiredFeatures.Values .Where(value => value is not null) .Cast() .Append(SaasFeatureCatalog.CoreBackoffice) .Distinct(StringComparer.Ordinal) .ToArray(); var existingFeatureCodes = await dbContext.SaasFeatures .Where(value => featureCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(); dbContext.SaasFeatures.AddRange(featureCodes.Except(existingFeatureCodes, StringComparer.Ordinal).Select(code => new SaasFeature { Code = code, Name = code, Category = code.Split('.')[0], IsCore = code == SaasFeatureCatalog.CoreBackoffice, Status = SaasFeatureStatus.Active })); var moduleCodes = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode).Distinct(StringComparer.Ordinal).ToArray(); var existingModuleCodes = await dbContext.PermissionModules.Where(value => moduleCodes.Contains(value.Code)).Select(value => value.Code).ToArrayAsync(); dbContext.PermissionModules.AddRange(moduleCodes.Except(existingModuleCodes, StringComparer.Ordinal).Select(code => new PermissionModule { Code = code, Name = code, Area = BackendPermissionArea.Tenant, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] })); await dbContext.SaveChangesAsync(); 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, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode), 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(); } } }