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)); }