Хелпер Support/TestTokenUsageEventStore: записи + полная логика агрегации (фильтры, группировки day/tenant/provider/model) читают состояние на момент вызова. Потребители (10 файлов) перетипизированы на .Store, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user