feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -6,6 +6,7 @@ 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;
@@ -29,6 +30,7 @@ public sealed class ApiTestFactory(
IObjectStorageService? objectStorageService = null,
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null,
IPlatformBillingPaymentGateway? platformBillingPaymentGateway = null,
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
ISmsProvider? smsProvider = null,
@@ -110,6 +112,12 @@ public sealed class ApiTestFactory(
services.AddSingleton(paymentProviderGateway);
}
if (platformBillingPaymentGateway is not null)
{
services.RemoveAll<IPlatformBillingPaymentGateway>();
services.AddSingleton(platformBillingPaymentGateway);
}
if (domainOwnershipVerifier is not null)
{
services.RemoveAll<IDomainOwnershipVerifier>();
@@ -138,54 +146,160 @@ public sealed class ApiTestFactory(
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 PlatformSaasPlan or ProductModule or PlanModuleEntitlement or TenantSubscription);
entity is SaasOffering or SaasOfferingVersion or TenantSaasSubscription);
if (tenants.Length > 0 && !hasExplicitCapabilitySetup)
{
const string integrationPlanCode = "integration-full-access";
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == integrationPlanCode))
{
dbContext.PlatformSaasPlans.Add(new PlatformSaasPlan
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 = integrationPlanCode,
Name = "Integration Full Access"
});
}
var existingModules = await dbContext.ProductModules
.Select(module => module.Code)
.ToArrayAsync();
foreach (var module in ProductModuleCatalog.All.Where(module => !existingModules.Contains(module.Key)))
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)
{
dbContext.ProductModules.Add(new ProductModule { Code = module.Key, Name = module.Value });
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 entitledModules = await dbContext.PlanModuleEntitlements
.Where(entitlement => entitlement.PlanCode == integrationPlanCode)
.Select(entitlement => entitlement.ModuleCode)
var entitledFeatures = await dbContext.SaasOfferingVersionFeatures
.Where(entitlement => entitlement.OfferingVersionId == version.Id)
.Select(entitlement => entitlement.FeatureCode)
.ToArrayAsync();
foreach (var moduleCode in ProductModuleCatalog.All.Keys.Except(entitledModules, StringComparer.Ordinal))
{
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
dbContext.SaasOfferingVersionFeatures.AddRange(SaasFeatureCatalog.All
.Where(code => code != SaasFeatureCatalog.CoreBackoffice)
.Except(entitledFeatures, StringComparer.Ordinal)
.Select(code => new SaasOfferingVersionFeature
{
PlanCode = integrationPlanCode,
ModuleCode = moduleCode
});
}
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;
entities = entities.Concat(tenants.Select(tenant => new TenantSubscription
var subscriptionGraphs = tenants.SelectMany(tenant =>
{
TenantId = tenant.Id,
PlanCode = integrationPlanCode,
Status = TenantSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
ExpiresAt = now.AddYears(1)
})).ToArray();
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>()
@@ -206,6 +320,34 @@ public sealed class ApiTestFactory(
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)
@@ -217,7 +359,7 @@ public sealed class ApiTestFactory(
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
Module = permissionCode.Split(':')[1],
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
IsSystem = true
});
}

View File

@@ -1,12 +1,15 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
@@ -125,6 +128,54 @@ public sealed class AssetManagementEndpointTests
Assert.Equal(seed.UserId, asset.VerifiedBy);
Assert.Equal(2048, asset.VerifiedSizeBytes);
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
Assert.False(dbContext.TenantFeatureUsages.Any(value =>
value.TenantId == seed.TenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes));
}
[Fact]
public async Task Verified_upload_consumes_storage_quota_and_archive_releases_it()
{
var storage = new FakeObjectStorageService { MetadataSizeBytes = 6 };
await using var factory = new ApiTestFactory(objectStorageService: storage);
var seed = await SeedAdminWithStorageQuotaAsync(factory, 10);
var firstAssetId = Guid.NewGuid();
var secondAssetId = Guid.NewGuid();
await factory.SeedAsync(
PendingAsset(firstAssetId, seed.TenantId, "first.pdf"),
PendingAsset(secondAssetId, seed.TenantId, "second.pdf"));
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var firstConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = firstAssetId });
Assert.Equal(HttpStatusCode.OK, firstConfirm.StatusCode);
Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId));
storage.MetadataSizeBytes = 5;
using var exhausted = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.Conflict, exhausted.StatusCode);
var exhaustedBody = await ReadJsonAsync(exhausted);
Assert.Equal("feature_quota_exhausted", exhaustedBody.RootElement.GetProperty("code").GetString());
Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId));
using var archive = await client.DeleteAsync($"/api/tenant-content/assets/{firstAssetId}");
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
Assert.Equal(0, await StorageUsageAsync(factory, seed.TenantId));
using var secondConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.OK, secondConfirm.StatusCode);
Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId));
using var repeatedConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode);
Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId));
}
[Fact]
@@ -281,6 +332,140 @@ public sealed class AssetManagementEndpointTests
return (tenantId, userId, phone);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminWithStorageQuotaAsync(
ApiTestFactory factory,
long storageLimit)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000001";
var offeringId = Guid.NewGuid();
var versionId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Storage Quota Tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
new User
{
Id = userId,
Phone = phone,
Name = "Storage Quota Admin"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new SaasFeature
{
Code = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Private question bank",
Category = "content",
Status = SaasFeatureStatus.Active
},
new SaasOffering
{
Id = offeringId,
Code = $"storage-quota-{tenantId:N}",
Name = "Storage Quota Plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
},
new SaasOfferingVersion
{
Id = versionId,
OfferingId = offeringId,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = 100,
AmountCents = 100,
EffectiveAt = now.AddDays(-1)
},
new SaasOfferingVersionFeature
{
OfferingVersionId = versionId,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank
},
new SaasFeatureLimitDefinition
{
MetricCode = SaasQuotaMetricCatalog.StorageBytes,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Storage bytes",
Unit = "byte",
Kind = SaasFeatureLimitKind.Current,
WarningPercent = 80,
IsHardLimit = true
},
new SaasOfferingVersionLimit
{
OfferingVersionId = versionId,
MetricCode = SaasQuotaMetricCatalog.StorageBytes,
LimitValue = storageLimit
},
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = versionId,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = versionId,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
});
using var scope = factory.CreateSystemScope("Publish storage quota fixture");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await dbContext.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = now;
await dbContext.SaveChangesAsync();
return (tenantId, userId, phone);
}
private static ContentAsset PendingAsset(Guid id, Guid tenantId, string fileName) => new()
{
Id = id,
TenantId = tenantId,
Title = fileName,
FileName = fileName,
StorageProvider = AssetStorageProvider.AliyunOss,
Bucket = "tenant-assets",
ObjectKey = $"{tenantId:N}/assets/{fileName}",
MimeType = "application/pdf",
AssetType = ContentAssetType.Pdf,
UploadStatus = AssetUploadStatus.Pending,
SecurityScanStatus = AssetSecurityScanStatus.Pending
};
private static async Task<long> StorageUsageAsync(ApiTestFactory factory, Guid tenantId)
{
using var scope = factory.CreateSystemScope("Read storage quota usage");
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>().TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes)
.Select(value => value.UsedValue)
.SingleOrDefaultAsync();
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
@@ -296,7 +481,7 @@ public sealed class AssetManagementEndpointTests
private sealed class FakeObjectStorageService : IObjectStorageService
{
public long? MetadataSizeBytes { get; init; }
public long? MetadataSizeBytes { get; set; }
public string? MetadataChecksumSha256 { get; init; }

View File

@@ -167,7 +167,7 @@ public sealed class AuthSessionLifecycleTests
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
Module = "test"
PermissionModuleCode = "tenant_dashboard"
},
role,
new TenantBackendRolePermission

View File

@@ -16,8 +16,8 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
private const int ExpectedActionCount = 332;
private const string ExpectedSha256 = "a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f";
private const int ExpectedActionCount = 358;
private const string ExpectedSha256 = "5a26d6f0817b2939eef921d1883ad0dc8f82405a1593b82df951e97654a0225d";
[Fact]
public void Controller_authorization_surface_matches_reviewed_manifest()
@@ -61,6 +61,13 @@ public sealed class AuthorizationManifestTests
Assert.NotNull(metadata);
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
Assert.Contains(metadata.Realm, new[] { "authenticated", "tenant", "platform" });
if (metadata.Realm == "tenant" &&
metadata.Module is { } module &&
PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var requiredFeature) &&
requiredFeature is not null)
{
Assert.Contains(requiredFeature, metadata.RequiredFeatures);
}
}
}
@@ -88,6 +95,37 @@ public sealed class AuthorizationManifestTests
}
}
[Theory]
[InlineData("questions", SaasFeatureCatalog.PrivateQuestionBank)]
[InlineData("vocabulary", SaasFeatureCatalog.Vocabulary)]
[InlineData("handbook", SaasFeatureCatalog.Handbook)]
[InlineData("scoreline", SaasFeatureCatalog.Scoreline)]
[InlineData("videos", SaasFeatureCatalog.Video)]
public void Content_import_route_maps_to_an_explicit_feature(string importType, string expectedFeature)
{
Assert.Equal(expectedFeature, SaasFeatureCatalog.ResolveContentImportFeature(importType));
}
[Fact]
public void Tenant_content_permissions_are_split_by_purchasable_feature()
{
var expected = new Dictionary<string, string>(StringComparer.Ordinal)
{
[BackendPermissions.TenantContentManage] = SaasFeatureCatalog.PrivateQuestionBank,
[BackendPermissions.TenantVocabularyManage] = SaasFeatureCatalog.Vocabulary,
[BackendPermissions.TenantHandbookManage] = SaasFeatureCatalog.Handbook,
[BackendPermissions.TenantVideoManage] = SaasFeatureCatalog.Video,
[BackendPermissions.TenantScorelineManage] = SaasFeatureCatalog.Scoreline,
[BackendPermissions.TenantSiteContentManage] = SaasFeatureCatalog.SiteContent
};
foreach (var pair in expected)
{
var module = PermissionModuleCatalog.ResolvePermissionModuleCode(pair.Key);
Assert.Equal(pair.Value, PermissionModuleCatalog.RequiredFeatures[module]);
}
}
private static string Describe(Type controller, MethodInfo action)
{
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;

View File

@@ -47,7 +47,7 @@ public sealed class BackofficeUiBootstrapTests
Code = BackendPermissions.TenantDashboardView,
Name = "Tenant dashboard",
Area = BackendPermissionArea.Tenant,
Module = "tenant_dashboard",
PermissionModuleCode = "tenant_dashboard",
IsSystem = true
},
new TenantBackendRole

View File

