Files
Deal/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs
T
Rustam Khalimov 5bfa92a4ac Сгруппировать тестовые проекты по областям
Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests
разложены по областям (Modules/<X>, Api, Infrastructure, Contracts,
Grpc, ...), общие хелперы -> Support; namespace = папка, using
между областями добавлены итеративно по ошибкам сборки.
2026-09-11 13:28:26 +03:00

223 lines
10 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.Net;
using System.Text.Json.Nodes;
using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Support;
/// <summary>
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler (план Task 7 Acceptance; без сети):
/// форма OpenAI-совместимого запроса ({base}/chat/completions, Bearer, temperature 0.2,
/// max_tokens 8000), форма Anthropic ({base}/v1/messages, x-api-key + anthropic-version),
/// разбор ответов/usage, отказ без ключа, reasoning-без-ответа, HTTP-ошибка и таймаут.
/// </summary>
public sealed class LlmHttpClientTests
{
// Таймауты тестового клиента (короткие — чтобы таймаут-сценарий не ждал 90/60 с).
private static readonly TimeSpan TestCallTimeout = TimeSpan.FromMilliseconds(60);
// Задержка заглушки для таймаут-сценария (должна превышать TestCallTimeout).
private static readonly TimeSpan TestHandlerDelay = TimeSpan.FromMilliseconds(300);
// База OpenAI-совместимого провайдера сценария (как у openai/ollama/lmstudio — с /v1).
private const string OpenAiBaseUrl = "https://api.example.com/v1";
// База Anthropic-провайдера сценария (без /v1 — путь добавляет клиент).
private const string AnthropicBaseUrl = "https://api.anthropic.com";
// Ответ OpenAI-совместимого API с текстом и usage.
private const string OpenAiJsonReply =
"""
{
"choices": [
{
"message": { "role": "assistant", "content": "{\"fit\": 1}" }
}
],
"usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 }
}
""";
// Ответ Anthropic Messages API: два text-блока и usage input/output.
private const string AnthropicJsonReply =
"""
{
"content": [
{ "type": "text", "text": "{\"fit\": 1}" },
{ "type": "text", "text": " ещё текст" }
],
"usage": { "input_tokens": 4, "output_tokens": 6 }
}
""";
/// <summary>
/// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens).
/// </summary>
[Fact]
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
LlmHttpClient client = CreateClient(handler);
ProviderChatResult result = await client.ChatAsync(
OpenAiConfig(apiKey: "secret-key"),
"Система",
"Сообщение",
CancellationToken.None);
CapturedHttpRequest request = handler.LastRequest;
Assert.StartsWith("POST https://api.example.com/v1/chat/completions", request.Url);
Assert.Equal("Bearer secret-key", StubHttpMessageHandler.BearerOf(request));
JsonObject body = StubHttpMessageHandler.BodyOf(request);
Assert.Equal("deepseek-v4-flash", (string?)body["model"]);
Assert.Equal(0.2, (double?)body["temperature"]);
Assert.Equal(8000, (int?)body["max_tokens"]);
JsonArray messages = (JsonArray)body["messages"]!;
Assert.Equal("system", (string?)messages[0]!["role"]);
Assert.Equal("Система", (string?)messages[0]!["content"]);
Assert.Equal("user", (string?)messages[1]!["role"]);
Assert.Equal("Сообщение", (string?)messages[1]!["content"]);
Assert.Equal("{\"fit\": 1}", result.Text);
Assert.Equal(new ProviderUsage(10, 20, 30), result.Usage);
}
/// <summary>
/// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся.
/// </summary>
[Fact]
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
LlmHttpClient client = CreateClient(handler);
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
Assert.Null(StubHttpMessageHandler.BearerOf(handler.LastRequest));
}
/// <summary>
/// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149151).
/// </summary>
[Fact]
public async Task ChatAsync_OpenAiReasoningOnly_Throws()
{
const string reasoningOnlyReply =
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
LlmHttpClient client = CreateClient(handler);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
Assert.Contains("reasoning", exception.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API.
/// </summary>
[Fact]
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
LlmHttpClient client = CreateClient(handler);
ProviderChatResult result = await client.ChatAsync(
AnthropicConfig(),
"Система",
"Сообщение",
CancellationToken.None);
CapturedHttpRequest request = handler.LastRequest;
Assert.StartsWith("POST https://api.anthropic.com/v1/messages", request.Url);
Assert.Equal("anthropic-key", request.Headers["x-api-key"]);
Assert.Equal("2023-06-01", request.Headers["anthropic-version"]);
JsonObject body = StubHttpMessageHandler.BodyOf(request);
Assert.Equal("claude-sonnet-5", (string?)body["model"]);
Assert.Equal(8000, (int?)body["max_tokens"]);
Assert.Equal("Система", (string?)body["system"]);
Assert.DoesNotContain("temperature", body);
JsonArray messages = (JsonArray)body["messages"]!;
Assert.Single(messages);
Assert.Equal("user", (string?)messages[0]!["role"]);
Assert.Equal("Сообщение", (string?)messages[0]!["content"]);
// Склейка text-блоков content[] + usage (input/output → total = сумма; ai.py L168172).
Assert.Equal("{\"fit\": 1} ещё текст", result.Text);
Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage);
}
/// <summary>
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая).
/// </summary>
[Fact]
public async Task ChatAsync_HttpError_Throws()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
LlmHttpClient client = CreateClient(handler);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
Assert.Contains("500", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение.
/// </summary>
[Fact]
public async Task ChatAsync_UnexpectedBody_Throws()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"),
});
LlmHttpClient client = CreateClient(handler);
await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
}
/// <summary>
/// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException.
/// </summary>
[Fact]
public async Task ChatAsync_Timeout_Throws()
{
var handler = new StubHttpMessageHandler(
StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
delay: TestHandlerDelay);
LlmHttpClient client = new(
TestHttpClient(handler),
TestCallTimeout,
TestCallTimeout);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
Assert.Contains("Таймаут", exception.Message, StringComparison.Ordinal);
}
// Конфиг OpenAI-совместимого вызова сценария.
// apiKey: Ключ API (null — локальный провайдер без ключа).
private static LlmConfig OpenAiConfig(string? apiKey = "test-key")
=> new("deepseek", OpenAiBaseUrl, "deepseek-v4-flash", apiKey, ApiStyle: null);
// Конфиг Anthropic-вызова сценария.
private static LlmConfig AnthropicConfig()
=> new("anthropic", AnthropicBaseUrl, "claude-sonnet-5", "anthropic-key", LlmConfig.AnthropicApiStyle);
// Клиент сценария над заглушкой (без общего таймаута — управление на попытку).
// handler: Заглушка обработчика.
private static LlmHttpClient CreateClient(StubHttpMessageHandler handler)
=> new(TestHttpClient(handler));
// HttpClient над заглушкой без общего таймаута (таймаут попытки задаёт клиент).
// handler: Заглушка обработчика.
private static HttpClient TestHttpClient(StubHttpMessageHandler handler)
=> new(handler) { Timeout = Timeout.InfiniteTimeSpan };
}