""" bot_test_helpers.py — общие фейки и харнесы для test_bot_*.py (вынесено из test_bot.py, P2 аудита). Не содержит тестов. """ from types import SimpleNamespace import asyncio import bot import lumen_telegram_transport class _FakeResolveResponse: def __init__(self, status: int, url: str): self.status = status self.url = url async def __aenter__(self): return self async def __aexit__(self, *args): return False class _FakeResolveSession: """Мокает session.head()/session.get() для _resolve_tiktok_short.""" def __init__(self, head_response, get_response): self._head_response = head_response self._get_response = get_response self.get_called = False def head(self, url, *args, **kwargs): return self._head_response def get(self, url, *args, **kwargs): self.get_called = True return self._get_response class _FakeChainResolveResponse: def __init__(self, status: int, url: str = "", headers=None): self.status = status self.url = url self.headers = headers or {} async def __aenter__(self): return self async def __aexit__(self, *args): return False class _FakeChainResolveSession: """head()/get() по карте URL с записью kwargs — для проверки ручной цепочки редиректов.""" def __init__(self, mapping: dict): self._mapping = mapping self.calls = [] def head(self, url, *args, **kwargs): self.calls.append(("head", url, dict(kwargs))) return self._mapping[url][0] def get(self, url, *args, **kwargs): self.calls.append(("get", url, dict(kwargs))) return self._mapping[url][1] class _FakeExc(Exception): def __init__(self, msg, status_code=None): super().__init__(msg) self.status_code = status_code class _FakeStatusExc(Exception): def __init__(self, msg, status_code=None): super().__init__(msg) self.status_code = status_code class _FakeAdminRequest: def __init__(self, headers=None, query_params=None): self.headers = headers or {} self.query_params = query_params or {} class _FakeVoiceBot: """Минимальная замена aiogram Bot для inline_tts — нужен только send_voice.""" def __init__(self): self.sent_voice: dict | None = None async def send_voice(self, **kwargs): self.sent_voice = kwargs return SimpleNamespace() class _FakeInlineData: def __init__(self, data, mime_type): self.data = data self.mime_type = mime_type class _FakePart: def __init__(self, inline_data): self.inline_data = inline_data class _FakeTTSContent: def __init__(self, parts): self.parts = parts class _FakeTTSCandidate: def __init__(self, content): self.content = content class _FakeTTSResponse: def __init__(self, candidates): self.candidates = candidates def _fake_tts_response(wav_bytes: bytes) -> "_FakeTTSResponse": return _FakeTTSResponse(candidates=[ _FakeTTSCandidate(_FakeTTSContent(parts=[_FakePart(_FakeInlineData(wav_bytes, "audio/wav"))])) ]) def _run_core_capturing_prompt(message, monkeypatch): captured = {} async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs): captured["prompt"] = ai_prompt return "ok", False monkeypatch.setattr(bot, "_run_route", fake_run_route) asyncio.run(bot._handle_message_core(message)) return captured.get("prompt", "") class _FakeCandidate: def __init__(self, finish_reason="STOP", content=None): self.finish_reason = finish_reason self.content = content class _FakeGeminiResponse: def __init__(self, text="", candidates=None): self._text = text self.candidates = candidates or [] @property def text(self): return self._text class _FakeSentMessage: def __init__(self): self.edits = [] self.deleted = False async def edit_text(self, text, **kwargs): self.edits.append((text, kwargs.get("parse_mode"))) return self async def delete(self): self.deleted = True class _FakeChat: def __init__(self, chat_id, chat_type): self.id = chat_id self.type = chat_type class _FakeIncomingMessage: def __init__(self, chat_id): self.chat = _FakeChat(chat_id, bot.ChatType.PRIVATE) self.reply_to_message = None self.sent: list[_FakeSentMessage] = [] async def reply(self, text, **kwargs): msg = _FakeSentMessage() self.sent.append(msg) return msg class _FakeAudioBot: """Минимальная замена aiogram Bot для _send_tiktok_music — нужен только send_audio.""" def __init__(self): self.sent_audio: dict | None = None async def send_audio(self, **kwargs): self.sent_audio = kwargs return SimpleNamespace() def _run_send_tiktok_music(media_data: dict, language_code: str | None = "ru"): """Общий harness: мокает скачивание байтов и запись MP3-тегов, возвращает (title, performer), которые реально дошли бы до _write_mp3_tags/send_audio.""" incoming = _FakeIncomingMessage(999601) incoming.message_id = 12345 incoming.from_user = SimpleNamespace(language_code=language_code) if language_code is not None else None captured: dict = {} async def fake_download(session, url, headers=None): return b"fake-bytes" def fake_write_tags(path, title, artist, cover): captured["title"] = title captured["artist"] = artist original_download = bot._download_url_bin original_write_tags = bot._write_mp3_tags original_bot = bot.bot bot._download_url_bin = fake_download bot._write_mp3_tags = fake_write_tags bot.bot = _FakeAudioBot() try: asyncio.run(bot._send_tiktok_music(None, media_data, incoming, "VideoPosterNickname", {})) finally: bot._download_url_bin = original_download bot._write_mp3_tags = original_write_tags bot.bot = original_bot return captured.get("title"), captured.get("artist") class _FakeAsyncLineIter: def __init__(self, lines: list[bytes]): self._lines = list(lines) def __aiter__(self): return self async def __anext__(self): if not self._lines: raise StopAsyncIteration return self._lines.pop(0) class _FakeSSEResponse: def __init__(self, lines: list[bytes], status: int = 200): self.status = status self._lines = lines async def __aenter__(self): return self async def __aexit__(self, *args): return False async def read(self): return b"" @property def content(self): return _FakeAsyncLineIter(self._lines) class _FakeSessionForSSE: def __init__(self, resp): self._resp = resp def post(self, *args, **kwargs): return self._resp class _FakeWebhookRequest: def __init__(self, headers: dict | None = None, body: dict | None = None): import json as _json self.headers = headers or {} self._body = body or {} self._raw = _json.dumps(self._body).encode("utf-8") self.json_calls = 0 self.body_calls = 0 async def json(self): self.json_calls += 1 return self._body async def body(self): self.body_calls += 1 return self._raw async def _run_webhook_handler(req: "_FakeWebhookRequest") -> dict: """asyncio.create_task внутри webhook_handler запускает обработку апдейта не дожидаясь её завершения — один await asyncio.sleep(0) после вызова даёт планировщику шанс выполнить уже запланированную задачу (фейковый обработчик ниже не делает собственных await, поэтому этого достаточно для детерминизма).""" result = await bot.webhook_handler(req) await asyncio.sleep(0) return result class _FakeOwnerBot: def __init__(self): self.sent: list[dict] = [] async def send_message(self, **kwargs): self.sent.append(kwargs) return SimpleNamespace() class _FakeTikTokBot: def __init__(self): self.sent_videos: list[dict] = [] async def send_video(self, **kwargs): self.sent_videos.append(kwargs) return SimpleNamespace() async def send_audio(self, **kwargs): return SimpleNamespace() class _FakeTikTokResponse: def __init__(self, status=200, json_body=None): import json as _json self.status = status self._json_body = json_body or {} # Честный фейк: настоящий aiohttp парсит .json() из тела — контент # выводим из того же json, чтобы код шёл боевым путём чтения с капом. self.headers = {} self.content = _FakeDownloadContent([_json.dumps(self._json_body).encode("utf-8")]) async def __aenter__(self): return self async def __aexit__(self, *args): return False async def json(self, content_type=None): return self._json_body class _FakeTikTokSession: """Мокает aiohttp.ClientSession для целого handle_tiktok: session.get() отдаёт ответ TikWM API. _download_url_bin/_resolve_tiktok_short патчатся отдельно (они принимают сессию, но не обязаны реально использовать её методы).""" def __init__(self, tikwm_json: dict): self._tikwm_json = tikwm_json def get(self, url, *args, **kwargs): if "tikwm.com/api" in url: return _FakeTikTokResponse(200, self._tikwm_json) return _FakeTikTokResponse(200, {}) class _FakeTikwmApiResponse: def __init__(self, status=200, json_body=None, body_bytes: bytes | None = None): import json as _json self.status = status self._json_body = json_body or {} # Явно заданные байты (включая пустые) едут и в read(), и в стрим, # как у настоящего ответа. self._body_bytes = body_bytes if body_bytes is not None else _json.dumps(self._json_body).encode("utf-8") self.headers = {} self.content = _FakeDownloadContent([self._body_bytes]) async def __aenter__(self): return self async def __aexit__(self, *args): return False async def json(self, content_type=None): return self._json_body async def read(self): return self._body_bytes class _FakeTikwmApiSession: """session.get(url) — очередь заранее заготовленных ответов (по одному на каждый вызов, в порядке вызова) плюс список реально запрошенных URL/заголовков — тесты ниже проверяют и содержимое ответов, и сам порядок/число обращений, и заголовки, с которыми ушёл каждый запрос.""" def __init__(self, responses: list): self._responses = list(responses) self.requested_urls: list[str] = [] self.requested_headers: list[dict] = [] def get(self, url, *args, **kwargs): self.requested_urls.append(url) self.requested_headers.append(kwargs.get("headers") or {}) return self._responses.pop(0) if self._responses else _FakeTikwmApiResponse(status=500) class _FakeDownloadContent: """Мокает resp.content.iter_chunked(n) — асинхронный генератор чанков байт, как у настоящего aiohttp.StreamReader.""" def __init__(self, chunks: list[bytes]): self._chunks = list(chunks) def iter_chunked(self, n: int): chunks = self._chunks async def _gen(): for c in chunks: yield c return _gen() class _FakeDownloadResponse: def __init__(self, chunks: list[bytes], *, status: int = 200, content_length: str | None = None): self.status = status self.headers = {"Content-Length": content_length} if content_length is not None else {} self.content = _FakeDownloadContent(chunks) async def __aenter__(self): return self async def __aexit__(self, *args): return False class _FakeDownloadSession: def __init__(self, resp): self._resp = resp def get(self, url, *args, **kwargs): return self._resp class _FakeProc: def __init__(self, *, hang: bool = False, fail_communicate: bool = False): self._hang = hang self._fail_communicate = fail_communicate self.killed = False self.kill_count = 0 self.wait_count = 0 self.returncode = None async def communicate(self): if self._fail_communicate: raise RuntimeError("pipe broken") while self._hang and not self.killed: await asyncio.sleep(0.01) return b"out", b"err" def kill(self): self.kill_count += 1 self.killed = True self.returncode = -9 self._hang = False async def wait(self): self.wait_count += 1 return self.returncode def _run_proxy_middleware(url, *, secret="proxy-secret-abc-0123456789abcdef", bases=("https://proxy.example/fetch/api.telegram.org",), headers=None): from multidict import CIMultiDict from yarl import URL (authenticate,) = lumen_telegram_transport.proxy_auth_middlewares( proxy_secret=secret, proxy_base_urls=bases, ) seen = {} class _FakeRequest: def __init__(self): self.url = URL(url) self.headers = CIMultiDict(headers or {}) async def _handler(request): seen["sent"] = dict(request.headers) info_headers = CIMultiDict(request.headers) class _FakeResponse: status = 200 def __init__(self): self.request_info = SimpleNamespace( url=request.url, method="GET", headers=info_headers, real_url=request.url, ) self.closed = False def close(self): self.closed = True return _FakeResponse() request = _FakeRequest() response = asyncio.run(authenticate(request, _handler)) return request, response, seen class _FakeQueryMessage: def __init__(self, chat_id=777, message_id=55): self.chat = SimpleNamespace(id=chat_id, type=bot.ChatType.PRIVATE) self.message_id = message_id self.edits = [] async def edit_text(self, text, **kwargs): self.edits.append((text, kwargs)) return self def _make_pick_query(data, user_id=111, msg=None): q = SimpleNamespace() q.data = data q.from_user = SimpleNamespace(id=user_id) q.message = msg if msg is not None else _FakeQueryMessage() q.answered = [] async def answer(text=None, show_alert=False): q.answered.append((text, show_alert)) q.answer = answer return q class _FakeRichMessage: def __init__(self): self.edits = [] self.deleted = False self.chat = SimpleNamespace(id=777) self.message_id = 42 async def edit_text(self, text, **kwargs): self.edits.append((text, kwargs.get("parse_mode"))) return self class _FakeRichBot: """Бот с рич-методами: фиксирует rich_message.html и возвращает НАСТОЯЩИЙ aiogram Message (нужен isinstance-гейт в хелперах).""" def __init__(self): from aiogram.types import Chat as TgChat from aiogram.types import Message as TgMessage self.rich_sent = [] self.rich_edited = [] self._TgMessage = TgMessage self._chat = TgChat(id=777, type="private") def _real_message(self, message_id): import datetime return self._TgMessage(message_id=message_id, date=datetime.datetime.now(), chat=self._chat) async def send_rich_message(self, **kwargs): self.rich_sent.append(kwargs) return self._real_message(43) async def edit_message_text(self, **kwargs): self.rich_edited.append(kwargs) return self._real_message(kwargs.get("message_id", 42)) class _FakeRichFailingBot: async def send_rich_message(self, **kwargs): raise RuntimeError("rich not supported here") async def edit_message_text(self, **kwargs): raise RuntimeError("rich not supported here") def _make_lang_query(data, chat_type=None, user_id=111): q = _make_pick_query(data, user_id=user_id) q.message.chat.type = chat_type or bot.ChatType.PRIVATE return q