using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Support; using Microsoft.Extensions.Logging.Abstractions; using Deal.Contracts.Integrations.Abstractions; using Deal.SharedKernel.Tenants.Abstractions; namespace Deal.Tests.Unit.Contracts; /// /// Тесты декоратора бюджетного гейта /// public sealed class BudgetedAiClassifierTests { // 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); // Разбор «платной» ветки — маркер: если тест увидел его, вызов ушёл на платного исполнителя. private static readonly AiParsedCardDto PaidParsedLead = new( "Платная ветка", Company: null, Format: null, Task: null, Requirements: null, Plus: null, Conditions: null, Summary: null, Stack: new[] { "Go" }, Budget: null, Contacts: Array.Empty(), IsVacancy: true, IsVacancyKnown: true, IsSpam: false, Board: null); [Fact] public async Task Classify_ZeroBudgetLimit_UsesLocalParseAndDoesNotCallPaid() { Context ctx = Create( limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); ctx.Paid.ClassifyResult = PaidParsedLead; const string text = "Вакансия: Python-разработчик в команду, оплата до 2000$ в месяц"; AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); Assert.Equal(0, ctx.Paid.ClassifyCalls); // платный ИИ не зван Assert.NotNull(result); Assert.NotEqual(PaidParsedLead, result); // разбор собран Local-реализацией, а не платной веткой Assert.Null(result.Board); // локальный путь не назначает смысловые колонки } [Fact] public async Task Filter_ExhaustedBudget_ReturnsLocalPassSkippedAndDoesNotCallPaid() { Context ctx = Create( limits => limits.Preload( TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); ctx.Paid.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама", Skipped: false); AiFilterResultDto result = await ctx.Decorator.FilterAsync("Купите канал", CancellationToken.None); Assert.Equal(0, ctx.Paid.FilterCalls); Assert.True(result.Pass); Assert.True(result.Skipped); Assert.Null(result.Reason); } [Fact] public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult() { Context ctx = Create(); ctx.Paid.ClassifyResult = PaidParsedLead; AiParsedCardDto result = await ctx.Decorator.ClassifyAsync("текст", CancellationToken.None); Assert.Same(PaidParsedLead, result); Assert.Equal(1, ctx.Paid.ClassifyCalls); } [Fact] public async Task Classify_SuspendedTenant_UsesLocalParseAndDoesNotCallPaid() { Context ctx = Create( limits => limits.Preload( TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, status: TenantStatuses.Suspended)); ctx.Paid.ClassifyResult = PaidParsedLead; AiParsedCardDto result = await ctx.Decorator.ClassifyAsync( "Вакансия: разработчик в команду", CancellationToken.None); Assert.Equal(0, ctx.Paid.ClassifyCalls); Assert.NotNull(result); Assert.NotEqual(PaidParsedLead, result); } [Fact] public async Task Filter_SuspendedTenant_ReturnsLocalPassSkipped() { Context ctx = Create( limits => limits.Preload( TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, status: TenantStatuses.Suspended)); AiFilterResultDto result = await ctx.Decorator.FilterAsync("текст", CancellationToken.None); Assert.Equal(0, ctx.Paid.FilterCalls); Assert.True(result.Pass); Assert.True(result.Skipped); } [Fact] public async Task Classify_LocalFallback_MatchesStandaloneLocalClassifier() { // Гейт-запрет уводит ровно на Local-реализацию: результат совпадает с прямым вызовом LocalAiClassifier. Context ctx = Create( limits => limits.Preload( TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings.Store)); const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$"; AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); AiParsedCardDto expected = await standaloneLocal.ClassifyAsync(text, CancellationToken.None); Assert.Equal(0, ctx.Paid.ClassifyCalls); // Record-равенство по списковым свойствам — ссылочное; сравниваем значимые поля разбора. Assert.Equal(expected.Title, result.Title); Assert.Equal(expected.Stack, result.Stack); Assert.Equal(expected.Budget, result.Budget); Assert.Equal(expected.IsVacancy, result.IsVacancy); Assert.Equal(expected.Board, result.Board); } // ─── Контекст сценария ───────────────────────────────────────────────── // Контекст теста: декоратор над платным фейком + реальным Local-классификатором (fallback). // Decorator: Декоратор бюджетного гейта. // Paid: Платный фейк-исполнитель (счётчики/ответы сценария). // Settings: KV-настройки тенанта (маркеры LocalFieldsParser). private sealed record Context( IAiClassifier Decorator, TestAiClassifier Paid, TestSettingsStore Settings); // Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст // (зеркало регистрации AddDealIntegrations при UseLocal=false). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // Возвращает: Контекст теста. private static Context Create(Action? configure = null) { var settings = new TestSettingsStore(); var limits = new TestTenantLimitStore(); configure?.Invoke(limits); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var paid = new TestAiClassifier(); IAiClassifier decorator = new BudgetedAiClassifier( paid.Classifier, new LocalAiClassifier(new LocalFieldsParser(settings.Store)), limits.Store, tenantContext, NullLogger.Instance); return new Context(decorator, paid, settings); } }