Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests разложены по областям (Modules/<X>, Api, Infrastructure, Contracts, Grpc, ...), общие хелперы -> Support; namespace = папка, using между областями добавлены итеративно по ошибкам сборки.
338 lines
15 KiB
C#
338 lines
15 KiB
C#
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));
|
||
}
|