Files
tiku-backend.net/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

1251 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, 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, 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/saas/subscriptions/trial", trialRequest);
var repeatedTrial = await client.PostAsJsonAsync("/api/platform/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/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/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/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/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/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/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/saas/features",
new UpsertSaasFeatureDto(
null,
SaasFeatureCatalog.PrivateQuestionBank,
"私有题库",
"content",
"租户私有题库",
20_000,
"CNY",
SaasFeatureStatus.Active,
10));
var limit = await client.PutAsJsonAsync(
"/api/platform/saas/feature-limits",
new UpsertSaasFeatureLimitDto(
null,
SaasQuotaMetricCatalog.PrivateQuestionCount,
SaasFeatureCatalog.PrivateQuestionBank,
"私有题目数",
"count",
SaasFeatureLimitKind.Current,
80,
true));
var offering = await client.PutAsJsonAsync(
"/api/platform/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/saas/offering-versions", versionRequest);
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
var versionId = await ReadGuidAsync(draft, "id");
var publish = await client.PostAsync($"/api/platform/saas/offering-versions/{versionId}/publish", null);
var mutatePublished = await client.PutAsJsonAsync(
"/api/platform/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, 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/saas/payments/manual/confirm",
confirmation);
var repeatedConfirm = await platformClient.PostAsJsonAsync(
"/api/platform/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/saas/refunds", refundRequest);
var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform/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/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/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/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/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/tenant/access/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/tenant/access/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/tenant/access/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/tenant/access/roles/{roleId}/bindings",
new ReplaceRoleBindingsDto
{
PermissionCodes = [BackendPermissions.TenantVideoManage],
MenuCodes = []
});
var questionBinding = await client.PutAsJsonAsync(
$"/api/tenant/access/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, 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), 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()
{
return new ApiTestFactory(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)
{
return new SaasOfferingVersion
{
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 LifecycleFixture(
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)
{
return 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);
}