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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user