using Deal.Api.Configuration; using Deal.Api.Hosting; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Api; /// /// Тесты логики прохода (этап 12, пакет B): авто-очистка /// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка /// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный /// . /// /// /// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (, /// , ), зеркалящими семантику /// EF-адаптеров. Срок хранения аудита — . /// public sealed class DataRetentionSchedulerTests { // Тенант сценария (строка лимита). private static readonly Guid TenantId = Guid.NewGuid(); // Срок хранения аудита сценария (дней). private const int RetentionDays = 180; [Fact] public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() { DateTimeOffset now = DateTimeOffset.UtcNow; var audit = new FakeAuditLogStore(); await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); var limits = new FakeTenantLimitStore(); // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); var counters = new FakeRateLimitCounterStore(); await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); await using ServiceProvider provider = BuildProvider(audit, limits, counters); DataRetentionScheduler scheduler = new( provider.GetRequiredService(), new DataRetentionOptions { Enabled = true, AuditRetentionDays = RetentionDays }, NullLogger.Instance); await scheduler.RunCycleAsync(CancellationToken.None); // Аудит: старая запись удалена, свежая осталась. var remainingAudit = Assert.Single(audit.Records); Assert.Equal(AuditEvents.TenantLoginOk, remainingAudit.EventType); // Лимиты: накопления прошедшего периода сброшены. Assert.Equal(0, limits.UsedTokens(TenantId)); // Счётчики: завершившееся окно удалено, активное осталось. Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); } /// /// Повторный проход на тех же данных — идемпотентен (чистить больше нечего). /// [Fact] public async Task RunCycle_IsIdempotent() { DateTimeOffset now = DateTimeOffset.UtcNow; var audit = new FakeAuditLogStore(); await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); var limits = new FakeTenantLimitStore(); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); DataRetentionScheduler scheduler = new( provider.GetRequiredService(), new DataRetentionOptions { AuditRetentionDays = RetentionDays }, NullLogger.Instance); await scheduler.RunCycleAsync(CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None); Assert.Empty(audit.Records); Assert.Equal(0, limits.UsedTokens(TenantId)); } // Строит DI-провайдер теста: три фейк-хранилища в scope прохода. // audit: Фейк-хранилище аудита (записи посеяны сценарием). // limits: Фейк-хранилище лимитов (строки посеяны сценарием). // counters: Фейк-хранилище счётчиков (окна посеяны сценарием). // Возвращает: Провайдер с сервисами цикла. private static ServiceProvider BuildProvider( FakeAuditLogStore audit, FakeTenantLimitStore limits, FakeRateLimitCounterStore counters) { var services = new ServiceCollection(); services.AddScoped(_ => audit); services.AddScoped(_ => limits); services.AddScoped(_ => counters); return services.BuildServiceProvider(); } // Запись аудита сценария. // eventType: Тип события. // at: Момент события (UTC). // Возвращает: DTO записи аудита. private static AuditRecordDto AuditRecord(string eventType, DateTimeOffset at) => new(eventType, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: "127.0.0.1", DetailJson: null, at); }