Хелпер 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<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
|
||||||
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
||||||
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
||||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new FakeTokenUsageEventStore()));
|
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||||
services.AddScoped<TokenUsageRecorder>();
|
services.AddScoped<TokenUsageRecorder>();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ public sealed class GrpcAiToolsTests
|
|||||||
tenantContext,
|
tenantContext,
|
||||||
connection,
|
connection,
|
||||||
new AiProviderConfigBuilder(settings, cipher),
|
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);
|
NullLogger<GrpcAiTools>.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ public sealed class GrpcMlClientTests
|
|||||||
settings ?? new FakeSettingsStore(),
|
settings ?? new FakeSettingsStore(),
|
||||||
new FakeTenantLimitStore(),
|
new FakeTenantLimitStore(),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new TokenUsageEventService(new FakeTokenUsageEventStore())),
|
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||||
NullLogger<GrpcMlClient>.Instance);
|
NullLogger<GrpcMlClient>.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ public sealed class IntegrationsDiTests
|
|||||||
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
|
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
|
||||||
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
|
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
|
||||||
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
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<LocalFieldsParser>();
|
||||||
services.AddScoped<AiClassifyContextBuilder>();
|
services.AddScoped<AiClassifyContextBuilder>();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ public sealed class PipelineWorkerGrpcAiTests
|
|||||||
connection,
|
connection,
|
||||||
new AiProviderConfigBuilder(settings, TestCiphers.New()),
|
new AiProviderConfigBuilder(settings, TestCiphers.New()),
|
||||||
new AiClassifyContextBuilder(settings, kanjStore),
|
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);
|
NullLogger<GrpcAiClassifier>.Instance);
|
||||||
IAiClassifier aiClassifier = budgeted
|
IAiClassifier aiClassifier = budgeted
|
||||||
? new BudgetedAiClassifier(
|
? new BudgetedAiClassifier(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||||
@@ -13,8 +14,8 @@ public sealed class TokenUsageEventServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AppendAsync_StampsAtWithUtcNow()
|
public async Task AppendAsync_StampsAtWithUtcNow()
|
||||||
{
|
{
|
||||||
var store = new FakeTokenUsageEventStore();
|
var store = new TestTokenUsageEventStore();
|
||||||
var service = new TokenUsageEventService(store);
|
var service = new TokenUsageEventService(store.Store);
|
||||||
|
|
||||||
DateTimeOffset before = DateTimeOffset.UtcNow;
|
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||||
await service.AppendAsync(
|
await service.AppendAsync(
|
||||||
@@ -31,9 +32,9 @@ public sealed class TokenUsageEventServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AggregateAsync_ProxiesStoreGrouping()
|
public async Task AggregateAsync_ProxiesStoreGrouping()
|
||||||
{
|
{
|
||||||
var store = new FakeTokenUsageEventStore();
|
var store = new TestTokenUsageEventStore();
|
||||||
var service = new TokenUsageEventService(store);
|
var service = new TokenUsageEventService(store.Store);
|
||||||
await store.AppendAsync(
|
await store.Store.AppendAsync(
|
||||||
new TokenUsageEventDto(
|
new TokenUsageEventDto(
|
||||||
TenantId, DateTimeOffset.UtcNow, "deepseek", "m", TokenUsageEventKinds.Ai, 10, 5, 15, null),
|
TenantId, DateTimeOffset.UtcNow, "deepseek", "m", TokenUsageEventKinds.Ai, 10, 5, 15, null),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
connection,
|
connection,
|
||||||
new AiProviderConfigBuilder(settings, cipher),
|
new AiProviderConfigBuilder(settings, cipher),
|
||||||
new AiClassifyContextBuilder(settings, kanjStore),
|
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);
|
NullLogger<GrpcAiClassifier>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
|
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, 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: "deepseek", prompt: 100, completion: 50, total: 150);
|
||||||
await SeedTokensAsync(events, ActiveTenant, provider: "openai", prompt: 40, completion: 10, total: 50);
|
await SeedTokensAsync(events, ActiveTenant, provider: "openai", prompt: 40, completion: 10, total: 50);
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tokens_GroupsByProviderWithTotal_AndRejectsUnknownGroupBy()
|
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, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150);
|
||||||
await SeedTokensAsync(events, SuspendedTenant, provider: "openai", prompt: 40, completion: 10, total: 50);
|
await SeedTokensAsync(events, SuspendedTenant, provider: "openai", prompt: 40, completion: 10, total: 50);
|
||||||
|
|
||||||
@@ -293,13 +293,13 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
private static Task SeedTokensAsync(
|
private static Task SeedTokensAsync(
|
||||||
FakeTokenUsageEventStore store,
|
TestTokenUsageEventStore store,
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
string provider,
|
string provider,
|
||||||
long prompt,
|
long prompt,
|
||||||
long completion,
|
long completion,
|
||||||
long total) =>
|
long total) =>
|
||||||
store.AppendAsync(
|
store.Store.AppendAsync(
|
||||||
new TokenUsageEventDto(
|
new TokenUsageEventDto(
|
||||||
TenantId: tenantId,
|
TenantId: tenantId,
|
||||||
At: DateTimeOffset.UtcNow.AddMinutes(-5),
|
At: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ internal static class OperatorAuthHttpHost
|
|||||||
TestTenantStore? tenantStore = null,
|
TestTenantStore? tenantStore = null,
|
||||||
FakeTenantLimitStore? limitStore = null,
|
FakeTenantLimitStore? limitStore = null,
|
||||||
RateLimitOptions? rateLimitOptions = null,
|
RateLimitOptions? rateLimitOptions = null,
|
||||||
FakeTokenUsageEventStore? tokenUsageStore = null) =>
|
TestTokenUsageEventStore? tokenUsageStore = null) =>
|
||||||
await RunCoreAsync(
|
await RunCoreAsync(
|
||||||
operatorStore,
|
operatorStore,
|
||||||
userStore,
|
userStore,
|
||||||
@@ -167,14 +167,14 @@ internal static class OperatorAuthHttpHost
|
|||||||
FakeTenantLimitStore? limitStore,
|
FakeTenantLimitStore? limitStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
|
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
|
||||||
RateLimitOptions? rateLimitOptions = null,
|
RateLimitOptions? rateLimitOptions = null,
|
||||||
FakeTokenUsageEventStore? tokenUsageStore = null,
|
TestTokenUsageEventStore? tokenUsageStore = null,
|
||||||
TestGlobalSettingsStore? globalSettingsStore = null)
|
TestGlobalSettingsStore? globalSettingsStore = null)
|
||||||
{
|
{
|
||||||
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
|
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
|
||||||
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
|
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
|
||||||
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
|
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
|
||||||
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
|
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
|
||||||
FakeTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new FakeTokenUsageEventStore();
|
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore();
|
||||||
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
|
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
|
||||||
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
|
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
|
||||||
int port = TestPort.Allocate();
|
int port = TestPort.Allocate();
|
||||||
@@ -196,7 +196,7 @@ internal static class OperatorAuthHttpHost
|
|||||||
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
|
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
|
||||||
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
|
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
|
||||||
builder.Services.AddScoped<TelegramKeysService>();
|
builder.Services.AddScoped<TelegramKeysService>();
|
||||||
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore);
|
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore.Store);
|
||||||
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
|
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
|
||||||
builder.Services.AddScoped<TenantSchemaMigrationService>();
|
builder.Services.AddScoped<TenantSchemaMigrationService>();
|
||||||
// Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте).
|
// Опции кук — по умолчанию (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.Infrastructure.Integrations.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
@@ -25,7 +26,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent()
|
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);
|
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]
|
[Fact]
|
||||||
public async Task AddAsync_AccumulatesAcrossCalls()
|
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 = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None);
|
||||||
await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "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]
|
[Fact]
|
||||||
public async Task AddAsync_NullUsage_IsNoop()
|
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);
|
await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits()
|
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 и событие пишутся (вызов состоялся), строка лимита
|
// Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
|
||||||
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
|
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
|
||||||
@@ -99,7 +100,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
var limits = new FakeTenantLimitStore();
|
var limits = new FakeTenantLimitStore();
|
||||||
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
|
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
|
||||||
var recorder = new TokenUsageRecorder(
|
var recorder = new TokenUsageRecorder(
|
||||||
settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore()));
|
settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
|
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
|
||||||
@@ -110,7 +111,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate()
|
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-счётчик не трогаются.
|
// 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются.
|
||||||
long estimated = await recorder.AddEstimatedAsync(
|
long estimated = await recorder.AddEstimatedAsync(
|
||||||
@@ -140,14 +141,14 @@ public sealed class TokenUsageRecorderTests
|
|||||||
|
|
||||||
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
|
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
|
||||||
// Возвращает: Кортеж (настройки, лимиты, история, recorder).
|
// Возвращает: Кортеж (настройки, лимиты, история, 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 settings = new FakeSettingsStore();
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new FakeTenantLimitStore();
|
||||||
var events = new FakeTokenUsageEventStore();
|
var events = new TestTokenUsageEventStore();
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
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);
|
return (settings, limits, events, recorder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user