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