using System.Text.Json.Nodes; using Deal.Ai.Llm; using Deal.Grpc.Ai; using Grpc.Core; using Microsoft.Extensions.DependencyInjection; namespace Deal.Ai.Tests.Ai; /// /// In-proc gRPC-тесты AiService поверх фейк-провайдера /// public sealed class AiRpcTests { // Имя провайдера сценариев (для текста ошибки UNAVAILABLE). private const string ProviderId = "deepseek"; // Usage API-ответа сценариев (проверка проброса в reply). private static readonly ProviderUsage SampleUsage = new(11, 5, 16); private const string UnavailableDetail = "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"; /// /// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа. /// [Fact] public async Task Filter_ModelPasses_ReturnsPassReasonAndUsage() { FakeProviderClient fake = Success("""{"pass": true, "reason": "похоже на заявку"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); FilterReply reply = await client.FilterAsync( new FilterRequest { Prompt = "Фильтр: {domain}", Text = "Ищем разработчика на проект", ProviderConfig = RequestConfig(), }, Options()); Assert.True(reply.Pass); Assert.Equal("похоже на заявку", reply.Reason); AssertUsage(reply.Usage, SampleUsage); FakeProviderCall call = Assert.Single(fake.Calls); Assert.Equal("Фильтр: {domain}", call.SystemPrompt); Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText); }); } /// /// Filter: модель отклоняет сообщение — pass=false с причиной отказа. /// [Fact] public async Task Filter_ModelRejects_ReturnsPassFalse() { FakeProviderClient fake = Success("""{"pass": false, "reason": "реклама канала"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); FilterReply reply = await client.FilterAsync( new FilterRequest { Prompt = "Фильтр", Text = "Подпишись на канал", ProviderConfig = RequestConfig() }, Options()); Assert.False(reply.Pass); Assert.Equal("реклама канала", reply.Reason); }); } /// /// Filter: модель не вернула pass — по умолчанию пропуск /// [Fact] public async Task Filter_MissingPassField_DefaultsToPass() { FakeProviderClient fake = Success("""{"note": "ничего не понял"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); FilterReply reply = await client.FilterAsync( new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, Options()); Assert.True(reply.Pass); Assert.False(reply.HasReason); }); } /// /// Filter: провайдер недоступен после ретраев /// [Fact] public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail() { FakeProviderClient fake = Failing(); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.FilterAsync( new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, Options()).ResponseAsync); Assert.Equal(StatusCode.Unavailable, exception.StatusCode); Assert.Equal(UnavailableDetail, exception.Status.Detail); Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); }); } /// /// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE /// [Fact] public async Task Filter_AnswerWithoutJson_ThrowsUnavailable() { FakeProviderClient fake = new((_, _, _) => Task.FromResult(new ProviderChatResult("какой-то текст без json", Usage: null))); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.FilterAsync( new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, Options()).ResponseAsync); Assert.Equal(StatusCode.Unavailable, exception.StatusCode); }); } /// /// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой /// [Fact] public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson() { const string modelJson = """{"board": "dev", "title": "Нужен python-разработчик", "budget": null, "contacts": []}"""; FakeProviderClient fake = Success(modelJson); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); ClassifyReply reply = await client.ClassifyAsync( new ClassifyRequest { SystemPrompt = "Система карточки", UserContext = "Доски:\n- dev: разработка\nНовое сообщение: ищем middle", ProviderConfig = RequestConfig(), }, Options()); Assert.True(reply.Ok); Assert.True(reply.HasJson); AssertUsage(reply.Usage, SampleUsage); JsonObject json = JsonNode.Parse(reply.Json)!.AsObject(); Assert.Equal("dev", (string?)json["board"]); Assert.Equal("Нужен python-разработчик", (string?)json["title"]); FakeProviderCall call = Assert.Single(fake.Calls); Assert.Equal("Система карточки", call.SystemPrompt); Assert.Equal("Доски:\n- dev: разработка\nНовое сообщение: ищем middle", call.UserText); }); } /// /// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе /// [Fact] public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage() { const string systemPrompt = "Система карточки"; const string userContext = "Доски и сообщение для разбора"; const string garbage = "простите, я не умею в json"; FakeProviderClient fake = new((_, _, _) => Task.FromResult(new ProviderChatResult(garbage, Usage: null))); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); ClassifyReply reply = await client.ClassifyAsync( new ClassifyRequest { SystemPrompt = systemPrompt, UserContext = userContext, ProviderConfig = RequestConfig(), }, Options()); Assert.False(reply.Ok); Assert.False(reply.HasJson); Assert.Equal(EstimateTokens(systemPrompt.Length + userContext.Length), reply.Usage.Prompt); Assert.Equal(EstimateTokens(garbage.Length), reply.Usage.Completion); Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); }); } /// /// Classify: провайдер недоступен — UNAVAILABLE /// [Fact] public async Task Classify_ProviderUnavailable_ThrowsUnavailable() { FakeProviderClient fake = Failing(); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.ClassifyAsync( new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() }, Options()).ResponseAsync); Assert.Equal(StatusCode.Unavailable, exception.StatusCode); Assert.Equal(UnavailableDetail, exception.Status.Detail); }); } /// /// GenerateKeywords /// [Fact] public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList() { FakeProviderClient fake = Success("""{"keywords": ["стройка", "ремонт квартир", "подряды"]}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( new GenerateKeywordsRequest { Description = "Поиск каналов со стройкой и ремонтом", ProviderConfig = RequestConfig(), }, Options()); Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords); AssertUsage(reply.Usage, SampleUsage); FakeProviderCall call = Assert.Single(fake.Calls); Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal); Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal); Assert.Equal("Описание ниши/задачи:\nПоиск каналов со стройкой и ремонтом", call.UserText); }); } /// /// GenerateKeywords /// [Fact] public async Task GenerateKeywords_NonStringItems_Skipped() { FakeProviderClient fake = Success("""{"keywords": ["php", 42, "c#"]}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() }, Options()); Assert.Equal(["php", "c#"], reply.Keywords); }); } /// /// GenerateKeywords /// [Fact] public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty() { FakeProviderClient fake = Success("""{"note": "не нашёл ключей"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() }, Options()); Assert.Empty(reply.Keywords); }); } /// /// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…». /// [Fact] public async Task EvaluateFit_ModelFits_ReturnsFitAndReason() { FakeProviderClient fake = Success("""{"fit": 1, "reason": "строительная тематика"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); EvaluateFitReply reply = await client.EvaluateFitAsync( new EvaluateFitRequest { Text = "Нужна бригада отделочников на объект", Description = "Поиск подрядчиков на отделку", ProviderConfig = RequestConfig(), }, Options()); Assert.True(reply.Fit); Assert.Equal("строительная тематика", reply.Reason); AssertUsage(reply.Usage, SampleUsage); FakeProviderCall call = Assert.Single(fake.Calls); Assert.Equal("Сообщение:\nНужна бригада отделочников на объект", call.UserText); Assert.Contains("Оцени, относится ли сообщение к сфере/задаче", call.SystemPrompt, StringComparison.Ordinal); Assert.Contains("Описание: Поиск подрядчиков на отделку.", call.SystemPrompt, StringComparison.Ordinal); }); } /// /// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую. /// [Fact] public async Task EvaluateFit_KeywordsJoinedIntoPrompt() { FakeProviderClient fake = Success("""{"fit": 0, "reason": "не совпадает"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); await client.EvaluateFitAsync( new EvaluateFitRequest { Text = "Текст", Description = "Описание", Keywords = { "стройка", " ремонт ", " ", "подряд" }, ProviderConfig = RequestConfig(), }, Options()); string systemPrompt = Assert.Single(fake.Calls).SystemPrompt; Assert.Contains("Ключи: стройка, ремонт, подряд.", systemPrompt, StringComparison.Ordinal); }); } /// /// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь. /// [Fact] public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason() { FakeProviderClient fake = Success("""{"fit": 0}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); EvaluateFitReply reply = await client.EvaluateFitAsync( new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, Options()); Assert.False(reply.Fit); Assert.Equal("не подходит", reply.Reason); }); } /// /// EvaluateFit: fit строкой «нет» — false, причина из модели. /// [Fact] public async Task EvaluateFit_StringFalsyFit_ReturnsFalse() { FakeProviderClient fake = Success("""{"fit": "нет", "reason": "другая сфера"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); EvaluateFitReply reply = await client.EvaluateFitAsync( new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, Options()); Assert.False(reply.Fit); Assert.Equal("другая сфера", reply.Reason); }); } /// /// EvaluateFit: длинная причина модели усекается до 200 символов. /// [Fact] public async Task EvaluateFit_LongReason_IsTruncatedTo200() { string longReason = new('а', 250); FakeProviderClient fake = Success($$"""{"fit": 1, "reason": "{{longReason}}"}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); EvaluateFitReply reply = await client.EvaluateFitAsync( new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, Options()); Assert.Equal(200, reply.Reason.Length); Assert.StartsWith("ааааа", reply.Reason, StringComparison.Ordinal); }); } /// /// Конфиг-валидация /// [Fact] public async Task Classify_WithoutProviderConfig_IsInvalidArgument() { FakeProviderClient fake = Success("{}"); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.ClassifyAsync( new ClassifyRequest { SystemPrompt = "S", UserContext = "U" }, Options()).ResponseAsync); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); Assert.Empty(fake.Calls); }); } /// /// Конфиг-валидация /// [Fact] public async Task GenerateKeywords_EmptyModel_IsInvalidArgument() { FakeProviderClient fake = Success("{}"); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.GenerateKeywordsAsync( new GenerateKeywordsRequest { Description = "Описание", ProviderConfig = new ProviderConfig { BaseUrl = "https://api.example.com/v1" }, }, Options()).ResponseAsync); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); Assert.Empty(fake.Calls); }); } /// /// Серверный лимит text /// [Fact] public async Task Filter_TooLongText_IsInvalidArgument() { FakeProviderClient fake = Success("""{"pass": true}"""); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.FilterAsync( new FilterRequest { Prompt = "Фильтр", Text = new string('а', 4001), ProviderConfig = RequestConfig(), }, Options()).ResponseAsync); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); Assert.Empty(fake.Calls); }); } /// /// Серверный лимит description /// [Fact] public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument() { FakeProviderClient fake = Success("{}"); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); RpcException exception = await Assert.ThrowsAsync(() => client.GenerateKeywordsAsync( new GenerateKeywordsRequest { Description = new string('а', 4001), ProviderConfig = RequestConfig(), }, Options()).ResponseAsync); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); Assert.Empty(fake.Calls); }); } /// /// Обязательный tenant-id в metadata /// [Fact] public async Task Classify_WithoutTenantId_IsUnauthenticated() { FakeProviderClient fake = Success("{}"); await AiTestHost.RunAsync(Host(fake), async channel => { var client = new AiService.AiServiceClient(channel); Metadata metadata = AiTestHost.CallMetadata(AiTestHost.DefaultToken, tenantId: null); RpcException exception = await Assert.ThrowsAsync(() => client.ClassifyAsync( new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() }, AiTestHost.CallOptions(metadata)).ResponseAsync); Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); Assert.Empty(fake.Calls); }); } // Конфиг DI сценария: фейк-провайдер + мгновенные паузы ретраев. // fake: Фейк-провайдер вызовов. private static Action Host(FakeProviderClient fake) => services => { AiTestHost.UseFakeProvider(services, fake); AiTestHost.DisableRetryDelays(services); }; // CallOptions с service-token и tenant-id сценария. private static CallOptions Options() => AiTestHost.CallOptions( AiTestHost.CallMetadata(AiTestHost.DefaultToken, AiTestHost.DefaultTenantId)); // Конфиг провайдера запроса (зеркало того, что ядро кладёт в тело запроса). private static ProviderConfig RequestConfig() => new() { ProviderId = ProviderId, BaseUrl = "https://api.example.com/v1", Model = "deepseek-v4-flash", ApiKey = "test-key", }; // Фейк, всегда отвечающий заданным JSON-текстом с usage API-ответа. // jsonText: JSON-текст ответа модели. private static FakeProviderClient Success(string jsonText) => new((_, _, _) => Task.FromResult(new ProviderChatResult(jsonText, SampleUsage))); // Фейк, всегда падающий транспортной ошибкой (повод для ретрая). private static FakeProviderClient Failing() => new((_, _, _) => Task.FromException(new LlmHttpException("сеть недоступна"))); // Оценка токенов по символам (зеркало TokenEstimator; для проверки usage в ответе). // charCount: Число символов. private static uint EstimateTokens(int charCount) => (uint)((charCount + 3) / 4); // Проверяет проброс usage провайдера в gRPC-ответ. // usage: Usage ответа RPC. // expected: Ожидаемое значение. private static void AssertUsage(Deal.Grpc.Ai.Usage usage, ProviderUsage expected) { Assert.Equal((uint)expected.PromptTokens, usage.Prompt); Assert.Equal((uint)expected.CompletionTokens, usage.Completion); Assert.Equal((uint)expected.TotalTokens, usage.Total); } }