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;
///
/// RPC-тесты discovery-операций поверх реального gRPC-хоста (план Task 11: Search/GetInfo/ReadForEval/
/// Join/Leave). Сеть Telegram не используется: ready-сессия — телефонный вход на фейк-клиенте, данные
/// discovery задаёт тест на фейке; анти-бан-пауза поиска — фейк-пейсер (без реальных задержек).
///
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;
///
/// Search ready-сессии: найденные источники в entries ответа + анти-бан-пауза 2–4 с.
///
[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));
});
}
///
/// Search без готовой сессии → FAILED_PRECONDITION «Telegram не подключён».
///
[Fact]
public async Task Search_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync(
() => client.SearchAsync(new SearchRequest { Query = "python" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail);
});
}
///
/// GetInfo: имя/username/kind/participants/is_forum из фейка (маппинг ChannelInfo).
///
[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);
});
}
///
/// ReadForEval: выборка ok:true с сообщениями (в т.ч. темами форума).
///
[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);
});
}
///
/// ReadForEval недоступной истории: ok:false + error=no_history (это НЕ ошибка RPC).
///
[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);
});
}
///
/// Join: вступление по username (нормализованному) → ok:true; пауз нет (внешний анти-бан — ядро).
///
[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);
});
}
///
/// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" (контракт).
///
[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(
() => client.JoinAsync(new JoinRequest { Username = "it_channel" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.ResourceExhausted, exception.StatusCode);
Assert.StartsWith("flood", exception.Status.Detail, StringComparison.Ordinal);
});
}
///
/// Join без username → INVALID_ARGUMENT (текст 1:1 прототипа).
///
[Fact]
public async Task Join_EmptyUsername_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync(
() => client.JoinAsync(new JoinRequest { Username = " " }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.JoinUsernameMissing, exception.Status.Detail);
});
}
///
/// Серверная граница длины Search.query: длиннее лимита → INVALID_ARGUMENT без сессии/поиска.
///
[Fact]
public async Task Search_TooLongQuery_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync(
() => 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);
});
}
///
/// Серверная граница длины Join.username (лимит Telegram 32): длиннее → INVALID_ARGUMENT.
///
[Fact]
public async Task Join_TooLongUsername_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync(
() => 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);
});
}
///
/// Изоляция тенантов: поиск идёт по сессии своего тенанта (metadata tenant-id, Ruling 1).
///
[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 scenario)
{
var factory = new FakeClientFactory();
var pacer = new RecordingPacer();
await TelegramTestHost.RunAsync(
TelegramTestHost.DefaultToken,
channel => scenario(channel, factory, pacer),
configureServices: services =>
{
services.AddSingleton(factory);
services.RemoveAll();
services.AddSingleton(pacer);
});
}
// Опции вызова с токеном и tenant-id.
// tenantId: Id тенанта.
private static CallOptions Options(string tenantId = TenantId)
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, tenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds));
}