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;
///
/// Тесты декоратора бюджетного гейта
///
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(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any());
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(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None));
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any());
}
[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(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None));
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any());
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(), Arg.Any(), Arg.Any>(), Arg.Any());
}
[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(
() => 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? 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.Instance);
return new Context(decorator, paid);
}
}