@@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
@@ -14,81 +13,182 @@ namespace Tiku.IntegrationTests.Api;
public sealed class CapabilityAuthorizationTests
{
[Fact]
public async Task Background_job_rechecks_capability_after_enqueue_before_execution()
public async Task Background_job_rechecks_feature_access_after_enqueue_before_execution()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Job Capability Tenant" },
new PlatformSaasPlan { Code = "job-capability-test", Name = "Job Capability Test" },
new PlanModuleEntitlement { PlanCode = "job-capability-test", ModuleCode = "content" },
new TenantSubscription
{
TenantId = tenantId,
PlanCode = "job-capability-test",
Status = TenantSubscriptionStatus.Active,
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
});
var fixture = CreateSubscriptionFixture(tenantId, SaasFeatureCatalog.PrivateQuestionBank, includeEntitlement: true);
await factory.SeedAsync(fixture.Entities);
using var scope = factory.CreateSystemScope("Verify job capability at consumption");
using var scope = factory.CreateSystemScope("Verify job feature access at consumption");
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"content_export",
JsonSerializer.SerializeToElement(new { exportType = "capability-test" })));
JsonSerializer.SerializeToElement(new { exportType = "feature-test" })));
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.TenantModuleOverrides.Add(new TenantModuleOverride
dbContext.TenantFeatureOverrides.Add(new TenantFeatureOverride
{
TenantId = tenantId,
ModuleCode = "content",
Mode = TenantModuleOverrideMode.Disabled,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
Mode = TenantFeatureOverrideMode.Disabled,
Reason = "Integration test revocation"
});
await dbContext.SaveChangesAsync();
Assert.True(await jobs.ProcessRequestedAsync(
job.Id, tenantId, job.JobType, "capability-test-worker"));
job.Id, tenantId, job.JobType, "feature-test-worker"));
var stored = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
Assert.Equal(BackgroundJobStatus.Failed, stored.Status);
Assert.Equal("Tenant capability was revoked before job execution.", stored.LastError);
Assert.Equal("Tenant feature entitlement was revoked before job execution.", stored.LastError);
}
[Fact]
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
public async Task Feature_access_is_database_backed_supports_overrides_and_makes_past_due_read_only()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
new TenantSubscription
{
TenantId = tenantId,
PlanCode = "capability-test",
Status = TenantSubscriptionStatus.Active,
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
});
var fixture = CreateSubscriptionFixture(tenantId, SaasFeatureCatalog.PrivateQuestionBank, includeEntitlement: true);
await factory.SeedAsync(fixture.Entities);
using var scope = factory.CreateSystemScope("Verify capability authorization");
var evaluator = scope.ServiceProvider.GetRequiredService<ICapabilityAccessEvaluator>();
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
await factory.SeedAsync(new SaasFeature
{
Code = SaasFeatureCatalog.Video,
Name = "Video",
Category = "content",
Status = SaasFeatureStatus.Active
});
using var scope = factory.CreateSystemScope("Verify feature authorization");
var access = scope.ServiceProvider.GetRequiredService<IFeatureAccessService>();
var missing = await access.EvaluateAsync(
tenantId,
SaasFeatureCatalog.Video,
FeatureAccessOperation.Read);
Assert.False(missing.Allowed);
Assert.Equal("feature_not_purchased", missing.DenialCode);
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
{
PlanCode = "capability-test",
ModuleCode = "content"
});
await dbContext.SaveChangesAsync();
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
Assert.True((await access.EvaluateAsync(
tenantId,
SaasFeatureCatalog.PrivateQuestionBank,
FeatureAccessOperation.Write)).Allowed);
var subscription = dbContext.TenantSubscriptions.Single(item => item.TenantId == tenantId);
subscription.Status = TenantSubscriptionStatus.PastDue;
var featureOverride = new TenantFeatureOverride
{
TenantId = tenantId,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
Mode = TenantFeatureOverrideMode.Disabled,
Reason = "Integration test disable"
};
dbContext.TenantFeatureOverrides.Add(featureOverride);
await dbContext.SaveChangesAsync();
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
var disabled = await access.EvaluateAsync(
tenantId,
SaasFeatureCatalog.PrivateQuestionBank,
FeatureAccessOperation.Read);
Assert.False(disabled.Allowed);
Assert.Equal("feature_disabled", disabled.DenialCode);
featureOverride.Mode = TenantFeatureOverrideMode.Enabled;
await dbContext.SaveChangesAsync();
Assert.True((await access.EvaluateAsync(
tenantId,
SaasFeatureCatalog.PrivateQuestionBank,
FeatureAccessOperation.Write)).Allowed);
var subscription = await dbContext.TenantSaasSubscriptions.SingleAsync(item => item.Id == fixture.SubscriptionId);
subscription.Status = TenantSaasSubscriptionStatus.PastDue;
await dbContext.SaveChangesAsync();
Assert.True((await access.EvaluateAsync(
tenantId,
SaasFeatureCatalog.PrivateQuestionBank,
FeatureAccessOperation.Read)).Allowed);
var pastDueWrite = await access.EvaluateAsync(
tenantId,
SaasFeatureCatalog.PrivateQuestionBank,
FeatureAccessOperation.Write);
Assert.False(pastDueWrite.Allowed);
Assert.Equal("subscription_read_only", pastDueWrite.DenialCode);
}
private static SubscriptionFixture CreateSubscriptionFixture(
Guid tenantId,
string featureCode,
bool includeEntitlement)
{
var offeringId = Guid.NewGuid();
var versionId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var entities = new List<object>
{
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Feature Access Tenant"
},
new SaasFeature
{
Code = featureCode,
Name = "Private Question Bank",
Category = "content",
Status = SaasFeatureStatus.Active
},
new SaasOffering
{
Id = offeringId,
Code = $"feature-test-{tenantId:N}",
Name = "Feature Access Test",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
},
new SaasOfferingVersion
{
Id = versionId,
OfferingId = offeringId,
Version = 1,
Status = SaasOfferingVersionStatus.Published,
PublishedAt = now.AddDays(-1),
EffectiveAt = now.AddDays(-1)
},
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = versionId,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddDays(30)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = versionId,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddDays(30)
}
};
if (includeEntitlement)
{
entities.Add(new SaasOfferingVersionFeature
{
OfferingVersionId = versionId,
FeatureCode = featureCode
});
}
return new SubscriptionFixture(versionId, subscriptionId, entities.ToArray());
}
private sealed record SubscriptionFixture(
Guid VersionId,
Guid SubscriptionId,
object[] Entities);
}

View File

