Хелпер Support/TestTenantLimitStore: строки лимитов с полной бюджетной семантикой (GetOrCreate/GetState/AddUsage/UpdateBudget, Warned80/ NotifiedExhausted, сброс истёкших периодов, сценарий сбоя чтения), DI получает .Store. Потребители (13 файлов) перетипизированы, фейк удалён, тесты 1340 зелёные.
157 lines
7.8 KiB
C#
157 lines
7.8 KiB
C#
using Deal.Contracts.Integrations.Models;
|
|
using Deal.Infrastructure.Data;
|
|
using Deal.Infrastructure.Integrations.Exceptions;
|
|
using Deal.Infrastructure.Integrations.Services;
|
|
using Deal.Modules.Tenants.Application.Models;
|
|
using Deal.SharedKernel.Tenants.Models;
|
|
using Deal.Tests.Unit.Modules.Tenants;
|
|
using Deal.Tests.Unit.Support;
|
|
using NSubstitute;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Deal.Contracts.Integrations.Abstractions;
|
|
using Deal.SharedKernel.Tenants.Abstractions;
|
|
|
|
namespace Deal.Tests.Unit.Contracts;
|
|
|
|
/// <summary>
|
|
/// Тесты декоратора бюджетного гейта <see cref="BudgetedAiTools"/>
|
|
/// </summary>
|
|
public sealed class BudgetedAiToolsTests
|
|
{
|
|
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
|
|
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
|
|
|
// Guid того же тенанта — ключ строки лимита.
|
|
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
|
|
|
// Начало периода строк лимита (не истёкший месяц).
|
|
private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task EvaluateFit_ExhaustedBudget_ThrowsAiUnavailableAndDoesNotCallPaid()
|
|
{
|
|
Context ctx = Create(
|
|
limits => limits.Preload(
|
|
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
|
|
ctx.Paid.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, Reason: "подходит"));
|
|
|
|
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
|
|
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
|
|
|
|
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
|
Assert.Contains("исчерпан", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateFit_ZeroBudgetLimit_ThrowsAiUnavailable()
|
|
{
|
|
Context ctx = Create(
|
|
limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
|
|
|
|
await Assert.ThrowsAsync<AiUnavailableException>(
|
|
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
|
|
|
|
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateFit_SuspendedTenant_ThrowsAiUnavailable()
|
|
{
|
|
Context ctx = Create(
|
|
limits => limits.Preload(
|
|
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100,
|
|
status: TenantStatuses.Suspended));
|
|
|
|
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
|
|
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
|
|
|
|
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
|
Assert.Contains("приостановлен", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateFit_LargeBudget_CallsPaidAndReturnsItsResult()
|
|
{
|
|
// Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов.
|
|
Context ctx = Create();
|
|
ctx.Paid.ReturnsFit(new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит"));
|
|
|
|
AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync(
|
|
"текст", "описание", new[] { "ключ" }, CancellationToken.None);
|
|
|
|
Assert.False(result.Fit);
|
|
await ctx.Paid.Received(1).EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateKeywords_ExhaustedBudget_ReturnsSoftErrorWithoutPaidCall()
|
|
{
|
|
Context ctx = Create(
|
|
limits => limits.Preload(
|
|
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
|
|
|
|
AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync(
|
|
"описание ниши", CancellationToken.None);
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Empty(result.Keywords);
|
|
Assert.Contains("исчерпан", result.Error);
|
|
// Платный GenerateKeywords бросает NotSupportedException — тест упал бы, если бы декоратор его позвал.
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateKeywords_SuspendedTenant_ReturnsSoftError()
|
|
{
|
|
Context ctx = Create(
|
|
limits => limits.Preload(
|
|
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100,
|
|
status: TenantStatuses.Suspended));
|
|
|
|
AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync(
|
|
"описание ниши", CancellationToken.None);
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Contains("приостановлен", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateKeywords_LargeBudget_DelegatesToPaid()
|
|
{
|
|
// Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю.
|
|
// Подставка бросает NotSupportedException на GenerateKeywordsAsync (вызов не ждали) — само
|
|
// исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный).
|
|
Context ctx = Create();
|
|
|
|
await Assert.ThrowsAsync<NotSupportedException>(
|
|
() => ctx.Decorator.GenerateKeywordsAsync("описание", CancellationToken.None));
|
|
}
|
|
|
|
// ─── Контекст сценария ─────────────────────────────────────────────────
|
|
|
|
// Контекст теста: декоратор над платным фейком инструментов и фейком лимитов.
|
|
// Decorator: Декоратор бюджетного гейта.
|
|
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
|
|
private sealed record Context(
|
|
IAiTools Decorator,
|
|
IAiTools Paid);
|
|
|
|
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
|
|
// AddDealIntegrations при UseLocal=false).
|
|
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
|
|
// Возвращает: Контекст теста.
|
|
private static Context Create(Action<TestTenantLimitStore>? configure = null)
|
|
{
|
|
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.Store,
|
|
tenantContext,
|
|
NullLogger<BudgetedAiTools>.Instance);
|
|
return new Context(decorator, paid);
|
|
}
|
|
}
|