Перевести FakeTenantLimitStore на NSubstitute

Хелпер Support/TestTenantLimitStore: строки лимитов с полной бюджетной
семантикой (GetOrCreate/GetState/AddUsage/UpdateBudget, Warned80/
NotifiedExhausted, сброс истёкших периодов, сценарий сбоя чтения), DI получает
.Store. Потребители (13 файлов) перетипизированы, фейк удалён,
тесты 1340 зелёные.
This commit is contained in:
2026-09-13 01:02:35 +03:00
parent a63bff7904
commit c5fd516a36
16 changed files with 107 additions and 89 deletions
@@ -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.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), 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); 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; DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new TestAuditLogStore(); var audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); 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); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore()); await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
DataRetentionScheduler scheduler = new( DataRetentionScheduler scheduler = new(
@@ -87,12 +87,12 @@ public sealed class DataRetentionSchedulerTests
// Возвращает: Провайдер с сервисами цикла. // Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
TestAuditLogStore audit, TestAuditLogStore audit,
FakeTenantLimitStore limits, TestTenantLimitStore limits,
TestRateLimitCounterStore counters) TestRateLimitCounterStore counters)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit.Store); services.AddScoped<IAuditLogStore>(_ => audit.Store);
services.AddScoped<ITenantLimitStore>(_ => limits); services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store); services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
@@ -164,7 +164,7 @@ public sealed class MlOutboxFlushSchedulerTests
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
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 TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>(); services.AddScoped<TokenUsageRecorder>();
services.AddLogging(); services.AddLogging();
@@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact] [Fact]
public async Task CollectAsync_CollectsBudgetRatiosPerTenant() public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
{ {
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
DateTimeOffset periodStart = DateTimeOffset.UtcNow; DateTimeOffset periodStart = DateTimeOffset.UtcNow;
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80); limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55); limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
@@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB, TenantC), PipelineStores(TenantA, TenantB, TenantC),
OutboxStores(TenantA, TenantB, TenantC), OutboxStores(TenantA, TenantB, TenantC),
TenantRecords(TenantA, TenantB, TenantC), TenantRecords(TenantA, TenantB, TenantC),
limits); limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact] [Fact]
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant() public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
{ {
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
limits.FailStateReads.Add(TenantA); limits.FailStateReads.Add(TenantA);
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80); limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
@@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB), PipelineStores(TenantA, TenantB),
OutboxStores(TenantA, TenantB), OutboxStores(TenantA, TenantB),
TenantRecords(TenantA, TenantB), TenantRecords(TenantA, TenantB),
limits); limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -158,7 +158,7 @@ public sealed class RuntimeDepthsCollectorTests
services.AddScoped<PipelineIngestService>(); services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>(); services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]); services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore()); services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
ServiceProvider provider = services.BuildServiceProvider(); ServiceProvider provider = services.BuildServiceProvider();
return new RuntimeDepthsCollector( return new RuntimeDepthsCollector(
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary> /// </summary>
public sealed class BudgetedAiClassifierTests public sealed class BudgetedAiClassifierTests
{ {
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита. // Guid того же тенанта — ключ строки лимита.
@@ -158,10 +158,10 @@ public sealed class BudgetedAiClassifierTests
// (зеркало регистрации AddDealIntegrations при UseLocal=false). // (зеркало регистрации AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста. // Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null) private static Context Create(Action<TestTenantLimitStore>? configure = null)
{ {
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
configure?.Invoke(limits); configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
@@ -169,7 +169,7 @@ public sealed class BudgetedAiClassifierTests
IAiClassifier decorator = new BudgetedAiClassifier( IAiClassifier decorator = new BudgetedAiClassifier(
paid.Classifier, paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)), new LocalAiClassifier(new LocalFieldsParser(settings)),
limits, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiClassifier>.Instance); NullLogger<BudgetedAiClassifier>.Instance);
return new Context(decorator, paid, settings); return new Context(decorator, paid, settings);
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary> /// </summary>
public sealed class BudgetedAiToolsTests public sealed class BudgetedAiToolsTests
{ {
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита. // Guid того же тенанта — ключ строки лимита.
@@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests
// AddDealIntegrations при UseLocal=false). // AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста. // Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null) private static Context Create(Action<TestTenantLimitStore>? configure = null)
{ {
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
configure?.Invoke(limits); configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = TestAiTools.New(); var paid = TestAiTools.New();
IAiTools decorator = new BudgetedAiTools( IAiTools decorator = new BudgetedAiTools(
paid, paid,
limits, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiTools>.Instance); NullLogger<BudgetedAiTools>.Instance);
return new Context(decorator, paid); return new Context(decorator, paid);
@@ -41,7 +41,7 @@ public sealed class GrpcAiToolsTests
service.GenerateKeywordsReply = reply; service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new(); FakeSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New(); ISecretCipher cipher = TestCiphers.New();
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, settings, cipher, limits); IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
@@ -88,7 +88,7 @@ public sealed class GrpcAiToolsTests
Reason = "другая сфера", Reason = "другая сфера",
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
}; };
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits); IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
@@ -130,8 +130,9 @@ public sealed class GrpcAiToolsTests
int port, int port,
FakeSettingsStore settings, FakeSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
FakeTenantLimitStore? limits = null) TestTenantLimitStore? limits = null)
{ {
limits ??= new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
@@ -139,7 +140,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 TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance); NullLogger<GrpcAiTools>.Instance);
} }
} }
@@ -324,7 +324,7 @@ public sealed class GrpcMlClientTests
cache ?? new MlStatusCache(), cache ?? new MlStatusCache(),
new TokenUsageRecorder( new TokenUsageRecorder(
settings ?? new FakeSettingsStore(), settings ?? new FakeSettingsStore(),
new FakeTenantLimitStore(), new TestTenantLimitStore().Store,
tenantContext, tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcMlClient>.Instance); NullLogger<GrpcMlClient>.Instance);
@@ -148,7 +148,7 @@ public sealed class IntegrationsDiTests
services.AddScoped<ISecretCipher>(_ => TestCiphers.New()); services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
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 TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<LocalFieldsParser>(); services.AddScoped<LocalFieldsParser>();
services.AddScoped<AiClassifyContextBuilder>(); services.AddScoped<AiClassifyContextBuilder>();
@@ -34,7 +34,7 @@ public sealed class PipelineWorkerGrpcAiTests
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service. // Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab"; private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage). // Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage).
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact] [Fact]
@@ -127,7 +127,7 @@ public sealed class PipelineWorkerGrpcAiTests
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, 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); limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000);
Context ctx = CreateContext(port, limits, budgeted: true); Context ctx = CreateContext(port, limits, budgeted: true);
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц")); ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
@@ -157,11 +157,11 @@ public sealed class PipelineWorkerGrpcAiTests
TestPipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, FakeKanjStore KanjStore,
FakeSettingsStore Settings, FakeSettingsStore Settings,
FakeTenantLimitStore Limits); TestTenantLimitStore Limits);
private static Context CreateContext( private static Context CreateContext(
int port, int port,
FakeTenantLimitStore? limits = null, TestTenantLimitStore? limits = null,
bool budgeted = false) bool budgeted = false)
{ {
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
@@ -175,19 +175,19 @@ public sealed class PipelineWorkerGrpcAiTests
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection( var connection = new AiGrpcConnection(
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
limits ??= new FakeTenantLimitStore(); limits ??= new TestTenantLimitStore();
var grpcClassifier = new GrpcAiClassifier( var grpcClassifier = new GrpcAiClassifier(
tenantContext, tenantContext,
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 TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier( ? new BudgetedAiClassifier(
grpcClassifier, grpcClassifier,
new LocalAiClassifier(fieldsParser), new LocalAiClassifier(fieldsParser),
limits, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiClassifier>.Instance) NullLogger<BudgetedAiClassifier>.Instance)
: grpcClassifier; : grpcClassifier;
@@ -1,13 +1,15 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using NSubstitute;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary> /// <summary>
/// In-memory реализация <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов /// Подставка <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов:
/// сервисы получают NSubstitute-подставку (<see cref="Store"/>), тесты управляют сценарием через свойства
/// </summary> /// </summary>
public sealed class FakeTenantLimitStore : ITenantLimitStore public sealed class TestTenantLimitStore
{ {
private sealed class Row private sealed class Row
{ {
@@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
private readonly Dictionary<Guid, Row> _rows = new(); private readonly Dictionary<Guid, Row> _rows = new();
private readonly TokenBudgetService _budgetService = new(); private readonly TokenBudgetService _budgetService = new();
/// <summary>
/// Подставка порта лимитов (создаётся в конструкторе).
/// </summary>
public ITenantLimitStore Store { get; }
private readonly Func<DateTimeOffset> _utcNow; private readonly Func<DateTimeOffset> _utcNow;
/// <summary> /// <summary>
/// Создаёт фейк с системными часами и статусом тенанта active. /// Создаёт подставку с системными часами и статусом тенанта active.
/// </summary> /// </summary>
public FakeTenantLimitStore() public TestTenantLimitStore()
: this(() => DateTimeOffset.UtcNow, TenantStatuses.Active) : this(() => DateTimeOffset.UtcNow, TenantStatuses.Active)
{ {
} }
/// <summary> /// <summary>
/// Создаёт фейк с заданными часами и статусом тенанта /// Создаёт подставку с заданными часами и статусом тенанта
/// </summary> /// </summary>
/// <param name="utcNow">Источник текущего времени (UTC).</param> /// <param name="utcNow">Источник текущего времени (UTC).</param>
/// <param name="tenantStatus">Статус тенанта для всех строк (константа <see cref="TenantStatuses"/>).</param> /// <param name="tenantStatus">Статус тенанта для всех строк (константа <see cref="TenantStatuses"/>).</param>
public FakeTenantLimitStore(Func<DateTimeOffset> utcNow, string tenantStatus = TenantStatuses.Active) public TestTenantLimitStore(Func<DateTimeOffset> utcNow, string tenantStatus = TenantStatuses.Active)
{ {
ArgumentNullException.ThrowIfNull(utcNow); ArgumentNullException.ThrowIfNull(utcNow);
_utcNow = utcNow; _utcNow = utcNow;
TenantStatus = tenantStatus; TenantStatus = tenantStatus;
Store = Substitute.For<ITenantLimitStore>();
Store.GetOrCreateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>(), Arg.Any<TokenLimitDefaults?>())
.Returns(ci => GetOrCreateAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1), ci.ArgAt<TokenLimitDefaults?>(2)));
Store.GetStateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => GetStateAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
Store.AddUsageAsync(Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(ci => AddUsageAsync(ci.ArgAt<Guid>(0), ci.ArgAt<long>(1), ci.ArgAt<CancellationToken>(2)));
Store.UpdateBudgetAsync(Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => UpdateBudgetAsync(ci.ArgAt<Guid>(0), ci.ArgAt<long>(1), ci.ArgAt<string>(2), ci.ArgAt<CancellationToken>(3)));
Store.TryMarkWarnedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => TryMarkWarnedAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
Store.TryMarkNotifiedExhaustedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => TryMarkNotifiedExhaustedAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
Store.ResetExpiredPeriodsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ResetExpiredPeriodsAsync(ci.ArgAt<DateTimeOffset>(0), ci.ArgAt<CancellationToken>(1)));
} }
/// <summary> /// <summary>
@@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
public string TenantStatus { get; set; } public string TenantStatus { get; set; }
/// <summary> /// <summary>
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков) /// Тенанты, для которых <see cref="GetStateAsync"/> подставки бросает исключение (устойчивость сборщиков)
/// </summary> /// </summary>
public HashSet<Guid> FailStateReads { get; } = new(); public HashSet<Guid> FailStateReads { get; } = new();
/// <summary> /// <summary>
/// Кладёт готовую строку лимита /// Кладёт готовую строку лимита (в состояние подставки)
/// </summary> /// </summary>
/// <param name="tenantId">Тенант.</param> /// <param name="tenantId">Тенант.</param>
/// <param name="budgetTokens">Бюджет периода.</param> /// <param name="budgetTokens">Бюджет периода.</param>
@@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
/// <returns>True — строка существует.</returns> /// <returns>True — строка существует.</returns>
public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId); public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId);
/// <inheritdoc /> private Task<TenantLimitDto> GetOrCreateAsync(
public Task<TenantLimitDto> GetOrCreateAsync(
Guid tenantId, Guid tenantId,
CancellationToken ct, CancellationToken ct,
TokenLimitDefaults? defaults = null) TokenLimitDefaults? defaults)
{ {
Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default); Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default);
return Task.FromResult(ToLimitDto(row, tenantId)); return Task.FromResult(ToLimitDto(row, tenantId));
} }
/// <inheritdoc /> private Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
{ {
if (FailStateReads.Contains(tenantId)) if (FailStateReads.Contains(tenantId))
{ {
@@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
} }
/// <inheritdoc /> private Task<BudgetStateDto> AddUsageAsync(
public Task<BudgetStateDto> AddUsageAsync(
Guid tenantId, Guid tenantId,
long tokens, long tokens,
CancellationToken ct) CancellationToken ct)
@@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
} }
/// <inheritdoc /> private Task<BudgetStateDto> UpdateBudgetAsync(
public Task<BudgetStateDto> UpdateBudgetAsync(
Guid tenantId, Guid tenantId,
long budgetTokens, long budgetTokens,
string period, string period,
@@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
} }
/// <inheritdoc /> private Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
public Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{ {
Row row = Ensure(tenantId, TokenBudgetDefaults.Default); Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row); ResetIfPeriodExpired(row);
@@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(true); return Task.FromResult(true);
} }
/// <inheritdoc /> private Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
public Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{ {
Row row = Ensure(tenantId, TokenBudgetDefaults.Default); Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row); ResetIfPeriodExpired(row);
@@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(true); return Task.FromResult(true);
} }
/// <inheritdoc /> private Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
public Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{ {
int reset = 0; int reset = 0;
foreach (Row row in _rows.Values) foreach (Row row in _rows.Values)
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Api.Hosting; using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact] [Fact]
public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant()
{ {
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
@@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact] [Fact]
public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet()
{ {
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests
Assert.Empty(ReadToasts(subscriptionA)); Assert.Empty(ReadToasts(subscriptionA));
// Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост).
Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None));
} }
[Fact] [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); limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact] [Fact]
public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold()
{ {
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests
SseSubscription subscriptionA = broker.Subscribe(TenantA); SseSubscription subscriptionA = broker.Subscribe(TenantA);
BudgetAlertScheduler scheduler = CreateScheduler(provider); 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); await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA));
@@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests
Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется
// Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. // Расход до исчерпания (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); await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA));
@@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests
// tenants: Фейк реестра тенантов (обход прохода). // tenants: Фейк реестра тенантов (обход прохода).
// limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода).
// Возвращает: Провайдер с сервисами цикла. // Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton(tenants); services.AddSingleton(tenants);
services.AddScoped<ITenantLimitStore>(_ => limits); services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddSingleton<SseBroker>(); services.AddSingleton<SseBroker>();
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
@@ -57,7 +57,7 @@ public sealed class GrpcAiClassifierTests
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None);
@@ -152,7 +152,7 @@ public sealed class GrpcAiClassifierTests
Order = 0, Order = 0,
}); });
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); 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); (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
@@ -287,8 +287,9 @@ public sealed class GrpcAiClassifierTests
FakeSettingsStore settings, FakeSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
FakeKanjStore kanjStore, FakeKanjStore kanjStore,
FakeTenantLimitStore? limits = null) TestTenantLimitStore? limits = null)
{ {
limits ??= new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
@@ -297,7 +298,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 TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
} }
@@ -115,11 +115,11 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync( public static async Task RunAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario, Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario,
TestAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null, TestTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null, TestTenantLimitStore? limitStore = null,
RateLimitOptions? rateLimitOptions = null, RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null) => TestTokenUsageEventStore? tokenUsageStore = null) =>
await RunCoreAsync( await RunCoreAsync(
@@ -164,8 +164,8 @@ internal static class OperatorAuthHttpHost
TestAuditLogStore? auditStore, TestAuditLogStore? auditStore,
TestInviteStore? inviteStore, TestInviteStore? inviteStore,
TestTenantStore? tenantStore, TestTenantStore? tenantStore,
FakeTenantLimitStore? limitStore, TestTenantLimitStore? limitStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario, Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null, RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null, TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) TestGlobalSettingsStore? globalSettingsStore = null)
@@ -173,7 +173,7 @@ internal static class OperatorAuthHttpHost
TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore(); TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
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(); TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore();
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore(); 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();
@@ -191,7 +191,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store); builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store); builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository); builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore); builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore.Store);
builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store); builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New()); builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
@@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario) => private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
@@ -284,14 +284,14 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit"; $"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null) private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
{ {
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
var tenantStore = new TestTenantStore( var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow), new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
new TenantRecordDto(SecondTenantId, SecondTenantName, 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(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true);
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
return OperatorAuthHttpHost.RunAsync( return OperatorAuthHttpHost.RunAsync(
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Support;
/// </summary> /// </summary>
public sealed class TokenUsageRecorderTests public sealed class TokenUsageRecorderTests
{ {
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore. // Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
@@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent() 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); 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] [Fact]
public async Task AddAsync_AccumulatesAcrossCalls() 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 = 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);
@@ -68,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, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None); await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
@@ -80,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, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
// Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита // Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению. // нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
@@ -97,10 +97,10 @@ public sealed class TokenUsageRecorderTests
public async Task AddAsync_OutsideTenantContext_Throws() public async Task AddAsync_OutsideTenantContext_Throws()
{ {
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
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 TestTokenUsageEventStore().Store)); settings, limits.Store, 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));
@@ -111,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, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (FakeSettingsStore settings, TestTenantLimitStore 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(
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории. // Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
// Возвращает: Кортеж (настройки, лимиты, история, recorder). // Возвращает: Кортеж (настройки, лимиты, история, 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 settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
var events = new TestTokenUsageEventStore(); 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.Store)); var recorder = new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(events.Store));
return (settings, limits, events, recorder); return (settings, limits, events, recorder);
} }
} }