@@ -0,0 +1,335 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class CurrentQuotaEnforcementTests
{
[Fact]
public async Task Reconciliation_uses_audited_tenant_scope_and_idempotently_records_actual_values_above_limits()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLimitedTenantAsync(factory);
var teacherId = Guid.NewGuid();
var studentA = Guid.NewGuid();
var studentB = Guid.NewGuid();
await factory.SeedAsync(
new User { Id = teacherId, Phone = "13940000001", Name = "Reconcile Teacher" },
new User { Id = studentA, Phone = "13940000002", Name = "Reconcile Student A" },
new User { Id = studentB, Phone = "13940000003", Name = "Reconcile Student B" },
new TenantMembership { TenantId = seed.TenantId, UserId = teacherId, Role = TenantRole.Teacher, Status = MembershipStatus.Active },
new TenantMembership { TenantId = seed.TenantId, UserId = studentA, Role = TenantRole.Student, Status = MembershipStatus.Active },
new TenantMembership { TenantId = seed.TenantId, UserId = studentB, Role = TenantRole.Student, Status = MembershipStatus.Active },
new Question { TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published },
new Question { TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Draft },
new ContentAsset
{
TenantId = seed.TenantId,
AssetKey = $"reconcile-{Guid.NewGuid():N}",
Status = ContentStatus.Active,
VerifiedSizeBytes = 2
});
using var scope = factory.CreateSystemScope("Run feature usage reconciliation test");
var service = scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>();
var request = new ReconcileFeatureUsageRequest(
seed.TenantId,
SystemScopeCallerType.Test,
nameof(CurrentQuotaEnforcementTests),
"Verify current feature usage reconciliation",
$"quota-reconcile-{Guid.NewGuid():N}");
var first = await service.ReconcileTenantAsync(request);
var firstVersions = await ReadUsageVersionsAsync(factory, seed.TenantId);
var second = await service.ReconcileTenantAsync(request with { CorrelationId = $"{request.CorrelationId}-again" });
var secondVersions = await ReadUsageVersionsAsync(factory, seed.TenantId);
Assert.Equal(4, first.Count);
Assert.All(first, item => Assert.True(item.Exceeded));
Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.StaffCount).ActualValue);
Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.StudentCount).ActualValue);
Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount).ActualValue);
Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.StorageBytes).ActualValue);
Assert.Equal(first, second);
Assert.Equal(firstVersions.Count, secondVersions.Count);
Assert.All(firstVersions, item => Assert.Equal(item.Value, secondVersions[item.Key]));
using var verificationScope = factory.CreateSystemScope("Verify reconciliation audit");
Assert.True(await verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>().AuditLogs.AnyAsync(item =>
item.TenantId == seed.TenantId &&
item.Action == "system_scope.completed" &&
item.TargetId == request.CorrelationId));
}
[Fact]
public async Task Private_question_quota_is_atomic_and_archiving_releases_capacity()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLimitedTenantAsync(factory);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
Task<HttpResponseMessage> CreateAsync(string content) => client.PostAsJsonAsync(
"/api/tenant-content/questions",
new DirectQuestionWriteDto
{
Type = "choice",
Content = content,
Status = "Published"
});
var responses = await Task.WhenAll(CreateAsync("quota question A"), CreateAsync("quota question B"));
Assert.Equal(1, responses.Count(response => response.StatusCode == HttpStatusCode.OK));
Assert.Equal(1, responses.Count(response => response.StatusCode == HttpStatusCode.Conflict));
var created = responses.Single(response => response.StatusCode == HttpStatusCode.OK);
var createdJson = await JsonDocument.ParseAsync(await created.Content.ReadAsStreamAsync());
var questionId = createdJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
var archive = await client.PatchAsJsonAsync(
"/api/tenant-content/questions",
new DirectQuestionWriteDto
{
QuestionId = questionId,
Type = "choice",
Content = "archived question",
Status = "Archived"
});
var replacement = await CreateAsync("replacement question");
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
Assert.Equal(HttpStatusCode.OK, replacement.StatusCode);
using var scope = factory.CreateSystemScope("Verify current question quota");
var usage = await scope.ServiceProvider.GetRequiredService<TikuDbContext>()
.TenantFeatureUsages.AsNoTracking()
.SingleAsync(item => item.TenantId == seed.TenantId && item.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount);
Assert.Equal(1, usage.UsedValue);
}
[Fact]
public async Task Staff_and_student_status_transitions_consume_and_release_current_quotas()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLimitedTenantAsync(factory);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
var firstTeacher = await client.PutAsJsonAsync(
"/api/tenant-admin/members",
Member("teacher", "13910000001", "Teacher One"));
var secondTeacher = await client.PutAsJsonAsync(
"/api/tenant-admin/members",
Member("teacher", "13910000002", "Teacher Two"));
Assert.Equal(HttpStatusCode.OK, firstTeacher.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, secondTeacher.StatusCode);
var firstStudent = await client.PutAsJsonAsync(
"/api/tenant-admin/students",
Student("13920000001", "Student One"));
var blockedStudent = await client.PutAsJsonAsync(
"/api/tenant-admin/students",
Student("13920000002", "Student Two"));
Assert.Equal(HttpStatusCode.OK, firstStudent.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, blockedStudent.StatusCode);
var studentJson = await JsonDocument.ParseAsync(await firstStudent.Content.ReadAsStreamAsync());
var studentId = studentJson.RootElement.GetProperty("item").GetProperty("userId").GetGuid();
var disableStudent = await client.PostAsJsonAsync(
"/api/tenant-admin/students/status",
new UpdateTenantAdminStudentStatusDto
{
UserId = studentId,
Status = "Disabled",
Reason = "quota release test"
});
var replacementStudent = await client.PutAsJsonAsync(
"/api/tenant-admin/students",
Student("13920000002", "Student Two"));
Assert.Equal(HttpStatusCode.OK, disableStudent.StatusCode);
Assert.Equal(HttpStatusCode.OK, replacementStudent.StatusCode);
using var scope = factory.CreateSystemScope("Verify current membership quotas");
var usages = await scope.ServiceProvider.GetRequiredService<TikuDbContext>()
.TenantFeatureUsages.AsNoTracking()
.Where(item => item.TenantId == seed.TenantId)
.ToDictionaryAsync(item => item.MetricCode, item => item.UsedValue);
Assert.Equal(1, usages[SaasQuotaMetricCatalog.StaffCount]);
Assert.Equal(1, usages[SaasQuotaMetricCatalog.StudentCount]);
}
[Fact]
public async Task Missing_current_quota_does_not_block_existing_creation_flows()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13730000001";
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Unlimited Tenant" },
new User { Id = userId, Phone = phone, Name = "Unlimited Admin" }.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
var teacher = await client.PutAsJsonAsync(
"/api/tenant-admin/members",
Member("teacher", "13930000001", "Unlimited Teacher"));
var student = await client.PutAsJsonAsync(
"/api/tenant-admin/students",
Student("13930000002", "Unlimited Student"));
var question = await client.PostAsJsonAsync(
"/api/tenant-content/questions",
new DirectQuestionWriteDto { Type = "choice", Content = "unlimited question", Status = "Published" });
Assert.Equal(HttpStatusCode.OK, teacher.StatusCode);
Assert.Equal(HttpStatusCode.OK, student.StatusCode);
Assert.Equal(HttpStatusCode.OK, question.StatusCode);
}
private static UpsertTenantAdminMemberDto Member(string role, string phone, string name) => new()
{
Role = role,
Status = "Active",
User = new TenantAdminUserLookupDto { Phone = phone, Name = name }
};
private static UpsertTenantAdminStudentDto Student(string phone, string name) => new()
{
User = new TenantAdminUserLookupDto { Phone = phone, Name = name }
};
private static async Task<(Guid TenantId, string Phone)> SeedLimitedTenantAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var adminId = Guid.NewGuid();
var offeringId = Guid.NewGuid();
var versionId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var phone = $"136{Random.Shared.Next(10_000_000, 99_999_999)}";
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Current Quota Tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
new User { Id = adminId, Phone = phone, Name = "Quota Admin" }.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = adminId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
Feature(SaasFeatureCatalog.CoreBackoffice, isCore: true),
Feature(SaasFeatureCatalog.PrivateQuestionBank),
Feature(SaasFeatureCatalog.StudentManagement),
new SaasOffering
{
Id = offeringId,
Code = $"current-quota-{tenantId:N}",
Name = "Current Quota Plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
},
new SaasOfferingVersion
{
Id = versionId,
OfferingId = offeringId,
Version = 1,
Status = SaasOfferingVersionStatus.Published,
PublishedAt = now.AddDays(-1),
EffectiveAt = now.AddDays(-1)
},
Entitlement(versionId, SaasFeatureCatalog.PrivateQuestionBank),
Entitlement(versionId, SaasFeatureCatalog.StudentManagement),
LimitDefinition(SaasQuotaMetricCatalog.StaffCount, SaasFeatureCatalog.CoreBackoffice),
LimitDefinition(SaasQuotaMetricCatalog.StudentCount, SaasFeatureCatalog.StudentManagement),
LimitDefinition(SaasQuotaMetricCatalog.PrivateQuestionCount, SaasFeatureCatalog.PrivateQuestionBank),
LimitDefinition(SaasQuotaMetricCatalog.StorageBytes, SaasFeatureCatalog.PrivateQuestionBank),
Limit(versionId, SaasQuotaMetricCatalog.StaffCount),
Limit(versionId, SaasQuotaMetricCatalog.StudentCount),
Limit(versionId, SaasQuotaMetricCatalog.PrivateQuestionCount),
Limit(versionId, SaasQuotaMetricCatalog.StorageBytes),
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = versionId,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = versionId,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
});
return (tenantId, phone);
}
private static SaasFeature Feature(string code, bool isCore = false) => new()
{
Code = code,
Name = code,
Category = "integration",
Status = SaasFeatureStatus.Active,
IsCore = isCore
};
private static SaasOfferingVersionFeature Entitlement(Guid versionId, string featureCode) => new()
{
OfferingVersionId = versionId,
FeatureCode = featureCode
};
private static SaasFeatureLimitDefinition LimitDefinition(string metricCode, string featureCode) => new()
{
MetricCode = metricCode,
FeatureCode = featureCode,
Name = metricCode,
Unit = "count",
Kind = SaasFeatureLimitKind.Current,
WarningPercent = 80,
IsHardLimit = true
};
private static SaasOfferingVersionLimit Limit(Guid versionId, string metricCode) => new()
{
OfferingVersionId = versionId,
MetricCode = metricCode,
LimitValue = 1
};
private static async Task<Dictionary<string, long>> ReadUsageVersionsAsync(ApiTestFactory factory, Guid tenantId)
{
using var scope = factory.CreateSystemScope("Read reconciled feature usage versions");
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>().TenantFeatureUsages.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.ToDictionaryAsync(item => item.MetricCode, item => item.Version);
}
}

View File

