From 31add804eb0dddd1754b693424a83532b3869a53 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Sat, 12 Sep 2026 22:49:41 +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=20FakeTokenUsageEventStore=20=D0=BD=D0=B0=20NSubst?= =?UTF-8?q?itute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хелпер Support/TestTokenUsageEventStore: записи + полная логика агрегации (фильтры, группировки day/tenant/provider/model) читают состояние на момент вызова. Потребители (10 файлов) перетипизированы на .Store, фейк удалён, тесты 1340 зелёные. --- .../Api/MlOutboxFlushSchedulerTests.cs | 2 +- .../Contracts/GrpcAiToolsTests.cs | 2 +- .../Contracts/GrpcMlClientTests.cs | 2 +- .../Contracts/IntegrationsDiTests.cs | 2 +- .../Contracts/PipelineWorkerGrpcAiTests.cs | 2 +- .../Tenants/TokenUsageEventServiceTests.cs | 11 +-- .../Support/GrpcAiClassifierTests.cs | 2 +- .../OperatorAnalyticsEndpointsHttpTests.cs | 8 +- .../Support/OperatorAuthHttpHost.cs | 8 +- .../Support/TestTokenUsageEventStore.cs | 81 +++++++++++++++++++ .../Support/TokenUsageRecorderTests.cs | 19 ++--- 11 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 src/core/tests/Deal.Tests.Unit/Support/TestTokenUsageEventStore.cs diff --git a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs index 546e8c8..3986016 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs @@ -165,7 +165,7 @@ public sealed class MlOutboxFlushSchedulerTests services.AddScoped(provider => storesByTenant[TenantOf(provider)]); services.AddScoped(_ => new FakeSettingsStore()); services.AddScoped(_ => new FakeTenantLimitStore()); - services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); + services.AddScoped(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped(); services.AddLogging(); return services.BuildServiceProvider(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index a042d0a..5a637a8 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -139,7 +139,7 @@ public sealed class GrpcAiToolsTests tenantContext, connection, new AiProviderConfigBuilder(settings, cipher), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), + new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), 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 af33bce..5a43988 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs @@ -326,7 +326,7 @@ public sealed class GrpcMlClientTests settings ?? new FakeSettingsStore(), new FakeTenantLimitStore(), tenantContext, - new TokenUsageEventService(new FakeTokenUsageEventStore())), + 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 ee77d65..ad859a0 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -149,7 +149,7 @@ public sealed class IntegrationsDiTests services.AddScoped(_ => new FakeKanjStore()); services.AddScoped(_ => new FakeMlLearningStore()); services.AddScoped(_ => new FakeTenantLimitStore()); - services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); + services.AddScoped(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped(); services.AddScoped(); services.AddLogging(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index c9b078f..1f34802 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -181,7 +181,7 @@ public sealed class PipelineWorkerGrpcAiTests connection, new AiProviderConfigBuilder(settings, TestCiphers.New()), new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), + new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); IAiClassifier aiClassifier = budgeted ? new BudgetedAiClassifier( diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs index 9a48815..25f064c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs @@ -1,4 +1,5 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; @@ -13,8 +14,8 @@ public sealed class TokenUsageEventServiceTests [Fact] public async Task AppendAsync_StampsAtWithUtcNow() { - var store = new FakeTokenUsageEventStore(); - var service = new TokenUsageEventService(store); + var store = new TestTokenUsageEventStore(); + var service = new TokenUsageEventService(store.Store); DateTimeOffset before = DateTimeOffset.UtcNow; await service.AppendAsync( @@ -31,9 +32,9 @@ public sealed class TokenUsageEventServiceTests [Fact] public async Task AggregateAsync_ProxiesStoreGrouping() { - var store = new FakeTokenUsageEventStore(); - var service = new TokenUsageEventService(store); - await store.AppendAsync( + var store = new TestTokenUsageEventStore(); + var service = new TokenUsageEventService(store.Store); + await store.Store.AppendAsync( new TokenUsageEventDto( TenantId, DateTimeOffset.UtcNow, "deepseek", "m", TokenUsageEventKinds.Ai, 10, 5, 15, null), CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index 3879639..7480205 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -297,7 +297,7 @@ public sealed class GrpcAiClassifierTests connection, new AiProviderConfigBuilder(settings, cipher), new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), + new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs index 8543dc8..22f4fcc 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs @@ -48,7 +48,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant); - var events = new FakeTokenUsageEventStore(); + var events = new TestTokenUsageEventStore(); await SeedTokensAsync(events, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150); await SeedTokensAsync(events, ActiveTenant, provider: "openai", prompt: 40, completion: 10, total: 50); @@ -127,7 +127,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests [Fact] public async Task Tokens_GroupsByProviderWithTotal_AndRejectsUnknownGroupBy() { - var events = new FakeTokenUsageEventStore(); + var events = new TestTokenUsageEventStore(); await SeedTokensAsync(events, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150); await SeedTokensAsync(events, SuspendedTenant, provider: "openai", prompt: 40, completion: 10, total: 50); @@ -293,13 +293,13 @@ public sealed class OperatorAnalyticsEndpointsHttpTests CancellationToken.None); private static Task SeedTokensAsync( - FakeTokenUsageEventStore store, + TestTokenUsageEventStore store, Guid tenantId, string provider, long prompt, long completion, long total) => - store.AppendAsync( + store.Store.AppendAsync( new TokenUsageEventDto( TenantId: tenantId, At: DateTimeOffset.UtcNow.AddMinutes(-5), diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index cd46324..cbbca52 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -121,7 +121,7 @@ internal static class OperatorAuthHttpHost TestTenantStore? tenantStore = null, FakeTenantLimitStore? limitStore = null, RateLimitOptions? rateLimitOptions = null, - FakeTokenUsageEventStore? tokenUsageStore = null) => + TestTokenUsageEventStore? tokenUsageStore = null) => await RunCoreAsync( operatorStore, userStore, @@ -167,14 +167,14 @@ internal static class OperatorAuthHttpHost FakeTenantLimitStore? limitStore, Func scenario, RateLimitOptions? rateLimitOptions = null, - FakeTokenUsageEventStore? tokenUsageStore = null, + TestTokenUsageEventStore? tokenUsageStore = null, TestGlobalSettingsStore? globalSettingsStore = null) { FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore(); TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore(); TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore(); FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); - FakeTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new FakeTokenUsageEventStore(); + TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore(); TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore(); RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions(); int port = TestPort.Allocate(); @@ -196,7 +196,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(TestCiphers.New()); // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). builder.Services.AddScoped(); - builder.Services.AddSingleton(effectiveTokenUsageStore); + builder.Services.AddSingleton(effectiveTokenUsageStore.Store); builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner); builder.Services.AddScoped(); // Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте). diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestTokenUsageEventStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestTokenUsageEventStore.cs new file mode 100644 index 0000000..5d4e51c --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestTokenUsageEventStore.cs @@ -0,0 +1,81 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты проверяют записи через . +/// +public sealed class TestTokenUsageEventStore +{ + private readonly List _records = []; + + /// + /// Подставка порта событий расхода токенов (создаётся в конструкторе). + /// + public ITokenUsageEventStore Store { get; } + + /// + /// Записи хранилища в порядке добавления. + /// + public IReadOnlyList Records => _records; + + /// + /// Создаёт подставку с пустым хранилищем. + /// + public TestTokenUsageEventStore() + { + Store = Substitute.For(); + Store.When(s => s.AppendAsync(Arg.Any(), Arg.Any())) + .Do(ci => _records.Add(ci.Arg())); + Store.AggregateAsync(Arg.Any(), Arg.Any()) + .Returns(ci => Aggregate(ci.Arg())); + } + + private IReadOnlyList Aggregate(TokenUsageEventQueryDto query) + { + IEnumerable filtered = _records.Where(record => + (query.TenantId is null || record.TenantId == query.TenantId) && + (string.IsNullOrWhiteSpace(query.Provider) || record.Provider == query.Provider) && + (string.IsNullOrWhiteSpace(query.Model) || record.Model == query.Model) && + (string.IsNullOrWhiteSpace(query.Kind) || record.Kind == query.Kind) && + (query.From is null || record.At >= query.From.Value) && + (query.To is null || record.At <= query.To.Value)); + + return query.GroupBy switch + { + TokenUsageGroupBys.Day => GroupByDay(filtered), + TokenUsageGroupBys.Tenant => GroupByString(filtered, record => record.TenantId.ToString("D")), + TokenUsageGroupBys.Provider => GroupByString(filtered, record => record.Provider), + TokenUsageGroupBys.Model => GroupByString(filtered, record => record.Model), + _ => throw new ArgumentException($"Неизвестная группировка: '{query.GroupBy}'.", nameof(query)), + }; + } + + private static IReadOnlyList GroupByDay(IEnumerable source) => + source + .GroupBy(record => record.At.UtcDateTime.Date) + .OrderBy(group => group.Key) + .Select(group => ToAggregate(group.Key.ToString("yyyy-MM-dd"), group)) + .ToList(); + + private static IReadOnlyList GroupByString(IEnumerable source, Func keySelector) => + source + .GroupBy(keySelector) + .Select(group => ToAggregate(group.Key, group)) + .OrderByDescending(row => row.TotalTokens) + .ToList(); + + private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable group) + { + List rows = group.ToList(); + return new TokenUsageAggregateDto( + key, + rows.Sum(row => row.PromptTokens), + rows.Sum(row => row.CompletionTokens), + rows.Sum(row => row.TotalTokens), + rows.Count); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs index dfe6e66..ab93c26 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs @@ -4,6 +4,7 @@ using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Modules.Settings; @@ -25,7 +26,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, FakeTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None); @@ -51,7 +52,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_AccumulatesAcrossCalls() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, FakeTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, FakeTenantLimitStore 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); @@ -67,7 +68,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_NullUsage_IsNoop() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, FakeTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None); @@ -79,7 +80,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, FakeTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); // Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита // нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению. @@ -99,7 +100,7 @@ public sealed class TokenUsageRecorderTests var limits = new FakeTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно. var recorder = new TokenUsageRecorder( - settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())); + settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)); await Assert.ThrowsAsync( () => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None)); @@ -110,7 +111,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, FakeTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); // 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются. long estimated = await recorder.AddEstimatedAsync( @@ -140,14 +141,14 @@ public sealed class TokenUsageRecorderTests // Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории. // Возвращает: Кортеж (настройки, лимиты, история, recorder). - private static (FakeSettingsStore Settings, FakeTenantLimitStore Limits, FakeTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() + private static (FakeSettingsStore Settings, FakeTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() { var settings = new FakeSettingsStore(); var limits = new FakeTenantLimitStore(); - var events = new FakeTokenUsageEventStore(); + var events = new TestTokenUsageEventStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); - var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events)); + var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events.Store)); return (settings, limits, events, recorder); } }