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