@@ -41,14 +41,14 @@ public sealed class DatabasePermissionServiceAuthorizationTests
Code = BackendPermissions.TenantCommissionManage,
Name = "Commission",
Area = BackendPermissionArea.Tenant,
Module = "commission"
PermissionModuleCode = "tenant_commission"
},
new BackendPermission
{
Code = BackendPermissions.TenantCrmManage,
Name = "CRM",
Area = BackendPermissionArea.Tenant,
Module = "crm"
PermissionModuleCode = "tenant_crm"
},
new TenantBackendRole
{

View File

@@ -19,7 +19,7 @@ namespace Tiku.IntegrationTests.Api;
public sealed class PlatformAdminEndpointTests
{
[Fact]
public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit()
public async Task Platform_admin_can_publish_immutable_saas_offering_and_manage_tenant_operations()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
@@ -48,13 +48,6 @@ public sealed class PlatformAdminEndpointTests
VerifiedAt = DateTimeOffset.UtcNow,
DnsVerifiedAt = DateTimeOffset.UtcNow,
TlsReadyAt = DateTimeOffset.UtcNow
},
new PlatformSaasPlan
{
Code = "standard",
Name = "Standard",
BaseAmountCents = 99900,
Status = PlatformSaasPlanStatus.Active
});
using var client = factory.CreateClient();
@@ -62,27 +55,52 @@ public sealed class PlatformAdminEndpointTests
var overview = await client.GetAsync("/api/platform-admin/overview");
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
var planModules = await client.PutAsJsonAsync(
"/api/platform-admin/plans/standard/modules",
new ReplacePlatformPlanModulesDto { ModuleCodes = ["content", "job", "settings"] });
var subscription = await client.PostAsJsonAsync(
"/api/platform-admin/subscriptions",
new UpsertPlatformSubscriptionDto
{
TenantId = tenantId,
PlanCode = "standard",
Status = TenantSubscriptionStatus.Active,
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
AmountCents = 99900
});
var moduleOverride = await client.PutAsJsonAsync(
$"/api/platform-admin/tenants/{tenantId}/module-overrides/content",
new UpsertPlatformTenantModuleOverrideDto
{
Mode = TenantModuleOverrideMode.Disabled,
Reason = "integration test capability revocation"
});
var feature = await client.PutAsJsonAsync(
"/api/platform-admin/saas/features",
new UpsertSaasFeatureDto(
null,
SaasFeatureCatalog.Exam,
"考试",
"learning",
"租户考试模块",
60_000,
"CNY",
SaasFeatureStatus.Active,
30));
var offering = await client.PutAsJsonAsync(
"/api/platform-admin/saas/offerings",
new UpsertSaasOfferingDto(
null,
$"standard-{Guid.NewGuid():N}",
"标准套餐",
SaasOfferingType.BasePlan,
SaasOfferingStatus.Draft,
"集成测试套餐",
10));
Assert.Equal(HttpStatusCode.OK, feature.StatusCode);
Assert.Equal(HttpStatusCode.OK, offering.StatusCode);
var offeringJson = await JsonDocument.ParseAsync(await offering.Content.ReadAsStreamAsync());
var offeringId = offeringJson.RootElement.GetProperty("id").GetGuid();
var versionRequest = new UpsertSaasOfferingVersionDto(
null,
offeringId,
PlatformBillingCycle.Yearly,
72_000,
60_000,
"CNY",
null,
[SaasFeatureCatalog.Exam],
new Dictionary<string, long>(),
JsonDefaults.Object());
var version = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest);
Assert.Equal(HttpStatusCode.OK, version.StatusCode);
var versionJson = await JsonDocument.ParseAsync(await version.Content.ReadAsStreamAsync());
var versionId = versionJson.RootElement.GetProperty("id").GetGuid();
var published = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null);
var immutableUpdate = await client.PutAsJsonAsync(
"/api/platform-admin/saas/offering-versions",
versionRequest with { Id = versionId, AmountCents = 50_000 });
var catalog = await client.GetAsync("/api/platform-admin/saas/catalog");
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
var suspended = await client.PatchAsJsonAsync(
"/api/platform-admin/tenants/status",
@@ -100,9 +118,9 @@ public sealed class PlatformAdminEndpointTests
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
Assert.Equal(HttpStatusCode.OK, planModules.StatusCode);
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode);
Assert.Equal(HttpStatusCode.OK, published.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, immutableUpdate.StatusCode);
Assert.Equal(HttpStatusCode.OK, catalog.StatusCode);
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
@@ -117,10 +135,16 @@ public sealed class PlatformAdminEndpointTests
log.Action == "platform.tenant.status_changed"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.plan.modules.replaced"));
log.Action == "platform.saas.feature.upserted"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.tenant.module_override.updated"));
log.Action == "platform.saas.offering_version.published"));
Assert.Equal(
60_000,
await dbContext.SaasOfferingVersions
.Where(item => item.Id == versionId)
.Select(item => item.AmountCents)
.SingleAsync());
}
[Fact]
@@ -153,7 +177,7 @@ public sealed class PlatformAdminEndpointTests
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
var response = await client.GetAsync("/api/platform-admin/overview");
var response = await client.GetAsync("/api/platform-admin/saas/catalog");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
@@ -178,23 +202,22 @@ public sealed class PlatformAdminEndpointTests
Status = TenantStatus.Active,
BillingStatus = BillingStatus.PastDue
},
new TenantInvoice
new PlatformBillingInvoice
{
Id = invoiceId,
TenantId = tenantId,
InvoiceNo = "INV-DUNNING-1",
Status = TenantInvoiceStatus.Overdue,
TotalCents = 10_000,
BalanceCents = 10_000
Status = PlatformBillingInvoiceStatus.Overdue,
TotalAmountCents = 10_000
},
new TenantInvoiceReminder
new PlatformBillingInvoiceReminder
{
Id = reminderId,
TenantId = tenantId,
InvoiceId = invoiceId,
ReminderType = TenantInvoiceReminderType.Overdue,
Channel = TenantInvoiceReminderChannel.Wechat,
Status = TenantInvoiceReminderStatus.Failed,
ReminderType = PlatformBillingInvoiceReminderType.Overdue,
Channel = PlatformBillingInvoiceReminderChannel.Wechat,
Status = PlatformBillingInvoiceReminderStatus.Failed,
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
BalanceCentsSnapshot = 10_000
});
@@ -202,12 +225,12 @@ public sealed class PlatformAdminEndpointTests
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var upsertResponse = await client.PutAsJsonAsync(
"/api/platform-admin/dunning-notification-channels",
new UpsertPlatformDunningChannelDto
"/api/platform-admin/saas/dunning/channels",
new UpsertPlatformBillingDunningChannelDto
{
ChannelCode = "wecom-overdue",
Name = "企业微信逾期提醒",
Provider = PlatformDunningProvider.Wecom,
Provider = PlatformBillingDunningProvider.Wecom,
WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key",
SecretRef = "platform_secrets:dunning:wecom:default",
ReminderTypes = ["overdue"],
@@ -218,14 +241,14 @@ public sealed class PlatformAdminEndpointTests
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
var channelJson = JsonDocument.Parse(upsertBody);
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
await factory.SeedAsync(new PlatformDunningNotificationEvent
await factory.SeedAsync(new PlatformBillingDunningNotificationEvent
{
TenantId = tenantId,
ChannelId = channelId,
ReminderId = reminderId,
InvoiceId = invoiceId,
Provider = PlatformDunningProvider.Wecom,
Status = PlatformDunningNotificationStatus.Failed,
Provider = PlatformBillingDunningProvider.Wecom,
Status = PlatformBillingDunningNotificationStatus.Failed,
Attempts = 2,
LastError = "timeout",
LastHttpCode = 500,
@@ -233,21 +256,21 @@ public sealed class PlatformAdminEndpointTests
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
});
var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom");
var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed");
var channelsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/channels?search=wecom");
var eventsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/events?status=failed");
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}");
var detailResponse = await client.GetAsync($"/api/platform-admin/saas/dunning/events/detail?eventId={eventId}");
var retryResponse = await client.PostAsJsonAsync(
"/api/platform-admin/dunning-notification-events/retry",
new RetryPlatformDunningEventDto
"/api/platform-admin/saas/dunning/events/retry",
new RetryPlatformBillingDunningEventDto
{
EventId = eventId,
Reason = "manual retry"
});
var disableResponse = await client.PostAsJsonAsync(
"/api/platform-admin/dunning-notification-channels/disable",
new DisablePlatformDunningChannelDto
"/api/platform-admin/saas/dunning/channels/disable",
new DisablePlatformBillingDunningChannelDto
{
ChannelId = channelId,
Reason = "disable test"
@@ -264,17 +287,17 @@ public sealed class PlatformAdminEndpointTests
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status);
var storedEvent = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
Assert.Equal(PlatformBillingDunningNotificationStatus.Pending, storedEvent.Status);
Assert.Null(storedEvent.LastError);
Assert.False(storedChannel.Enabled);
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.dunning_event.retry_requested"));
log.Action == "platform.billing_dunning_event.retry_requested"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.dunning_channel.disabled"));
log.Action == "platform.billing_dunning_channel.disabled"));
}
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
@@ -282,16 +305,29 @@ public sealed class PlatformAdminEndpointTests
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"platform-{Guid.NewGuid():N}@example.test";
var modules = BackendPermissions.Platform
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.Select(code => new PermissionModule
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
})
.Cast<object>()
.ToList();
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
Module = code.Split(':')[1],
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
IsSystem = true
}).Cast<object>().ToList();
await factory.SeedAsync(
[
..modules,
..permissions,
new User
{

View File

@@ -0,0 +1,272 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Domain.Common;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class PlatformBillingCallbackTests
{
[Theory]
[InlineData("wechat_pay", "application/json", "{\"code\":\"SUCCESS\"")]
[InlineData("alipay", "text/plain", "success")]
public async Task Valid_provider_callback_settles_once(
string provider,
string expectedMediaType,
string expectedBody)
{
var fixture = CreateFixture(provider);
var gateway = new FakePlatformBillingPaymentGateway(fixture.Notification);
await using var factory = new ApiTestFactory(platformBillingPaymentGateway: gateway);
await factory.SeedAsync(fixture.Entities);
await PublishVersionAsync(factory, fixture.VersionId);
using var client = factory.CreateClient();
var first = await client.PostAsJsonAsync($"/api/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId });
var repeated = await client.PostAsJsonAsync($"/api/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId });
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeated.StatusCode);
Assert.Equal(expectedMediaType, first.Content.Headers.ContentType?.MediaType);
Assert.Contains(expectedBody, await first.Content.ReadAsStringAsync(), StringComparison.Ordinal);
Assert.All(gateway.ParsedProviders, value => Assert.Equal(provider, value));
using var scope = factory.CreateSystemScope("Verify platform payment callback idempotency");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(PlatformBillingPaymentStatus.Succeeded,
(await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == fixture.PaymentId)).Status);
Assert.Single(await db.PlatformBillingPaymentEvents.AsNoTracking()
.Where(value => value.PaymentId == fixture.PaymentId)
.ToArrayAsync());
Assert.Single(await db.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.OrderId == fixture.OrderId)
.ToArrayAsync());
Assert.Single(await db.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == fixture.TenantId)
.ToArrayAsync());
}
[Fact]
public async Task Invalid_signature_is_rejected_without_settlement()
{
var fixture = CreateFixture(PaymentProviders.WechatPay);
var invalid = fixture.Notification with { SignatureValid = false };
await using var factory = new ApiTestFactory(
platformBillingPaymentGateway: new FakePlatformBillingPaymentGateway(invalid));
await factory.SeedAsync(fixture.Entities);
await PublishVersionAsync(factory, fixture.VersionId);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/platform-billing/callbacks/wechat_pay",
new { eventId = invalid.EventId });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("platform_billing_notification_invalid", await ReadCodeAsync(response));
await AssertPaymentRemainsPendingAsync(factory, fixture.PaymentId);
}
[Fact]
public async Task Callback_amount_mismatch_is_rejected_without_settlement()
{
var fixture = CreateFixture(PaymentProviders.Alipay);
var mismatched = fixture.Notification with { AmountCents = fixture.Notification.AmountCents + 1 };
await using var factory = new ApiTestFactory(
platformBillingPaymentGateway: new FakePlatformBillingPaymentGateway(mismatched));
await factory.SeedAsync(fixture.Entities);
await PublishVersionAsync(factory, fixture.VersionId);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/platform-billing/callbacks/alipay",
new { eventId = mismatched.EventId });
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("platform_billing_payment_amount_mismatch", await ReadCodeAsync(response));
await AssertPaymentRemainsPendingAsync(factory, fixture.PaymentId);
}
private static CallbackFixture CreateFixture(string provider)
{
const int amountCents = 8_800;
var tenantId = Guid.NewGuid();
var feature = new SaasFeature
{
Code = $"callback.feature.{Guid.NewGuid():N}",
Name = "Callback feature",
Category = "integration",
Status = SaasFeatureStatus.Active
};
var offering = new SaasOffering
{
Id = Guid.NewGuid(),
Code = $"callback-offering-{Guid.NewGuid():N}",
Name = "Callback offering",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
var version = new SaasOfferingVersion
{
Id = Guid.NewGuid(),
OfferingId = offering.Id,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = amountCents,
AmountCents = amountCents,
Currency = "CNY",
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1)
};
var order = new PlatformBillingOrder
{
Id = Guid.NewGuid(),
TenantId = tenantId,
QuoteId = Guid.NewGuid(),
OrderNo = $"SO{Guid.NewGuid():N}",
IdempotencyKey = $"order-{Guid.NewGuid():N}",
Purpose = PlatformBillingOrderPurpose.NewSubscription,
Status = PlatformBillingOrderStatus.PendingPayment,
OriginalAmountCents = amountCents,
TotalAmountCents = amountCents,
Currency = "CNY",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Snapshot = JsonDefaults.Object()
};
var quote = new PlatformBillingQuote
{
Id = order.QuoteId,
TenantId = tenantId,
QuoteNo = $"SQ{Guid.NewGuid():N}",
IdempotencyKey = $"quote-{Guid.NewGuid():N}",
Purpose = PlatformBillingOrderPurpose.NewSubscription,
Status = PlatformBillingQuoteStatus.Converted,
OriginalAmountCents = amountCents,
TotalAmountCents = amountCents,
Currency = "CNY",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
FeatureSnapshot = JsonDefaults.Array(),
LimitSnapshot = JsonDefaults.Object()
};
var orderItem = new PlatformBillingOrderItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
OrderId = order.Id,
OfferingVersionId = version.Id,
ItemType = PlatformBillingItemType.BasePlan,
UnitAmountCents = amountCents,
AmountCents = amountCents,
Snapshot = JsonDefaults.Object()
};
var payment = new PlatformBillingPayment
{
Id = Guid.NewGuid(),
TenantId = tenantId,
OrderId = order.Id,
PaymentNo = $"SP{Guid.NewGuid():N}",
IdempotencyKey = $"payment-{Guid.NewGuid():N}",
Provider = provider,
Method = provider == PaymentProviders.Alipay ? "web" : "jsapi",
AmountCents = amountCents,
Status = PlatformBillingPaymentStatus.Pending
};
var notification = new PaymentNotificationResult(
provider,
"payment.succeeded",
$"event-{Guid.NewGuid():N}",
order.OrderNo,
$"trade-{Guid.NewGuid():N}",
amountCents,
true,
true,
DateTimeOffset.UtcNow,
JsonDefaults.Object());
object[] entities =
[
new Tenant
{
Id = tenantId,
Slug = $"callback-{tenantId:N}",
Name = "Callback tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Trial
},
feature,
offering,
version,
new SaasOfferingVersionFeature { OfferingVersionId = version.Id, FeatureCode = feature.Code },
quote,
order,
orderItem,
payment
];
return new CallbackFixture(tenantId, version.Id, order.Id, payment.Id, notification, entities);
}
private static async Task PublishVersionAsync(ApiTestFactory factory, Guid versionId)
{
using var scope = factory.CreateSystemScope("Publish callback plan fixture");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await db.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
private static async Task AssertPaymentRemainsPendingAsync(ApiTestFactory factory, Guid paymentId)
{
using var scope = factory.CreateSystemScope("Verify rejected platform callback");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(PlatformBillingPaymentStatus.Pending,
(await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == paymentId)).Status);
Assert.False(await db.PlatformBillingPaymentEvents.AnyAsync(value => value.PaymentId == paymentId));
}
private static async Task<string> ReadCodeAsync(HttpResponseMessage response)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return payload.RootElement.GetProperty("code").GetString() ?? string.Empty;
}
private sealed record CallbackFixture(
Guid TenantId,
Guid VersionId,
Guid OrderId,
Guid PaymentId,
PaymentNotificationResult Notification,
object[] Entities);
private sealed class FakePlatformBillingPaymentGateway(PaymentNotificationResult notification)
: IPlatformBillingPaymentGateway
{
public List<string> ParsedProviders { get; } = [];
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
string provider,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new CreatePaymentProviderResult(
provider,
request.Method,
"pending",
null,
JsonDefaults.Object(),
JsonDefaults.Object()));
public Task<PaymentNotificationResult> ParseNotificationAsync(
string provider,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
ParsedProviders.Add(provider);
return Task.FromResult(notification);
}
}
}

