forked from xiongyuxing/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ public sealed class AuthSessionLifecycleTests
|
||||
Code = permissionCode,
|
||||
Name = permissionCode,
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = "test"
|
||||
PermissionModuleCode = "tenant_dashboard"
|
||||
},
|
||||
role,
|
||||
new TenantBackendRolePermission
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
335
Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs
Normal file
335
Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
272
Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs
Normal file
272
Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
962
Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs
Normal file
962
Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs
Normal 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);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ public sealed class TenantPublicEndpointTests
|
||||
Slug = "master",
|
||||
Name = "升本刷题通",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.PlatformOwned
|
||||
Mode = TenantMode.Saas
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user