Сгруппировать тестовые проекты по областям
Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests разложены по областям (Modules/<X>, Api, Infrastructure, Contracts, Grpc, ...), общие хелперы -> Support; namespace = папка, using между областями добавлены итеративно по ошибкам сборки.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user