Files
Deal/src/telegram-service/Deal.Telegram.Tests/FakeSessionClient.cs
T
Rustam Khalimov e3a2692507 Добить структуру Api, Contracts, SharedKernel и сервисов
Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
2026-09-11 13:25:18 +03:00

476 lines
17 KiB
C#

using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Deal.Telegram.Extensions;
namespace Deal.Telegram.Tests;
// Фейковый клиент Telegram для unit/in-proc тестов (план Task 9: фазовые переходы на fake-клиенте
// абстракции ISessionClient; без сети). Поведение задаётся скриптом: ошибки/результаты кода/пароля,
// QR-вход ждёт явного «сканирования» (CompleteQrScanAsync) — тест управляет моментом авторизации.
internal sealed class FakeSessionClient : ISessionClient
{
/// <summary>
/// URL, который фейк выдаёт первым колбэком QR-входа.
/// </summary>
public const string DefaultQrUrl = "tg://login?token=fake_token";
private readonly TaskCompletionSource<bool> _qrScanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool _authorized;
/// <summary>
/// Создаёт фейк под ключи приложения (как реальный клиент).
/// </summary>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="storedSession">Байты «сохранённой сессии» (для проверки передачи в фабрику).</param>
public FakeSessionClient(
int apiId,
string apiHash,
byte[]? storedSession)
{
ApiId = apiId;
ApiHash = apiHash;
SessionBytes = storedSession ?? SessionMarker;
_authorized = storedSession is not null;
}
/// <summary>
/// Сколько раз вызван ConnectAsync (проверка попыток переподключения в тестах).
/// </summary>
public int ConnectAttempts { get; private set; }
/// <summary>
/// Непустой — ConnectAsync ждёт завершения этой задачи (эмуляция «зависшего» connect;
/// отмена токена прерывает ожидание). null — мгновенный успех.
/// </summary>
public TaskCompletionSource<bool>? ConnectGate { get; set; }
/// <summary>
/// True — первый URL QR не выдаётся сразу (эмуляция ожидания QR до сканирования/отмены).
/// </summary>
public bool DelayFirstQrUrl { get; set; }
/// <summary>
/// True — фоновый QR-вход (StartQrAsync) остановлен отменой до сканирования.
/// </summary>
public bool QrLoginCancelled { get; private set; }
/// <summary>
/// Маркерные байты сессии (детект сохранения файла в тестах).
/// </summary>
public static byte[] SessionMarker { get; } = [1, 3, 3, 7, 42];
/// <summary>
/// Исключение, которое бросит RequestCodeAsync (null — успех).
/// </summary>
public Exception? RequestCodeError { get; set; }
/// <summary>
/// Исключение SubmitCodeAsync (null — успех).
/// </summary>
public Exception? CodeError { get; set; }
/// <summary>
/// Исключение SubmitPasswordAsync (null — успех).
/// </summary>
public Exception? PasswordError { get; set; }
/// <summary>
/// Результат SubmitCodeAsync: "password" — нужен 2FA; null — авторизация завершена.
/// </summary>
public string? CodeResult { get; set; }
/// <summary>
/// Аккаунт, возвращаемый GetAccountAsync.
/// </summary>
public string Account { get; set; } = "@fake_user";
/// <summary>
/// Был ли вызван LogOutAsync.
/// </summary>
public bool LoggedOut { get; private set; }
/// <summary>
/// Запрошенные номера (RequestCodeAsync).
/// </summary>
public List<string> RequestedPhones { get; } = [];
/// <summary>
/// Отправленные коды (SubmitCodeAsync).
/// </summary>
public List<string> SubmittedCodes { get; } = [];
/// <summary>
/// Отправленные пароли (SubmitPasswordAsync).
/// </summary>
public List<string> SubmittedPasswords { get; } = [];
/// <summary>
/// Был ли вызван StartQrAsync.
/// </summary>
public bool QrStarted { get; private set; }
/// <summary>
/// Завершает «сканирование» QR — авторизует фейк (как подтверждение на телефоне).
/// </summary>
public void CompleteQrScan()
=> _qrScanTcs.TrySetResult(true);
/// <inheritdoc />
public bool IsAuthorized => _authorized;
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <inheritdoc />
public int ApiId { get; }
/// <inheritdoc />
public string ApiHash { get; }
/// <inheritdoc />
public byte[] SessionBytes { get; private set; }
/// <inheritdoc />
public async Task ConnectAsync(CancellationToken cancellationToken)
{
ConnectAttempts++;
if (ConnectGate is not null)
{
// «Зависший» connect: ждём gate (или отмену токена — реальный клиент прерывается по ct).
await ConnectGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
IsConnected = true;
}
/// <inheritdoc />
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken)
{
RequestedPhones.Add(phone);
if (RequestCodeError is not null)
{
throw RequestCodeError;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken)
{
SubmittedCodes.Add(code);
if (CodeError is not null)
{
throw CodeError;
}
if (CodeResult == "password")
{
return Task.FromResult<string?>(CodeResult);
}
_authorized = true;
SessionBytes = SessionMarker;
return Task.FromResult<string?>(null);
}
/// <inheritdoc />
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken)
{
SubmittedPasswords.Add(password);
if (PasswordError is not null)
{
throw PasswordError;
}
_authorized = true;
SessionBytes = SessionMarker;
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken)
{
QrStarted = true;
// Реальный WTelegramClient при QR-входе сам устанавливает соединение (LoginWithQRCode → ConnectAsync).
IsConnected = true;
if (!DelayFirstQrUrl)
{
onQrUrl(DefaultQrUrl);
}
try
{
await _qrScanTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Отмена до сканирования: фоновый вход остановлен (не «скрытая» авторизация).
QrLoginCancelled = true;
throw;
}
_authorized = true;
SessionBytes = SessionMarker;
}
/// <inheritdoc />
public Task LogOutAsync(CancellationToken cancellationToken)
{
LoggedOut = true;
_authorized = false;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string> GetAccountAsync(CancellationToken cancellationToken)
=> Task.FromResult(Account);
/// <inheritdoc />
public event Func<TelegramMessage, Task>? MessageReceived;
/// <summary>
/// Список диалогов, который фейк возвращает из GetDialogsAsync (по умолчанию пуст).
/// </summary>
public List<TelegramDialog> Dialogs { get; } = [];
/// <summary>
/// Сообщения диалогов (ключ — подписанный id), которые возвращает GetMessagesAsync.
/// </summary>
public Dictionary<string, List<TelegramMessage>> Messages { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Сколько раз вызван GetDialogsAsync.
/// </summary>
public int DialogListCallCount { get; private set; }
/// <summary>
/// Диалоги, которые GetMessagesAsync/MarkReadAsync получали (в порядке вызовов).
/// </summary>
public List<string> ReadDialogs { get; } = [];
/// <summary>
/// Диалоги, помеченные прочитанными (MarkReadAsync), в порядке вызовов.
/// </summary>
public List<string> MarkedReadDialogs { get; } = [];
/// <summary>
/// Исключение GetDialogsAsync (null — успех).
/// </summary>
public Exception? DialogsError { get; set; }
/// <summary>
/// Исключение GetMessagesAsync (null — успех).
/// </summary>
public Exception? MessagesError { get; set; }
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken)
{
DialogListCallCount++;
if (DialogsError is not null)
{
throw DialogsError;
}
return Task.FromResult<IReadOnlyList<TelegramDialog>>(Dialogs.ToArray());
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(
string dialogId,
int limit,
CancellationToken cancellationToken)
{
ReadDialogs.Add(dialogId);
if (MessagesError is not null)
{
throw MessagesError;
}
return Task.FromResult<IReadOnlyList<TelegramMessage>>(
Messages.TryGetValue(dialogId, out List<TelegramMessage>? messages)
? messages.Take(limit).ToArray()
: []);
}
/// <inheritdoc />
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{
MarkedReadDialogs.Add(dialogId);
return Task.CompletedTask;
}
// --- Discovery (план Task 11): данные/ошибки операций задаёт тест, сети нет ---
/// <summary>
/// Результат поиска, который фейк возвращает из SearchAsync (chats затем users).
/// </summary>
public List<TelegramDialog> SearchResults { get; } = [];
/// <summary>
/// Поисковые запросы (query/limit), в порядке вызовов.
/// </summary>
public List<(string Query, int Limit)> SearchQueries { get; } = [];
/// <summary>
/// Исключение SearchAsync (null — успех).
/// </summary>
public Exception? SearchError { get; set; }
/// <summary>
/// Инфо источников (ключ — подписанный id), возвращаемое GetInfoAsync.
/// </summary>
public Dictionary<string, TelegramSourceInfo> SourceInfos { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Id источников, по которым запрошено инфо, в порядке вызовов.
/// </summary>
public List<string> InfoRequests { get; } = [];
/// <summary>
/// Результаты чтения выборки (ключ — подписанный id), возвращаемые ReadForEvalAsync.
/// </summary>
public Dictionary<string, DiscoveryReadResult> ReadForEvalResults { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Id источников, по которым запрошено чтение, в порядке вызовов.
/// </summary>
public List<string> ReadRequests { get; } = [];
/// <summary>
/// Username вступлений (после нормализации), в порядке вызовов.
/// </summary>
public List<string> JoinedUsernames { get; } = [];
/// <summary>
/// Исключение JoinAsync (null — успех; flood эмулируется SessionException RESOURCE_EXHAUSTED).
/// </summary>
public Exception? JoinError { get; set; }
/// <summary>
/// Id диалогов выхода, в порядке вызовов.
/// </summary>
public List<string> LeaveCalls { get; } = [];
/// <summary>
/// Исключение LeaveAsync (null — успех).
/// </summary>
public Exception? LeaveError { get; set; }
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(
string query,
int limit,
CancellationToken cancellationToken)
{
SearchQueries.Add((query, limit));
if (SearchError is not null)
{
throw SearchError;
}
return Task.FromResult<IReadOnlyList<TelegramDialog>>(SearchResults.ToArray());
}
/// <inheritdoc />
public Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{
InfoRequests.Add(dialogId);
return Task.FromResult(
SourceInfos.TryGetValue(dialogId, out TelegramSourceInfo? info)
? info
: new TelegramSourceInfo(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false));
}
/// <inheritdoc />
public Task<DiscoveryReadResult> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken cancellationToken)
{
ReadRequests.Add(dialogId);
return Task.FromResult(
ReadForEvalResults.TryGetValue(dialogId, out DiscoveryReadResult? result)
? result
: DiscoveryReadResult.Empty);
}
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken cancellationToken)
{
JoinedUsernames.Add(username);
if (JoinError is not null)
{
throw JoinError;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken cancellationToken)
{
LeaveCalls.Add(dialogId);
if (LeaveError is not null)
{
throw LeaveError;
}
return Task.CompletedTask;
}
/// <summary>
/// Поднимает событие нового сообщения (эмуляция realtime-события Telegram).
/// </summary>
/// <param name="message">Входящее сообщение диалога.</param>
public async Task RaiseMessageAsync(TelegramMessage message)
{
Func<TelegramMessage, Task>? handler = MessageReceived;
if (handler is null)
{
return;
}
foreach (Delegate subscriber in handler.GetInvocationList())
{
await ((Func<TelegramMessage, Task>)subscriber)(message).ConfigureAwait(false);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}
// Фейковая фабрика клиентов: создаёт FakeSessionClient и запоминает экземпляры
// (тест управляет «сканированием» QR и проверяет передачи ключей/байт сессии).
internal sealed class FakeClientFactory : ITelegramClientFactory
{
/// <summary>
/// Все созданные фабрикой клиенты (в порядке создания).
/// </summary>
public List<FakeSessionClient> CreatedClients { get; } = [];
/// <summary>
/// Колбэк настройки клиента перед возвратом (null — клиент по умолчанию).
/// </summary>
public Action<FakeSessionClient>? OnClientCreated { get; set; }
/// <inheritdoc />
public ISessionClient Create(
int apiId,
string apiHash,
byte[]? storedSession)
{
var client = new FakeSessionClient(apiId, apiHash, storedSession);
OnClientCreated?.Invoke(client);
CreatedClients.Add(client);
return client;
}
}