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