Files
Deal/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs
T
Rustam Khalimov 713d554dc2 Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
2026-09-11 19:01:42 +03:00

577 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// In-proc gRPC-тесты AiService поверх фейк-провайдера
/// </summary>
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) не ответил корректно — повторите попытку через несколько секунд";
/// <summary>
/// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа.
/// </summary>
[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);
});
}
/// <summary>
/// Filter: модель отклоняет сообщение — pass=false с причиной отказа.
/// </summary>
[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);
});
}
/// <summary>
/// Filter: модель не вернула pass — по умолчанию пропуск
/// </summary>
[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);
});
}
/// <summary>
/// Filter: провайдер недоступен после ретраев
/// </summary>
[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<RpcException>(() =>
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);
});
}
/// <summary>
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE
/// </summary>
[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<RpcException>(() =>
client.FilterAsync(
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
Options()).ResponseAsync);
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
});
}
/// <summary>
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой
/// </summary>
[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);
});
}
/// <summary>
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе
/// </summary>
[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);
});
}
/// <summary>
/// Classify: провайдер недоступен — UNAVAILABLE
/// </summary>
[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<RpcException>(() =>
client.ClassifyAsync(
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
Options()).ResponseAsync);
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
Assert.Equal(UnavailableDetail, exception.Status.Detail);
});
}
/// <summary>
/// GenerateKeywords
/// </summary>
[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);
});
}
/// <summary>
/// GenerateKeywords
/// </summary>
[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);
});
}
/// <summary>
/// GenerateKeywords
/// </summary>
[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);
});
}
/// <summary>
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…».
/// </summary>
[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);
});
}
/// <summary>
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую.
/// </summary>
[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);
});
}
/// <summary>
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь.
/// </summary>
[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);
});
}
/// <summary>
/// EvaluateFit: fit строкой «нет» — false, причина из модели.
/// </summary>
[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);
});
}
/// <summary>
/// EvaluateFit: длинная причина модели усекается до 200 символов.
/// </summary>
[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);
});
}
/// <summary>
/// Конфиг-валидация
/// </summary>
[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<RpcException>(() =>
client.ClassifyAsync(
new ClassifyRequest { SystemPrompt = "S", UserContext = "U" },
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Конфиг-валидация
/// </summary>
[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<RpcException>(() =>
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);
});
}
/// <summary>
/// Серверный лимит text
/// </summary>
[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<RpcException>(() =>
client.FilterAsync(
new FilterRequest
{
Prompt = "Фильтр",
Text = new string('а', 4001),
ProviderConfig = RequestConfig(),
},
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Серверный лимит description
/// </summary>
[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<RpcException>(() =>
client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = new string('а', 4001),
ProviderConfig = RequestConfig(),
},
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Обязательный tenant-id в metadata
/// </summary>
[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<RpcException>(() =>
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<IServiceCollection> 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<ProviderChatResult>(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);
}
}