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