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,154 @@
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 Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты декоратора бюджетного гейта <see cref="BudgetedAiTools"/>
/// </summary>
public sealed class BudgetedAiToolsTests
{
// 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);
[Fact]
public async Task EvaluateFit_ExhaustedBudget_ThrowsAiUnavailableAndDoesNotCallPaid()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: true, Reason: "подходит");
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
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<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
}
[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<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
Assert.Contains("приостановлен", exception.Message);
}
[Fact]
public async Task EvaluateFit_LargeBudget_CallsPaidAndReturnsItsResult()
{
// Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов.
Context ctx = Create();
ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит");
AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync(
"текст", "описание", new[] { "ключ" }, CancellationToken.None);
Assert.False(result.Fit);
Assert.Equal(1, ctx.Paid.EvaluateFitCalls);
}
[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 → декоратор делегирует платному исполнителю.
// FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само
// исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный).
Context ctx = Create();
await Assert.ThrowsAsync<NotSupportedException>(
() => ctx.Decorator.GenerateKeywordsAsync("описание", CancellationToken.None));
}
// ─── Контекст сценария ─────────────────────────────────────────────────
// Контекст теста: декоратор над платным фейком инструментов и фейком лимитов.
// Decorator: Декоратор бюджетного гейта.
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
private sealed record Context(
IAiTools Decorator,
FakeAiTools Paid);
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
// AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
{
var limits = new FakeTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiTools();
IAiTools decorator = new BudgetedAiTools(
paid,
limits,
tenantContext,
NullLogger<BudgetedAiTools>.Instance);
return new Context(decorator, paid);
}
}