Сгруппировать тестовые проекты по областям
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,214 @@
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Ai;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8).
|
||||
///
|
||||
/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через AiServiceHost.Create —
|
||||
/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку
|
||||
/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor
|
||||
/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к
|
||||
/// методу (реализация Task 8: пустой запрос без конфига провайдера → INVALID_ARGUMENT); при
|
||||
/// незаданном DEAL_SERVICE_TOKEN — fail-closed.
|
||||
///
|
||||
/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN) — тесты выставляют env на время
|
||||
/// сценария и восстанавливают исходное значение. Все тесты класса живут в одном процессе/классе,
|
||||
/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно).
|
||||
/// </summary>
|
||||
public sealed class AiServiceHostTests
|
||||
{
|
||||
// Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor).
|
||||
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
// Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey).
|
||||
private const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
// Токен сценариев теста.
|
||||
private const string ValidToken = "task4-test-token";
|
||||
|
||||
// Id тенанта запросов (доходит до метода при верном токене; Ruling 1).
|
||||
private const string TenantId = "tenant-test";
|
||||
|
||||
// Deadline RPC-вызовов теста (сек).
|
||||
private const int RpcDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура
|
||||
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
ValidToken,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse response = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: null,
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithWrongToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: "wrong-token",
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают:
|
||||
/// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется
|
||||
/// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: ValidToken,
|
||||
expected: StatusCode.InvalidArgument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда,
|
||||
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
|
||||
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
serviceToken: null,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse healthResponse = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
|
||||
|
||||
// Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену).
|
||||
await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated);
|
||||
await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated);
|
||||
});
|
||||
}
|
||||
|
||||
// Прогоняет Classify с заданным заголовком «service-token» (null — без заголовка) и проверяет
|
||||
// ожидаемый код статуса RPC-исключения.
|
||||
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
|
||||
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
|
||||
// expected: Ожидаемый StatusCode ответа.
|
||||
private static async Task AssertDealRpcRejectedAsync(
|
||||
string? serviceToken,
|
||||
string? tokenHeader,
|
||||
StatusCode expected)
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
serviceToken,
|
||||
channel => AssertRejectedAsync(channel, tokenHeader, expected));
|
||||
}
|
||||
|
||||
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
|
||||
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
|
||||
// до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке.
|
||||
// channel: Канал к хосту ai-service.
|
||||
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
|
||||
// expected: Ожидаемый StatusCode.
|
||||
private static async Task AssertRejectedAsync(
|
||||
GrpcChannel channel,
|
||||
string? tokenHeader,
|
||||
StatusCode expected)
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
Metadata metadata = new() { { "tenant-id", TenantId } };
|
||||
if (tokenHeader is not null)
|
||||
{
|
||||
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
|
||||
}
|
||||
|
||||
var callOptions = new CallOptions(
|
||||
metadata,
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
AsyncUnaryCall<ClassifyReply> call = client.ClassifyAsync(new ClassifyRequest(), callOptions);
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
|
||||
|
||||
Assert.Equal(expected, exception.StatusCode);
|
||||
}
|
||||
|
||||
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
|
||||
// гасит хост/канал и восстанавливает исходный env.
|
||||
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
|
||||
// scenario: Сценарий с каналом к поднятому хосту.
|
||||
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
|
||||
{
|
||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
||||
|
||||
WebApplication? app = null;
|
||||
GrpcChannel? channel = null;
|
||||
try
|
||||
{
|
||||
int port = FreeTcpPort();
|
||||
app = AiServiceHost.Create(port);
|
||||
await app.StartAsync();
|
||||
|
||||
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
|
||||
await scenario(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (channel is not null)
|
||||
{
|
||||
channel.Dispose();
|
||||
}
|
||||
|
||||
if (app is not null)
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
|
||||
private static int FreeTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user