Files
tiku-backend.net/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs

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