Files
Deal/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.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

338 lines
15 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 Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
using Deal.Telegram.Extensions;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Xunit;
using Deal.Telegram.Tests.Support;
using Deal.Telegram.Tests.Telegram;
namespace Deal.Telegram.Tests.Grpc;
/// <summary>
/// RPC-тесты discovery-операций поверх реального gRPC-хоста (план Task 11: Search/GetInfo/ReadForEval/
/// Join/Leave). Сеть Telegram не используется: ready-сессия — телефонный вход на фейк-клиенте, данные
/// discovery задаёт тест на фейке; анти-бан-пауза поиска — фейк-пейсер (без реальных задержек).
/// </summary>
public sealed class DiscoveryRpcTests
{
private const string TenantId = TelegramTestHost.DefaultTenantId;
private const string TenantB = "tenant-discovery-b";
private const string Phone = "+79990001122";
private const string Code = "11111";
private const string ChannelA = "-100111";
private const string ChannelB = "-100222";
private const int PollTimeoutMilliseconds = 5000;
/// <summary>
/// Search ready-сессии: найденные источники в entries ответа + анти-бан-пауза 2–4 с.
/// </summary>
[Fact]
public async Task Search_ReturnsFoundEntries_AndWaits()
{
await RunScenarioAsync(
async (channel, factory, pacer) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().SearchResults.AddRange(
[
new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0),
new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Group, 0, 0),
]);
SearchReply reply = await client.SearchAsync(new SearchRequest { Query = "python", Limit = 10 }, Options());
Assert.Equal(2, reply.Results.Count);
Assert.Equal(ChannelA, reply.Results[0].Id);
Assert.Equal("Канал А", reply.Results[0].Name);
Assert.Equal("ch_a", reply.Results[0].Username);
Assert.Equal(DialogKinds.Channel, reply.Results[0].Kind);
Assert.Equal(DialogHue.Compute(ChannelA, "Канал А"), reply.Results[0].Hue);
Assert.Equal((2.0, 4.0), Assert.Single(pacer.Waits));
});
}
/// <summary>
/// Search без готовой сессии → FAILED_PRECONDITION «Telegram не подключён».
/// </summary>
[Fact]
public async Task Search_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.SearchAsync(new SearchRequest { Query = "python" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail);
});
}
/// <summary>
/// GetInfo: имя/username/kind/participants/is_forum из фейка (маппинг ChannelInfo).
/// </summary>
[Fact]
public async Task GetInfo_ReturnsChannelInfo()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().SourceInfos[ChannelA] = new TelegramSourceInfo(
ChannelA, "IT Канал", "it_channel", DialogKinds.Channel, participants: 42_000, isForum: true);
GetInfoReply reply = await client.GetInfoAsync(new GetInfoRequest { DialogId = ChannelA }, Options());
Assert.Equal(ChannelA, reply.Info.Id);
Assert.Equal("IT Канал", reply.Info.Name);
Assert.Equal("it_channel", reply.Info.Username);
Assert.Equal(DialogKinds.Channel, reply.Info.Kind);
Assert.Equal(42_000, reply.Info.Participants);
Assert.True(reply.Info.IsForum);
Assert.Equal(DialogHue.Compute(ChannelA, "IT Канал"), reply.Info.Hue);
});
}
/// <summary>
/// ReadForEval: выборка ok:true с сообщениями (в т.ч. темами форума).
/// </summary>
[Fact]
public async Task ReadForEval_ReturnsMessages_Ok()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().ReadForEvalResults[ChannelA] = new DiscoveryReadResult(
ok: true,
error: null,
messages:
[
new DiscoveryMessage(10, "Сообщение темы", 1_700_000_000_010, topicId: 3, topicTitle: "Вакансии"),
new DiscoveryMessage(9, "Обычное", 1_700_000_000_009, null, null),
]);
ReadForEvalReply reply = await client.ReadForEvalAsync(
new ReadForEvalRequest { DialogId = ChannelA, Limit = 30 }, Options());
Assert.True(reply.Ok);
Assert.False(reply.HasError);
Assert.Equal(2, reply.Messages.Count);
Assert.Equal(3, reply.Messages[0].TopicId);
Assert.Equal("Вакансии", reply.Messages[0].TopicTitle);
Assert.False(reply.Messages[1].HasTopicId);
Assert.Equal(9, reply.Messages[1].Id);
});
}
/// <summary>
/// ReadForEval недоступной истории: ok:false + error=no_history (это НЕ ошибка RPC).
/// </summary>
[Fact]
public async Task ReadForEval_HistoryUnavailable_OkFalseNoHistory()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().ReadForEvalResults[ChannelB] = DiscoveryReadResult.NoHistory();
ReadForEvalReply reply = await client.ReadForEvalAsync(
new ReadForEvalRequest { DialogId = ChannelB, Limit = 30 }, Options());
Assert.False(reply.Ok);
Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error);
Assert.Empty(reply.Messages);
});
}
/// <summary>
/// Join: вступление по username (нормализованному) → ok:true; пауз нет (внешний анти-бан — ядро).
/// </summary>
[Fact]
public async Task Join_JoinsByNormalizedUsername_Ok()
{
await RunScenarioAsync(
async (channel, factory, pacer) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
JoinReply reply = await client.JoinAsync(new JoinRequest { Username = "@it_channel" }, Options());
Assert.True(reply.Ok);
Assert.Equal(["it_channel"], factory.CreatedClients.Single().JoinedUsernames);
Assert.Empty(pacer.Waits);
});
}
/// <summary>
/// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" (контракт).
/// </summary>
[Fact]
public async Task Join_FloodWait_ResourceExhausted()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().JoinError = new Sessions.SessionException(
StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120");
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.JoinAsync(new JoinRequest { Username = "it_channel" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.ResourceExhausted, exception.StatusCode);
Assert.StartsWith("flood", exception.Status.Detail, StringComparison.Ordinal);
});
}
/// <summary>
/// Join без username → INVALID_ARGUMENT (текст 1:1 прототипа).
/// </summary>
[Fact]
public async Task Join_EmptyUsername_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.JoinAsync(new JoinRequest { Username = " " }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.JoinUsernameMissing, exception.Status.Detail);
});
}
/// <summary>
/// Серверная граница длины Search.query: длиннее лимита → INVALID_ARGUMENT без сессии/поиска.
/// </summary>
[Fact]
public async Task Search_TooLongQuery_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.SearchAsync(new SearchRequest { Query = new string('a', 201) }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.SearchQueryTooLong, exception.Status.Detail);
});
}
/// <summary>
/// Серверная граница длины Join.username (лимит Telegram 32): длиннее → INVALID_ARGUMENT.
/// </summary>
[Fact]
public async Task Join_TooLongUsername_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.JoinAsync(new JoinRequest { Username = new string('u', 33) }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.UsernameTooLong, exception.Status.Detail);
});
}
/// <summary>
/// Изоляция тенантов: поиск идёт по сессии своего тенанта (metadata tenant-id, Ruling 1).
/// </summary>
[Fact]
public async Task TenantIsolation_SearchOnOwnTenantSession()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
await LoginAsync(client, TenantB, "+79990001133");
factory.CreatedClients[0].SearchResults.Add(new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0));
factory.CreatedClients[1].SearchResults.Add(new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel, 0, 0));
SearchReply forA = await client.SearchAsync(new SearchRequest { Query = "a" }, Options(TenantId));
SearchReply forB = await client.SearchAsync(new SearchRequest { Query = "b" }, Options(TenantB));
Assert.Equal([ChannelA], forA.Results.Select(entry => entry.Id).ToArray());
Assert.Equal([ChannelB], forB.Results.Select(entry => entry.Id).ToArray());
});
}
// Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode).
// client: Клиент TelegramService.
// tenantId: Id тенанта сценария.
// phone: Номер телефона.
private static async Task LoginAsync(
TelegramService.TelegramServiceClient client,
string tenantId = TenantId,
string phone = Phone)
{
await client.StartPhoneAsync(
new StartPhoneRequest { Phone = phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash },
Options(tenantId));
await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options(tenantId));
await WaitForPhaseAsync(client, tenantId, "ready");
}
// Поллинг GetStatus до ожидаемой фазы.
// client: Клиент TelegramService.
// tenantId: Id тенанта.
// phase: Ожидаемая фаза ("ready").
private static async Task WaitForPhaseAsync(
TelegramService.TelegramServiceClient client,
string tenantId,
string phase)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options(tenantId));
if (status.Phase == phase)
{
return;
}
await Task.Delay(25);
}
}
// Прогоняет сценарий на хосте с фейковой фабрикой клиентов и фейк-пейсером.
// scenario: Сценарий (канал, фабрика, фейк-пейсер).
private static async Task RunScenarioAsync(Func<GrpcChannel, FakeClientFactory, RecordingPacer, Task> scenario)
{
var factory = new FakeClientFactory();
var pacer = new RecordingPacer();
await TelegramTestHost.RunAsync(
TelegramTestHost.DefaultToken,
channel => scenario(channel, factory, pacer),
configureServices: services =>
{
services.AddSingleton<ITelegramClientFactory>(factory);
services.RemoveAll<IBackfillPacer>();
services.AddSingleton<IBackfillPacer>(pacer);
});
}
// Опции вызова с токеном и tenant-id.
// tenantId: Id тенанта.
private static CallOptions Options(string tenantId = TenantId)
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, tenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds));
}