View File

@@ -0,0 +1,962 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class SaasBillingLifecycleTests
{
[Fact]
public async Task Platform_can_publish_feature_limit_and_immutable_offering_version()
{
await using var factory = PlatformFactory();
var platform = await SeedPlatformAdminAsync(factory);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var feature = await client.PutAsJsonAsync(
"/api/platform-admin/saas/features",
new UpsertSaasFeatureDto(
null,
SaasFeatureCatalog.PrivateQuestionBank,
"私有题库",
"content",
"租户私有题库",
20_000,
"CNY",
SaasFeatureStatus.Active,
10));
var limit = await client.PutAsJsonAsync(
"/api/platform-admin/saas/feature-limits",
new UpsertSaasFeatureLimitDto(
null,
SaasQuotaMetricCatalog.PrivateQuestionCount,
SaasFeatureCatalog.PrivateQuestionBank,
"私有题目数",
"count",
SaasFeatureLimitKind.Current,
80,
true));
var offering = await client.PutAsJsonAsync(
"/api/platform-admin/saas/offerings",
new UpsertSaasOfferingDto(
null,
$"private-bank-{Guid.NewGuid():N}",
"题库基础套餐",
SaasOfferingType.BasePlan,
SaasOfferingStatus.Draft,
null,
10));
Assert.Equal(HttpStatusCode.OK, feature.StatusCode);
Assert.Equal(HttpStatusCode.OK, limit.StatusCode);
Assert.Equal(HttpStatusCode.OK, offering.StatusCode);
var offeringId = await ReadGuidAsync(offering, "id");
var versionRequest = new UpsertSaasOfferingVersionDto(
null,
offeringId,
PlatformBillingCycle.Yearly,
24_000,
20_000,
"CNY",
null,
[SaasFeatureCatalog.PrivateQuestionBank],
new Dictionary<string, long>
{
[SaasQuotaMetricCatalog.PrivateQuestionCount] = 10_000
},
JsonDefaults.Object());
var draft = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest);
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
var versionId = await ReadGuidAsync(draft, "id");
var publish = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null);
var mutatePublished = await client.PutAsJsonAsync(
"/api/platform-admin/saas/offering-versions",
versionRequest with { Id = versionId, AmountCents = 19_000 });
Assert.Equal(HttpStatusCode.OK, publish.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, mutatePublished.StatusCode);
Assert.Equal("saas_offering_version_immutable", await ReadCodeAsync(mutatePublished));
using var scope = factory.CreateSystemScope("Verify immutable SaaS catalog version");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var stored = await dbContext.SaasOfferingVersions.AsNoTracking().SingleAsync(value => value.Id == versionId);
Assert.Equal(SaasOfferingVersionStatus.Published, stored.Status);
Assert.Equal(20_000, stored.AmountCents);
Assert.True(await dbContext.SaasOfferingVersionLimits.AnyAsync(value =>
value.OfferingVersionId == versionId &&
value.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount &&
value.LimitValue == 10_000));
}
[Fact]
public async Task Tenant_can_purchase_manual_plan_and_other_tenant_cannot_read_order()
{
await using var factory = PlatformFactory();
var platform = await SeedPlatformAdminAsync(factory);
var platformTenantId = Guid.NewGuid();
var tenantA = await SeedTenantAdminWithoutSubscriptionAsync(factory, "billing-a");
await factory.SeedAsync(new Tenant
{
Id = platformTenantId,
Slug = $"platform-{platformTenantId:N}",
Name = "Platform Owner",
Mode = TenantMode.PlatformOwned,
Status = TenantStatus.Active
});
var catalog = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null);
using var tenantClient = factory.CreateClient();
tenantClient.UseAccessToken(await tenantClient.LoginAsTenantAsync(tenantA.TenantId, tenantA.Phone));
var catalogResponse = await tenantClient.GetAsync("/api/tenant-billing/catalog");
var quoteIdempotencyKey = $"quote-{Guid.NewGuid():N}";
var quoteRequest = new CreatePlatformBillingQuoteDto(
catalog.VersionId,
[],
PlatformBillingOrderPurpose.NewSubscription,
quoteIdempotencyKey);
var quoteResponse = await tenantClient.PostAsJsonAsync(
"/api/tenant-billing/quotes",
quoteRequest);
var repeatedQuoteResponse = await tenantClient.PostAsJsonAsync(
"/api/tenant-billing/quotes",
quoteRequest);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, quoteResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedQuoteResponse.StatusCode);
var quoteId = await ReadGuidAsync(quoteResponse, "id");
Assert.Equal(quoteId, await ReadGuidAsync(repeatedQuoteResponse, "id"));
var idempotencyKey = $"order-{Guid.NewGuid():N}";
var orderResponse = await tenantClient.PostAsJsonAsync(
"/api/tenant-billing/orders",
new CreatePlatformBillingOrderDto(quoteId, idempotencyKey));
var repeatedOrder = await tenantClient.PostAsJsonAsync(
"/api/tenant-billing/orders",
new CreatePlatformBillingOrderDto(quoteId, idempotencyKey));
Assert.Equal(HttpStatusCode.OK, orderResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedOrder.StatusCode);
var orderNo = await ReadStringAsync(orderResponse, "orderNo");
Assert.Equal(orderNo, await ReadStringAsync(repeatedOrder, "orderNo"));
var paymentResponse = await tenantClient.PostAsJsonAsync(
$"/api/tenant-billing/orders/{orderNo}/payments",
new CreatePlatformBillingPaymentDto(
"manual",
"bank_transfer",
$"payment-{Guid.NewGuid():N}",
null,
null,
null));
Assert.Equal(HttpStatusCode.OK, paymentResponse.StatusCode);
var paymentId = await ReadGuidAsync(paymentResponse, "id");
using var platformClient = factory.CreateClient();
platformClient.UseAccessToken(await platformClient.LoginAsPlatformAsync(platform.Email));
var confirmation = new ConfirmManualPlatformPaymentDto(
paymentId,
$"manual-{Guid.NewGuid():N}",
DateTimeOffset.UtcNow,
"integration test receipt");
var confirm = await platformClient.PostAsJsonAsync(
"/api/platform-admin/saas/payments/manual/confirm",
confirmation);
var repeatedConfirm = await platformClient.PostAsJsonAsync(
"/api/platform-admin/saas/payments/manual/confirm",
confirmation);
Assert.Equal(HttpStatusCode.OK, confirm.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode);
var subscription = await tenantClient.GetAsync("/api/tenant-billing/subscription");
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
Assert.Equal("Active", await ReadStringAsync(subscription, "status"));
var tenantB = await SeedTenantAdminWithoutSubscriptionAsync(factory, "billing-b");
using var tenantBClient = factory.CreateClient();
tenantBClient.UseAccessToken(await tenantBClient.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone));
var crossTenantOrder = await tenantBClient.GetAsync($"/api/tenant-billing/orders/{orderNo}");
Assert.Equal(HttpStatusCode.NotFound, crossTenantOrder.StatusCode);
using var scope = factory.CreateSystemScope("Verify SaaS billing settlement idempotency");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Single(await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenantA.TenantId)
.ToArrayAsync());
Assert.Single(await dbContext.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.TenantId == tenantA.TenantId)
.ToArrayAsync());
Assert.Single(await dbContext.PlatformBillingPaymentEvents.AsNoTracking()
.Where(value => value.TenantId == tenantA.TenantId && value.PaymentId == paymentId)
.ToArrayAsync());
}
[Fact]
public async Task Tenant_role_cannot_bind_permission_for_unpurchased_feature()
{
await using var factory = new ApiTestFactory();
var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "role-feature");
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenant.TenantId, tenant.Phone));
var roleResponse = await client.PostAsJsonAsync(
"/api/backoffice/tenant/roles",
new UpsertBackofficeRoleDto
{
Code = $"content_editor_{Guid.NewGuid():N}",
Name = "内容编辑",
Status = BackendRoleStatus.Active,
DataScope = JsonDefaults.Object()
});
Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode);
var roleId = await ReadGuidAsync(roleResponse, "id");
var bind = await client.PutAsJsonAsync(
$"/api/backoffice/tenant/roles/{roleId}/bindings",
new ReplaceRoleBindingsDto
{
PermissionCodes = [BackendPermissions.TenantContentManage],
MenuCodes = []
});
Assert.Equal(HttpStatusCode.BadRequest, bind.StatusCode);
Assert.Equal("feature_not_available", await ReadCodeAsync(bind));
using var scope = factory.CreateSystemScope("Verify rejected tenant role binding");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.False(await dbContext.TenantBackendRolePermissions.AnyAsync(value =>
value.TenantId == tenant.TenantId &&
value.RoleId == roleId &&
value.PermissionCode == BackendPermissions.TenantContentManage));
}
[Fact]
public async Task Video_only_subscription_can_bind_video_permission_without_private_question_permission()
{
await using var factory = new ApiTestFactory();
var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "video-role-feature");
var graph = CreatePublishedPlan(SaasFeatureCatalog.Video);
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
graph.Offering,
graph.Version,
graph.Entitlement,
new TenantSaasSubscription
{
Id = graph.SubscriptionId,
TenantId = tenant.TenantId,
BaseOfferingVersionId = graph.Version.Id,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenant.TenantId,
SubscriptionId = graph.SubscriptionId,
OfferingVersionId = graph.Version.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
});
await PublishFixtureVersionAsync(factory, graph.Version.Id);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenant.TenantId, tenant.Phone));
var roleResponse = await client.PostAsJsonAsync(
"/api/backoffice/tenant/roles",
new UpsertBackofficeRoleDto
{
Code = $"video_editor_{Guid.NewGuid():N}",
Name = "视频编辑",
Status = BackendRoleStatus.Active,
DataScope = JsonDefaults.Object()
});
Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode);
var roleId = await ReadGuidAsync(roleResponse, "id");
var videoBinding = await client.PutAsJsonAsync(
$"/api/backoffice/tenant/roles/{roleId}/bindings",
new ReplaceRoleBindingsDto
{
PermissionCodes = [BackendPermissions.TenantVideoManage],
MenuCodes = []
});
var questionBinding = await client.PutAsJsonAsync(
$"/api/backoffice/tenant/roles/{roleId}/bindings",
new ReplaceRoleBindingsDto
{
PermissionCodes = [BackendPermissions.TenantContentManage],
MenuCodes = []
});
Assert.Equal(HttpStatusCode.OK, videoBinding.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, questionBinding.StatusCode);
Assert.Equal("feature_not_available", await ReadCodeAsync(questionBinding));
}
[Fact]
public async Task Quota_warns_at_eighty_percent_and_concurrent_consumers_cannot_exceed_limit()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
await SeedQuotaSubscriptionAsync(factory, tenantId, limit: 10);
using (var scope = factory.CreateSystemScope("Seed SaaS quota usage"))
{
var featureAccess = scope.ServiceProvider.GetRequiredService<IFeatureAccessService>();
Assert.True(await featureAccess.TryConsumeQuotaAsync(
tenantId,
SaasQuotaMetricCatalog.ExportCount,
8));
var atWarning = Assert.Single(await featureAccess.GetQuotaSummaryAsync(tenantId));
Assert.Equal(80, atWarning.UsedPercent);
Assert.True(atWarning.Warning);
Assert.False(atWarning.Exceeded);
}
async Task<bool> ConsumeFinalCapacityAsync()
{
using var scope = factory.CreateSystemScope("Race SaaS quota consumption");
return await scope.ServiceProvider.GetRequiredService<IFeatureAccessService>()
.TryConsumeQuotaAsync(tenantId, SaasQuotaMetricCatalog.ExportCount, 2);
}
var results = await Task.WhenAll(ConsumeFinalCapacityAsync(), ConsumeFinalCapacityAsync());
Assert.Equal(1, results.Count(value => value));
using var verificationScope = factory.CreateSystemScope("Verify SaaS quota atomicity");
var finalUsage = Assert.Single(await verificationScope.ServiceProvider
.GetRequiredService<IFeatureAccessService>()
.GetQuotaSummaryAsync(tenantId));
Assert.Equal(10, finalUsage.UsedValue);
Assert.True(finalUsage.Warning);
Assert.True(finalUsage.Exceeded);
}
[Fact]
public async Task Onboarding_is_ready_when_required_delivery_steps_are_complete()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
var ownerId = Guid.NewGuid();
var phone = $"139{Random.Shared.Next(10_000_000, 99_999_999)}";
var plan = CreatePublishedPlan(SaasFeatureCatalog.Exam);
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Onboarding Tenant",
OwnerUserId = ownerId,
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
new User
{
Id = ownerId,
Phone = phone,
Name = "Tenant Owner",
ForcePasswordChange = false
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = ownerId,
Role = TenantRole.TenantOwner,
Status = MembershipStatus.Active
},
plan.Feature,
plan.Offering,
plan.Version,
plan.Entitlement,
new TenantSaasSubscription
{
Id = plan.SubscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = plan.Version.Id,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = plan.SubscriptionId,
OfferingVersionId = plan.Version.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
},
new TenantDomain
{
TenantId = tenantId,
Host = $"{tenantId:N}.example.test",
Status = TenantDomainStatus.Active,
IsPrimary = true,
VerifiedAt = now,
DnsVerifiedAt = now,
TlsReadyAt = now
},
new TenantFrontendConfig
{
TenantId = tenantId,
PublishedAt = now,
ConfigVersion = 1
},
new TenantAuthPolicy
{
TenantId = tenantId,
AllowedStudentLoginMethods = ["password"]
});
await PublishFixtureVersionAsync(factory, plan.Version.Id);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
var response = await client.GetAsync("/api/tenant-onboarding/status");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
Assert.True(payload.RootElement.GetProperty("readyForStudentTraffic").GetBoolean());
Assert.Equal(
payload.RootElement.GetProperty("requiredSteps").GetInt32(),
payload.RootElement.GetProperty("completedRequiredSteps").GetInt32());
var requiredIncomplete = payload.RootElement.GetProperty("steps").EnumerateArray()
.Where(value => value.GetProperty("required").GetBoolean() && !value.GetProperty("completed").GetBoolean())
.ToArray();
Assert.Empty(requiredIncomplete);
}
[Fact]
public async Task Subscription_lifecycle_activates_scheduled_plan_once_at_period_boundary()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var periodEnd = DateTimeOffset.UtcNow.AddMinutes(-5);
var oldFeature = new SaasFeature
{
Code = $"lifecycle.old.{Guid.NewGuid():N}",
Name = "Old plan feature",
Category = "integration",
Status = SaasFeatureStatus.Active
};
var newFeature = new SaasFeature
{
Code = $"lifecycle.new.{Guid.NewGuid():N}",
Name = "New plan feature",
Category = "integration",
Status = SaasFeatureStatus.Active
};
var oldOffering = new SaasOffering
{
Code = $"lifecycle-old-{Guid.NewGuid():N}",
Name = "Old lifecycle plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
var newOffering = new SaasOffering
{
Code = $"lifecycle-new-{Guid.NewGuid():N}",
Name = "New lifecycle plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
var oldVersion = CreatePublishedVersion(oldOffering.Id, 1);
var newVersion = CreatePublishedVersion(newOffering.Id, 1);
var nextPeriodEnd = periodEnd.AddMonths(1);
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = $"lifecycle-{tenantId:N}",
Name = "Lifecycle Tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
oldFeature,
newFeature,
oldOffering,
newOffering,
oldVersion,
newVersion,
new SaasOfferingVersionFeature
{
OfferingVersionId = oldVersion.Id,
FeatureCode = oldFeature.Code
},
new SaasOfferingVersionFeature
{
OfferingVersionId = newVersion.Id,
FeatureCode = newFeature.Code
},
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = oldVersion.Id,
ScheduledBaseOfferingVersionId = newVersion.Id,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = periodEnd.AddMonths(-1),
CurrentPeriodStart = periodEnd.AddMonths(-1),
CurrentPeriodEnd = periodEnd
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = oldVersion.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = periodEnd.AddMonths(-1),
EndsAt = periodEnd
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = newVersion.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Scheduled,
StartsAt = periodEnd,
EndsAt = nextPeriodEnd
});
async Task<int> RunLifecycleAsync(string reason)
{
using var lifecycleScope = factory.CreateSystemScope(reason);
return await lifecycleScope.ServiceProvider
.GetRequiredService<ISaasSubscriptionLifecycleService>()
.ProcessDueAsync(periodEnd.AddMinutes(1));
}
var concurrentResults = await Task.WhenAll(
RunLifecycleAsync("Run first concurrent SaaS subscription lifecycle worker"),
RunLifecycleAsync("Run second concurrent SaaS subscription lifecycle worker"));
Assert.Equal(1, concurrentResults.Count(value => value == 1));
Assert.Equal(1, concurrentResults.Count(value => value == 0));
Assert.Equal(0, await RunLifecycleAsync("Repeat completed SaaS subscription lifecycle worker"));
using var verificationScope = factory.CreateSystemScope("Verify scheduled SaaS subscription activation");
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.SingleAsync(value => value.Id == subscriptionId);
var items = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.SubscriptionId == subscriptionId)
.OrderBy(value => value.StartsAt)
.ToArrayAsync();
Assert.Equal(newVersion.Id, subscription.BaseOfferingVersionId);
Assert.Null(subscription.ScheduledBaseOfferingVersionId);
Assert.Equal(TenantSaasSubscriptionStatus.Active, subscription.Status);
Assert.Equal(periodEnd, subscription.CurrentPeriodStart);
Assert.Equal(nextPeriodEnd, subscription.CurrentPeriodEnd);
Assert.Equal(1, subscription.LifecycleVersion);
Assert.Equal(TenantSaasSubscriptionItemStatus.Expired, items[0].Status);
Assert.Equal(TenantSaasSubscriptionItemStatus.Active, items[1].Status);
Assert.Single(await dbContext.AuditLogs.AsNoTracking().Where(value =>
value.TenantId == tenantId &&
value.Action == "platform_billing.subscription.scheduled_plan_activated").ToArrayAsync());
}
[Fact]
public async Task Subscription_lifecycle_applies_cancel_trial_past_due_and_expired_transitions_idempotently()
{
await using var factory = new ApiTestFactory();
var asOf = DateTimeOffset.UtcNow;
var feature = new SaasFeature
{
Code = $"lifecycle.status.{Guid.NewGuid():N}",
Name = "Lifecycle status feature",
Category = "integration",
Status = SaasFeatureStatus.Active
};
var offering = new SaasOffering
{
Code = $"lifecycle-status-{Guid.NewGuid():N}",
Name = "Lifecycle status plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
var version = CreatePublishedVersion(offering.Id, 1);
var fixtures = new[]
{
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-1), cancelAtPeriodEnd: true),
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Trial, asOf.AddDays(-1)),
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-8)),
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.PastDue, asOf.AddDays(-8))
};
await factory.SeedAsync(
[
feature,
offering,
version,
new SaasOfferingVersionFeature
{
OfferingVersionId = version.Id,
FeatureCode = feature.Code
},
..fixtures.SelectMany(value => new object[] { value.Tenant, value.Subscription, value.Item })
]);
using var lifecycleScope = factory.CreateSystemScope("Run SaaS subscription terminal lifecycle transitions");
var lifecycle = lifecycleScope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>();
Assert.Equal(4, await lifecycle.ProcessDueAsync(asOf));
Assert.Equal(1, await lifecycle.ProcessDueAsync(asOf));
Assert.Equal(0, await lifecycle.ProcessDueAsync(asOf));
using var verificationScope = factory.CreateSystemScope("Verify SaaS subscription terminal lifecycle transitions");
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => fixtures.Select(fixture => fixture.Subscription.Id).Contains(value.Id))
.ToDictionaryAsync(value => value.Id);
Assert.Equal(TenantSaasSubscriptionStatus.Cancelled, subscriptions[fixtures[0].Subscription.Id].Status);
Assert.Equal(TenantSaasSubscriptionStatus.Expired, subscriptions[fixtures[1].Subscription.Id].Status);
Assert.Equal(TenantSaasSubscriptionStatus.Expired, subscriptions[fixtures[2].Subscription.Id].Status);
Assert.Equal(TenantSaasSubscriptionStatus.Expired, subscriptions[fixtures[3].Subscription.Id].Status);
Assert.Equal(1, subscriptions[fixtures[0].Subscription.Id].LifecycleVersion);
Assert.Equal(1, subscriptions[fixtures[1].Subscription.Id].LifecycleVersion);
Assert.Equal(2, subscriptions[fixtures[2].Subscription.Id].LifecycleVersion);
Assert.Equal(1, subscriptions[fixtures[3].Subscription.Id].LifecycleVersion);
}
private static ApiTestFactory PlatformFactory() => new(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
{
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"saas-platform-{Guid.NewGuid():N}@example.test";
var modules = BackendPermissions.Platform
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.Select(code => new PermissionModule
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
})
.Cast<object>();
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
IsSystem = true
}).Cast<object>();
await factory.SeedAsync(
[
..modules,
..permissions,
new User
{
Id = userId,
Email = email,
NormalizedEmail = email.ToUpperInvariant(),
UserName = email,
NormalizedUserName = email.ToUpperInvariant(),
Name = "SaaS Platform Admin",
PrimaryRole = "platform_admin",
RawProfile = JsonDefaults.Object()
}.WithTestPassword(),
new PlatformBackendRole
{
Id = roleId,
Code = $"saas_platform_admin_{Guid.NewGuid():N}",
Name = "SaaS Platform Admin",
Status = BackendRoleStatus.Active,
IsSystem = true
},
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
{
RoleId = roleId,
PermissionCode = code
}),
new PlatformBackendUserRole
{
UserId = userId,
RoleId = roleId
}
]);
return (userId, email);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedTenantAdminWithoutSubscriptionAsync(
ApiTestFactory factory,
string slugPrefix)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = $"138{Random.Shared.Next(10_000_000, 99_999_999)}";
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = $"{slugPrefix} tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Trial
},
new User
{
Id = userId,
Phone = phone,
Name = $"{slugPrefix} admin"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new SaasOffering
{
Code = $"fixture-suppress-default-{Guid.NewGuid():N}",
Name = "Fixture only",
Type = SaasOfferingType.AddOn,
Status = SaasOfferingStatus.Draft
});
return (tenantId, userId, phone);
}
private static async Task<(Guid OfferingId, Guid VersionId)> SeedPublishedPlanAsync(
ApiTestFactory factory,
string featureCode,
(string MetricCode, long Value)? limit)
{
var graph = CreatePublishedPlan(featureCode);
var entities = new List<object> { graph.Feature, graph.Offering, graph.Version, graph.Entitlement };
if (limit is { } quota)
{
entities.Add(new SaasFeatureLimitDefinition
{
MetricCode = quota.MetricCode,
FeatureCode = featureCode,
Name = quota.MetricCode,
Unit = "count",
Kind = SaasFeatureLimitKind.Period,
WarningPercent = 80,
IsHardLimit = true
});
entities.Add(new SaasOfferingVersionLimit
{
OfferingVersionId = graph.Version.Id,
MetricCode = quota.MetricCode,
LimitValue = quota.Value
});
}
await factory.SeedAsync([.. entities]);
await PublishFixtureVersionAsync(factory, graph.Version.Id);
return (graph.Offering.Id, graph.Version.Id);
}
private static async Task SeedQuotaSubscriptionAsync(ApiTestFactory factory, Guid tenantId, long limit)
{
var graph = CreatePublishedPlan(SaasFeatureCatalog.PrivateQuestionBank);
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = $"quota-{tenantId:N}",
Name = "Quota Tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
graph.Feature,
graph.Offering,
graph.Version,
graph.Entitlement,
new SaasFeatureLimitDefinition
{
MetricCode = SaasQuotaMetricCatalog.ExportCount,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Export count",
Unit = "count",
Kind = SaasFeatureLimitKind.Period,
WarningPercent = 80,
IsHardLimit = true
},
new SaasOfferingVersionLimit
{
OfferingVersionId = graph.Version.Id,
MetricCode = SaasQuotaMetricCatalog.ExportCount,
LimitValue = limit
},
new TenantSaasSubscription
{
Id = graph.SubscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = graph.Version.Id,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = graph.SubscriptionId,
OfferingVersionId = graph.Version.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
});
await PublishFixtureVersionAsync(factory, graph.Version.Id);
}
private static PublishedPlanGraph CreatePublishedPlan(string featureCode)
{
var offering = new SaasOffering
{
Id = Guid.NewGuid(),
Code = $"plan-{Guid.NewGuid():N}",
Name = "Integration SaaS Plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
var version = new SaasOfferingVersion
{
Id = Guid.NewGuid(),
OfferingId = offering.Id,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = 1_000,
AmountCents = 1_000,
Currency = "CNY",
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1),
PublishedAt = null
};
return new PublishedPlanGraph(
new SaasFeature
{
Code = featureCode,
Name = featureCode,
Category = "integration",
Status = SaasFeatureStatus.Active
},
offering,
version,
new SaasOfferingVersionFeature
{
OfferingVersionId = version.Id,
FeatureCode = featureCode
},
Guid.NewGuid());
}
private static SaasOfferingVersion CreatePublishedVersion(Guid offeringId, int version) => new()
{
OfferingId = offeringId,
Version = version,
Status = SaasOfferingVersionStatus.Published,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = 1_000,
AmountCents = 1_000,
Currency = "CNY",
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1),
PublishedAt = DateTimeOffset.UtcNow.AddDays(-1)
};
private static LifecycleFixture CreateLifecycleFixture(
Guid offeringVersionId,
TenantSaasSubscriptionStatus status,
DateTimeOffset periodEnd,
bool cancelAtPeriodEnd = false)
{
var tenantId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var periodStart = periodEnd.AddMonths(-1);
return new(
new Tenant
{
Id = tenantId,
Slug = $"lifecycle-status-{tenantId:N}",
Name = "Lifecycle Status Tenant",
Status = TenantStatus.Active,
BillingStatus = status == TenantSaasSubscriptionStatus.PastDue
? BillingStatus.PastDue
: BillingStatus.Active
},
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = offeringVersionId,
Status = status,
StartsAt = periodStart,
CurrentPeriodStart = periodStart,
CurrentPeriodEnd = periodEnd,
CancelAtPeriodEnd = cancelAtPeriodEnd
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = offeringVersionId,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = periodStart,
EndsAt = periodEnd
});
}
private static async Task PublishFixtureVersionAsync(ApiTestFactory factory, Guid versionId)
{
using var scope = factory.CreateSystemScope("Publish integration SaaS plan fixture");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await dbContext.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = DateTimeOffset.UtcNow;
version.EffectiveAt ??= version.PublishedAt;
await dbContext.SaveChangesAsync();
}
private static async Task<Guid> ReadGuidAsync(HttpResponseMessage response, string propertyName)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return payload.RootElement.GetProperty(propertyName).GetGuid();
}
private static async Task<string> ReadStringAsync(HttpResponseMessage response, string propertyName)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return payload.RootElement.GetProperty(propertyName).GetString()
?? throw new InvalidOperationException($"Response property '{propertyName}' was null.");
}
private static Task<string> ReadCodeAsync(HttpResponseMessage response) => ReadStringAsync(response, "code");
private sealed record PublishedPlanGraph(
SaasFeature Feature,
SaasOffering Offering,
SaasOfferingVersion Version,
SaasOfferingVersionFeature Entitlement,
Guid SubscriptionId);
private sealed record LifecycleFixture(
Tenant Tenant,
TenantSaasSubscription Subscription,
TenantSaasSubscriptionItem Item);
}

