Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,176 @@
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 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 ключа строк лимита FakeTenantLimitStore.
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));
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,
FakeAiClassifier Paid,
FakeSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
{
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier(
paid,
new LocalAiClassifier(new LocalFieldsParser(settings)),
limits,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance);
return new Context(decorator, paid, settings);
}
}