Перевести FakeTenantLimitStore на NSubstitute
Хелпер Support/TestTenantLimitStore: строки лимитов с полной бюджетной семантикой (GetOrCreate/GetState/AddUsage/UpdateBudget, Warned80/ NotifiedExhausted, сброс истёкших периодов, сценарий сбоя чтения), DI получает .Store. Потребители (13 файлов) перетипизированы, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
@@ -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;
|
||||
|
||||
+38
-23
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user