Перевести FakeTokenUsageEventStore на NSubstitute
ci / build-test (push) Successful in 2m52s

Хелпер Support/TestTokenUsageEventStore: записи + полная логика агрегации
(фильтры, группировки day/tenant/provider/model) читают состояние на момент
вызова. Потребители (10 файлов) перетипизированы на .Store, фейк удалён,
тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 22:49:41 +03:00
parent 1db703c93b
commit 31add804eb
11 changed files with 111 additions and 28 deletions
@@ -165,7 +165,7 @@ public sealed class MlOutboxFlushSchedulerTests
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new FakeTokenUsageEventStore()));
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>();
services.AddLogging();
return services.BuildServiceProvider();
@@ -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<GrpcAiTools>.Instance);
}
}
@@ -326,7 +326,7 @@ public sealed class GrpcMlClientTests
settings ?? new FakeSettingsStore(),
new FakeTenantLimitStore(),
tenantContext,
new TokenUsageEventService(new FakeTokenUsageEventStore())),
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcMlClient>.Instance);
}
}
@@ -149,7 +149,7 @@ public sealed class IntegrationsDiTests
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new FakeTokenUsageEventStore()));
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<LocalFieldsParser>();
services.AddScoped<AiClassifyContextBuilder>();
services.AddLogging();
@@ -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<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier(
@@ -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);
@@ -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<GrpcAiClassifier>.Instance);
}
@@ -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),
@@ -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<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> 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<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore);
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore.Store);
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped<TenantSchemaMigrationService>();
// Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте).
@@ -0,0 +1,81 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ITokenUsageEventStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты проверяют записи через <see cref="Records"/>.
/// </summary>
public sealed class TestTokenUsageEventStore
{
private readonly List<TokenUsageEventDto> _records = [];
/// <summary>
/// Подставка порта событий расхода токенов (создаётся в конструкторе).
/// </summary>
public ITokenUsageEventStore Store { get; }
/// <summary>
/// Записи хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<TokenUsageEventDto> Records => _records;
/// <summary>
/// Создаёт подставку с пустым хранилищем.
/// </summary>
public TestTokenUsageEventStore()
{
Store = Substitute.For<ITokenUsageEventStore>();
Store.When(s => s.AppendAsync(Arg.Any<TokenUsageEventDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _records.Add(ci.Arg<TokenUsageEventDto>()));
Store.AggregateAsync(Arg.Any<TokenUsageEventQueryDto>(), Arg.Any<CancellationToken>())
.Returns(ci => Aggregate(ci.Arg<TokenUsageEventQueryDto>()));
}
private IReadOnlyList<TokenUsageAggregateDto> Aggregate(TokenUsageEventQueryDto query)
{
IEnumerable<TokenUsageEventDto> 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<TokenUsageAggregateDto> GroupByDay(IEnumerable<TokenUsageEventDto> 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<TokenUsageAggregateDto> GroupByString(IEnumerable<TokenUsageEventDto> source, Func<TokenUsageEventDto, string> keySelector) =>
source
.GroupBy(keySelector)
.Select(group => ToAggregate(group.Key, group))
.OrderByDescending(row => row.TotalTokens)
.ToList();
private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable<TokenUsageEventDto> group)
{
List<TokenUsageEventDto> rows = group.ToList();
return new TokenUsageAggregateDto(
key,
rows.Sum(row => row.PromptTokens),
rows.Sum(row => row.CompletionTokens),
rows.Sum(row => row.TotalTokens),
rows.Count);
}
}
@@ -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<InvalidOperationException>(
() => 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);
}
}