Хелпер Support/TestSettingsStore: словарь настроек, GetAsync/GetAllAsync/SetAsync/ RemoveAsync читают состояние на момент вызова (GetAllAsync — ленивый, ключ для снапшот-сервисов). DI получает .Store, тесты сохраняют Preload/GetStoredJson.
178 lines
8.3 KiB
C#
178 lines
8.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Тесты декоратора бюджетного гейта <see cref="BudgetedAiClassifier"/>
|
|
/// </summary>
|
|
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<AiContactDto>(),
|
|
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<TestTenantLimitStore>? 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<BudgetedAiClassifier>.Instance);
|
|
return new Context(decorator, paid, settings);
|
|
}
|
|
}
|