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); } }