forked from gongxuegit/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user