336 lines
16 KiB
C#
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);
|
|
}
|
|
}
|