Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations и SharedKernel/Tenants -> Abstractions/Models; extension-классы telegram/ml -> Extensions. namespace/using/FQN мигрированы, using дедуплицированы.
476 lines
17 KiB
C#
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;
|
|
}
|
|
}
|