544 lines
22 KiB
C#
544 lines
22 KiB
C#
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<string, string?>? configurationOverrides = null,
|
|
DbCommandInterceptor? dbCommandInterceptor = null) : WebApplicationFactory<ApiProgramMarker>
|
|
{
|
|
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<string, string?>
|
|
{
|
|
["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<TikuDbContext>) ||
|
|
descriptor.ServiceType == typeof(TikuDbContext))
|
|
.ToArray())
|
|
{
|
|
services.Remove(descriptor);
|
|
}
|
|
|
|
services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString));
|
|
services.AddScoped<TenantIsolationSaveChangesInterceptor>();
|
|
services.AddDbContext<TikuDbContext>((serviceProvider, options) =>
|
|
{
|
|
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
|
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>();
|
|
|
|
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<IPlatformBillingPaymentGateway>();
|
|
services.AddSingleton(platformBillingPaymentGateway);
|
|
}
|
|
|
|
if (domainOwnershipVerifier is not null)
|
|
{
|
|
services.RemoveAll<IDomainOwnershipVerifier>();
|
|
services.AddSingleton(domainOwnershipVerifier);
|
|
}
|
|
|
|
if (domainGatewayProvisioner is not null)
|
|
{
|
|
services.RemoveAll<IDomainGatewayProvisioner>();
|
|
services.AddSingleton(domainGatewayProvisioner);
|
|
}
|
|
|
|
if (smsProvider is not null)
|
|
{
|
|
services.RemoveAll<ISmsProvider>();
|
|
services.AddSingleton(smsProvider);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task SeedAsync(params object[] entities)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
|
.InitializeSystem(null, "Integration test fixture seeding");
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var tenants = entities.OfType<Tenant>().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<BackendPermission>().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<PermissionModule>().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<string>()
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
var existingRequiredFeatures = await dbContext.SaasFeatures
|
|
.Where(value => requiredFeatureCodes.Contains(value.Code))
|
|
.Select(value => value.Code)
|
|
.ToArrayAsync();
|
|
var suppliedFeatureCodes = entities.OfType<SaasFeature>().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<SaasOfferingVersion>()
|
|
.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<TenantMembership>()
|
|
.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<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)
|
|
{
|
|
var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray();
|
|
var featureCodes = PermissionModuleCatalog.RequiredFeatures.Values
|
|
.Where(value => value is not null)
|
|
.Cast<string>()
|
|
.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<ITenantContextInitializer>()
|
|
.InitializeSystem(null, reason);
|
|
return scope;
|
|
}
|
|
|
|
public IServiceScope CreateTenantScope(Guid tenantId, string? tenantCode = null)
|
|
{
|
|
var scope = Services.CreateScope();
|
|
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
|
.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<ITenantContextInitializer>()
|
|
.InitializeSystem(question.TenantId, "Integration test question version linking");
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
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<TenantQuestionReference> 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<Guid> 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<object>
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
}
|