View File

@@ -174,7 +174,7 @@ public sealed class TenantPublicEndpointTests
Slug = "master",
Name = "升本刷题通",
Status = TenantStatus.Active,
Mode = TenantMode.PlatformOwned
Mode = TenantMode.Saas
},
new TenantDomain
{

View File

@@ -26,7 +26,7 @@ public sealed class MigrationExecutionTests
}
[Fact]
public async Task Migration_script_contains_tenant_isolation_guard_triggers()
public async Task Migration_script_contains_database_guard_triggers()
{
await using var factory = new ApiTestFactory();
using var scope = factory.Services.CreateScope();
@@ -41,5 +41,14 @@ public sealed class MigrationExecutionTests
Assert.Contains("drop trigger if exists trg_taxonomy_nodes_parent_platform_or_self_owner", script);
Assert.Contains("create or replace function tiku_guard_taxonomy_parent_owner()", script);
Assert.Contains("create trigger trg_taxonomy_nodes_parent_platform_or_self_owner", script);
Assert.Contains("drop trigger if exists trg_saas_offering_versions_published_immutable", script);
Assert.Contains("create or replace function tiku_guard_saas_offering_version_immutable()", script);
Assert.Contains("create trigger trg_saas_offering_versions_published_immutable", script);
Assert.Contains("create or replace function tiku_guard_saas_offering_version_child_immutable()", script);
Assert.Contains("create trigger trg_saas_offering_version_features_published_immutable", script);
Assert.Contains("create trigger trg_saas_offering_version_limits_published_immutable", script);
Assert.Contains("create trigger trg_tenant_saas_subscriptions_base_plan", script);
Assert.Contains("create trigger trg_tenant_saas_subscription_items_offering_type", script);
Assert.Contains("create or replace function tiku_guard_saas_subscription_offering_type()", script);
}
}

