Сгруппировать тестовые проекты по областям
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,590 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// In-proc gRPC-тесты AiService поверх фейк-провайдера (план Task 8, Acceptance): все 4 RPC
|
||||
/// (Filter/Classify/GenerateKeywords/EvaluateFit) с подменой LLM-фасада (без сети) через
|
||||
/// реальный хост (Kestrel HTTP/2, интерцептор service-token). Проверяются: разбор решений и
|
||||
/// usage в ответах, собранные сервисом промпты, недоступность провайдера → UNAVAILABLE с текстом
|
||||
/// 1:1 Ruling 5, ответ без JSON в Classify → ok=false (не ошибка), INVALID_ARGUMENT конфига.
|
||||
/// </summary>
|
||||
public sealed class AiRpcTests
|
||||
{
|
||||
// Имя провайдера сценариев (для текста ошибки UNAVAILABLE).
|
||||
private const string ProviderId = "deepseek";
|
||||
|
||||
// Usage API-ответа сценариев (проверка проброса в reply).
|
||||
private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
|
||||
|
||||
// Текст ошибки UNAVAILABLE 1:1 Ruling 5 / ai.py L115–117.
|
||||
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);
|
||||
|
||||
// Сервис передаёт промпт system-сообщением и оборачивает текст как ai.py L193.
|
||||
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 — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))).
|
||||
/// </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: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5
|
||||
/// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём).
|
||||
/// </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 (у метода нет ok-поля).
|
||||
/// </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 = извлечённый ответ строкой (маппинг в ядре),
|
||||
/// usage пробрасывается.
|
||||
/// </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-ошибка
|
||||
/// (README ai.proto L201–204); 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 (ядро падает в локальный разбор, aiFail).
|
||||
/// </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: ключи из JSON-ответа + фиксированный промпт с описанием задачи.
|
||||
/// </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);
|
||||
|
||||
// Фиксированный промпт (routes L36–47) и пользовательское сообщение с описанием.
|
||||
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 + причина; промпт собран сервисом из описания и ключей (discovery_eval
|
||||
/// L50–54), сообщение — как «Сообщение:\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: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt).
|
||||
/// </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 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason
|
||||
/// discovery_eval L167–171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158–164).
|
||||
/// </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 (паритет _ai_fit), причина из модели.
|
||||
/// </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 символов (1:1 _AI_REASON_LIMIT).
|
||||
/// </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>
|
||||
/// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT.
|
||||
/// </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>
|
||||
/// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен).
|
||||
/// </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 (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT.
|
||||
/// </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 (ai.proto GenerateKeywords.description: core обрезает до 4000).
|
||||
/// </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 (Ruling 1): отсутствует → UNAUTHENTICATED до вызова.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user