Files
Deal/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs
T
Rustam Khalimov 31add804eb
ci / build-test (push) Successful in 2m52s
Перевести FakeTokenUsageEventStore на NSubstitute
Хелпер Support/TestTokenUsageEventStore: записи + полная логика агрегации
(фильтры, группировки day/tenant/provider/model) читают состояние на момент
вызова. Потребители (10 файлов) перетипизированы на .Store, фейк удалён,
тесты 1340 зелёные.
2026-09-12 22:49:41 +03:00

146 lines
7.1 KiB
C#

using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
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>
/// Тесты gRPC-адаптера IAiTools к ai-service
/// </summary>
[Collection("MlGrpcTests")]
public sealed class GrpcAiToolsTests
{
// Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id.
private const string TenantIdValue = "fedcba9876543210fedcba9876543210";
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact]
public async Task GenerateKeywordsAsync_MapsKeywordsAndSendsProviderConfigAndMetadata()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
var reply = new GenerateKeywordsReply { Usage = new Usage { Prompt = 300, Completion = 60, Total = 360 } };
reply.Keywords.Add("python разработка");
reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New();
FakeTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal(new[] { "python разработка", "telegram каналы" }, result.Keywords);
Assert.Null(result.Error);
Assert.NotNull(service.LastKeywords);
Assert.Equal("Бэкенд-разработка на Python", service.LastKeywords!.Description);
Assert.Equal("deepseek", service.LastKeywords.ProviderConfig.ProviderId);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens));
Assert.Equal("{\"prompt\":300,\"completion\":60,\"total\":360}", settings.GetStoredJson(SettingsKeys.AiTokenUsage));
Assert.Equal(360, limits.UsedTokens(TenantGuid));
});
}
[Fact]
public async Task GenerateKeywordsAsync_Unavailable_ReturnsSoftErrorWithKeywordsEmpty()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.KeywordsUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
Assert.False(result.Ok);
Assert.Empty(result.Keywords);
Assert.NotNull(result.Error);
Assert.Contains("не ответил корректно", result.Error);
});
}
[Fact]
public async Task EvaluateFitAsync_MapsReplyAndSendsTaskFields()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.EvaluateFitReply = new EvaluateFitReply
{
Fit = false,
Reason = "другая сфера",
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
};
FakeTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
Assert.False(result.Fit);
Assert.Equal("другая сфера", result.Reason);
Assert.NotNull(service.LastFit);
Assert.Equal("Ищу дизайнера для лендинга", service.LastFit!.Text);
Assert.Equal("Разработка сайтов на Python", service.LastFit.Description);
Assert.Equal(new[] { "python", "бэкенд" }, service.LastFit.Keywords);
Assert.Equal("deepseek", service.LastFit.ProviderConfig.ProviderId);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens));
Assert.Equal(210, limits.UsedTokens(TenantGuid));
});
}
[Fact]
public async Task EvaluateFitAsync_Unavailable_ThrowsAiUnavailable()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
});
}
// Создаёт GrpcAiTools к хосту-фейку на эфемерном порту в tenant-контексте теста.
// port: Порт хоста-фейка ai-service.
// settings: KV-хранилище тенанта (дефолты при пустом).
// cipher: Шифр секретов (расшифровка apiKey).
// limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр).
// Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools(
int port,
FakeSettingsStore settings,
ISecretCipher cipher,
FakeTenantLimitStore? limits = null)
{
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiTools(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, cipher),
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance);
}
}