View File

@@ -89,8 +89,16 @@ public sealed class PersistenceModelTests
Assert.Contains("activation_codes", tableNames);
Assert.Contains("coupons", tableNames);
Assert.Contains("coupon_redemptions", tableNames);
Assert.Contains("tenant_subscriptions", tableNames);
Assert.Contains("tenant_usage_records", tableNames);
Assert.Contains("saas_features", tableNames);
Assert.Contains("permission_modules", tableNames);
Assert.Contains("saas_offerings", tableNames);
Assert.Contains("saas_offering_versions", tableNames);
Assert.Contains("tenant_saas_subscriptions", tableNames);
Assert.Contains("tenant_saas_subscription_items", tableNames);
Assert.Contains("tenant_feature_usage", tableNames);
Assert.Contains("platform_billing_orders", tableNames);
Assert.Contains("platform_billing_payments", tableNames);
Assert.Contains("platform_billing_invoices", tableNames);
Assert.Contains("commerce_refund_requests", tableNames);
Assert.Contains("commerce_refund_events", tableNames);
Assert.Contains("commerce_reconciliation_batches", tableNames);
@@ -128,16 +136,13 @@ public sealed class PersistenceModelTests
Assert.Contains("tenant_content_notifications", tableNames);
Assert.Contains("tenant_theme_templates", tableNames);
Assert.Contains("tenant_theme_configs", tableNames);
Assert.Contains("platform_saas_plans", tableNames);
Assert.Contains("tenant_billing_profiles", tableNames);
Assert.Contains("tenant_invoices", tableNames);
Assert.Contains("tenant_invoice_items", tableNames);
Assert.Contains("tenant_invoice_payments", tableNames);
Assert.Contains("tenant_invoice_reminders", tableNames);
Assert.Contains("platform_billing_invoices", tableNames);
Assert.Contains("platform_billing_invoice_reminders", tableNames);
Assert.Contains("platform_audit_alert_rules", tableNames);
Assert.Contains("platform_audit_alerts", tableNames);
Assert.Contains("platform_dunning_notification_channels", tableNames);
Assert.Contains("platform_dunning_notification_events", tableNames);
Assert.Contains("platform_billing_dunning_notification_channels", tableNames);
Assert.Contains("platform_billing_dunning_notification_events", tableNames);
Assert.Contains("pb_import_runs", tableNames);
Assert.Contains("pb_raw_records", tableNames);
Assert.Contains("pb_import_issues", tableNames);
@@ -572,8 +577,7 @@ public sealed class PersistenceModelTests
[InlineData(typeof(Payment), nameof(Payment.RawPayload), "'{}'::jsonb")]
[InlineData(typeof(PaymentEvent), nameof(PaymentEvent.Payload), "'{}'::jsonb")]
[InlineData(typeof(Entitlement), nameof(Entitlement.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantSubscription), nameof(TenantSubscription.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantUsageRecord), nameof(TenantUsageRecord.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantSaasSubscription), nameof(TenantSaasSubscription.Metadata), "'{}'::jsonb")]
[InlineData(typeof(CommerceRefundRequest), nameof(CommerceRefundRequest.Metadata), "'{}'::jsonb")]
[InlineData(typeof(CommerceRefundEvent), nameof(CommerceRefundEvent.Details), "'{}'::jsonb")]
[InlineData(typeof(CommerceReconciliationBatch), nameof(CommerceReconciliationBatch.Metadata), "'{}'::jsonb")]
@@ -802,20 +806,18 @@ public sealed class PersistenceModelTests
}
[Theory]
[InlineData(typeof(PlatformSaasPlan), nameof(PlatformSaasPlan.IncludedQuotas), "'{}'::jsonb")]
[InlineData(typeof(PlatformSaasPlan), nameof(PlatformSaasPlan.OveragePrices), "'{}'::jsonb")]
[InlineData(typeof(PlatformSaasPlan), nameof(PlatformSaasPlan.FeatureFlags), "'{}'::jsonb")]
[InlineData(typeof(SaasOfferingVersion), nameof(SaasOfferingVersion.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformBillingQuote), nameof(PlatformBillingQuote.FeatureSnapshot), "'[]'::jsonb")]
[InlineData(typeof(PlatformBillingQuote), nameof(PlatformBillingQuote.LimitSnapshot), "'{}'::jsonb")]
[InlineData(typeof(TenantBillingProfile), nameof(TenantBillingProfile.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantInvoice), nameof(TenantInvoice.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantInvoiceItem), nameof(TenantInvoiceItem.Metadata), "'{}'::jsonb")]
[InlineData(typeof(TenantInvoicePayment), nameof(TenantInvoicePayment.RawPayload), "'{}'::jsonb")]
[InlineData(typeof(TenantInvoiceReminder), nameof(TenantInvoiceReminder.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformBillingInvoice), nameof(PlatformBillingInvoice.BillingProfileSnapshot), "'{}'::jsonb")]
[InlineData(typeof(PlatformBillingInvoiceReminder), nameof(PlatformBillingInvoiceReminder.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Conditions), "'{}'::jsonb")]
[InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformAuditAlert), nameof(PlatformAuditAlert.Details), "'{}'::jsonb")]
[InlineData(typeof(PlatformDunningNotificationChannel), nameof(PlatformDunningNotificationChannel.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformDunningNotificationEvent), nameof(PlatformDunningNotificationEvent.RequestPayload), "'{}'::jsonb")]
[InlineData(typeof(PlatformDunningNotificationEvent), nameof(PlatformDunningNotificationEvent.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformBillingDunningNotificationChannel), nameof(PlatformBillingDunningNotificationChannel.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PlatformBillingDunningNotificationEvent), nameof(PlatformBillingDunningNotificationEvent.RequestPayload), "'{}'::jsonb")]
[InlineData(typeof(PlatformBillingDunningNotificationEvent), nameof(PlatformBillingDunningNotificationEvent.Metadata), "'{}'::jsonb")]
public void Platform_operations_json_properties_are_mapped_to_jsonb(
Type entityType,
string propertyName,
@@ -836,27 +838,23 @@ public sealed class PersistenceModelTests
{
using var context = new TikuDbContext(Options);
AssertHasUniqueIndex<PlatformSaasPlan>(nameof(PlatformSaasPlan.Code));
AssertHasUniqueIndex<TenantInvoice>(
nameof(TenantInvoice.TenantId),
nameof(TenantInvoice.InvoiceNo));
AssertHasUniqueIndex<TenantInvoice>(
nameof(TenantInvoice.TenantId),
nameof(TenantInvoice.BillingPeriodStart),
nameof(TenantInvoice.BillingPeriodEnd));
AssertHasUniqueIndex<TenantInvoiceReminder>(
nameof(TenantInvoiceReminder.TenantId),
nameof(TenantInvoiceReminder.InvoiceId),
nameof(TenantInvoiceReminder.ReminderType),
nameof(TenantInvoiceReminder.Channel),
nameof(TenantInvoiceReminder.ReminderDate));
AssertHasUniqueIndex<SaasFeature>(nameof(SaasFeature.Code));
AssertHasUniqueIndex<PlatformBillingInvoice>(
nameof(PlatformBillingInvoice.TenantId),
nameof(PlatformBillingInvoice.InvoiceNo));
AssertHasUniqueIndex<PlatformBillingInvoiceReminder>(
nameof(PlatformBillingInvoiceReminder.TenantId),
nameof(PlatformBillingInvoiceReminder.InvoiceId),
nameof(PlatformBillingInvoiceReminder.ReminderType),
nameof(PlatformBillingInvoiceReminder.Channel),
nameof(PlatformBillingInvoiceReminder.ReminderDate));
AssertHasUniqueIndex<PlatformAuditAlertRule>(nameof(PlatformAuditAlertRule.Code));
AssertHasUniqueIndex<PlatformAuditAlert>(nameof(PlatformAuditAlert.RuleId), nameof(PlatformAuditAlert.AuditLogId));
AssertHasUniqueIndex<PlatformDunningNotificationChannel>(nameof(PlatformDunningNotificationChannel.ChannelCode));
AssertHasUniqueIndex<PlatformDunningNotificationEvent>(
nameof(PlatformDunningNotificationEvent.TenantId),
nameof(PlatformDunningNotificationEvent.ChannelId),
nameof(PlatformDunningNotificationEvent.ReminderId));
AssertHasUniqueIndex<PlatformBillingDunningNotificationChannel>(nameof(PlatformBillingDunningNotificationChannel.ChannelCode));
AssertHasUniqueIndex<PlatformBillingDunningNotificationEvent>(
nameof(PlatformBillingDunningNotificationEvent.TenantId),
nameof(PlatformBillingDunningNotificationEvent.ChannelId),
nameof(PlatformBillingDunningNotificationEvent.ReminderId));
Assert.Equal(
"text[]",
@@ -865,12 +863,12 @@ public sealed class PersistenceModelTests
.GetColumnType());
Assert.Equal(
"uuid[]",
context.Model.FindEntityType(typeof(PlatformDunningNotificationChannel))!
.FindProperty(nameof(PlatformDunningNotificationChannel.TenantIds))!
context.Model.FindEntityType(typeof(PlatformBillingDunningNotificationChannel))!
.FindProperty(nameof(PlatformBillingDunningNotificationChannel.TenantIds))!
.GetColumnType());
var total = context.Model.FindEntityType(typeof(TenantInvoice))!
.FindProperty(nameof(TenantInvoice.TotalCents))!;
var total = context.Model.FindEntityType(typeof(PlatformBillingInvoice))!
.FindProperty(nameof(PlatformBillingInvoice.TotalAmountCents))!;
Assert.Equal(typeof(int), total.ClrType);
void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Learning;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Commerce;
using Tiku.Domain.Catalog;
@@ -9,6 +10,7 @@ using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.IntegrationTests.Api;
@@ -138,18 +140,22 @@ public sealed class PhaseThreeTenantIsolationTests
}
[Theory]
[InlineData(TenantSubscriptionStatus.Trial, true)]
[InlineData(TenantSubscriptionStatus.Active, true)]
[InlineData(TenantSubscriptionStatus.PastDue, false)]
[InlineData(TenantSubscriptionStatus.Cancelled, false)]
[InlineData(TenantSaasSubscriptionStatus.Trial, true)]
[InlineData(TenantSaasSubscriptionStatus.Active, true)]
[InlineData(TenantSaasSubscriptionStatus.PastDue, false)]
[InlineData(TenantSaasSubscriptionStatus.Cancelled, false)]
public async Task Public_question_reference_requires_current_subscription(
TenantSubscriptionStatus subscriptionStatus,
TenantSaasSubscriptionStatus subscriptionStatus,
bool allowed)
{
await using var factory = new ApiTestFactory();
var platformId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var questionId = Guid.NewGuid();
var offeringId = Guid.NewGuid();
var versionId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
Tenant(platformId, "platform", TenantMode.PlatformOwned),
Tenant(tenantId, "tenant-a"),
@@ -160,13 +166,42 @@ public sealed class PhaseThreeTenantIsolationTests
Type = "choice",
Status = QuestionStatus.Published
},
new TenantSubscription
new SaasFeature
{
Code = SaasFeatureCatalog.Practice,
Name = "Practice",
Category = "learning",
Status = SaasFeatureStatus.Active
},
new SaasOffering { Id = offeringId, Code = "standard", Name = "Standard", Type = SaasOfferingType.BasePlan, Status = SaasOfferingStatus.Active },
new SaasOfferingVersion
{
Id = versionId,
OfferingId = offeringId,
Status = SaasOfferingVersionStatus.Published,
OriginalAmountCents = 100,
AmountCents = 100,
PublishedAt = now.AddDays(-1)
},
new SaasOfferingVersionFeature { OfferingVersionId = versionId, FeatureCode = SaasFeatureCatalog.Practice },
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = versionId,
Status = subscriptionStatus,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddDays(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
PlanCode = "standard",
Status = subscriptionStatus,
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1)
SubscriptionId = subscriptionId,
OfferingVersionId = versionId,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddDays(1)
});
using var scope = factory.CreateTenantScope(tenantId, "tenant-a");
@@ -210,14 +245,7 @@ public sealed class PhaseThreeTenantIsolationTests
await factory.SeedAsync(
Tenant(platformId, "platform", TenantMode.PlatformOwned),
Tenant(tenantId, "tenant-a"),
new User { Id = userId, Phone = "13800009999" },
new TenantSubscription
{
TenantId = tenantId,
PlanCode = "standard",
Status = TenantSubscriptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
});
new User { Id = userId, Phone = "13800009999" });
await factory.SeedQuestionWithVersionAsync(
new Question
{