1236 lines
55 KiB
C#
1236 lines
55 KiB
C#
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.PlatformAdmin;
|
|
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 Commercial_processor_generates_one_renewal_receivable_and_due_reminder_on_replay()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "renewal-worker");
|
|
var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null);
|
|
var subscriptionId = Guid.NewGuid();
|
|
var periodEnd = new DateTimeOffset(DateTime.UtcNow.Date.AddDays(7).AddHours(12), TimeSpan.Zero);
|
|
using (var scope = factory.CreateSystemScope("Set renewal fixture owner"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var storedTenant = await dbContext.Tenants.SingleAsync(value => value.Id == tenant.TenantId);
|
|
storedTenant.OwnerUserId = tenant.UserId;
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
await factory.SeedAsync(
|
|
new TenantBillingPolicy
|
|
{
|
|
TenantId = tenant.TenantId,
|
|
CollectionMode = TenantBillingCollectionMode.Manual,
|
|
DefaultPaymentProvider = "manual",
|
|
AutoGenerateRenewal = true,
|
|
RenewalLeadDays = 14
|
|
},
|
|
new TenantSaasSubscription
|
|
{
|
|
Id = subscriptionId,
|
|
TenantId = tenant.TenantId,
|
|
BaseOfferingVersionId = plan.VersionId,
|
|
Status = TenantSaasSubscriptionStatus.Active,
|
|
StartsAt = periodEnd.AddMonths(-1),
|
|
CurrentPeriodStart = periodEnd.AddMonths(-1),
|
|
CurrentPeriodEnd = periodEnd
|
|
},
|
|
new TenantSaasSubscriptionItem
|
|
{
|
|
TenantId = tenant.TenantId,
|
|
SubscriptionId = subscriptionId,
|
|
OfferingVersionId = plan.VersionId,
|
|
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
|
Status = TenantSaasSubscriptionItemStatus.Active,
|
|
StartsAt = periodEnd.AddMonths(-1),
|
|
EndsAt = periodEnd
|
|
});
|
|
|
|
for (var iteration = 0; iteration < 2; iteration++)
|
|
{
|
|
using var scope = factory.CreateSystemScope("Process replayed commercial renewal cycle");
|
|
await scope.ServiceProvider.GetRequiredService<ICommercialBillingProcessor>().ProcessDueAsync();
|
|
}
|
|
|
|
using var verificationScope = factory.CreateSystemScope("Verify renewal receivable idempotency");
|
|
var verificationDb = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var order = Assert.Single(await verificationDb.PlatformBillingOrders.AsNoTracking()
|
|
.Where(value => value.TenantId == tenant.TenantId && value.IdempotencyKey.StartsWith("auto-renewal:"))
|
|
.ToArrayAsync());
|
|
var invoice = Assert.Single(await verificationDb.PlatformBillingInvoices.AsNoTracking()
|
|
.Where(value => value.TenantId == tenant.TenantId && value.OrderId == order.Id)
|
|
.ToArrayAsync());
|
|
Assert.Equal(PlatformBillingInvoiceStatus.Issued, invoice.Status);
|
|
Assert.Single(await verificationDb.PlatformBillingInvoiceReminders.AsNoTracking()
|
|
.Where(value => value.TenantId == tenant.TenantId && value.InvoiceId == invoice.Id &&
|
|
value.Channel == PlatformBillingInvoiceReminderChannel.Internal)
|
|
.ToArrayAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Platform_trial_and_subscription_operations_project_tenant_billing_status()
|
|
{
|
|
await using var factory = PlatformFactory();
|
|
var platform = await SeedPlatformAdminAsync(factory);
|
|
var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "trial-state");
|
|
var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null);
|
|
using var client = factory.CreateClient();
|
|
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
|
var trialRequest = new GrantTenantTrialDto(
|
|
tenant.TenantId,
|
|
plan.VersionId,
|
|
14,
|
|
$"trial-{Guid.NewGuid():N}",
|
|
"integration trial grant");
|
|
|
|
var trial = await client.PostAsJsonAsync("/api/platform-admin/saas/subscriptions/trial", trialRequest);
|
|
var repeatedTrial = await client.PostAsJsonAsync("/api/platform-admin/saas/subscriptions/trial", trialRequest);
|
|
Assert.Equal(HttpStatusCode.OK, trial.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, repeatedTrial.StatusCode);
|
|
var subscriptionId = await ReadGuidAsync(trial, "id");
|
|
Assert.Equal(subscriptionId, await ReadGuidAsync(repeatedTrial, "id"));
|
|
var duplicateTrial = await client.PostAsJsonAsync(
|
|
"/api/platform-admin/saas/subscriptions/trial",
|
|
trialRequest with { IdempotencyKey = $"trial-duplicate-{Guid.NewGuid():N}" });
|
|
Assert.Equal(HttpStatusCode.Conflict, duplicateTrial.StatusCode);
|
|
Assert.Equal("tenant_saas_subscription_exists", await ReadCodeAsync(duplicateTrial));
|
|
|
|
var suspended = await client.PostAsJsonAsync(
|
|
$"/api/platform-admin/saas/subscriptions/{subscriptionId}/suspend",
|
|
new ChangePlatformSubscriptionDto("integration suspension"));
|
|
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
|
await AssertSubscriptionProjectionAsync(
|
|
factory,
|
|
tenant.TenantId,
|
|
subscriptionId,
|
|
TenantSaasSubscriptionStatus.Suspended,
|
|
BillingStatus.Suspended);
|
|
|
|
var resumed = await client.PostAsJsonAsync(
|
|
$"/api/platform-admin/saas/subscriptions/{subscriptionId}/resume",
|
|
new ChangePlatformSubscriptionDto("integration resume"));
|
|
Assert.Equal(HttpStatusCode.OK, resumed.StatusCode);
|
|
await AssertSubscriptionProjectionAsync(
|
|
factory,
|
|
tenant.TenantId,
|
|
subscriptionId,
|
|
TenantSaasSubscriptionStatus.Active,
|
|
BillingStatus.Active);
|
|
|
|
DateTimeOffset beforeExtend;
|
|
using (var scope = factory.CreateSystemScope("Read subscription before extension"))
|
|
{
|
|
beforeExtend = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().TenantSaasSubscriptions
|
|
.Where(value => value.Id == subscriptionId)
|
|
.Select(value => value.CurrentPeriodEnd)
|
|
.SingleAsync();
|
|
}
|
|
var extended = await client.PostAsJsonAsync(
|
|
$"/api/platform-admin/saas/subscriptions/{subscriptionId}/extend",
|
|
new ChangePlatformSubscriptionDto("integration extension", 10));
|
|
Assert.Equal(HttpStatusCode.OK, extended.StatusCode);
|
|
using (var scope = factory.CreateSystemScope("Verify subscription extension"))
|
|
{
|
|
var periodEnd = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().TenantSaasSubscriptions
|
|
.Where(value => value.Id == subscriptionId)
|
|
.Select(value => value.CurrentPeriodEnd)
|
|
.SingleAsync();
|
|
Assert.Equal(beforeExtend.AddDays(10), periodEnd);
|
|
}
|
|
|
|
var cancelled = await client.PostAsJsonAsync(
|
|
$"/api/platform-admin/saas/subscriptions/{subscriptionId}/cancel",
|
|
new ChangePlatformSubscriptionDto("integration cancellation"));
|
|
Assert.Equal(HttpStatusCode.OK, cancelled.StatusCode);
|
|
await AssertSubscriptionProjectionAsync(
|
|
factory,
|
|
tenant.TenantId,
|
|
subscriptionId,
|
|
TenantSaasSubscriptionStatus.Cancelled,
|
|
BillingStatus.Cancelled);
|
|
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/platform-admin/saas/metrics")).StatusCode);
|
|
}
|
|
|
|
[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
|
|
},
|
|
new PlatformApprovalPolicy
|
|
{
|
|
Code = PlatformApprovalPolicyCodes.FinancialAdjustment,
|
|
Name = "Financial adjustment",
|
|
RequiredPermission = BackendPermissions.PlatformSaasBillingManage,
|
|
AmountThresholdCents = 5_000_000
|
|
});
|
|
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);
|
|
using var paymentPayload = await JsonDocument.ParseAsync(await paymentResponse.Content.ReadAsStreamAsync());
|
|
var paymentId = paymentPayload.RootElement.GetProperty("id").GetGuid();
|
|
var paymentAmountCents = paymentPayload.RootElement.GetProperty("amountCents").GetInt32();
|
|
|
|
using var platformClient = factory.CreateClient();
|
|
platformClient.UseAccessToken(await platformClient.LoginAsPlatformAsync(platform.Email));
|
|
var confirmationIdempotencyKey = $"manual-confirm-{Guid.NewGuid():N}";
|
|
var confirmation = new ConfirmManualPlatformPaymentDto(
|
|
paymentId,
|
|
$"manual-{Guid.NewGuid():N}",
|
|
DateTimeOffset.UtcNow,
|
|
"integration test receipt");
|
|
platformClient.DefaultRequestHeaders.Add("Idempotency-Key", confirmationIdempotencyKey);
|
|
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);
|
|
platformClient.DefaultRequestHeaders.Remove("Idempotency-Key");
|
|
Assert.True(confirm.StatusCode == HttpStatusCode.OK, await confirm.Content.ReadAsStringAsync());
|
|
Assert.True(repeatedConfirm.StatusCode == HttpStatusCode.OK, await repeatedConfirm.Content.ReadAsStringAsync());
|
|
|
|
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);
|
|
|
|
var refundKey = $"refund-{Guid.NewGuid():N}";
|
|
var refundRequest = new RequestPlatformRefundDto(
|
|
paymentId,
|
|
paymentAmountCents / 2,
|
|
"integration test partial refund",
|
|
refundKey,
|
|
PlatformBillingRefundSubscriptionEffect.KeepService);
|
|
var refundResponse = await platformClient.PostAsJsonAsync("/api/platform-admin/saas/refunds", refundRequest);
|
|
var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform-admin/saas/refunds", refundRequest);
|
|
Assert.Equal(HttpStatusCode.OK, refundResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, repeatedRefund.StatusCode);
|
|
var refundId = await ReadSubmissionResultGuidAsync(refundResponse, "id");
|
|
Assert.Equal(refundId, await ReadSubmissionResultGuidAsync(repeatedRefund, "id"));
|
|
var approved = await platformClient.PostAsJsonAsync(
|
|
$"/api/platform-admin/saas/refunds/{refundId}/approve",
|
|
new ReviewPlatformRefundDto("integration test approval"));
|
|
Assert.Equal(HttpStatusCode.OK, approved.StatusCode);
|
|
using (var processorScope = factory.CreateSystemScope("Execute approved SaaS refund"))
|
|
{
|
|
var processed = await processorScope.ServiceProvider.GetRequiredService<ICommercialBillingProcessor>()
|
|
.ProcessDueAsync();
|
|
Assert.True(processed >= 1);
|
|
}
|
|
using (var partialScope = factory.CreateSystemScope("Verify partial SaaS refund"))
|
|
{
|
|
var partialDb = partialScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
Assert.Equal(PlatformBillingOrderStatus.Paid, await partialDb.PlatformBillingOrders.AsNoTracking()
|
|
.Where(value => value.OrderNo == orderNo)
|
|
.Select(value => value.Status)
|
|
.SingleAsync());
|
|
}
|
|
var excessiveRefund = await platformClient.PostAsJsonAsync(
|
|
"/api/platform-admin/saas/refunds",
|
|
refundRequest with
|
|
{
|
|
AmountCents = paymentAmountCents,
|
|
IdempotencyKey = $"refund-excessive-{Guid.NewGuid():N}"
|
|
});
|
|
Assert.Equal(HttpStatusCode.Conflict, excessiveRefund.StatusCode);
|
|
Assert.Equal("platform_billing_refund_amount_invalid", await ReadCodeAsync(excessiveRefund));
|
|
|
|
var finalRefund = await platformClient.PostAsJsonAsync(
|
|
"/api/platform-admin/saas/refunds",
|
|
refundRequest with
|
|
{
|
|
AmountCents = paymentAmountCents - paymentAmountCents / 2,
|
|
Reason = "integration test remaining refund",
|
|
IdempotencyKey = $"refund-final-{Guid.NewGuid():N}"
|
|
});
|
|
Assert.Equal(HttpStatusCode.OK, finalRefund.StatusCode);
|
|
var finalRefundId = await ReadSubmissionResultGuidAsync(finalRefund, "id");
|
|
var finalApproved = await platformClient.PostAsJsonAsync(
|
|
$"/api/platform-admin/saas/refunds/{finalRefundId}/approve",
|
|
new ReviewPlatformRefundDto("integration test final approval"));
|
|
Assert.Equal(HttpStatusCode.OK, finalApproved.StatusCode);
|
|
using (var processorScope = factory.CreateSystemScope("Execute remaining SaaS refund"))
|
|
{
|
|
var processed = await processorScope.ServiceProvider.GetRequiredService<ICommercialBillingProcessor>()
|
|
.ProcessDueAsync();
|
|
Assert.True(processed >= 1);
|
|
}
|
|
var tenantRefunds = await tenantClient.GetAsync("/api/tenant-billing/refunds");
|
|
Assert.Equal(HttpStatusCode.OK, tenantRefunds.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());
|
|
Assert.Equal(PlatformBillingRefundStatus.Succeeded, await dbContext.PlatformBillingRefunds.AsNoTracking()
|
|
.Where(value => value.Id == refundId)
|
|
.Select(value => value.Status)
|
|
.SingleAsync());
|
|
Assert.Equal(PlatformBillingRefundStatus.Succeeded, await dbContext.PlatformBillingRefunds.AsNoTracking()
|
|
.Where(value => value.Id == finalRefundId)
|
|
.Select(value => value.Status)
|
|
.SingleAsync());
|
|
Assert.Equal(PlatformBillingOrderStatus.Refunded, await dbContext.PlatformBillingOrders.AsNoTracking()
|
|
.Where(value => value.OrderNo == orderNo)
|
|
.Select(value => value.Status)
|
|
.SingleAsync());
|
|
}
|
|
|
|
[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"]
|
|
},
|
|
new TenantBillingPolicy
|
|
{
|
|
TenantId = tenantId,
|
|
CollectionMode = TenantBillingCollectionMode.Manual,
|
|
DefaultPaymentProvider = "manual",
|
|
AutoGenerateRenewal = true,
|
|
RenewalLeadDays = 14
|
|
});
|
|
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 AssertSubscriptionProjectionAsync(
|
|
ApiTestFactory factory,
|
|
Guid tenantId,
|
|
Guid subscriptionId,
|
|
TenantSaasSubscriptionStatus expectedSubscriptionStatus,
|
|
BillingStatus expectedBillingStatus)
|
|
{
|
|
using var scope = factory.CreateSystemScope("Verify subscription billing projection");
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
Assert.Equal(expectedSubscriptionStatus, await dbContext.TenantSaasSubscriptions.AsNoTracking()
|
|
.Where(value => value.Id == subscriptionId)
|
|
.Select(value => value.Status)
|
|
.SingleAsync());
|
|
Assert.Equal(expectedBillingStatus, await dbContext.Tenants.AsNoTracking()
|
|
.Where(value => value.Id == tenantId)
|
|
.Select(value => value.BillingStatus)
|
|
.SingleAsync());
|
|
}
|
|
|
|
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<Guid> ReadSubmissionResultGuidAsync(HttpResponseMessage response, string propertyName)
|
|
{
|
|
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
|
return payload.RootElement.GetProperty("result").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);
|
|
}
|