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; /// /// Тесты gRPC-адаптера IAiTools к ai-service /// [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( () => 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.Instance); } }