Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests разложены по областям (Modules/<X>, Api, Infrastructure, Contracts, Grpc, ...), общие хелперы -> Support; namespace = папка, using между областями добавлены итеративно по ошибкам сборки.
223 lines
10 KiB
C#
223 lines
10 KiB
C#
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 L149–151).
|
||
/// </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 L168–172).
|
||
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 };
|
||
}
|