From c5fd516a3602080ec5f5f73f3c3f8e11f278a595 Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 01:02:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20FakeTenantLimitStore=20=D0=BD=D0=B0=20NSubstitut?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хелпер Support/TestTenantLimitStore: строки лимитов с полной бюджетной семантикой (GetOrCreate/GetState/AddUsage/UpdateBudget, Warned80/ NotifiedExhausted, сброс истёкших периодов, сценарий сбоя чтения), DI получает .Store. Потребители (13 файлов) перетипизированы, фейк удалён, тесты 1340 зелёные. --- .../Api/DataRetentionSchedulerTests.cs | 8 +-- .../Api/MlOutboxFlushSchedulerTests.cs | 2 +- .../Api/RuntimeDepthsCollectorTests.cs | 12 ++-- .../Contracts/BudgetedAiClassifierTests.cs | 8 +-- .../Contracts/BudgetedAiToolsTests.cs | 8 +-- .../Contracts/GrpcAiToolsTests.cs | 9 +-- .../Contracts/GrpcMlClientTests.cs | 2 +- .../Contracts/IntegrationsDiTests.cs | 2 +- .../Contracts/PipelineWorkerGrpcAiTests.cs | 14 ++--- ...tLimitStore.cs => TestTenantLimitStore.cs} | 61 ++++++++++++------- .../Support/BudgetAlertSchedulerTests.cs | 19 +++--- .../Support/GrpcAiClassifierTests.cs | 11 ++-- .../Support/OperatorAuthHttpHost.cs | 12 ++-- .../OperatorHealthEndpointsHttpTests.cs | 2 +- .../OperatorLimitsEndpointsHttpTests.cs | 4 +- .../Support/TokenUsageRecorderTests.cs | 22 +++---- 16 files changed, 107 insertions(+), 89 deletions(-) rename src/core/tests/Deal.Tests.Unit/Modules/Tenants/{FakeTenantLimitStore.cs => TestTenantLimitStore.cs} (72%) diff --git a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs index bf5ce5e..60b1d53 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs @@ -28,7 +28,7 @@ public sealed class DataRetentionSchedulerTests await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); @@ -65,7 +65,7 @@ public sealed class DataRetentionSchedulerTests DateTimeOffset now = DateTimeOffset.UtcNow; var audit = new TestAuditLogStore(); await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore()); DataRetentionScheduler scheduler = new( @@ -87,12 +87,12 @@ public sealed class DataRetentionSchedulerTests // Возвращает: Провайдер с сервисами цикла. private static ServiceProvider BuildProvider( TestAuditLogStore audit, - FakeTenantLimitStore limits, + TestTenantLimitStore limits, TestRateLimitCounterStore counters) { var services = new ServiceCollection(); services.AddScoped(_ => audit.Store); - services.AddScoped(_ => limits); + services.AddScoped(_ => limits.Store); services.AddScoped(_ => counters.Store); return services.BuildServiceProvider(); } diff --git a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs index 3986016..1958270 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs @@ -164,7 +164,7 @@ public sealed class MlOutboxFlushSchedulerTests // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. services.AddScoped(provider => storesByTenant[TenantOf(provider)]); services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => new FakeTenantLimitStore()); + services.AddScoped(_ => new TestTenantLimitStore().Store); services.AddScoped(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped(); services.AddLogging(); diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index 6a46433..7fe73cc 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests [Fact] public async Task CollectAsync_CollectsBudgetRatiosPerTenant() { - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); DateTimeOffset periodStart = DateTimeOffset.UtcNow; limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80); limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55); @@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests PipelineStores(TenantA, TenantB, TenantC), OutboxStores(TenantA, TenantB, TenantC), TenantRecords(TenantA, TenantB, TenantC), - limits); + limits.Store); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); @@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests [Fact] public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant() { - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); limits.FailStateReads.Add(TenantA); limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80); @@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests PipelineStores(TenantA, TenantB), OutboxStores(TenantA, TenantB), TenantRecords(TenantA, TenantB), - limits); + limits.Store); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); @@ -158,7 +158,7 @@ public sealed class RuntimeDepthsCollectorTests services.AddScoped(); services.AddScoped(); services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)]); - services.AddSingleton(limitStore ?? new FakeTenantLimitStore()); + services.AddSingleton(limitStore ?? new TestTenantLimitStore().Store); ServiceProvider provider = services.BuildServiceProvider(); return new RuntimeDepthsCollector( @@ -172,4 +172,4 @@ public sealed class RuntimeDepthsCollectorTests ITenantContext context = provider.GetRequiredService(); return context.TenantId!.Value.Value; } -} +} \ No newline at end of file diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs index 5956995..4503889 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs @@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts; /// public sealed class BudgetedAiClassifierTests { - // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. + // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore. private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; // Guid того же тенанта — ключ строки лимита. @@ -158,10 +158,10 @@ public sealed class BudgetedAiClassifierTests // (зеркало регистрации AddDealIntegrations при UseLocal=false). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // Возвращает: Контекст теста. - private static Context Create(Action? configure = null) + private static Context Create(Action? configure = null) { var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); configure?.Invoke(limits); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); @@ -169,7 +169,7 @@ public sealed class BudgetedAiClassifierTests IAiClassifier decorator = new BudgetedAiClassifier( paid.Classifier, new LocalAiClassifier(new LocalFieldsParser(settings)), - limits, + limits.Store, tenantContext, NullLogger.Instance); return new Context(decorator, paid, settings); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs index daac8df..5a5b6dd 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs @@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts; /// public sealed class BudgetedAiToolsTests { - // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. + // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore. private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; // Guid того же тенанта — ключ строки лимита. @@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests // AddDealIntegrations при UseLocal=false). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // Возвращает: Контекст теста. - private static Context Create(Action? configure = null) + private static Context Create(Action? configure = null) { - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); configure?.Invoke(limits); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var paid = TestAiTools.New(); IAiTools decorator = new BudgetedAiTools( paid, - limits, + limits.Store, tenantContext, NullLogger.Instance); return new Context(decorator, paid); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index 5a637a8..76cfd5c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -41,7 +41,7 @@ public sealed class GrpcAiToolsTests service.GenerateKeywordsReply = reply; FakeSettingsStore settings = new(); ISecretCipher cipher = TestCiphers.New(); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiTools tools = CreateTools(port, settings, cipher, limits); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); @@ -88,7 +88,7 @@ public sealed class GrpcAiToolsTests Reason = "другая сфера", Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, }; - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits); AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( @@ -130,8 +130,9 @@ public sealed class GrpcAiToolsTests int port, FakeSettingsStore settings, ISecretCipher cipher, - FakeTenantLimitStore? limits = null) + TestTenantLimitStore? limits = null) { + limits ??= new TestTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); @@ -139,7 +140,7 @@ public sealed class GrpcAiToolsTests tenantContext, connection, new AiProviderConfigBuilder(settings, cipher), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), + new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs index 5a43988..13ecced 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs @@ -324,7 +324,7 @@ public sealed class GrpcMlClientTests cache ?? new MlStatusCache(), new TokenUsageRecorder( settings ?? new FakeSettingsStore(), - new FakeTenantLimitStore(), + new TestTenantLimitStore().Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs index ad859a0..5bd8604 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -148,7 +148,7 @@ public sealed class IntegrationsDiTests services.AddScoped(_ => TestCiphers.New()); services.AddScoped(_ => new FakeKanjStore()); services.AddScoped(_ => new FakeMlLearningStore()); - services.AddScoped(_ => new FakeTenantLimitStore()); + services.AddScoped(_ => new TestTenantLimitStore().Store); services.AddScoped(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped(); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index ccb3043..4ea2314 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -34,7 +34,7 @@ public sealed class PipelineWorkerGrpcAiTests // Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service. private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab"; - // Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage). + // Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage). private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); [Fact] @@ -127,7 +127,7 @@ public sealed class PipelineWorkerGrpcAiTests Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, }; - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000); Context ctx = CreateContext(port, limits, budgeted: true); ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц")); @@ -157,11 +157,11 @@ public sealed class PipelineWorkerGrpcAiTests TestPipelineStore PipelineStore, FakeKanjStore KanjStore, FakeSettingsStore Settings, - FakeTenantLimitStore Limits); + TestTenantLimitStore Limits); private static Context CreateContext( int port, - FakeTenantLimitStore? limits = null, + TestTenantLimitStore? limits = null, bool budgeted = false) { var settings = new FakeSettingsStore(); @@ -175,19 +175,19 @@ public sealed class PipelineWorkerGrpcAiTests tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection( new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); - limits ??= new FakeTenantLimitStore(); + limits ??= new TestTenantLimitStore(); var grpcClassifier = new GrpcAiClassifier( tenantContext, connection, new AiProviderConfigBuilder(settings, TestCiphers.New()), new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), + new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); IAiClassifier aiClassifier = budgeted ? new BudgetedAiClassifier( grpcClassifier, new LocalAiClassifier(fieldsParser), - limits, + limits.Store, tenantContext, NullLogger.Instance) : grpcClassifier; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TestTenantLimitStore.cs similarity index 72% rename from src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs rename to src/core/tests/Deal.Tests.Unit/Modules/Tenants/TestTenantLimitStore.cs index cb1cb2c..ff224e1 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TestTenantLimitStore.cs @@ -1,13 +1,15 @@ using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; +using NSubstitute; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит-тестов recorder'а и бюджетного гейта/алертов +/// Подставка для юнит-тестов recorder'а и бюджетного гейта/алертов: +/// сервисы получают NSubstitute-подставку (), тесты управляют сценарием через свойства /// -public sealed class FakeTenantLimitStore : ITenantLimitStore +public sealed class TestTenantLimitStore { private sealed class Row { @@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore private readonly Dictionary _rows = new(); private readonly TokenBudgetService _budgetService = new(); + + /// + /// Подставка порта лимитов (создаётся в конструкторе). + /// + public ITenantLimitStore Store { get; } private readonly Func _utcNow; /// - /// Создаёт фейк с системными часами и статусом тенанта active. + /// Создаёт подставку с системными часами и статусом тенанта active. /// - public FakeTenantLimitStore() + public TestTenantLimitStore() : this(() => DateTimeOffset.UtcNow, TenantStatuses.Active) { } /// - /// Создаёт фейк с заданными часами и статусом тенанта + /// Создаёт подставку с заданными часами и статусом тенанта /// /// Источник текущего времени (UTC). /// Статус тенанта для всех строк (константа ). - public FakeTenantLimitStore(Func utcNow, string tenantStatus = TenantStatuses.Active) + public TestTenantLimitStore(Func utcNow, string tenantStatus = TenantStatuses.Active) { ArgumentNullException.ThrowIfNull(utcNow); _utcNow = utcNow; TenantStatus = tenantStatus; + Store = Substitute.For(); + Store.GetOrCreateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => GetOrCreateAsync(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Store.GetStateAsync(Arg.Any(), Arg.Any()) + .Returns(ci => GetStateAsync(ci.ArgAt(0), ci.ArgAt(1))); + Store.AddUsageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => AddUsageAsync(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Store.UpdateBudgetAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => UpdateBudgetAsync(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2), ci.ArgAt(3))); + Store.TryMarkWarnedAsync(Arg.Any(), Arg.Any()) + .Returns(ci => TryMarkWarnedAsync(ci.ArgAt(0), ci.ArgAt(1))); + Store.TryMarkNotifiedExhaustedAsync(Arg.Any(), Arg.Any()) + .Returns(ci => TryMarkNotifiedExhaustedAsync(ci.ArgAt(0), ci.ArgAt(1))); + Store.ResetExpiredPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ResetExpiredPeriodsAsync(ci.ArgAt(0), ci.ArgAt(1))); } /// @@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore public string TenantStatus { get; set; } /// - /// Тенанты, для которых бросает исключение (устойчивость сборщиков) + /// Тенанты, для которых подставки бросает исключение (устойчивость сборщиков) /// public HashSet FailStateReads { get; } = new(); /// - /// Кладёт готовую строку лимита + /// Кладёт готовую строку лимита (в состояние подставки) /// /// Тенант. /// Бюджет периода. @@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore /// True — строка существует. public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId); - /// - public Task GetOrCreateAsync( + private Task GetOrCreateAsync( Guid tenantId, CancellationToken ct, - TokenLimitDefaults? defaults = null) + TokenLimitDefaults? defaults) { Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default); return Task.FromResult(ToLimitDto(row, tenantId)); } - /// - public Task GetStateAsync(Guid tenantId, CancellationToken ct) + private Task GetStateAsync(Guid tenantId, CancellationToken ct) { if (FailStateReads.Contains(tenantId)) { @@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(ToStateDto(row, tenantId)); } - /// - public Task AddUsageAsync( + private Task AddUsageAsync( Guid tenantId, long tokens, CancellationToken ct) @@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(ToStateDto(row, tenantId)); } - /// - public Task UpdateBudgetAsync( + private Task UpdateBudgetAsync( Guid tenantId, long budgetTokens, string period, @@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(ToStateDto(row, tenantId)); } - /// - public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) + private Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) { Row row = Ensure(tenantId, TokenBudgetDefaults.Default); ResetIfPeriodExpired(row); @@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(true); } - /// - public Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) + private Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) { Row row = Ensure(tenantId, TokenBudgetDefaults.Default); ResetIfPeriodExpired(row); @@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(true); } - /// - public Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) + private Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) { int reset = 0; foreach (Row row in _rows.Values) diff --git a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs index 18cd384..0243af3 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs @@ -3,6 +3,7 @@ using Deal.Api.Events; using Deal.Api.Hosting; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Tenants; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() { - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); await using ServiceProvider provider = BuildProvider( @@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() { - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); @@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests Assert.Empty(ReadToasts(subscriptionA)); // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). - Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); + Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None)); } [Fact] @@ -74,7 +75,7 @@ public sealed class BudgetAlertSchedulerTests { // Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены, // расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу. - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); @@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() { - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); @@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests SseSubscription subscriptionA = broker.Subscribe(TenantA); BudgetAlertScheduler scheduler = CreateScheduler(provider); - await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); + await limits.Store.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None); Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); @@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется // Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. - await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); + await limits.Store.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None); Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); @@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests // tenants: Фейк реестра тенантов (обход прохода). // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). // Возвращает: Провайдер с сервисами цикла. - private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) + private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits) { var services = new ServiceCollection(); services.AddSingleton(tenants); - services.AddScoped(_ => limits); + services.AddScoped(_ => limits.Store); services.AddSingleton(); return services.BuildServiceProvider(); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index 7480205..a084a1b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -57,7 +57,7 @@ public sealed class GrpcAiClassifierTests settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); @@ -152,7 +152,7 @@ public sealed class GrpcAiClassifierTests Order = 0, }); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); @@ -193,7 +193,7 @@ public sealed class GrpcAiClassifierTests }; (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). @@ -287,8 +287,9 @@ public sealed class GrpcAiClassifierTests FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore, - FakeTenantLimitStore? limits = null) + TestTenantLimitStore? limits = null) { + limits ??= new TestTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); @@ -297,7 +298,7 @@ public sealed class GrpcAiClassifierTests connection, new AiProviderConfigBuilder(settings, cipher), new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), + new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index f52554f..4a5f1dc 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -115,11 +115,11 @@ internal static class OperatorAuthHttpHost public static async Task RunAsync( TestOperatorAuthStore operatorStore, FakeAuthStore userStore, - Func scenario, + Func scenario, TestAuditLogStore? auditStore = null, TestInviteStore? inviteStore = null, TestTenantStore? tenantStore = null, - FakeTenantLimitStore? limitStore = null, + TestTenantLimitStore? limitStore = null, RateLimitOptions? rateLimitOptions = null, TestTokenUsageEventStore? tokenUsageStore = null) => await RunCoreAsync( @@ -164,8 +164,8 @@ internal static class OperatorAuthHttpHost TestAuditLogStore? auditStore, TestInviteStore? inviteStore, TestTenantStore? tenantStore, - FakeTenantLimitStore? limitStore, - Func scenario, + TestTenantLimitStore? limitStore, + Func scenario, RateLimitOptions? rateLimitOptions = null, TestTokenUsageEventStore? tokenUsageStore = null, TestGlobalSettingsStore? globalSettingsStore = null) @@ -173,7 +173,7 @@ internal static class OperatorAuthHttpHost TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore(); TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore(); TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore(); - FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); + TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore(); TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore(); TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore(); RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions(); @@ -191,7 +191,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(effectiveAuditStore.Store); builder.Services.AddSingleton(effectiveInviteStore.Store); builder.Services.AddSingleton(effectiveTenantStore.Repository); - builder.Services.AddSingleton(effectiveLimitStore); + builder.Services.AddSingleton(effectiveLimitStore.Store); builder.Services.AddSingleton(effectiveGlobalSettingsStore.Store); builder.Services.AddSingleton(TestCiphers.New()); // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs index 190ea3b..127a6aa 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs @@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests } // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). - private static Task RunAsync(Func scenario) => + private static Task RunAsync(Func scenario) => OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); // Фейк-хранилище оператора с активным оператором operator/operator. diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs index 7803157..b3c389f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs @@ -284,14 +284,14 @@ public sealed class OperatorLimitsEndpointsHttpTests $"{baseAddress}/api/operator/tenants/{tenantId}/limit"; // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. - private static Task RunAsync(Func scenario, TestAuditLogStore? auditStore = null) + private static Task RunAsync(Func scenario, TestAuditLogStore? auditStore = null) { // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). var tenantStore = new TestTenantStore( new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow), new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow)); - var limitStore = new FakeTenantLimitStore(); + var limitStore = new TestTenantLimitStore(); limitStore.Preload(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true); limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); return OperatorAuthHttpHost.RunAsync( diff --git a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs index ab93c26..0590c00 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs @@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Support; /// public sealed class TokenUsageRecorderTests { - // Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore. + // Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore. private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); @@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None); @@ -52,7 +52,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_AccumulatesAcrossCalls() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None); await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None); @@ -68,7 +68,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_NullUsage_IsNoop() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None); @@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); // Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита // нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению. @@ -97,10 +97,10 @@ public sealed class TokenUsageRecorderTests public async Task AddAsync_OutsideTenantContext_Throws() { var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно. var recorder = new TokenUsageRecorder( - settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)); + settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)); await Assert.ThrowsAsync( () => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None)); @@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); // 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются. long estimated = await recorder.AddEstimatedAsync( @@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests // Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории. // Возвращает: Кортеж (настройки, лимиты, история, recorder). - private static (FakeSettingsStore Settings, FakeTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() + private static (FakeSettingsStore Settings, TestTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() { var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); var events = new TestTokenUsageEventStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); - var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events.Store)); + var recorder = new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(events.Store)); return (settings, limits, events, recorder); } }