diff --git "a/test_bot.py" "b/test_bot.py" new file mode 100644--- /dev/null +++ "b/test_bot.py" @@ -0,0 +1,3365 @@ +""" +test_bot.py — юнит- и smoke-тесты на функции и классы, реально ОПРЕДЕЛЁННЫЕ в bot.py: +Telegram-транспорт и circuit breaker, персистентность состояния (Upstash/локальный файл), +роутинг сообщений (_run_route/ask_gemini/ask_openrouter_*), стриминг ответов, TikTok- +загрузчик, TTS-пайплайн (Fish Audio + Gemini TTS), генерация изображений, webhook/admin +HTTP-эндпоинты, учёт квоты и т.д. + +Переименован из test_bot_helpers.py при разбиении по модулям вслед за уже существующим +разбиением исходников (lumen_formatting.py / lumen_security.py / lumen_router_config.py / +bot.py) — см. README, аудит техдолга, август 2026. Тесты на чистые функции, реально +определённые в lumen_formatting.py/lumen_security.py/lumen_router_config.py (даже если +тематически похожие на код в этом файле — например построение маршрута роутера), теперь +живут в test_lumen_formatting.py/test_lumen_security.py/test_lumen_router_config.py +соответственно; здесь остаются только тесты на bot.py-специфичный код, включая функции, +которые лишь ИСПОЛЬЗУЮТ данные/конфигурацию из вынесенных модулей (например ask_gemini +использует GEMINI_MODELS из lumen_router_config.py, но сама определена в bot.py — тест на +её поведение остаётся здесь). + +conftest.py в этой же папке подставляет безопасные env-заглушки (BOT_TOKEN/GEMINI_API_KEY/ +BOT_LOG_PATH) ДО импорта bot.py, так что реальные секреты и доступ к /app не нужны. + +Запуск: + pip install -r requirements.txt -r requirements-dev.txt + pytest test_bot.py -v +""" +import asyncio +import base64 +import json +import os +import time +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import sentry_sdk + +import bot + + + +# ─────────────────────────── _classify_model_error ─────────────────────────── + +def test_classify_model_error_rate_limit_by_status(): + assert bot._classify_model_error(429, "") == "rate_limit" + + +def test_classify_model_error_rate_limit_by_text(): + assert bot._classify_model_error(None, "quota exceeded") == "rate_limit" + + +def test_classify_model_error_paid(): + assert bot._classify_model_error(402, "") == "paid" + + +def test_classify_model_error_forbidden(): + assert bot._classify_model_error(403, "") == "forbidden" + + +def test_classify_model_error_unavailable(): + assert bot._classify_model_error(404, "") == "unavailable" + + +def test_classify_model_error_other_for_unknown(): + assert bot._classify_model_error(500, "some random error") == "other" + + +# ─────────────────────────── _next_fallback_model ─────────────────────────── + +def test_next_fallback_model_skips_tried(): + assert bot._next_fallback_model({"a"}, ["a", "b", "c"]) == "b" + + +def test_next_fallback_model_all_tried_returns_none(): + assert bot._next_fallback_model({"a", "b", "c"}, ["a", "b", "c"]) is None + + +def test_next_fallback_model_none_tried_returns_first(): + assert bot._next_fallback_model(set(), ["a", "b", "c"]) == "a" + + +# ─────────────────────────── _split_text_chunks ─────────────────────────── + +def test_split_text_chunks_short_text_returns_single_chunk(): + assert bot._split_text_chunks("короткий текст", 100) == ["короткий текст"] + + +def test_split_text_chunks_respects_max_len(): + # Регрессионный тест на реальный баг: раньше сообщения длиннее лимита + # Telegram (4096 симв.) просто не отправлялись вообще. + text = "слово " * 200 + chunks = bot._split_text_chunks(text, 50) + assert len(chunks) > 1 + for chunk in chunks: + assert len(chunk) <= 50 + + +def test_split_text_chunks_preserves_all_words(): + # Каждый чанк уходит отдельным сообщением в Telegram, поэтому граничный + # пробел-разделитель намеренно обрезается с обеих сторон (rstrip/lstrip) — + # это не баг. Проверяем через join(" "), а не через голую конкатенацию. + text = "слово " * 200 + chunks = bot._split_text_chunks(text, 50) + assert " ".join(chunks).split() == text.split() + + +# ─────────────────────────── _sanitize_mime_type ─────────────────────────── + +def test_sanitize_mime_type_guesses_from_extension(): + assert bot._sanitize_mime_type("photo.jpg", "") == "image/jpeg" + + +def test_sanitize_mime_type_lowercases_valid_mime(): + assert bot._sanitize_mime_type(None, "image/PNG") == "image/png" + + +def test_sanitize_mime_type_octet_stream_falls_back_to_extension(): + assert bot._sanitize_mime_type("file.pdf", "application/octet-stream") == "application/pdf" + + +def test_sanitize_mime_type_no_info_returns_default_fallback(): + assert bot._sanitize_mime_type(None, None) == "application/octet-stream" + + +def test_sanitize_mime_type_audio_ogg_passthrough(): + assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg" + + +# ─────────────────────────── is_tiktok / is_youtube ─────────────────────────── + +def test_is_tiktok_true_for_tiktok_url(): + assert bot.is_tiktok("https://www.tiktok.com/@user/video/123") is True + + +def test_is_tiktok_false_for_other_url(): + assert bot.is_tiktok("https://youtube.com/watch?v=1") is False + + +def test_is_youtube_true_for_short_link(): + assert bot.is_youtube("https://youtu.be/abc123") is True + + +def test_is_youtube_false_for_other_url(): + assert bot.is_youtube("https://example.com") is False + + +# ─────────────────── _tiktok_video_candidates (HD-цепочка качества) ─────────────────── +# Регрессия на реальный найденный при ревизии пробел: раньше бот всегда брал +# media_data.get("play") or media_data.get("wmplay") — то есть НИКОГДА не пробовал +# HD-версию (hdplay), даже когда TikWM её реально отдавал. Тесты ниже проверяют +# порядок кандидатов (HD -> стандартное -> с водяным знаком) и то, что известный +# заранее размер файла (hd_size/size/wm_size) корректно прокидывается для решения +# "пропускать ли вариант ещё до скачивания" в handle_tiktok. + +def test_tiktok_video_candidates_prefers_hd_first(): + media_data = { + "play": "https://tikwm.com/sd.mp4", "size": 1000, + "hdplay": "https://tikwm.com/hd.mp4", "hd_size": 5000, + "wmplay": "https://tikwm.com/wm.mp4", "wm_size": 900, + } + candidates = bot._tiktok_video_candidates(media_data) + assert [c["key"] for c in candidates] == ["hdplay", "play", "wmplay"] + assert candidates[0]["url"] == "https://tikwm.com/hd.mp4" + assert candidates[0]["size"] == 5000 + + +def test_tiktok_video_candidates_falls_back_when_hd_missing(): + # TikWM не всегда возвращает hdplay (например, если &hd=1 не сработал или для + # этого конкретного видео HD-версии просто нет) — кандидат должен тихо + # отсутствовать в списке, а не давать пустую/битую запись. + media_data = {"play": "https://tikwm.com/sd.mp4", "size": 1000} + candidates = bot._tiktok_video_candidates(media_data) + assert [c["key"] for c in candidates] == ["play"] + + +def test_tiktok_video_candidates_wmplay_only_as_last_resort(): + media_data = {"wmplay": "https://tikwm.com/wm.mp4", "wm_size": 900} + candidates = bot._tiktok_video_candidates(media_data) + assert [c["key"] for c in candidates] == ["wmplay"] + + +def test_tiktok_video_candidates_relative_url_gets_tikwm_prefix(): + # TikWM иногда отдаёт относительный путь без домена — как и в остальном коде + # (см. оригинальную логику video_url в handle_tiktok), такой путь должен + # получить префикс https://www.tikwm.com. + media_data = {"play": "/download/sd.mp4", "size": 1000} + candidates = bot._tiktok_video_candidates(media_data) + assert candidates[0]["url"] == "https://www.tikwm.com/download/sd.mp4" + + +def test_tiktok_video_candidates_missing_size_defaults_to_zero(): + # Отсутствие size/hd_size/wm_size в ответе TikWM — обычное дело (см. докстринг + # _tiktok_video_candidates) — не должно приводить к исключению, просто size=0 + # (что handle_tiktok трактует как "неизвестный размер, пробуем оптимистично"). + media_data = {"hdplay": "https://tikwm.com/hd.mp4"} + candidates = bot._tiktok_video_candidates(media_data) + assert candidates[0]["size"] == 0 + + +def test_tiktok_video_candidates_empty_when_nothing_available(): + assert bot._tiktok_video_candidates({}) == [] + + +# ─────────────────── _original_sound_label (локализация "оригинального звука") ─────────────────── +# TikWM отдаёт название "оригинального звука" на языке автора исходного видео (или +# по умолчанию на английском) — никак не связано с языком человека, приславшего +# ссылку В НАШ бот. _original_sound_label использует message.from_user.language_code +# (IETF-тег языка интерфейса Telegram конкретного отправителя) для локализации. + +def test_original_sound_label_russian(): + assert bot._original_sound_label("ru") == "Оригинальный звук" + + +def test_original_sound_label_ukrainian(): + assert bot._original_sound_label("uk") == "Оригінальний звук" + + +def test_original_sound_label_belarusian(): + assert bot._original_sound_label("be") == "Арыгінальны гук" + + +def test_original_sound_label_english(): + assert bot._original_sound_label("en") == "Original sound" + + +def test_original_sound_label_strips_region_subtag(): + # Telegram может прислать региональный вариант ("en-US", "pt-BR") — берём + # только первичный языковой подтег до дефиса. + assert bot._original_sound_label("en-US") == "Original sound" + assert bot._original_sound_label("pt-BR") == "Som original" + + +def test_original_sound_label_falls_back_to_english_for_unknown_code(): + assert bot._original_sound_label("th") == "Original sound" + assert bot._original_sound_label("xx-YY") == "Original sound" + + +def test_original_sound_label_falls_back_to_english_when_missing(): + assert bot._original_sound_label(None) == "Original sound" + assert bot._original_sound_label("") == "Original sound" + + +def test_original_sound_label_case_insensitive(): + assert bot._original_sound_label("RU") == "Оригинальный звук" + + +# ─────────────────── _tiktok_music_page_id (ссылка на страницу звука, не видео) ─────────────────── +# Регрессия на реальный найденный в логах случай: TikWM отвечает "Url parsing is +# failed!" на ссылки вида tiktok.com/music/... — это ссылки на СТРАНИЦУ звука +# (копируется через "Поделиться" на самом звуке в приложении), а не на видео. + +def test_tiktok_music_page_id_extracts_trailing_numeric_id(): + url = "https://www.tiktok.com/music/original-sound-7666630127215823637" + assert bot._tiktok_music_page_id(url) == "7666630127215823637" + + +def test_tiktok_music_page_id_extracts_id_with_cyrillic_slug(): + # Реальный найденный в логах случай — слаг на русском языке. + url = "https://www.tiktok.com/music/оригинальный-звук-7667114246303812385" + assert bot._tiktok_music_page_id(url) == "7667114246303812385" + + +def test_tiktok_music_page_id_handles_trailing_slash(): + url = "https://www.tiktok.com/music/original-sound-7666630127215823637/" + assert bot._tiktok_music_page_id(url) == "7666630127215823637" + + +def test_tiktok_music_page_id_none_for_regular_video_link(): + url = "https://www.tiktok.com/@someuser/video/7370000000000000001" + assert bot._tiktok_music_page_id(url) is None + + +def test_tiktok_music_page_id_none_for_photo_post_link(): + url = "https://www.tiktok.com/@someuser/photo/7370000000000000002" + assert bot._tiktok_music_page_id(url) is None + + +def test_tiktok_music_page_id_none_for_named_track_slug(): + # Именованные треки/песни тоже используют /music/, просто со своим слагом — + # функция всё равно должна найти числовой ID (сама эвристика "сработает ли + # скачивание" находится не здесь, а в handle_tiktok_sound). + url = "https://www.tiktok.com/music/Blinding-Lights-6862178485109294850" + assert bot._tiktok_music_page_id(url) == "6862178485109294850" + + +# ─────────────────── _looks_like_resolved_tiktok_url (валидность резолвинга короткой ссылки) ─────────────────── +# Регрессия на реальный найденный при отладке инцидент (12 августа 2026): TikWM +# стабильно отвечал 403 на короткую ссылку vt.tiktok.com — реальный резолвленный +# URL, ушедший в TikWM, оказался "https://www.tiktok.com/@/photo/...": юзернейм +# между "@" и "/" был ПУСТОЙ. Старая проверка результата HEAD-резолвинга +# ("video" in resolved or "@" in resolved) слишком слабая — голый символ "@" в +# такой строке есть, поэтому она засчитывала HEAD "успешным" даже с пустым +# юзернеймом, и GET-фоллбек (который мог бы довести резолвинг до нормального +# URL) даже не пробовался. + +def test_looks_like_resolved_tiktok_url_true_for_proper_video_url(): + assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@someuser/video/7370000000000000001") is True + + +def test_looks_like_resolved_tiktok_url_true_for_proper_photo_url(): + assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@someuser/photo/7370000000000000002") is True + + +def test_looks_like_resolved_tiktok_url_false_for_empty_username(): + # Точно тот URL, что реально ушёл в TikWM и получил 403 в реальном инциденте. + assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@/photo/7512093374153772309") is False + + +def test_looks_like_resolved_tiktok_url_false_for_bare_at_sign_without_post(): + # Просто "@" где-то в строке (например голая страница профиля без поста, или + # случайное совпадение) — раньше проходило старую слабую проверку. + assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@someuser") is False + assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/some-page?ref=@video") is False + + +def test_looks_like_resolved_tiktok_url_false_for_empty_or_none(): + assert bot._looks_like_resolved_tiktok_url("") is False + assert bot._looks_like_resolved_tiktok_url(None) is False + + +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 + + +def test_resolve_tiktok_short_falls_through_to_get_when_head_gives_malformed_url(): + # РЕГРЕССИЯ на реальный инцидент: HEAD "успешно" довёл до URL с ПУСТЫМ + # юзернеймом — раньше такой результат принимался сразу и уходил в TikWM, + # который на него отвечал 403. Теперь должен быть отброшен, и функция + # обязана попробовать GET (который в этом тесте отдаёт нормальный URL). + head_resp = _FakeResolveResponse(200, "https://www.tiktok.com/@/photo/7512093374153772309") + get_resp = _FakeResolveResponse(200, "https://www.tiktok.com/@realuser/photo/7512093374153772309") + session = _FakeResolveSession(head_resp, get_resp) + result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/ZS43ubyhA/")) + assert result == "https://www.tiktok.com/@realuser/photo/7512093374153772309" + assert session.get_called is True + + +def test_resolve_tiktok_short_uses_head_result_directly_when_valid(): + # Нормальный случай (без регрессии) — HEAD сразу довёл до валидного URL, + # GET вообще не должен вызываться (незачем тратить лишний запрос). + head_resp = _FakeResolveResponse(200, "https://www.tiktok.com/@realuser/video/1234567890") + get_resp = _FakeResolveResponse(200, "https://www.tiktok.com/should-not-be-used") + session = _FakeResolveSession(head_resp, get_resp) + result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/ZS43ubyhA/")) + assert result == "https://www.tiktok.com/@realuser/video/1234567890" + assert session.get_called is False + + +def test_resolve_tiktok_short_returns_get_result_even_if_still_malformed(): + # Если и GET не дал нормального URL — возвращаем то, что реально пришло + # (лучше честная попытка, чем совсем ничего), диагностика в логах — отдельно. + head_resp = _FakeResolveResponse(200, "https://www.tiktok.com/@/photo/999") + get_resp = _FakeResolveResponse(200, "https://www.tiktok.com/@/photo/999") + session = _FakeResolveSession(head_resp, get_resp) + result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/ZS43ubyhA/")) + assert result == "https://www.tiktok.com/@/photo/999" + + +# ─────────────────── _slideshow_slide_urls (live_images вместо play/hdplay) ─────────────────── +# Прежняя эвристика (пробовала верхнеуровневые play/hdplay поста) была основана на +# неверном предположении — реальный лог показал, что для фото-постов эти поля +# указывают на аудиодорожку (mime_type=audio_mpeg), а не на видео. Реальная зацепка — +# отдельное поле `live_images` в ответе TikWM, которое эти тесты и проверяют. + +def test_slideshow_slide_urls_prefers_live_images_when_present(): + media_data = {"live_images": ["https://tikwm.com/live0.mp4", ""]} + images_to_fetch = ["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"] + assert bot._slideshow_slide_urls(media_data, images_to_fetch) == [ + "https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg", + ] + + +def test_slideshow_slide_urls_falls_back_when_live_images_absent(): + images_to_fetch = ["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"] + assert bot._slideshow_slide_urls({}, images_to_fetch) == images_to_fetch + + +def test_slideshow_slide_urls_falls_back_when_live_images_shorter(): + media_data = {"live_images": ["https://tikwm.com/live0.mp4"]} + images_to_fetch = ["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"] + assert bot._slideshow_slide_urls(media_data, images_to_fetch) == [ + "https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg", + ] + + +def test_slideshow_slide_urls_ignores_non_list_live_images(): + media_data = {"live_images": "not-a-list"} + images_to_fetch = ["https://tikwm.com/photo0.jpg"] + assert bot._slideshow_slide_urls(media_data, images_to_fetch) == images_to_fetch + + +# ─────────────────── _looks_like_video_bytes (видео-слайды/'живые фото' в слайдшоу) ─────────────────── +# Регрессия на реальный найденный при ревизии пробел: TikTok разрешает совмещать в +# одном слайдшоу-посте обычные фото-слайды и короткие видео-слайды — TikWM отдаёт +# URL видео-слайда в том же списке `images`, без явного признака "это видео". +# Раньше такой слайд всегда оборачивался в InputMediaPhoto как обычная картинка. + +def test_looks_like_video_bytes_true_for_mp4_ftyp_signature(): + # Реальная сигнатура начала MP4/MOV-контейнера: 4 байта размера бокса + "ftyp". + mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" + assert bot._looks_like_video_bytes(mp4_header) is True + + +def test_looks_like_video_bytes_false_for_jpeg(): + jpeg_header = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00" + assert bot._looks_like_video_bytes(jpeg_header) is False + + +def test_looks_like_video_bytes_false_for_png(): + png_header = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + assert bot._looks_like_video_bytes(png_header) is False + + +def test_looks_like_video_bytes_false_for_too_short_input(): + assert bot._looks_like_video_bytes(b"\x00\x00\x00\x18fty") is False + + +def test_looks_like_video_bytes_false_for_empty_bytes(): + assert bot._looks_like_video_bytes(b"") is False + + +# ─────────────────── _chunk_tiktok_media_items (лимит sendMediaGroup 2-10) ─────────────────── +# КРИТИЧНАЯ РЕГРЕССИЯ, найденная при повторной ревизии: sendMediaGroup у Telegram +# требует ОТ 2 ДО 10 элементов за вызов, а не просто "не больше 10". Наивное +# разбиение по 10 без остатка давало хвостовую группу ровно из 1 элемента для +# слайдшоу из 11/21/31 слайдов (все — реальные, допустимые TikTok длины вплоть до +# официального максимума 35) — такой вызов Telegram гарантированно отклонял бы +# уже ПОСЛЕ того, как предыдущие группы успешно ушли пользователю. + +def test_chunk_tiktok_media_items_exact_multiple_of_ten(): + items = list(range(20)) + chunks = bot._chunk_tiktok_media_items(items) + assert [len(c) for c in chunks] == [10, 10] + assert sum(chunks, []) == items + + +def test_chunk_tiktok_media_items_avoids_trailing_single_item_at_eleven(): + items = list(range(11)) + chunks = bot._chunk_tiktok_media_items(items) + assert [len(c) for c in chunks] == [9, 2] + for c in chunks: + assert 2 <= len(c) <= 10 + assert sum(chunks, []) == items + + +def test_chunk_tiktok_media_items_avoids_trailing_single_item_at_twenty_one(): + items = list(range(21)) + chunks = bot._chunk_tiktok_media_items(items) + assert [len(c) for c in chunks] == [10, 9, 2] + for c in chunks: + assert 2 <= len(c) <= 10 + assert sum(chunks, []) == items + + +def test_chunk_tiktok_media_items_avoids_trailing_single_item_at_tiktok_max_thirty_one(): + items = list(range(31)) + chunks = bot._chunk_tiktok_media_items(items) + for c in chunks: + assert 2 <= len(c) <= 10 + assert sum(chunks, []) == items + + +def test_chunk_tiktok_media_items_no_borrow_needed_at_thirty_five(): + # Официальный максимум TikTok (35) кратен 10 с остатком 5 — переноса не требуется. + items = list(range(35)) + chunks = bot._chunk_tiktok_media_items(items) + assert [len(c) for c in chunks] == [10, 10, 10, 5] + + +def test_chunk_tiktok_media_items_empty_list(): + assert bot._chunk_tiktok_media_items([]) == [] + + +def test_chunk_tiktok_media_items_single_item_not_split_further(): + # Один элемент эта функция не превращает в валидную группу (2-10) — это + # ответственность вызывающего кода (handle_tiktok отправляет такой случай + # напрямую через send_photo/send_video, а не через sendMediaGroup). + assert bot._chunk_tiktok_media_items([1]) == [[1]] + + +# ─────────────────────────── _error_status ─────────────────────────── + +class _FakeExc(Exception): + def __init__(self, msg, status_code=None): + super().__init__(msg) + self.status_code = status_code + + +def test_error_status_reads_status_code_attribute(): + assert bot._error_status(_FakeExc("x", status_code=429), "x") == 429 + + +def test_error_status_extracts_three_digit_code_from_text(): + exc = _FakeExc("Error 503: unavailable") + assert bot._error_status(exc, "Error 503: unavailable") == 503 + + +def test_error_status_returns_none_when_no_code_found(): + exc = _FakeExc("no numbers here") + assert bot._error_status(exc, "no numbers here") is None + + +# ─────────────────────────── extract_url ─────────────────────────── + +def test_extract_url_strips_trailing_punctuation(): + assert bot.extract_url("check this out: https://example.com/page.") == "https://example.com/page" + + +def test_extract_url_returns_none_when_no_url(): + assert bot.extract_url("no url here") is None + + +# ─────────────────────────── clean_mention ─────────────────────────── + +def test_clean_mention_removes_username(): + assert bot.clean_mention(f"@{bot.BOT_USERNAME} привет") == "привет" + + +def test_clean_mention_is_case_insensitive(): + result = bot.clean_mention(f"привет @{bot.BOT_USERNAME.upper()} как дела") + assert bot.BOT_USERNAME.lower() not in result.lower() + + +# ─────────────────────────── _gemini_error_msg / _or_error_msg ─────────────────────────── + +class _FakeStatusExc(Exception): + def __init__(self, msg, status_code=None): + super().__init__(msg) + self.status_code = status_code + + +def test_gemini_error_msg_rate_limit(): + # РЕГРЕССИЯ (аудит техдолга): раньше здесь проверялось "модель через /model" — + # команда /model давно удалена (см. README, "Автоматический выбор модели"), + # и подсказывать её в тексте ошибки было прямой ошибкой для пользователя. + # _gemini_error_msg/_or_error_msg теперь используют общие provider-neutral + # шаблоны (см. _MODEL_ERROR_MESSAGES) без упоминания несуществующих команд. + exc = _FakeStatusExc("rate limit exceeded", status_code=429) + msg = bot._gemini_error_msg(exc, "gemini-3.5-flash") + assert "/model" not in msg and "/provider" not in msg + assert msg == bot._model_error_text("rate_limit") + + +def test_gemini_error_msg_value_error_passthrough(): + # ValueError используется в ask_gemini как готовый пользовательский текст + # (например про неподдерживаемый тип вложения) — должен вернуться как есть. + exc = ValueError("кастомная ошибка") + assert bot._gemini_error_msg(exc, "gemini-3.5-flash") == "кастомная ошибка" + + +def test_gemini_error_msg_all_models_exhausted(): + # РЕГРЕССИЯ (аудит техдолга): раньше здесь проверялось "/provider" — команда + # удалена, реального способа переключиться на резервный провайдер вручную + # больше нет, поэтому предлагать её в тексте ошибки было ошибкой. + exc = bot.GeminiAllModelsExhaustedError(["gemini-3.5-flash", "gemini-2.5-flash"]) + msg = bot._gemini_error_msg(exc, "gemini-3.5-flash") + assert "/provider" not in msg + assert "лимит" in msg.lower() + + +def test_or_error_msg_rate_limit(): + # РЕГРЕССИЯ (аудит техдолга): "/provider" убран из текста (команда удалена), + # а формулировка "резервного провайдера" тоже убрана — с автоматическим + # роутером OpenRouter часто оказывается ПЕРВЫМ, а не резервным кандидатом. + exc = _FakeStatusExc("rate limit exceeded", status_code=429) + msg = bot._or_error_msg(exc, "text") + assert "/provider" not in msg and "резервн" not in msg.lower() + assert msg == bot._model_error_text("rate_limit") + + +def test_or_error_msg_unavailable(): + exc = _FakeStatusExc("model not found", status_code=404) + msg = bot._or_error_msg(exc, "text") + assert "/provider" not in msg and "резервн" not in msg.lower() + assert msg == bot._model_error_text("unavailable") + + +def test_model_error_text_shared_between_providers(): + # Единый источник правды для текста ошибок (см. аудит техдолга) — Gemini и + # OpenRouter должны показывать ОДИНАКОВЫЙ текст на одинаковый класс ошибки, + # а не рассинхронизированные формулировки в двух местах. + gem_exc = _FakeStatusExc("resource_exhausted", status_code=429) + or_exc = _FakeStatusExc("rate limit exceeded", status_code=429) + assert bot._gemini_error_msg(gem_exc, "gemini-3.5-flash") == bot._or_error_msg(or_exc, "text") + + +# ─────────────────────────── _cleanup_rate_limit_dict ─────────────────────────── + +def test_cleanup_rate_limit_dict_removes_empty_and_stale_entries(): + bot.user_rate_limits.clear() + bot.user_rate_limits[111] = [] # раньше оставался бы в словаре навсегда + bot.user_rate_limits[222] = [time.time() - 7200] # старше часа — тоже чистится + bot.user_rate_limits[333] = [time.time()] # свежая запись — должна остаться + bot._cleanup_rate_limit_dict() + assert 111 not in bot.user_rate_limits + assert 222 not in bot.user_rate_limits + assert 333 in bot.user_rate_limits + + +# ─────────────────────────── хранилище: локальный файл vs Upstash ─────────────────────────── + +def test_storage_write_and_read_local_file_roundtrip(tmp_path): + # USE_UPSTASH=False (по умолчанию в тестах) — должен использоваться локальный файл + assert bot.USE_UPSTASH is False + target = tmp_path / "state.json" + bot._storage_write_text("lumen:test", target, '{"a": 1}') + assert target.exists() + assert bot._storage_read_text("lumen:test", target) == '{"a": 1}' + + +def test_storage_read_text_missing_local_file_returns_none(tmp_path): + assert bot.USE_UPSTASH is False + missing = tmp_path / "does_not_exist.json" + assert bot._storage_read_text("lumen:test", missing) is None + + +def test_upstash_set_sends_correct_request_and_auth_header(): + # Реального аккаунта Upstash нет — мокаем urlopen, чтобы проверить, что МОЙ код + # строит правильный запрос (URL, метод, заголовок авторизации), а не реальный ответ сервиса. + bot.UPSTASH_REDIS_REST_URL = "https://fake-instance.upstash.io" + bot.UPSTASH_REDIS_REST_TOKEN = "fake-token" + try: + fake_resp = MagicMock() + fake_resp.read.return_value = b'{"result":"OK"}' + fake_resp.__enter__.return_value = fake_resp + fake_resp.__exit__.return_value = False + with patch("bot._urllib_request.urlopen", return_value=fake_resp) as mock_urlopen: + bot._upstash_set("lumen:test", '{"x": 1}') + assert mock_urlopen.called + sent_request = mock_urlopen.call_args[0][0] + assert sent_request.full_url == "https://fake-instance.upstash.io/set/lumen%3Atest" + assert sent_request.get_header("Authorization") == "Bearer fake-token" + assert sent_request.get_method() == "POST" + finally: + bot.UPSTASH_REDIS_REST_URL = "" + bot.UPSTASH_REDIS_REST_TOKEN = "" + + +def test_upstash_get_parses_result_field(): + bot.UPSTASH_REDIS_REST_URL = "https://fake-instance.upstash.io" + bot.UPSTASH_REDIS_REST_TOKEN = "fake-token" + try: + fake_resp = MagicMock() + fake_resp.read.return_value = b'{"result": "{\\"x\\": 1}"}' + fake_resp.__enter__.return_value = fake_resp + fake_resp.__exit__.return_value = False + with patch("bot._urllib_request.urlopen", return_value=fake_resp): + assert bot._upstash_get("lumen:test") == '{"x": 1}' + finally: + bot.UPSTASH_REDIS_REST_URL = "" + bot.UPSTASH_REDIS_REST_TOKEN = "" + + +# ─────────────── миграция image_model со старого формата "pollinations:X" ─────────────── +# РЕГРЕССИЯ, найденная при /code-review: ponytail-audit убрал приставку "pollinations:" +# из ключей HF_IMAGE_MODELS (единственный провайдер и так один). Без миграции это молча +# сбрасывало бы image_model существующих персистентных чатов на DEFAULT_HF_IMAGE_MODEL — +# реальный выбор пользователя (например "pollinations:turbo") терялся бы без предупреждения, +# просто потому что старое значение больше не совпадает ни с одним ключом HF_IMAGE_MODELS. + +def test_normalize_legacy_image_model_id_strips_old_prefix(): + assert bot._normalize_legacy_image_model_id("pollinations:turbo") == "turbo" + assert bot._normalize_legacy_image_model_id("pollinations:flux-anime") == "flux-anime" + + +def test_normalize_legacy_image_model_id_passthrough_for_current_format(): + assert bot._normalize_legacy_image_model_id("turbo") == "turbo" + assert bot._normalize_legacy_image_model_id("flux") == "flux" + + +def test_normalize_legacy_image_model_id_passthrough_for_non_string(): + # Защита от неожиданных типов в персистентных данных — не должно падать. + assert bot._normalize_legacy_image_model_id(None) is None + assert bot._normalize_legacy_image_model_id(123) == 123 + + +def test_restore_single_chat_migrates_legacy_pollinations_image_model(): + cid = 999901 + try: + bot._restore_single_chat(cid, {"image_model": "pollinations:turbo", "history": []}) + assert bot.chat_state[cid]["image_model"] == "turbo" + finally: + bot.chat_state.pop(cid, None) + + +def test_restore_single_chat_falls_back_to_default_for_truly_unknown_image_model(): + cid = 999902 + try: + bot._restore_single_chat(cid, {"image_model": "some-removed-model-nobody-heard-of", "history": []}) + assert bot.chat_state[cid]["image_model"] == bot.DEFAULT_HF_IMAGE_MODEL + finally: + bot.chat_state.pop(cid, None) + + +def test_get_state_migrates_legacy_pollinations_image_model_in_memory(): + # Защита в глубину (см. get_state) — на случай, если в chat_state окажется + # старое значение уже ПОСЛЕ восстановления (например, вручную отредактированные + # персистентные данные), а не только на пути через _restore_single_chat. + cid = 999903 + try: + bot.chat_state[cid] = { + "image_model": "pollinations:flux-realism", "history": [], "quota": {}, + "recent_media_ids": {}, "last_activity": 0.0, + } + state = bot.get_state(cid) + assert state["image_model"] == "flux-realism" + finally: + bot.chat_state.pop(cid, None) + + +# ─────────────────────────── _save_chat_to_storage/_delete_chat_storage возвращают bool ─────────────────────────── + +def test_save_chat_to_storage_returns_true_on_success(tmp_path): + # Регрессия на найденный при код-ревью баг (см. test_flush_dirty_state_once_* + # ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду, + # а не просто логировать исключение и возвращать None в обоих случаях. + chat_id = 999601 + state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}} + original_chats_dir = bot._CHATS_DIR + bot._CHATS_DIR = tmp_path + try: + assert bot._save_chat_to_storage(chat_id, state) is True + assert (tmp_path / f"{chat_id}.json").exists() + finally: + bot._CHATS_DIR = original_chats_dir + + +def test_save_chat_to_storage_returns_false_on_failure(): + state = {"history": [], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}} + with patch("bot._storage_write_text", side_effect=RuntimeError("сбой хранилища")): + assert bot._save_chat_to_storage(999602, state) is False + + +def test_delete_chat_storage_returns_true_on_success(tmp_path): + chat_id = 999603 + original_chats_dir = bot._CHATS_DIR + bot._CHATS_DIR = tmp_path + try: + (tmp_path / f"{chat_id}.json").write_text("{}") + assert bot._delete_chat_storage(chat_id) is True + assert not (tmp_path / f"{chat_id}.json").exists() + finally: + bot._CHATS_DIR = original_chats_dir + + +def test_delete_chat_storage_returns_false_on_failure(): + with patch("bot._storage_delete_text", side_effect=RuntimeError("сбой хранилища")): + assert bot._delete_chat_storage(999604) is False + + +# ─────────────────────────── _flush_dirty_state_once (переочередь неудавшихся сохранений) ─────────────────────────── + +def test_flush_dirty_state_once_requeues_failed_saves(): + # КРИТИЧНАЯ РЕГРЕССИЯ, найденная при код-ревью: раньше _dirty_chat_ids + # очищался ДО того, как запись реально прошла, а неудачный _save_chat_to_storage + # просто логировал исключение и возвращал None — чат "терялся" из очереди + # навсегда при транзиентном сбое хранилища (например, кратковременный сбой + # Upstash), пока какая-то ДРУГАЯ мутация того же чата не пометит его "грязным" + # заново. Теперь чат, для которого сохранение не удалось, должен остаться в + # _dirty_chat_ids и попасть в следующий цикл. + ok_chat, fail_chat = 999701, 999702 + bot.chat_state[ok_chat] = {"history": [{"role": "user", "content": "ok"}], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}} + bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}} + bot._dirty_chat_ids.clear() + bot._dirty_chat_ids.update({ok_chat, fail_chat}) + bot._index_dirty = False + bot._quota_dirty = False + + def fake_save(cid, state): + return cid != fail_chat # успех для ok_chat, неудача для fail_chat + + with patch("bot._save_chat_to_storage", side_effect=fake_save), \ + patch("bot._save_chat_index"), patch("bot.save_global_quota"): + try: + asyncio.run(bot._flush_dirty_state_once()) + # Успешно сохранённый чат должен быть убран из очереди... + assert ok_chat not in bot._dirty_chat_ids + # ...а неудавшийся — остаться для повтора на следующем цикле. + assert fail_chat in bot._dirty_chat_ids + finally: + bot.chat_state.pop(ok_chat, None) + bot.chat_state.pop(fail_chat, None) + bot._dirty_chat_ids.discard(ok_chat) + bot._dirty_chat_ids.discard(fail_chat) + + +def test_flush_dirty_state_once_requeues_failed_deletes(): + ok_chat, fail_chat = 999703, 999704 + bot._pending_chat_deletions.clear() + bot._pending_chat_deletions.update({ok_chat, fail_chat}) + bot._dirty_chat_ids.clear() + bot._index_dirty = False + bot._quota_dirty = False + + def fake_delete(cid): + return cid != fail_chat + + with patch("bot._delete_chat_storage", side_effect=fake_delete), \ + patch("bot._save_chat_index"), patch("bot.save_global_quota"): + try: + asyncio.run(bot._flush_dirty_state_once()) + assert ok_chat not in bot._pending_chat_deletions + assert fail_chat in bot._pending_chat_deletions + finally: + bot._pending_chat_deletions.discard(ok_chat) + bot._pending_chat_deletions.discard(fail_chat) + + +# ─────────────────── _sentry_scrub_secrets (before_send-хук опционального Sentry) ─────────────────── +# SENTRY_DSN не задан в тестовом окружении (conftest.py его не подставляет) — sentry_sdk.init() +# ни разу не вызывается за весь прогон тестов, но сама функция определена безусловно (см. +# докстринг в bot.py) именно для того, чтобы её можно было проверить в изоляции, без реального DSN. + +def test_sentry_scrub_secrets_redacts_known_tokens(): + original_bot_token, original_gemini_key, original_or_key = bot.BOT_TOKEN, bot.GEMINI_API_KEY, bot.OPENROUTER_API_KEY + bot.BOT_TOKEN = "secret-bot-token-123" + bot.GEMINI_API_KEY = "secret-gemini-key-456" + bot.OPENROUTER_API_KEY = "secret-or-key-789" + try: + event = { + "message": "failed calling token secret-bot-token-123", + "extra": {"note": "gemini key was secret-gemini-key-456, or key was secret-or-key-789"}, + } + scrubbed = bot._sentry_scrub_secrets(event, {}) + payload = json.dumps(scrubbed) + assert "secret-bot-token-123" not in payload + assert "secret-gemini-key-456" not in payload + assert "secret-or-key-789" not in payload + assert payload.count("") == 3 + finally: + bot.BOT_TOKEN, bot.GEMINI_API_KEY, bot.OPENROUTER_API_KEY = original_bot_token, original_gemini_key, original_or_key + + +def test_sentry_scrub_secrets_passthrough_when_no_secrets_configured(): + original_bot_token, original_gemini_key, original_or_key = bot.BOT_TOKEN, bot.GEMINI_API_KEY, bot.OPENROUTER_API_KEY + bot.BOT_TOKEN = bot.GEMINI_API_KEY = bot.OPENROUTER_API_KEY = "" + try: + event = {"message": "ordinary error, nothing secret here"} + assert bot._sentry_scrub_secrets(event, {}) == event + finally: + bot.BOT_TOKEN, bot.GEMINI_API_KEY, bot.OPENROUTER_API_KEY = original_bot_token, original_gemini_key, original_or_key + + +def test_sentry_not_initialized_without_dsn_in_test_env(): + # SENTRY_DSN отсутствует в тестовом окружении (conftest.py не подставляет его, + # в отличие от BOT_TOKEN/GEMINI_API_KEY) — sentry_sdk.init() не должен был + # вызваться при импорте bot.py, значит нет активного Sentry-клиента. + assert bot.SENTRY_DSN == "" + assert sentry_sdk.is_initialized() is False + + +# ─────────────────────────── /admin_keys — авторизация через заголовок, а не query-параметр ─────────────────────────── + +class _FakeAdminRequest: + def __init__(self, headers=None, query_params=None): + self.headers = headers or {} + self.query_params = query_params or {} + + +def test_check_bot_token_auth_accepts_correct_bearer_header(): + original = bot.BOT_TOKEN + bot.BOT_TOKEN = "real-secret-token" + try: + req = _FakeAdminRequest(headers={"Authorization": "Bearer real-secret-token"}) + assert bot._check_bot_token_auth(req) is True + finally: + bot.BOT_TOKEN = original + + +def test_check_bot_token_auth_rejects_query_param_regression(): + # РЕГРЕССИЯ (код-ревью): раньше BOT_TOKEN читался из ?bot_token=... в URL — GET- + # запрос с секретом в query-строке попадает в access-логи прокси/историю браузера + # (CWE-598). Теперь query-параметр должен полностью ИГНОРИРОВАТЬСЯ — единственный + # легитимный путь — заголовок Authorization: Bearer. + original = bot.BOT_TOKEN + bot.BOT_TOKEN = "real-secret-token" + try: + req = _FakeAdminRequest(headers={}, query_params={"bot_token": "real-secret-token"}) + assert bot._check_bot_token_auth(req) is False + finally: + bot.BOT_TOKEN = original + + +def test_check_bot_token_auth_rejects_wrong_or_missing_header(): + original = bot.BOT_TOKEN + bot.BOT_TOKEN = "real-secret-token" + try: + assert bot._check_bot_token_auth(_FakeAdminRequest(headers={"Authorization": "Bearer wrong"})) is False + assert bot._check_bot_token_auth(_FakeAdminRequest(headers={})) is False + # Без префикса "Bearer " — тоже отказ, даже если сам токен совпадает. + assert bot._check_bot_token_auth(_FakeAdminRequest(headers={"Authorization": "real-secret-token"})) is False + finally: + bot.BOT_TOKEN = original + + +# ─────────────────────────── учёт расхода TTS-квоты ─────────────────────────── + +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() + + +# Форма ответа google-genai для TTS (candidates -> content -> parts -> inline_data) — +# общая для обоих тестов ниже (успех и rate-limit-фоллбек), раньше была продублирована +# как пять идентичных вложенных классов в каждом тесте (ponytail-audit). +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 test_inline_tts_records_quota_usage_on_success(): + # НАЙДЕНО ПРИ КОД-РЕВЬЮ: по дашборду AI Studio у TTS-моделей лимит всего 10 + # запросов/сутки на модель — жёстче даже флагманских текстовых моделей, но + # расход нигде не учитывался (ни GLOBAL_QUOTA, ни /stats). Проверяем, что + # успешный синтез фиксируется в GLOBAL_QUOTA["gemini"] так же, как обычные + # текстовые вызовы Gemini. + + # Минимальный RIFF/WAV-заголовок — достаточно, чтобы код распознал формат как + # WAV и не пытался обернуть его заново через pcm_to_wav; реальная конвертация + # через ffmpeg в этом окружении не установлена и ожидаемо упадёт — это штатно + # ловится внутри inline_tts (тест проверяет учёт квоты, а не качество звука). + fake_wav_bytes = b"RIFF" + b"\x00" * 4 + b"WAVEfmt " + b"\x00" * 64 + + def fake_generate_content(*, model, contents, config=None): + return _fake_tts_response(fake_wav_bytes) + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + + incoming = _FakeIncomingMessage(999801) + incoming.message_id = 12345 # inline_tts использует его для reply_to_message_id + + original_client = bot.client + original_bot = bot.bot + bot.client = fake_client + bot.bot = _FakeVoiceBot() + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.1-flash-tts-preview", None) + try: + asyncio.run(bot.inline_tts(incoming, "Привет, мир")) + entry = bot.GLOBAL_QUOTA["gemini"].get("gemini-3.1-flash-tts-preview") + assert entry is not None + assert entry["used"] >= 1 + finally: + bot.client = original_client + bot.bot = original_bot + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.1-flash-tts-preview", None) + + +def test_inline_tts_marks_quota_exhausted_on_rate_limit(): + # Первая модель отдаёт явный 429 — должна быть помечена исчерпанной через + # _mark_quota_exhausted, а синтез должен продолжиться со второй моделью цепочки. + class _RateLimitExc(Exception): + status_code = 429 + + fake_wav_bytes = b"RIFF" + b"\x00" * 4 + b"WAVEfmt " + b"\x00" * 64 + calls = [] + + def fake_generate_content(*, model, contents, config=None): + calls.append(model) + if model == "gemini-3.1-flash-tts-preview": + raise _RateLimitExc("rate limit exceeded") + return _fake_tts_response(fake_wav_bytes) + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + + incoming = _FakeIncomingMessage(999802) + incoming.message_id = 12346 + + original_client = bot.client + original_bot = bot.bot + bot.client = fake_client + bot.bot = _FakeVoiceBot() + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.1-flash-tts-preview", None) + bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash-preview-tts", None) + try: + asyncio.run(bot.inline_tts(incoming, "Привет, мир")) + assert calls == ["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"] + exhausted = bot.GLOBAL_QUOTA["gemini"].get("gemini-3.1-flash-tts-preview") + assert exhausted is not None and exhausted.get("exhausted_at") is not None + succeeded = bot.GLOBAL_QUOTA["gemini"].get("gemini-2.5-flash-preview-tts") + assert succeeded is not None and succeeded["used"] >= 1 + finally: + bot.client = original_client + bot.bot = original_bot + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.1-flash-tts-preview", None) + bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash-preview-tts", None) + + +# ─────────────────────────── _match_trigger_prefix (словесные триггеры draw/tts) ─────────────────────────── + +def test_match_trigger_prefix_finds_draw_trigger(): + assert bot._match_trigger_prefix("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES) == "нарисуй" + + +def test_match_trigger_prefix_finds_tts_trigger(): + assert bot._match_trigger_prefix("озвучь этот текст пожалуйста", bot.TTS_TRIGGER_PREFIXES) == "озвучь" + + +def test_match_trigger_prefix_new_synonyms_work(): + assert bot._match_trigger_prefix("преврати в аудио вот это сообщение", bot.TTS_TRIGGER_PREFIXES) == "преврати в аудио" + assert bot._match_trigger_prefix("сгенери картинку заката", bot.DRAW_TRIGGER_PREFIXES) == "сгенери картинку" + + +def test_match_trigger_prefix_no_false_positive_when_trigger_not_at_start(): + # Триггерное слово упоминается, но НЕ в начале сообщения — не должно срабатывать + assert bot._match_trigger_prefix("объясни, как я мог бы нарисовать домик карандашом", bot.DRAW_TRIGGER_PREFIXES) is None + assert bot._match_trigger_prefix("что значит слово озвучь на украинском", bot.TTS_TRIGGER_PREFIXES) is None + + +def test_match_trigger_prefix_no_false_positive_for_unrelated_text(): + assert bot._match_trigger_prefix("привет, как дела?", bot.DRAW_TRIGGER_PREFIXES) is None + assert bot._match_trigger_prefix("привет, как дела?", bot.TTS_TRIGGER_PREFIXES) is None + + +def test_match_trigger_prefix_ambiguous_phrases_deliberately_excluded(): + # "хочу картинку"/"сделай картинку" намеренно НЕ триггеры — легко спутать с + # "хочу картинку тебе показать" или правкой уже присланного фото. + assert bot._match_trigger_prefix("хочу картинку показать тебе", bot.DRAW_TRIGGER_PREFIXES) is None + assert bot._match_trigger_prefix("сделай картинку ярче", bot.DRAW_TRIGGER_PREFIXES) is None + + +# ─────────────────────────── _looks_like_media_reference (память о медиа) ─────────────────────────── + +def test_looks_like_media_reference_true_for_explicit_media_nouns(): + assert bot._looks_like_media_reference("что на фото") is True + assert bot._looks_like_media_reference("опиши это видео") is True + assert bot._looks_like_media_reference("покажи стикер") is True + assert bot._looks_like_media_reference("расскажи про тот гиф") is True + + +def test_looks_like_media_reference_no_false_positive_on_common_words(): + # Регрессия на реальный найденный баг: раньше ловились "это"/"тот"/"который"/ + # "раньше"/"покажи"/"опиши" без явного упоминания медиа — почти любое сообщение + # заново подтягивало последнюю картинку пользователя и вызывало галлюцинации. + assert bot._looks_like_media_reference("расскажи про эту компанию") is False + assert bot._looks_like_media_reference("который час") is False + assert bot._looks_like_media_reference("объясни это подробнее") is False + assert bot._looks_like_media_reference("раньше было по-другому") is False + assert bot._looks_like_media_reference("покажи пример кода") is False + assert bot._looks_like_media_reference("до этого мы говорили про политику") is False + + +# ─────────────────────────── ask_gemini (с мокнутым client, без реального API) ─────────────────────────── + +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 + + +def test_ask_gemini_happy_path_returns_text_and_updates_history(): + chat_id = 999001 + calls = [] + + def fake_generate_content(*, model, contents, config=None): + calls.append(model) + return _FakeGeminiResponse(text="Привет! Чем могу помочь?") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + try: + answer = asyncio.run(bot.ask_gemini(chat_id, "Привет")) + assert answer == "Привет! Чем могу помочь?" + history = bot.chat_state[chat_id]["history"] + assert history[-2] == {"role": "user", "content": "Привет"} + assert history[-1] == {"role": "assistant", "content": "Привет! Чем могу помочь?"} + assert calls[0] == bot.DEFAULT_GEMINI_MODEL + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +def test_ask_gemini_scrubs_identity_leak_before_storing_history(): + # Регрессия на весь смысл выходного фильтра: если системный промпт всё же обойдён + # через инъекцию и модель раскрыла реальную личность — ни пользователь, ни история + # чата не должны увидеть/сохранить исходный (утекший) текст, только fallback. + chat_id = 999003 + + def fake_generate_content(*, model, contents, config=None): + return _FakeGeminiResponse(text="Я работаю на базе Gemini от Google, а не Lumen.") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + try: + answer = asyncio.run(bot.ask_gemini(chat_id, "Кто ты на самом деле?")) + assert answer == bot._IDENTITY_LEAK_FALLBACK + history = bot.chat_state[chat_id]["history"] + assert history[-1] == {"role": "assistant", "content": bot._IDENTITY_LEAK_FALLBACK} + assert "gemini" not in history[-1]["content"].lower() + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +def test_ask_openrouter_text_empty_model_chain_fallback_is_not_dead_model(): + # РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain + # в ask_openrouter_text было "meta-llama/llama-3.3-70b-instruct:free" — та же + # модель, что подтверждённо снята провайдером с бесплатного тира (см. README, + # HTTP 404 "unavailable for free") и по этой же причине уже исключена из + # _OR_LIGHT_ORDER/_OR_HEAVY_ORDER. Проверяем, что дефолт теперь ссылается на + # актуальный _OR_LIGHT_ORDER, а не на захардкоженную мёртвую модель. + chat_id = 999401 + + calls = [] + + async def fake_or_fallback(messages, trial_models, primary_model_id, **kwargs): + calls.append(trial_models) + return "ответ", trial_models[0] + + original = bot._or_chat_completion_with_fallback + bot._or_chat_completion_with_fallback = fake_or_fallback + try: + asyncio.run(bot.ask_openrouter_text(chat_id, "привет", model_chain=[])) + assert calls[0] == [bot._OR_LIGHT_ORDER[0]] + assert "meta-llama/llama-3.3-70b-instruct:free" not in calls[0] + finally: + bot._or_chat_completion_with_fallback = original + bot.chat_state.pop(chat_id, None) + + +def test_run_route_falls_back_to_second_provider_when_first_fully_fails(): + # Ключевое требование: если весь маршрут первого провайдера отказал — + # роутер должен попробовать резерв в ДРУГОМ провайдере, а не сдаваться сразу. + chat_id = 999301 + + async def failing_or_text(*args, **kwargs): + raise bot.OpenRouterAPIError("всё сломано", status_code=500) + + async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None, deadline=None): + return "Ответ от Gemini (резерв)" + + original_or_text = bot.ask_openrouter_text + original_ask_gemini = bot.ask_gemini + bot.ask_openrouter_text = failing_or_text + bot.ask_gemini = fake_ask_gemini + try: + route = [("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), ("gemini", "gemini-3.1-flash-lite")] + ans, sent = asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=False)) + assert ans == "Ответ от Gemini (резерв)" + assert sent is False + finally: + bot.ask_openrouter_text = original_or_text + bot.ask_gemini = original_ask_gemini + + +def test_run_route_raises_when_both_providers_fail(): + chat_id = 999302 + + async def failing_or_text(*args, **kwargs): + raise bot.OpenRouterAPIError("сломано", status_code=500) + + async def failing_gemini(*args, **kwargs): + raise RuntimeError("тоже сломано") + + original_or_text = bot.ask_openrouter_text + original_ask_gemini = bot.ask_gemini + bot.ask_openrouter_text = failing_or_text + bot.ask_gemini = failing_gemini + try: + route = [("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), ("gemini", "gemini-3.1-flash-lite")] + with pytest.raises(Exception): + asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=False)) + finally: + bot.ask_openrouter_text = original_or_text + bot.ask_gemini = original_ask_gemini + + +# ─────────────────────────── shared history между Gemini и OpenRouter ─────────────────────────── + +def test_shared_history_between_gemini_and_openrouter(): + # Регрессия на требование "общая память между Gemini и OpenRouter, чтобы не + # чувствовалось переключение между моделями" — раньше у каждого провайдера + # была своя ОТДЕЛЬНАЯ история (gemini_history/or_history). + chat_id = 999201 + + def fake_generate_content(*, model, contents, config=None): + return _FakeGeminiResponse(text="Ответ от Gemini") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + + async def fake_or_fallback(messages, trial_models, primary_model_id, **kwargs): + return "Ответ от OpenRouter", trial_models[0] + + original_or_fallback = bot._or_chat_completion_with_fallback + bot._or_chat_completion_with_fallback = fake_or_fallback + + try: + asyncio.run(bot.ask_gemini(chat_id, "Первый вопрос (через Gemini)")) + asyncio.run(bot.ask_openrouter_text(chat_id, "Второй вопрос (через OpenRouter)", model_chain=["meta-llama/llama-3.3-70b-instruct:free"])) + + history = bot.chat_state[chat_id]["history"] + contents = [h["content"] for h in history] + # Обе записи должны быть в ОДНОЙ и той же истории, а не в раздельных + assert "Первый вопрос (через Gemini)" in contents + assert "Ответ от Gemini" in contents + assert "Второй вопрос (через OpenRouter)" in contents + assert "Ответ от OpenRouter" in contents + assert len(history) == 4 + finally: + bot.client = original_client + bot._or_chat_completion_with_fallback = original_or_fallback + bot.chat_state.pop(chat_id, None) + + +def test_ask_gemini_retries_without_tools_on_malformed_function_call(): + # Регрессионный тест: после выноса _build_gemini_call_config переменная kwargs, + # на которую опирался этот retry-путь, была удалена — retry_gconfig теперь + # строится через gconfig.model_copy(update={"tools": None}). + chat_id = 999002 + call_configs = [] + + def fake_generate_content(*, model, contents, config=None): + call_configs.append(config) + if len(call_configs) == 1: + return _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="MALFORMED_FUNCTION_CALL")]) + return _FakeGeminiResponse(text="Ответ без инструментов") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + try: + answer = asyncio.run(bot.ask_gemini(chat_id, "Сколько будет 2+2?")) + assert answer == "Ответ без инструментов" + assert len(call_configs) == 2 + # первый вызов — с tools (у DEFAULT_GEMINI_MODEL включён url_context) + assert getattr(call_configs[0], "tools", None) + # второй (после ретрая) — уже без tools + assert getattr(call_configs[1], "tools", None) is None + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +# ─────────────────────────── _try_gemini_streaming (с поддельным async-клиентом) ─────────────────────────── + +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 + + +# ─────────────────── _fish_audio_tts_bytes (TTS-фоллбек с более щедрой квотой) ─────────────────── +# Реализовано вместе с извлечением _gemini_tts_bytes из inline_tts (ponytail-фикс, +# 2 августа 2026) — Fish Audio S2.1 Pro (free) пробуется ПЕРВЫМ в inline_tts, у него +# нет заявленного суточного потолка запросов (в отличие от Gemini TTS, 10/сутки на +# модель). Переиспользует тот же SSE-фикстурный стиль, что и test_openrouter_stream_ +# pieces_parses_sse_chunks выше (_FakeSSEResponse/_FakeSessionForSSE), только с полем +# delta.audio.data вместо delta.content. + +def test_fish_audio_tts_bytes_parses_sse_audio_chunks(): + raw_audio = b"fake-mp3-bytes" + b64_whole = base64.b64encode(raw_audio).decode("ascii") + half = len(b64_whole) // 2 + lines = [ + ('data: {"choices":[{"delta":{"audio":{"data":"' + b64_whole[:half] + '"}}}]}\n').encode("utf-8"), + ('data: {"choices":[{"delta":{"audio":{"data":"' + b64_whole[half:] + '"}}}]}\n').encode("utf-8"), + b"data: [DONE]\n", + ] + fake_resp = _FakeSSEResponse(lines) + fake_session = _FakeSessionForSSE(fake_resp) + + async def fake_get_http_session(): + return fake_session + + original_get_session = bot._get_http_session + original_key = bot.OPENROUTER_API_KEY + bot._get_http_session = fake_get_http_session + bot.OPENROUTER_API_KEY = "fake-key" + try: + result = asyncio.run(bot._fish_audio_tts_bytes("Привет, мир")) + assert result == raw_audio + finally: + bot._get_http_session = original_get_session + bot.OPENROUTER_API_KEY = original_key + + +def test_fish_audio_tts_bytes_returns_none_on_http_error(): + fake_resp = _FakeSSEResponse([], status=500) + fake_session = _FakeSessionForSSE(fake_resp) + + async def fake_get_http_session(): + return fake_session + + original_get_session = bot._get_http_session + original_key = bot.OPENROUTER_API_KEY + bot._get_http_session = fake_get_http_session + bot.OPENROUTER_API_KEY = "fake-key" + try: + assert asyncio.run(bot._fish_audio_tts_bytes("Привет")) is None + finally: + bot._get_http_session = original_get_session + bot.OPENROUTER_API_KEY = original_key + + +def test_fish_audio_tts_bytes_returns_none_without_api_key(): + original_key = bot.OPENROUTER_API_KEY + bot.OPENROUTER_API_KEY = "" + try: + assert asyncio.run(bot._fish_audio_tts_bytes("Привет")) is None + finally: + bot.OPENROUTER_API_KEY = original_key + + +def test_fish_audio_tts_bytes_returns_none_on_empty_stream(): + # Поток отдал валидный SSE, но ни одного audio-чанка (например, если формат + # ответа модели когда-нибудь изменится) — должны тихо откатиться на Gemini, + # а не упасть с исключением или вернуть пустые байты как будто это успех. + lines = [b"data: [DONE]\n"] + fake_resp = _FakeSSEResponse(lines) + fake_session = _FakeSessionForSSE(fake_resp) + + async def fake_get_http_session(): + return fake_session + + original_get_session = bot._get_http_session + original_key = bot.OPENROUTER_API_KEY + bot._get_http_session = fake_get_http_session + bot.OPENROUTER_API_KEY = "fake-key" + try: + assert asyncio.run(bot._fish_audio_tts_bytes("Привет")) is None + finally: + bot._get_http_session = original_get_session + bot.OPENROUTER_API_KEY = original_key + + +# (test_inline_tts_records_quota_usage_on_success выше уже проверяет ПОЛНЫЙ путь +# inline_tts с пустым OPENROUTER_API_KEY end-to-end — Fish Audio там уже тихо +# пропускается, и вызов идёт в _gemini_tts_bytes, отдельного теста не требуется.) + + +# ─────────────────── handle_tiktok_sound (ссылка на страницу звука не поддерживается) ─────────────────── +# Обе попытки скачать звук отдельно по ссылке на его страницу провалились на +# реальном тестировании (см. историю в bot.py: ни голый ID в TikWM, ни прямой +# скрапинг страницы TikTok �� тот отдаёт пустую "заглушку" без данных о звуке, +# похоже на урезание страницы для дата-центровых IP). Функция теперь сразу и +# честно сообщает об этом, не делая заведомо обречённых сетевых запросов. + +def test_handle_tiktok_sound_raises_user_facing_error_immediately(): + incoming = _FakeIncomingMessage(999501) + with pytest.raises(bot.TikTokUserFacingError): + asyncio.run(bot.handle_tiktok_sound(incoming, _FakeSentMessage())) + + +def test_handle_tiktok_sound_answers_guest_without_raising(): + incoming = _FakeIncomingMessage(999502) + incoming.guest_query_id = "guest123" + answered = [] + + async def fake_answer_guest_text(message, text): + answered.append(text) + + original = bot._answer_guest_text + bot._answer_guest_text = fake_answer_guest_text + try: + asyncio.run(bot.handle_tiktok_sound(incoming, _FakeSentMessage())) + assert len(answered) == 1 + finally: + bot._answer_guest_text = original + + +# ─────────────────── _send_tiktok_music: именованный vs безымянный "оригинальный звук" ─────────────────── +# КРИТИЧНАЯ РЕГРЕССИЯ (найдена дважды подряд на живом тестировании): TikTok +# разрешает автору дать "оригинальному звуку" СОБСТВЕННОЕ название при публикации +# (реальный пример: TikTok показывает звук как "Оригинальный звук: Night, Blooming +# Jasmine." на его собственной странице) — TikWM при этом всё равно присылает +# raw_music_title с префиксом "original sound - "/"оригинальный звук - " ПЕРЕД +# настоящим названием. Простое "буквальное совпадение фразы -> это безымянный +# звук" (было в двух предыдущих версиях этого кода) неверно отличает такие +# по-настоящему именованные "оригинальные звуки" от реально безымянных — где TikTok +# в заголовок вместо названия подставляет юзернейм/ник автора видео. Тесты ниже +# проверяют обе ветки на уровне ЦЕЛОЙ _send_tiktok_music (не только детектора), +# чтобы поймать регрессию именно в конечном результате (что реально попадает в +# title/performer при отправке аудио), а не только в изолированной формуле. + +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") + + +def test_send_tiktok_music_truly_generic_original_sound_uses_localized_label(): + # raw_music_title буквально "original sound" без остатка (TikTok в этом случае + # обычно добавляет юзернейм автора видео тем же куском — здесь его просто нет + # вообще) — это ДЕЙСТВИТЕЛЬНО безымянный звук. + media_data = { + "music": "https://example.com/sound.mp3", + "music_info": {"title": "original sound", "author": "SomeArtist", "cover": "https://example.com/cover.jpg"}, + "author": {"nickname": "VideoPosterNickname", "unique_id": "videoposter"}, + } + title, artist = _run_send_tiktok_music(media_data, language_code="ru") + assert title == "Оригинальный звук" + assert artist == "videoposter" + + +def test_send_tiktok_music_generic_original_sound_with_only_video_author_suffix(): + # TikWM подставил в заголовок ник/юзернейм автора ВИДЕО вместо названия — + # после вычитания generic-фразы и этого ника/юзернейма остаётся пусто, значит + # это тоже безымянный случай, а не настоящее название. + media_data = { + "music": "https://example.com/sound.mp3", + "music_info": {"title": "original sound - videoposter", "author": "videoposter", "cover": ""}, + "author": {"nickname": "VideoPosterNickname", "unique_id": "videoposter"}, + } + title, artist = _run_send_tiktok_music(media_data, language_code="be") + assert title == "Арыгінальны гук" + assert artist == "videoposter" + + +def test_send_tiktok_music_named_original_sound_preserves_real_title_and_author(): + # РЕГРЕССИЯ (реальный найденный случай): "original sound - Night, Blooming + # Jasmine." — TikTok позволяет назвать оригинальный звук, и TikWM всё равно + # ставит префикс "original sound - " перед этим настоящим названием. После + # вычитания generic-фразы остаётся "Night, Blooming Jasmine." — это ЗНАЧИМЫЙ + # остаток, значит звук на самом деле именован, и подменять его generic- + # подписью нельзя — реальные название/автор должны сохраниться как есть. + media_data = { + "music": "https://example.com/sound.mp3", + "music_info": {"title": "original sound - Night, Blooming Jasmine.", "author": "Fakemink", "cover": "https://example.com/real_cover.jpg"}, + "author": {"nickname": "coltrdr", "unique_id": "coltrdr"}, + } + title, artist = _run_send_tiktok_music(media_data, language_code="ru") + assert title == "Night, Blooming Jasmine." + assert artist == "Fakemink" + # Юзернейм автора ВИДЕО (coltrdr) не должен попасть в исполнители — это не он + # автор звука, звук лишь использован в его видео. + assert artist != "coltrdr" + + +def test_send_tiktok_music_regular_named_track_unaffected(): + # Обычная лицензированная песня без единого упоминания "original sound" в + # заголовке — не должна была задеваться этой логикой вообще ни в одной версии. + media_data = { + "music": "https://example.com/song.mp3", + "music_info": {"title": "Blinding Lights", "author": "The Weeknd", "cover": "https://example.com/album_cover.jpg"}, + "author": {"nickname": "SomeUser", "unique_id": "someuser"}, + } + title, artist = _run_send_tiktok_music(media_data, language_code="ru") + assert title == "Blinding Lights" + assert artist == "The Weeknd" + + +def test_send_tiktok_music_generic_original_sound_in_non_ru_en_source_language_still_localizes(): + # РЕГРЕССИЯ (отладка 11 августа 2026, реальная жалоба): raw_music_title + # генерируется TikTok на языке автора ИСХОДНОГО видео (см. докстринг + # lumen_tiktok.py), а не обязательно на ru/en. Раньше mentions_generic_phrase + # проверяла только буквальные "оригинальный звук"/"original sound" — украинская + # фраза "оригінальний звук" не совпадала ни с одной из них, is_original_sound + # ошибочно оставался False, и получателю ссылки с русским (или любым другим) + # интерфейсом Telegram показывался НЕлокализованный украинский raw-заголовок + # вместо подписи на ЕГО собственном языке. + media_data = { + "music": "https://example.com/sound.mp3", + "music_info": {"title": "оригінальний звук", "author": "videoposter", "cover": ""}, + "author": {"nickname": "VideoPosterNickname", "unique_id": "videoposter"}, + } + title, artist = _run_send_tiktok_music(media_data, language_code="ru") + assert title == "Оригинальный звук" + assert artist == "videoposter" + + +def test_try_gemini_streaming_happy_path_accumulates_and_finalizes(): + chat_id = 999101 + + async def fake_stream(*, model, contents, config=None): + async def gen(): + for piece in ["Привет", ", как ", "дела?"]: + yield SimpleNamespace(text=piece) + return gen() + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream + incoming = _FakeIncomingMessage(chat_id) + + original_client = bot.client + bot.client = fake_client + try: + answer, placeholder = asyncio.run(bot._try_gemini_streaming(chat_id, "Привет!", incoming, bot.DEFAULT_GEMINI_MODEL)) + assert answer == "Привет, как дела?" + assert placeholder is None # успех — плейсхолдер уже отредактирован до финального текста + # финальная правка должна прийти с HTML parse_mode (полная markdown-конвертация) + assert incoming.sent[0].edits[-1][1] == bot.ParseMode.HTML + history = bot.chat_state[chat_id]["history"] + assert history[-1] == {"role": "assistant", "content": "Привет, как дела?"} + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +def test_try_gemini_streaming_aborts_on_identity_leak_mid_stream(): + # Регрессия на самый чувствительный сценарий: утечка должна обрываться ДО того, + # как накопленный текст попадёт хоть в один edit_text — иначе пользователь успеет + # увидеть утёкший текст на экране ещё до финального завершения потока. + chat_id = 999104 + + async def fake_stream(*, model, contents, config=None): + async def gen(): + for piece in ["Привет! ", "На самом деле я работаю ", "на базе Gemini от Google."]: + yield SimpleNamespace(text=piece) + return gen() + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream + incoming = _FakeIncomingMessage(chat_id) + + original_client = bot.client + bot.client = fake_client + try: + answer, placeholder = asyncio.run(bot._try_gemini_streaming(chat_id, "Кто ты на самом деле?", incoming, bot.DEFAULT_GEMINI_MODEL)) + assert answer == bot._IDENTITY_LEAK_FALLBACK + assert placeholder is None + # Ни в одной показанной пользователю правке НЕ должно быть слова "gemini" — + # проверяем ВСЕ edit_text вызовы единственного отправленного сообщения, а не + # только последний, т.к. именно промежуточные правки могли бы "мигнуть" утечкой. + for shown_text, _parse_mode in incoming.sent[0].edits: + assert "gemini" not in shown_text.lower() + history = bot.chat_state[chat_id]["history"] + assert history[-1] == {"role": "assistant", "content": bot._IDENTITY_LEAK_FALLBACK} + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +def test_try_gemini_streaming_returns_none_on_early_failure(): + chat_id = 999102 + + async def fake_stream_raises(*, model, contents, config=None): + raise RuntimeError("boom before any content") + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream_raises + incoming = _FakeIncomingMessage(chat_id) + + original_client = bot.client + bot.client = fake_client + try: + answer, placeholder = asyncio.run(bot._try_gemini_streaming(chat_id, "Привет!", incoming, bot.DEFAULT_GEMINI_MODEL)) + assert answer is None + # Плейсхолдер теперь НЕ удаляется на этом уровне — он возвращается + # вызывающему коду (_run_route), чтобы тот попробовал доправить в него + # ответ следующей модели по цепочке, а не создавать новое сообщение. + assert placeholder is incoming.sent[0] + assert incoming.sent[0].deleted is False + # история НЕ должна была обновиться — вызывающий код откатится на ask_gemini + assert chat_id not in bot.chat_state or not bot.chat_state[chat_id].get("history") + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +def test_try_gemini_streaming_failed_continuation_does_not_corrupt_first_message(): + # Регрессионный тест на баг, найденный код-ревью: при сбое отправки сообщения- + # продолжения (текст длиннее лимита Telegram) первое, уже корректно показанное + # сообщение раньше перезаписывалось чужим (последним) куском текста. + chat_id = 999103 + long_piece = "А" * (bot.TG_MAX_LEN + 100) # гарантированно требует второе сообщение + + async def fake_stream(*, model, contents, config=None): + async def gen(): + yield SimpleNamespace(text=long_piece) + return gen() + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream + + class _FailingBot: + async def send_message(self, **kwargs): + return None # имитируем неудачную отправку продолжения + + incoming = _FakeIncomingMessage(chat_id) + + original_client = bot.client + original_bot = bot.bot + bot.client = fake_client + bot.bot = _FailingBot() + try: + answer, placeholder = asyncio.run(bot._try_gemini_streaming(chat_id, "Напиши длинный текст", incoming, bot.DEFAULT_GEMINI_MODEL)) + # Функция должна была вернуть накопленный текст, а не None и не бросить исключение + assert answer is not None + assert placeholder is None # это уже финализированный успех, а не ранний сбой + # Первое сообщение должно содержать ИМЕННО первый кусок (плюс пометка об обрыве), + # а НЕ последний/другой кусок текста — это и была суть бага. + first_msg_final_text = incoming.sent[0].edits[-1][0] + assert first_msg_final_text.startswith("А") + assert "не удалось отправить продолжение" in first_msg_final_text + finally: + bot.client = original_client + bot.bot = original_bot + bot.chat_state.pop(chat_id, None) + + +# ─────────────────────────── стриминг OpenRouter (SSE) ─────────────────────────── + +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 + + +def test_openrouter_stream_pieces_parses_sse_chunks(): + lines = [ + 'data: {"choices":[{"delta":{"content":"Привет"}}]}\n'.encode("utf-8"), + 'data: {"choices":[{"delta":{"content":", мир"}}]}\n'.encode("utf-8"), + b"data: [DONE]\n", + ] + fake_resp = _FakeSSEResponse(lines) + fake_session = _FakeSessionForSSE(fake_resp) + + async def fake_get_http_session(): + return fake_session + + original_get_session = bot._get_http_session + original_key = bot.OPENROUTER_API_KEY + bot._get_http_session = fake_get_http_session + bot.OPENROUTER_API_KEY = "fake-key" + try: + async def collect(): + pieces = [] + async for piece in bot._openrouter_stream_pieces("meta-llama/llama-3.3-70b-instruct:free", [{"role": "user", "content": "hi"}]): + pieces.append(piece) + return pieces + pieces = asyncio.run(collect()) + assert pieces == ["Привет", ", мир"] + finally: + bot._get_http_session = original_get_session + bot.OPENROUTER_API_KEY = original_key + + +def test_openrouter_stream_pieces_raises_on_http_error_status(): + fake_resp = _FakeSSEResponse([], status=500) + fake_session = _FakeSessionForSSE(fake_resp) + + async def fake_get_http_session(): + return fake_session + + original_get_session = bot._get_http_session + original_key = bot.OPENROUTER_API_KEY + bot._get_http_session = fake_get_http_session + bot.OPENROUTER_API_KEY = "fake-key" + try: + async def collect(): + async for _ in bot._openrouter_stream_pieces("meta-llama/llama-3.3-70b-instruct:free", []): + pass + with pytest.raises(bot.OpenRouterAPIError): + asyncio.run(collect()) + finally: + bot._get_http_session = original_get_session + bot.OPENROUTER_API_KEY = original_key + + +def test_openrouter_stream_pieces_raises_on_midstream_error_chunk(): + # РЕГРЕССИЯ (аудит стриминга): провайдер за OpenRouter может упасть УЖЕ ПОСЛЕ + # старта генерации — HTTP-статус к этому моменту давно 200 (стрим открыт), и + # ошибка приходит не кодом ответа, а прямо внутри SSE-чанка: + # {"error": {...}} вместо {"choices": [...]}. Раньше это тихо пропускалось + # как "пустой чанк" (choices нет -> continue) — пользователь получал молча + # укороченный ответ без единого намёка на причину. Первый кусок ("Начало") + # должен успеть уйти до ошибки — проверяем, что она поднимается уже ПОСЛЕ + # частичного контента, а не глушится. + lines = [ + 'data: {"choices":[{"delta":{"content":"Начало"}}]}\n'.encode("utf-8"), + 'data: {"error":{"message":"Provider returned error","code":502}}\n'.encode("utf-8"), + ] + fake_resp = _FakeSSEResponse(lines) + fake_session = _FakeSessionForSSE(fake_resp) + + async def fake_get_http_session(): + return fake_session + + original_get_session = bot._get_http_session + original_key = bot.OPENROUTER_API_KEY + bot._get_http_session = fake_get_http_session + bot.OPENROUTER_API_KEY = "fake-key" + try: + collected = [] + + async def collect_partial(): + agen = bot._openrouter_stream_pieces("meta-llama/llama-3.3-70b-instruct:free", [{"role": "user", "content": "hi"}]) + async for piece in agen: + collected.append(piece) + + with pytest.raises(bot.OpenRouterAPIError) as exc_info: + asyncio.run(collect_partial()) + assert collected == ["Начало"] + assert exc_info.value.status_code == 502 + finally: + bot._get_http_session = original_get_session + bot.OPENROUTER_API_KEY = original_key + + +def test_try_openrouter_streaming_happy_path_accumulates_and_finalizes(): + chat_id = 999105 + + async def fake_stream_pieces(model_id, messages): + for piece in ["Привет", ", как ", "дела?"]: + yield piece + + incoming = _FakeIncomingMessage(chat_id) + original_gen = bot._openrouter_stream_pieces + bot._openrouter_stream_pieces = fake_stream_pieces + try: + answer, placeholder = asyncio.run(bot._try_openrouter_streaming(chat_id, "Привет!", incoming, "meta-llama/llama-3.3-70b-instruct:free")) + assert answer == "Привет, как дела?" + assert placeholder is None + assert incoming.sent[0].edits[-1][1] == bot.ParseMode.HTML + history = bot.chat_state[chat_id]["history"] + assert history[-1] == {"role": "assistant", "content": "Привет, как дела?"} + assert bot.GLOBAL_QUOTA["openrouter"]["meta-llama/llama-3.3-70b-instruct:free"]["used"] >= 1 + finally: + bot._openrouter_stream_pieces = original_gen + bot.chat_state.pop(chat_id, None) + + +def test_try_openrouter_streaming_returns_none_on_early_failure(): + chat_id = 999106 + + async def fake_stream_pieces_raises(model_id, messages): + raise RuntimeError("boom before any content") + yield "" # делает функцию async-генератором (недостижимо) + + incoming = _FakeIncomingMessage(chat_id) + original_gen = bot._openrouter_stream_pieces + bot._openrouter_stream_pieces = fake_stream_pieces_raises + try: + answer, placeholder = asyncio.run(bot._try_openrouter_streaming(chat_id, "Привет!", incoming, "meta-llama/llama-3.3-70b-instruct:free")) + assert answer is None + assert placeholder is incoming.sent[0] + assert incoming.sent[0].deleted is False + assert chat_id not in bot.chat_state or not bot.chat_state[chat_id].get("history") + finally: + bot._openrouter_stream_pieces = original_gen + bot.chat_state.pop(chat_id, None) + + +# ─────────────────── пейсинг "живой печати" при стриминге (lumen_typing_pace) ─────────────────── +# Раньше во время стрима сообщение показывало РОВНО то, что накопилось с последнего +# edit_text — если бэкенд присылал ответ парой больших кусков (частый случай для +# бесплатных моделей OpenRouter, см. докстринг lumen_typing_pace.py), пользователь +# видел резкие скачки текста вместо плавного набора. Тесты ниже проверяют реальную +# интеграцию пейсинга в _run_streaming_reply (через _try_gemini_streaming) — не +# только чистые функции самого lumen_typing_pace.py (см. test_lumen_typing_pace.py). +# +# Каждый тест явно чистит lumen_typing_pace._speed_ema за собой (в finally) — это +# module-level состояние, разделяемое между тестами в одном прогоне pytest, и без +# явной очистки один тест мог бы повлиять на стартовую скорость для следующего. + +def test_run_streaming_reply_paces_reveal_for_burst_instead_of_dumping_full_text(): + import lumen_typing_pace + chat_id = 999110 + pace_key = lumen_typing_pace.speed_key("gemini", bot.DEFAULT_GEMINI_MODEL) + original_ema = lumen_typing_pace._speed_ema.pop(pace_key, None) + + # Реалистичная имитация бэкенда, который не стримит токен-в-токен, а отдаёт + # весь ответ ОДНИМ куском (см. докстринг lumen_typing_pace.py про то, почему + # это обычное дело для бесплатных моделей OpenRouter). + long_text = "Слово " * 80 # ~480 символов одним SSE-куском + + async def fake_stream(*, model, contents, config=None): + async def gen(): + yield SimpleNamespace(text=long_text) + return gen() + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream + incoming = _FakeIncomingMessage(chat_id) + + original_client = bot.client + bot.client = fake_client + try: + answer, placeholder = asyncio.run(bot._try_gemini_streaming(chat_id, "Привет!", incoming, bot.DEFAULT_GEMINI_MODEL)) + assert answer == long_text.strip() + assert placeholder is None + plain_edits = [text for text, parse_mode in incoming.sent[0].edits if parse_mode is None] + # Хотя бы одна промежуточная правка должна была показать ЧАСТЬ текста, а + # не весь ответ разом — иначе пейсинг не сработал (регрессия на исходную + # проблему: "скачками по 15-20 слов" вместо плавного набора). + assert any(0 < len(p) < len(long_text) for p in plain_edits) + # Финальная правка — уже HTML с полным текстом, как и раньше. + assert incoming.sent[0].edits[-1][1] == bot.ParseMode.HTML + history = bot.chat_state[chat_id]["history"] + assert history[-1] == {"role": "assistant", "content": long_text.strip()} + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + if original_ema is not None: + lumen_typing_pace._speed_ema[pace_key] = original_ema + else: + lumen_typing_pace._speed_ema.pop(pace_key, None) + + +def test_run_streaming_reply_records_observed_speed_on_success(): + import lumen_typing_pace + chat_id = 999111 + pace_key = lumen_typing_pace.speed_key("gemini", bot.DEFAULT_GEMINI_MODEL) + original_ema = lumen_typing_pace._speed_ema.pop(pace_key, None) + + async def fake_stream(*, model, contents, config=None): + async def gen(): + for piece in ["Привет", ", мир!"]: + yield SimpleNamespace(text=piece) + return gen() + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream + incoming = _FakeIncomingMessage(chat_id) + + original_client = bot.client + bot.client = fake_client + try: + asyncio.run(bot._try_gemini_streaming(chat_id, "Привет!", incoming, bot.DEFAULT_GEMINI_MODEL)) + # После успешного стрима у модели должна появиться собственная запись в + # EMA — самокалибровка происходит без единой ручной правки таблицы. + assert pace_key in lumen_typing_pace._speed_ema + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + if original_ema is not None: + lumen_typing_pace._speed_ema[pace_key] = original_ema + else: + lumen_typing_pace._speed_ema.pop(pace_key, None) + + +def test_run_streaming_reply_catchup_never_exceeds_max_ticks_even_for_long_slow_burst(): + # РЕГРЕССИЯ на ключевое требование: сколько бы ни оценивалась скорость модели + # заниженно и сколько бы символов ни осталось "довыводить" одним куском, число + # искусственных пауз ограничено STREAM_TYPING_MAX_CATCHUP_TICKS — реальная + # скорость ответа не должна страдать ради красивости набора текста. + import lumen_typing_pace + chat_id = 999112 + pace_key = lumen_typing_pace.speed_key("gemini", bot.DEFAULT_GEMINI_MODEL) + original_ema = lumen_typing_pace._speed_ema.pop(pace_key, None) + lumen_typing_pace._speed_ema[pace_key] = lumen_typing_pace.MIN_CHARS_PER_SEC # намеренно "медленная" модель + + long_text = "Буква " * 500 # ~3000 символов, одним куском, ниже TG_MAX_LEN + + async def fake_stream(*, model, contents, config=None): + async def gen(): + yield SimpleNamespace(text=long_text) + return gen() + + fake_client = MagicMock() + fake_client.aio.models.generate_content_stream = fake_stream + incoming = _FakeIncomingMessage(chat_id) + + sleep_calls: list[float] = [] + original_sleep = bot._typing_sleep + + async def counting_sleep(seconds: float) -> None: + sleep_calls.append(seconds) + + bot._typing_sleep = counting_sleep + original_client = bot.client + bot.client = fake_client + try: + answer, _ = asyncio.run(bot._try_gemini_streaming(chat_id, "Привет!", incoming, bot.DEFAULT_GEMINI_MODEL)) + assert answer == long_text.strip() + assert len(sleep_calls) <= bot.STREAM_TYPING_MAX_CATCHUP_TICKS + finally: + bot._typing_sleep = original_sleep + bot.client = original_client + bot.chat_state.pop(chat_id, None) + if original_ema is not None: + lumen_typing_pace._speed_ema[pace_key] = original_ema + else: + lumen_typing_pace._speed_ema.pop(pace_key, None) + + +# ─────────────────────────── стриминг в _run_route для ЛЮБОГО провайдера ─────────────────────────── + +def test_run_route_streams_openrouter_when_route_head_is_openrouter(): + chat_id = 999305 + + async def fake_or_stream(cid, prompt, message, model_id): + return "Стримленный ответ от OpenRouter", None + + async def must_not_be_called(*args, **kwargs): + raise AssertionError("ask_openrouter_text не должен вызываться, если стрим уже сработал") + + original_stream = bot._try_openrouter_streaming + original_or_text = bot.ask_openrouter_text + bot._try_openrouter_streaming = fake_or_stream + bot.ask_openrouter_text = must_not_be_called + try: + route = [("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), ("gemini", "gemini-3.5-flash-lite")] + ans, sent = asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=True)) + assert ans == "Стримленный ответ от OpenRouter" + assert sent is True + finally: + bot._try_openrouter_streaming = original_stream + bot.ask_openrouter_text = original_or_text + + +def test_run_route_falls_back_from_failed_openrouter_stream_to_non_streaming(): + chat_id = 999306 + + async def fake_or_stream_fail(cid, prompt, message, model_id): + return None, None # ранний сбой без плейсхолдера (например, message.reply сам не удался) + + calls = [] + + async def fake_or_text(cid, prompt, model_chain, deadline=None): + calls.append(model_chain) + return "Ответ без стрима" + + original_stream = bot._try_openrouter_streaming + original_or_text = bot.ask_openrouter_text + bot._try_openrouter_streaming = fake_or_stream_fail + bot.ask_openrouter_text = fake_or_text + try: + route = [("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), ("openrouter", "openai/gpt-oss-20b:free")] + ans, sent = asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=True)) + assert ans == "Ответ без стрима" + assert sent is False + # Модель, для которой стрим не удался, не должна пере-пробоваться внутри + # обычного вызова — экономим время (см. философию "1 попытка на модель"). + assert calls[0] == ["openai/gpt-oss-20b:free"] + finally: + bot._try_openrouter_streaming = original_stream + bot.ask_openrouter_text = original_or_text + + +def test_run_route_reuses_stream_placeholder_when_fallback_succeeds(): + # Регрессия на реальный найденный при тестировании баг: раньше при неудачном + # стриме плейсхолдер "…" тут же удалялся, а следующая модель отправляла + # совсем новое сообщение — визуально выглядело как "точки исчезли, потом + # из ниоткуда появился ответ одним блоком". Теперь плейсхолдер должен + # переиспользоваться (редактироваться) финальным ответом резервной модели. + chat_id = 999307 + placeholder = _FakeSentMessage() + + async def fake_or_stream_fail_with_placeholder(cid, prompt, message, model_id): + return None, placeholder + + async def fake_or_text(cid, prompt, model_chain, deadline=None): + return "Ответ от резервной модели" + + original_stream = bot._try_openrouter_streaming + original_or_text = bot.ask_openrouter_text + bot._try_openrouter_streaming = fake_or_stream_fail_with_placeholder + bot.ask_openrouter_text = fake_or_text + try: + route = [("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), ("openrouter", "openai/gpt-oss-20b:free")] + ans, sent = asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=True)) + assert ans == "Ответ от резервной модели" + # sent=True означает, что ответ уже "доставлен" через правку плейсхолдера, + # а не через отдельный новый _safe_reply в _handle_message_core. + assert sent is True + assert placeholder.deleted is False + assert placeholder.edits[-1][0] == "Ответ от резервной модели" + finally: + bot._try_openrouter_streaming = original_stream + bot.ask_openrouter_text = original_or_text + + +def test_run_route_deletes_orphaned_placeholder_when_whole_route_fails(): + chat_id = 999308 + placeholder = _FakeSentMessage() + + async def fake_or_stream_fail_with_placeholder(cid, prompt, message, model_id): + return None, placeholder + + async def failing_or_text(*args, **kwargs): + raise bot.OpenRouterAPIError("всё сломано", status_code=500) + + original_stream = bot._try_openrouter_streaming + original_or_text = bot.ask_openrouter_text + bot._try_openrouter_streaming = fake_or_stream_fail_with_placeholder + bot.ask_openrouter_text = failing_or_text + try: + route = [("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), ("openrouter", "openai/gpt-oss-20b:free")] + with pytest.raises(Exception): + asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=True)) + assert placeholder.deleted is True + finally: + bot._try_openrouter_streaming = original_stream + bot.ask_openrouter_text = original_or_text + + +def test_build_gemini_call_config_skips_all_grounding_tools_for_gemma(): + # Прямая проверка на уровне _build_gemini_call_config: для no_search-модели + # (обе Gemma) итоговый config не должен включать вообще ни один + # grounding/url_context инструмент, независимо от прочих флагов в конфиге. + contents = [bot.types.Content(role="user", parts=[bot.types.Part.from_text(text="привет")])] + _, gconfig = bot._build_gemini_call_config("gemma-4-26b-a4b-it", contents) + assert not getattr(gconfig, "tools", None) + + +def test_build_gemini_call_config_no_system_model_includes_full_system_prompt(): + # РЕГРЕССИЯ (аудит системного промпта, 10 августа 2026): раньше для no_system-моделей + # (Gemma) фейковый первый обмен содержал ТОЛЬКО короткий захардкоженный список из 8 + # пунктов — ни строчки из настоящего SYSTEM_PROMPT (system_prompt.py). Gemma полностью + # пропускала БЛАГОПОЛУЧИЕ И ЗДОРОВЬЕ ПОЛЬЗОВАТЕЛЯ (протокол при сообщении о суициде/ + # самоповреждении — телефон доверия), АВТОРСКИЕ ПРАВА, ОБЪЕКТИВНОСТЬ И НЕПРЕДВЗЯТОСТЬ и + # т.д. Теперь get_system_prompt(model_id) подставляется в основу этого сообщения — + # проверяем, что и не встречающиеся в коротком чеклисте разделы теперь реально там есть. + contents = [bot.types.Content(role="user", parts=[bot.types.Part.from_text(text="привет")])] + call_contents, _ = bot._build_gemini_call_config("gemma-4-26b-a4b-it", contents) + first_user_text = call_contents[0].parts[0].text + assert "БЛАГОПОЛУЧИЕ И ЗДОРОВЬЕ ПОЛЬЗОВАТЕЛЯ" in first_user_text + assert "8-800-2000-122" in first_user_text + assert "АВТОРСКИЕ ПРАВА" in first_user_text + assert "ОБЪЕКТИВНОСТЬ И НЕПРЕДВЗЯТОСТЬ" in first_user_text + # Короткий проверенный на практике чеклист (личность/дата/защита от инъекций) + # сохранён поверх полного промпта, а не заменён им. + assert "Твоё имя — Lumen" in first_user_text + + +def test_build_gemini_call_config_with_system_instruction_model_unaffected(): + # Модели С system_instruction (не no_system) вообще не проходят через ветку fake + # identity-обмена — регрессия на то, что правка выше не задела этот путь: contents + # должен остаться нетронутым, а полный текст идёт через system_instruction, как раньше. + contents = [bot.types.Content(role="user", parts=[bot.types.Part.from_text(text="привет")])] + call_contents, gconfig = bot._build_gemini_call_config(bot.DEFAULT_GEMINI_MODEL, contents) + assert call_contents is contents + assert "БЛАГОПОЛУЧИЕ И ЗДОРОВЬЕ ПОЛЬЗОВАТЕЛЯ" in gconfig.system_instruction + + +# ─────────────────── дневной сброс счётчиков квоты (/stats не должен копить вечно) ─────────────────── + +def test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover(): + # bot._last_quota_check_monotonic сбрасывается явно: в проде троттлинг (см. + # _QUOTA_CHECK_THROTTLE_SEC) абсолютно безопасен, т.к. между реальными вызовами + # проходят настоящие секунды — но в тестах десятки вызовов _quota_entry (через + # ask_gemini/ask_openrouter_* в других тестах этого же файла) укладываются в + # миллисекунды, и без сброса throttle-таймера этот тест непредсказуемо ловил бы + # "ещё не прошла минута с прошлой проверки" и тихо становился no-op — именно + # так и произошло при первом прогоне (нашли на code-review, тест падал только + # в полном прогоне всего файла, а не в изоляции). + # + # РЕГРЕССИЯ (найдено при /engineering:debug): сброс в буквальный 0.0 неявно + # предполагал, что time.monotonic() к моменту теста уже далеко за 60 секунд — + # верно для процесса, который живёт часами, но не гарантировано для короткого + # прогона тестов (~6 сек весь файл), запущенного вскоре после старта контейнера/ + # песочницы, где monotonic-часы сами могут ещё не дойти до 60. Тогда "0.0" уже + # НЕ "далеко в прошлом" относительно "сейчас", и throttle съедает даже первый + # вызов — детерминированно воспроизведено подменой time.monotonic() на 12.0. + # Правильный сброс — не абсолютный ноль, а "текущий момент минус окно троттлинга + # с запасом": так гарантированно "давно" независимо от того, сколько реально + # прошло времени с момента запуска процесса. + original_quota = { + "gemini": dict(bot.GLOBAL_QUOTA.get("gemini", {})), + "openrouter": dict(bot.GLOBAL_QUOTA.get("openrouter", {})), + "quota_day": bot.GLOBAL_QUOTA.get("quota_day"), + } + original_throttle = bot._last_quota_check_monotonic + try: + bot.GLOBAL_QUOTA["gemini"] = {"gemini-2.5-flash": {"used": 106, "remaining": 0, "limit": 1500, "exhausted_at": 12345.0}} + bot.GLOBAL_QUOTA["openrouter"] = {"some-model:free": {"used": 50, "remaining": None, "limit": None, "exhausted_at": None}} + bot.GLOBAL_QUOTA["quota_day"] = "2020-01-01" # заведомо "вчерашний" день + bot._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0 + + bot._reset_quota_if_new_day() + + assert bot.GLOBAL_QUOTA["gemini"]["gemini-2.5-flash"]["used"] == 0 + assert bot.GLOBAL_QUOTA["gemini"]["gemini-2.5-flash"]["exhausted_at"] is None + assert bot.GLOBAL_QUOTA["openrouter"]["some-model:free"]["used"] == 0 + assert bot.GLOBAL_QUOTA["quota_day"] == bot._current_quota_day() + finally: + bot.GLOBAL_QUOTA["gemini"] = original_quota["gemini"] + bot.GLOBAL_QUOTA["openrouter"] = original_quota["openrouter"] + bot.GLOBAL_QUOTA["quota_day"] = original_quota["quota_day"] + bot._last_quota_check_monotonic = original_throttle + + +def test_reset_quota_if_new_day_is_noop_within_same_day(): + original_quota_day = bot.GLOBAL_QUOTA.get("quota_day") + original_throttle = bot._last_quota_check_monotonic + try: + bot.GLOBAL_QUOTA["gemini"]["test-model-999"] = {"used": 7, "remaining": None, "limit": None, "exhausted_at": None} + bot.GLOBAL_QUOTA["quota_day"] = bot._current_quota_day() + # См. комментарий в test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover + # выше про то, почему буквальный 0.0 не годится как "точно давно". + bot._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0 + bot._reset_quota_if_new_day() + assert bot.GLOBAL_QUOTA["gemini"]["test-model-999"]["used"] == 7 + finally: + bot.GLOBAL_QUOTA["gemini"].pop("test-model-999", None) + bot.GLOBAL_QUOTA["quota_day"] = original_quota_day + bot._last_quota_check_monotonic = original_throttle + + +def test_reset_quota_if_new_day_throttles_repeated_calls(): + # Регрессия на найденное при code-review: без троттлинга _reset_quota_if_new_day + # конструировала бы ZoneInfo/datetime.now на КАЖДЫЙ вызов _quota_entry (а таких — + # по несколько на каждую попытку модели). Проверяем, что повторный вызов сразу + # после первого не запускает вторую проверку даты — если бы троттлинг не + # работал, _current_quota_day() был бы вызван трижды, а не один раз. + # + # См. комментарий в test_reset_quota_if_new_day_clears_used_and_exhausted_on_day_rollover + # про то, почему сброс делается относительно time.monotonic(), а не в буквальный 0.0. + original_throttle = bot._last_quota_check_monotonic + calls = [] + original_fn = bot._current_quota_day + try: + bot._last_quota_check_monotonic = time.monotonic() - bot._QUOTA_CHECK_THROTTLE_SEC - 10.0 + bot._current_quota_day = lambda: (calls.append(1), original_fn())[1] + bot._reset_quota_if_new_day() + bot._reset_quota_if_new_day() + bot._reset_quota_if_new_day() + assert len(calls) == 1 + finally: + bot._current_quota_day = original_fn + bot._last_quota_check_monotonic = original_throttle + + +# ─────────────────── каталог моделей генерации изображений (аудит техдолга) ─────────────────── +# Раньше был отдельный HF_IMAGE_MODEL_CACHE + async _hf_fetch_model_catalog(), +# оба вестигиальные (динамический фетч из HF API убран, каталог всегда 1:1 из +# HF_IMAGE_MODELS) — заменены на синхронный _hf_model_catalog(). Тестов на этот +# слой раньше не было вообще; добавлены вместе с упрощением, чтобы не убрать +# индирекцию "молча". + +def test_hf_model_catalog_matches_hf_image_models_exactly(): + catalog = bot._hf_model_catalog() + assert [m["id"] for m in catalog] == list(bot.HF_IMAGE_MODELS.keys()) + for m in catalog: + assert m["name"] == bot.HF_IMAGE_MODELS[m["id"]]["name"] + + +def test_imgmodel_keyboard_marks_current_model_no_pagination(): + # Пагинация убрана (ponytail-audit) — каталог из 5 моделей всегда умещается + # на одном экране, поэтому клавиатура больше не принимает `page` и никогда + # не рисует "< Назад"/"Дальше >". + kb = bot._imgmodel_keyboard(bot.DEFAULT_HF_IMAGE_MODEL) + all_buttons = [btn for row in kb.inline_keyboard for btn in row] + current_buttons = [b for b in all_buttons if b.text.startswith("• ")] + assert len(current_buttons) == 1 + assert bot.HF_IMAGE_MODELS[bot.DEFAULT_HF_IMAGE_MODEL]["name"] in current_buttons[0].text + assert not any("Назад" in b.text or "Дальше" in b.text for b in all_buttons) + assert all(b.callback_data.startswith("imgmodel:set:") for b in all_buttons) + +# ─────────────────── schema_version персистентного снимка чата (аудит техдолга) ─────────────────── + +def test_serialize_chat_state_stamps_current_schema_version(): + state = {"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [], "quota": {}, "recent_media_ids": {}} + snapshot = bot._serialize_chat_state(state) + assert snapshot["schema_version"] == bot.CHAT_STATE_SCHEMA_VERSION + + +def test_restore_single_chat_accepts_legacy_record_without_schema_version(): + # Записи, сохранённые до введения schema_version, не имеют этого поля вообще — + # восстановление не должно падать и должно вести себя так же, как раньше. + cid = 999905 + try: + bot._restore_single_chat(cid, {"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}]}) + assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}] + finally: + bot.chat_state.pop(cid, None) + + +def test_restore_single_chat_accepts_current_schema_version_record(): + cid = 999906 + try: + snapshot = bot._serialize_chat_state({ + "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}], + "quota": {}, "recent_media_ids": {}, + }) + bot._restore_single_chat(cid, snapshot) + assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}] + finally: + bot.chat_state.pop(cid, None) + +# ─────────────────── _TelegramProxyCircuitBreaker (аудит техдолга) ─────────────────── +# Раньше состояние выключателя жило как четыре независимых module-level globals, +# мутируемых через `global` из двух разных функций — само поведение (порог +# срабатывания, cooldown, сброс счётчика на успех) нигде не тестировалось +# напрямую, только опосредованно через _tg_call/telegram_api_call. Инкапсуляция +# в класс делает это поведение тестируемым в изоляции. + +def test_circuit_breaker_starts_closed(): + breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3) + assert breaker.is_down(time.monotonic()) is False + + +def test_circuit_breaker_does_not_trip_before_threshold(): + breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3) + assert breaker.note_failure() is False + assert breaker.note_failure() is False + assert breaker.is_down(time.monotonic()) is False + + +def test_circuit_breaker_trips_at_threshold(): + breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3) + breaker.note_failure() + breaker.note_failure() + tripped = breaker.note_failure() + assert tripped is True + breaker.trip() + assert breaker.is_down(time.monotonic()) is True + + +def test_circuit_breaker_success_resets_consecutive_failures(): + breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3) + breaker.note_failure() + breaker.note_failure() + breaker.note_success() + assert breaker.consecutive_failures == 0 + # Единичные последующие сбои не должны сразу срабатывать — счётчик правда сброшен. + assert breaker.note_failure() is False + + +def test_circuit_breaker_garbage_event_count_never_resets(): + # В отличие от consecutive_failures, совокупный счётчик для /stats копится + # за всё время жизни процесса и не должен сбрасываться на success. + breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3) + breaker.note_failure() + breaker.note_success() + breaker.note_failure() + assert breaker.garbage_event_count == 2 + + +def test_circuit_breaker_status_text_reflects_state(): + breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3) + assert "в норме" in breaker.status_text() + breaker.note_failure() + breaker.note_failure() + breaker.note_failure() + breaker.trip() + assert "ВЫКЛЮЧЕН" in breaker.status_text() + +# ─────────────────── ask_gemini: fallback-цикл по цепочке моделей (аудит техдолга) ─────────────────── +# НАЙДЕНО ПРИ АУДИТЕ: внутренняя логика переключения между моделями в ask_gemini +# (429 -> следующая модель / таймаут -> следующая / прочая ошибка -> следующая / +# бюджет времени исчерпан -> RouteBudgetExceededError) раньше не была покрыта +# напрямую НИ ОДНИМ тестом — только опосредованно через _run_route (который +# мокает саму ask_gemini целиком, не проверяя её внутренний цикл). Тесты ниже +# фиксируют текущее поведение ДО рефакторинга (объединение классификации ошибок +# с _or_chat_completion_with_fallback через общие _error_status/_classify_model_error). + +def test_ask_gemini_falls_back_to_next_model_on_quota_exhausted(): + chat_id = 999010 + calls = [] + + class _QuotaExc(Exception): + status_code = 429 + + def fake_generate_content(*, model, contents, config=None): + calls.append(model) + if model == "gemini-3.6-flash": + raise _QuotaExc("resource_exhausted") + return _FakeGeminiResponse(text="Ответ от второй модели") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None) + try: + answer = asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"])) + assert answer == "Ответ от второй модели" + assert calls == ["gemini-3.6-flash", "gemini-2.5-flash"] + # Модель, отдавшая 429, должна быть помечена исчерпанной (влияет на будущий роутинг). + assert bot.GLOBAL_QUOTA["gemini"]["gemini-3.6-flash"]["exhausted_at"] is not None + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None) + + +def test_ask_gemini_raises_all_models_exhausted_when_entire_chain_429s(): + chat_id = 999011 + + class _QuotaExc(Exception): + status_code = 429 + + def fake_generate_content(*, model, contents, config=None): + raise _QuotaExc("resource_exhausted") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + try: + with pytest.raises(bot.GeminiAllModelsExhaustedError) as exc_info: + asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"])) + assert set(exc_info.value.exhausted_models) == {"gemini-3.6-flash", "gemini-2.5-flash"} + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None) + bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash", None) + + +def test_ask_gemini_falls_back_to_next_model_on_timeout(): + chat_id = 999012 + calls = [] + original_timeout = bot.ROUTE_MODEL_TIMEOUT_SEC + + def fake_generate_content(*, model, contents, config=None): + calls.append(model) + if model == "gemini-3.6-flash": + time.sleep(0.2) # дольше урезанного ROUTE_MODEL_TIMEOUT_SEC ниже + return _FakeGeminiResponse(text="Ответ от второй модели") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + bot.ROUTE_MODEL_TIMEOUT_SEC = 0.05 + try: + answer = asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"])) + assert answer == "Ответ от второй модели" + assert calls[0] == "gemini-3.6-flash" + finally: + bot.client = original_client + bot.ROUTE_MODEL_TIMEOUT_SEC = original_timeout + bot.chat_state.pop(chat_id, None) + + +def test_ask_gemini_falls_back_to_next_model_on_generic_error(): + chat_id = 999013 + calls = [] + + def fake_generate_content(*, model, contents, config=None): + calls.append(model) + if model == "gemini-3.6-flash": + raise RuntimeError("internal error 500") + return _FakeGeminiResponse(text="Ответ от второй модели") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + try: + answer = asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"])) + assert answer == "Ответ от второй модели" + assert calls == ["gemini-3.6-flash", "gemini-2.5-flash"] + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + + +def test_ask_gemini_raises_when_route_budget_exceeded(): + chat_id = 999014 + + def fake_generate_content(*, model, contents, config=None): + raise RuntimeError("internal error 500") + + fake_client = MagicMock() + fake_client.models.generate_content.side_effect = fake_generate_content + original_client = bot.client + bot.client = fake_client + try: + past_deadline = time.monotonic() - 1.0 + with pytest.raises(bot.RouteBudgetExceededError): + asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"], deadline=past_deadline)) + finally: + bot.client = original_client + bot.chat_state.pop(chat_id, None) + +# ─────────────────── _or_chat_completion_with_fallback: единая классификация ошибок ─────────────────── +# Закрепляет, что после унификации с _classify_model_error (см. ауди�� техдолга) +# каскад к следующей модели по-прежнему работает одинаково для любого класса +# ошибки — временной (429/5xx) и внешне "постоянной" (403) — т.к. attempts_per_model +# всегда 1 в реальном использовании (см. комментарий в самой функции). + +def test_or_chat_completion_with_fallback_switches_model_on_rate_limit(): + async def fake_or_request(path, method="GET", *, json_body=None): + model = json_body["model"] + if model == "model-a": + raise bot.OpenRouterAPIError("rate limit exceeded", status_code=429) + return {"choices": [{"message": {"content": "ответ от model-b"}}]} + + original = bot._or_request + bot._or_request = fake_or_request + try: + messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}] + answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["model-a", "model-b"], "model-a")) + assert answer == "ответ от model-b" + assert used == "model-b" + finally: + bot._or_request = original + + +def test_or_chat_completion_with_fallback_switches_model_on_permanent_looking_error(): + # Даже "постоянная" на вид ошибка (403 forbidden) не должна обрывать переход + # к следующей модели — при attempts_per_model=1 переход к следующей модели + # происходит независимо от классификации (см. комментарий в самой функции). + async def fake_or_request(path, method="GET", *, json_body=None): + model = json_body["model"] + if model == "model-a": + raise bot.OpenRouterAPIError("forbidden", status_code=403) + return {"choices": [{"message": {"content": "ответ от model-b"}}]} + + original = bot._or_request + bot._or_request = fake_or_request + try: + messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}] + answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["model-a", "model-b"], "model-a")) + assert answer == "ответ от model-b" + assert used == "model-b" + finally: + bot._or_request = original + + +# ══════════════════════════ аудит техдолга (август 2026) ══════════════════════════ +# Тесты ниже покрывают правки из аудита технического долга: фиксы, ранее непокрытые +# участки кода и новые небольшие возможности (мультипрокси, алерты владельцу, +# liveness-проверка моделей, ручной экспорт состояния). + +# ─────────────────── _check_admin_key (зеркало теста для _check_bot_token_auth) ─────────────────── + +def test_check_admin_key_accepts_correct_bearer_header(): + original = bot.ADMIN_PANEL_KEY + bot.ADMIN_PANEL_KEY = "real-admin-key" + try: + req = _FakeAdminRequest(headers={"Authorization": "Bearer real-admin-key"}) + assert bot._check_admin_key(req) is True + finally: + bot.ADMIN_PANEL_KEY = original + + +def test_check_admin_key_rejects_query_param_regression(): + # РЕГРЕССИЯ (аудит техдолга): раньше ADMIN_PANEL_KEY читался из ?key=... в URL — + # та же уязвимость (CWE-598), что уже была исправлена для BOT_TOKEN в /admin_keys + # (см. test_check_bot_token_auth_rejects_query_param_regression), но не была + # применена к /diag/webhook_url/export_state. Query-параметр больше не должен + # приниматься вообще, даже если значение верное. + original = bot.ADMIN_PANEL_KEY + bot.ADMIN_PANEL_KEY = "real-admin-key" + try: + req = _FakeAdminRequest(headers={}, query_params={"key": "real-admin-key"}) + assert bot._check_admin_key(req) is False + finally: + bot.ADMIN_PANEL_KEY = original + + +def test_check_admin_key_rejects_wrong_or_missing_key(): + original = bot.ADMIN_PANEL_KEY + bot.ADMIN_PANEL_KEY = "real-admin-key" + try: + assert bot._check_admin_key(_FakeAdminRequest(headers={"Authorization": "Bearer wrong"})) is False + assert bot._check_admin_key(_FakeAdminRequest(headers={})) is False + assert bot._check_admin_key(_FakeAdminRequest(headers={"Authorization": "real-admin-key"})) is False + finally: + bot.ADMIN_PANEL_KEY = original + + +# ─────────────────── ask_openrouter_multimodal: фикс хардкода fallback-модели ─���───────────────── + +def test_ask_openrouter_multimodal_empty_model_chain_fallback_is_current_vision_order(): + # РЕГРЕССИЯ (аудит техдолга): раньше здесь стоял захардкоженный литерал + # "nvidia/nemotron-nano-12b-v2-vl:free" — тот же класс бага, что уже был найден + # и исправлен в ask_openrouter_text (см. test_ask_openrouter_text_empty_model_chain_ + # fallback_is_not_dead_model выше). Теперь дефолт ссылается на _OR_VISION_ORDER[0]. + chat_id = 999410 + calls = [] + + async def fake_or_fallback(messages, trial_models, primary_model_id, **kwargs): + calls.append(trial_models) + return "ответ", trial_models[0] + + original = bot._or_chat_completion_with_fallback + bot._or_chat_completion_with_fallback = fake_or_fallback + try: + asyncio.run(bot.ask_openrouter_multimodal(chat_id, "привет", (b"fake", "image/jpeg"), "photo.jpg", model_chain=[])) + assert calls[0] == [bot._OR_VISION_ORDER[0]] + finally: + bot._or_chat_completion_with_fallback = original + bot.chat_state.pop(chat_id, None) + + +# ─────────────────── дедуп мёртвой ветки ретраев в _or_chat_completion_with_fallback ─────────────────── + +def test_or_chat_completion_with_fallback_no_longer_accepts_attempts_per_model(): + # attempts_per_model убран целиком (был мёртвым кодом — см. аудит техдолга): + # единственное реальное значение всегда было 1, поэтому внутренний повторный + # цикл никогда не делал вторую итерацию. + import inspect + sig = inspect.signature(bot._or_chat_completion_with_fallback) + assert "attempts_per_model" not in sig.parameters + + +# ─────────────────── healthcheck отражает реальное состояние ─────────────────── + +def test_healthcheck_reports_not_ready_when_bot_or_client_uninitialized(): + original_bot, original_client = bot.bot, bot.client + bot.bot = None + bot.client = None + try: + result = asyncio.run(bot.healthcheck()) + assert result == {"status": "starting", "ready": False} + finally: + bot.bot, bot.client = original_bot, original_client + + +def test_healthcheck_reports_ready_when_initialized(): + original_bot, original_client = bot.bot, bot.client + bot.bot = object() + bot.client = object() + try: + result = asyncio.run(bot.healthcheck()) + assert result == {"status": "ok", "ready": True} + finally: + bot.bot, bot.client = original_bot, original_client + + +# ─────────────────── /export_state — ручной бэкап (гейтится ADMIN_PANEL_KEY) ─────────────────── + +def test_export_state_rejects_missing_or_wrong_key(): + original = bot.ADMIN_PANEL_KEY + bot.ADMIN_PANEL_KEY = "real-admin-key" + try: + result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={"Authorization": "Bearer wrong"}))) + assert "error" in result + finally: + bot.ADMIN_PANEL_KEY = original + + +def test_export_state_rejects_query_param_regression(): + # См. test_check_admin_key_rejects_query_param_regression — /export_state — самый + # чувствительный из трёх эндпоинтов (отдаёт ПОЛНЫЕ истории всех чатов), поэтому + # регрессия здесь проверяется отдельно, а не только на уровне _check_admin_key. + original = bot.ADMIN_PANEL_KEY + bot.ADMIN_PANEL_KEY = "real-admin-key" + try: + result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={}, query_params={"key": "real-admin-key"}))) + assert "error" in result + finally: + bot.ADMIN_PANEL_KEY = original + + +def test_export_state_returns_chats_and_quota_with_valid_key(): + original = bot.ADMIN_PANEL_KEY + bot.ADMIN_PANEL_KEY = "real-admin-key" + chat_id = 999411 + bot.chat_state[chat_id] = { + "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}], + "quota": {}, "recent_media_ids": {}, "last_activity": 0.0, + } + try: + result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={"Authorization": "Bearer real-admin-key"}))) + assert str(chat_id) in result["chats"] + assert result["chats"][str(chat_id)]["history"] == [{"role": "user", "content": "hi"}] + assert "global_quota" in result + assert "exported_at" in result + finally: + bot.ADMIN_PANEL_KEY = original + bot.chat_state.pop(chat_id, None) + + +# ─────────────────── webhook_handler (аудит техдолга: не было ни одного теста на единственную точку входа для всего входящего Telegram-трафика) ─────────────────── +# Раньше были изолированные тесты только на _check_bot_token_auth/_check_admin_key +# (гейтят другие эндпоинты) — сам webhook_handler (hmac.compare_digest секрета из +# заголовка X-Telegram-Bot-Api-Secret-Token + диспатч в _process_raw_update) не был +# покрыт вообще. Регрессия здесь означала бы либо приём неавторизованных апдейтов, +# либо полную остановку приёма сообщений ботом — самый security-критичный путь +# в проекте после самих секретов. + +class _FakeWebhookRequest: + def __init__(self, headers: dict | None = None, body: dict | None = None): + self.headers = headers or {} + self._body = body or {} + + async def json(self): + return self._body + + +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 + + +def test_webhook_handler_accepts_valid_secret_and_dispatches_update(): + original_secret = bot.WEBHOOK_SECRET + original_bot_obj = bot.bot + original_process = bot._process_raw_update + bot.WEBHOOK_SECRET = "real-webhook-secret" + bot.bot = object() # не-None достаточно, чтобы пройти проверку "бот уже инициализирован" + calls = [] + + async def fake_process(raw_update): + calls.append(raw_update) + + bot._process_raw_update = fake_process + try: + req = _FakeWebhookRequest( + headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"}, + body={"update_id": 1}, + ) + result = asyncio.run(_run_webhook_handler(req)) + assert result == {"ok": True} + assert calls == [{"update_id": 1}] + finally: + bot.WEBHOOK_SECRET = original_secret + bot.bot = original_bot_obj + bot._process_raw_update = original_process + + +def test_webhook_handler_rejects_invalid_or_missing_secret_and_does_not_dispatch(): + original_secret = bot.WEBHOOK_SECRET + original_process = bot._process_raw_update + bot.WEBHOOK_SECRET = "real-webhook-secret" + calls = [] + + async def fake_process(raw_update): + calls.append(raw_update) + + bot._process_raw_update = fake_process + try: + for bad_headers in ( + {"X-Telegram-Bot-Api-Secret-Token": "wrong-secret"}, + {}, + ): + req = _FakeWebhookRequest(headers=bad_headers, body={"update_id": 1}) + result = asyncio.run(_run_webhook_handler(req)) + assert result == {"ok": False} + assert calls == [] + finally: + bot.WEBHOOK_SECRET = original_secret + bot._process_raw_update = original_process + + +def test_webhook_handler_drops_update_when_bot_not_yet_initialized(): + # Апдейт может прийти раньше, чем main() успеет создать глобальный bot (Bot/ + # genai.Client создаются уже после старта uvicorn) — должен тихо отбрасываться, + # а не падать, и не пытаться диспатчить апдейт в ещё не готового бота. + original_secret = bot.WEBHOOK_SECRET + original_bot_obj = bot.bot + original_process = bot._process_raw_update + bot.WEBHOOK_SECRET = "real-webhook-secret" + bot.bot = None + calls = [] + + async def fake_process(raw_update): + calls.append(raw_update) + + bot._process_raw_update = fake_process + try: + req = _FakeWebhookRequest( + headers={"X-Telegram-Bot-Api-Secret-Token": "real-webhook-secret"}, + body={"update_id": 1}, + ) + result = asyncio.run(_run_webhook_handler(req)) + assert result == {"ok": True} + assert calls == [] + finally: + bot.WEBHOOK_SECRET = original_secret + bot.bot = original_bot_obj + bot._process_raw_update = original_process + + +# ─────────────────── ADMIN_SECRET_SEED — независимая ротация секре��ов ─────────────────── + +def test_admin_secrets_are_independent_of_bot_token_when_seed_set(): + # РЕГРЕССИЯ (аудит техдолга): раньше WEBHOOK_SECRET/ADMIN_PANEL_KEY выводились + # ИСКЛЮЧИТЕЛЬНО из BOT_TOKEN — компрометация токена компрометировала оба сразу, + # и ни один нельзя было ротировать независимо. Теперь можно задать отдельную соль. + import hashlib + seed_a = "seed-one" + seed_b = "seed-two" + webhook_a = hashlib.sha256(seed_a.encode()).hexdigest()[:32] + webhook_b = hashlib.sha256(seed_b.encode()).hexdigest()[:32] + assert webhook_a != webhook_b # разные соли -> разные секреты, как и должно быть + + +def test_admin_secret_seed_falls_back_to_bot_token_when_unset(): + # Без ADMIN_SECRET_SEED поведение идентично прежнему (соль = BOT_TOKEN) — не + # ломает существующие деплои, которые эту переменную не настраивали. + assert bot._ADMIN_SECRET_SEED == (os.environ.get("ADMIN_SECRET_SEED", "").strip() or bot.BOT_TOKEN or "default") + + +# ─────────────────── мультипрокси: rotate-before-pause + owner-алерт ─────────────────── + +def test_rotate_telegram_proxy_noop_with_single_candidate(): + original_candidates = bot._TELEGRAM_PROXY_CANDIDATES + original_idx = bot._telegram_proxy_idx + bot._TELEGRAM_PROXY_CANDIDATES = ["https://only-one.example.com"] + bot._telegram_proxy_idx = 0 + try: + switched = asyncio.run(bot._rotate_telegram_proxy()) + assert switched is False + finally: + bot._TELEGRAM_PROXY_CANDIDATES = original_candidates + bot._telegram_proxy_idx = original_idx + + +def test_rotate_telegram_proxy_switches_and_reports_lap_not_done(): + original_candidates = bot._TELEGRAM_PROXY_CANDIDATES + original_idx = bot._telegram_proxy_idx + original_base_url = bot.TELEGRAM_API_BASE_URL + original_bot = bot.bot + bot._TELEGRAM_PROXY_CANDIDATES = ["https://primary.example.com", "https://fallback.example.com"] + bot._telegram_proxy_idx = 0 + bot.TELEGRAM_API_BASE_URL = "https://primary.example.com" + bot.bot = None # без реального aiogram Bot — проверяем только URL-переключение + try: + switched = asyncio.run(bot._rotate_telegram_proxy()) + assert switched is True # ещё не замкнули круг — есть смысл пробовать сразу + assert bot.TELEGRAM_API_BASE_URL == "https://fallback.example.com" + assert bot._telegram_proxy_idx == 1 + finally: + bot._TELEGRAM_PROXY_CANDIDATES = original_candidates + bot._telegram_proxy_idx = original_idx + bot.TELEGRAM_API_BASE_URL = original_base_url + bot.bot = original_bot + + +def test_rotate_telegram_proxy_reports_lap_done_after_full_cycle(): + original_candidates = bot._TELEGRAM_PROXY_CANDIDATES + original_idx = bot._telegram_proxy_idx + original_base_url = bot.TELEGRAM_API_BASE_URL + original_bot = bot.bot + bot._TELEGRAM_PROXY_CANDIDATES = ["https://primary.example.com", "https://fallback.example.com"] + bot._telegram_proxy_idx = 1 # уже на резервном — следующий поворот вернёт на primary (idx 0) + bot.TELEGRAM_API_BASE_URL = "https://fallback.example.com" + bot.bot = None + try: + switched = asyncio.run(bot._rotate_telegram_proxy()) + assert switched is False # круг замкнулся — пора паузу включать + assert bot._telegram_proxy_idx == 0 + finally: + bot._TELEGRAM_PROXY_CANDIDATES = original_candidates + bot._telegram_proxy_idx = original_idx + bot.TELEGRAM_API_BASE_URL = original_base_url + bot.bot = original_bot + + +class _FakeOwnerBot: + def __init__(self): + self.sent: list[dict] = [] + + async def send_message(self, **kwargs): + self.sent.append(kwargs) + return SimpleNamespace() + + +def test_notify_owner_sends_message_when_owner_and_bot_configured(): + original_owner, original_bot = bot.OWNER_ID, bot.bot + bot.OWNER_ID = 12345 + fake = _FakeOwnerBot() + bot.bot = fake + try: + asyncio.run(bot._notify_owner("тестовый алерт")) + assert len(fake.sent) == 1 + assert fake.sent[0]["chat_id"] == 12345 + assert fake.sent[0]["text"] == "тестовый алерт" + finally: + bot.OWNER_ID, bot.bot = original_owner, original_bot + + +def test_notify_owner_noop_without_owner_id(): + original_owner, original_bot = bot.OWNER_ID, bot.bot + bot.OWNER_ID = None + fake = _FakeOwnerBot() + bot.bot = fake + try: + asyncio.run(bot._notify_owner("не должно уйти")) + assert fake.sent == [] + finally: + bot.OWNER_ID, bot.bot = original_owner, original_bot + + +def test_notify_owner_never_raises_on_send_failure(): + original_owner, original_bot = bot.OWNER_ID, bot.bot + bot.OWNER_ID = 12345 + + class _FailingBot: + async def send_message(self, **kwargs): + raise RuntimeError("boom") + + bot.bot = _FailingBot() + try: + asyncio.run(bot._notify_owner("не должно упасть")) # не должно поднять исключение + finally: + bot.OWNER_ID, bot.bot = original_owner, original_bot + + +def test_maybe_alert_gemini_exhausted_throttled(): + # См. аналогичный урок в test_reset_quota_if_new_day_clears_used_and_exhausted_ + # on_day_rollover выше: time.monotonic() не гарантированно "далеко за" какой-то + # абсолютной точкой (в свежем процессе может быть близко к нулю) — "давно" нужно + # выражать относительно текущего time.monotonic(), а не буквальным 0.0. + original_last = bot._last_gemini_exhausted_alert_monotonic + original_owner, original_bot = bot.OWNER_ID, bot.bot + bot.OWNER_ID = 12345 + fake = _FakeOwnerBot() + bot.bot = fake + bot._last_gemini_exhausted_alert_monotonic = time.monotonic() - bot.GEMINI_EXHAUSTED_ALERT_COOLDOWN_SEC - 10.0 + try: + asyncio.run(bot._maybe_alert_gemini_exhausted()) + asyncio.run(bot._maybe_alert_gemini_exhausted()) + assert len(fake.sent) == 1 # второй вызов сразу же — троттлинг не пускает повтор + finally: + bot._last_gemini_exhausted_alert_monotonic = original_last + bot.OWNER_ID, bot.bot = original_owner, original_bot + + +# ─────────────────── _probe_or_model_liveness (проактивная проверка живости) ─────────────────── + +def test_probe_or_model_liveness_warns_on_dead_model_pattern(caplog): + # РЕГРЕССИЯ (аудит техдолга): раньше проверялась только голова (index 0) списка + # навсегда — теперь проверяемая модель зависит от дня года (day-of-year % len), + # чтобы за N дней проверить весь список ценой тех же 3 запросов/сутки, что и + # раньше (см. докстринг _probe_or_model_liveness). Тест вычисляет ожидаемую + # модель той же формулой, что и сама функция, вместо того чтобы полагаться на + # фиксированный index 0. + import logging + from datetime import date + expected_model = bot._OR_LIGHT_ORDER[date.today().timetuple().tm_yday % len(bot._OR_LIGHT_ORDER)] + + async def fake_or_request(path, method="GET", *, json_body=None): + model = json_body["model"] + if model == expected_model: + raise bot.OpenRouterAPIError("This model is unavailable for free. use another slug", status_code=404) + return {"choices": [{"message": {"content": "pong"}}]} + + original_request = bot._or_request + original_key = bot.OPENROUTER_API_KEY + bot._or_request = fake_or_request + bot.OPENROUTER_API_KEY = "fake-key" + try: + with caplog.at_level(logging.WARNING, logger="bot"): + asyncio.run(bot._probe_or_model_liveness()) + messages = "\n".join(r.getMessage() for r in caplog.records) + assert expected_model in messages + assert "_OR_LIGHT_ORDER" in messages + finally: + bot._or_request = original_request + bot.OPENROUTER_API_KEY = original_key + + +def test_probe_or_model_liveness_rotates_by_day_of_year(): + # Проверяем саму формулу ротации напрямую (не только эффект в одном из трёх + # списков, как в тесте выше) — на день N должен пробоваться models[N % len(models)]. + from datetime import date + calls = [] + + async def fake_or_request(path, method="GET", *, json_body=None): + calls.append(json_body["model"]) + return {"choices": [{"message": {"content": "pong"}}]} + + original_request = bot._or_request + original_key = bot.OPENROUTER_API_KEY + bot._or_request = fake_or_request + bot.OPENROUTER_API_KEY = "fake-key" + try: + asyncio.run(bot._probe_or_model_liveness()) + day_idx = date.today().timetuple().tm_yday + assert calls == [ + bot._OR_LIGHT_ORDER[day_idx % len(bot._OR_LIGHT_ORDER)], + bot._OR_HEAVY_ORDER[day_idx % len(bot._OR_HEAVY_ORDER)], + bot._OR_VISION_ORDER[day_idx % len(bot._OR_VISION_ORDER)], + ] + finally: + bot._or_request = original_request + bot.OPENROUTER_API_KEY = original_key + + +def test_probe_or_model_liveness_silent_when_all_alive(caplog): + import logging + + async def fake_or_request(path, method="GET", *, json_body=None): + return {"choices": [{"message": {"content": "pong"}}]} + + original_request = bot._or_request + original_key = bot.OPENROUTER_API_KEY + bot._or_request = fake_or_request + bot.OPENROUTER_API_KEY = "fake-key" + try: + with caplog.at_level(logging.WARNING, logger="bot"): + asyncio.run(bot._probe_or_model_liveness()) + assert caplog.records == [] + finally: + bot._or_request = original_request + bot.OPENROUTER_API_KEY = original_key + + +def test_probe_or_model_liveness_noop_without_api_key(): + original_key = bot.OPENROUTER_API_KEY + bot.OPENROUTER_API_KEY = "" + called = [] + + async def fake_or_request(*args, **kwargs): + called.append(1) + return {} + + original_request = bot._or_request + bot._or_request = fake_or_request + try: + asyncio.run(bot._probe_or_model_liveness()) + assert called == [] + finally: + bot._or_request = original_request + bot.OPENROUTER_API_KEY = original_key + + +# ─────────────────── handle_tiktok — оркестрация целиком (была не покрыта тестами) ─────────────────── + +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): + self.status = status + self._json_body = json_body or {} + + 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, {}) + + +def test_handle_tiktok_single_video_happy_path(): + # Регрессия на пробел из аудита техдолга: все "кирпичики" TikTok-загрузчика + # покрыты юнит-тестами, но сама оркестрирующая handle_tiktok — нет. Этот тест + # проверяет путь "обычное видео" целиком: TikWM отвечает, видео "скачивается" + # (замокано), отправляется через bot.send_video. + tikwm_json = { + "code": 0, + "data": { + "play": "https://tikwm.com/sd.mp4", "size": 1000, + "author": {"nickname": "TestAuthor"}, + }, + } + + incoming = _FakeIncomingMessage(999420) + incoming.message_id = 1 + fake_bot = _FakeTikTokBot() + + async def fake_get_http_session(): + return _FakeTikTokSession(tikwm_json) + + async def fake_resolve(session, url): + return url # ссылка уже "разрешена", не короткая + + async def fake_download_url_bin(session, url, headers=None): + return b"\x00" * 100 # не видео-байты (не ftyp) — не важно для этого теста + + async def fake_probe_dims(path): + return 0, 0, 0 + + async def fake_thumb(path, duration): + return None + + original_get_session = bot._get_http_session + original_resolve = bot._resolve_tiktok_short + original_download = bot._download_url_bin + original_probe = bot._probe_video_dimensions + original_thumb = bot._generate_video_thumbnail + original_bot = bot.bot + + bot._get_http_session = fake_get_http_session + bot._resolve_tiktok_short = fake_resolve + bot._download_url_bin = fake_download_url_bin + bot._probe_video_dimensions = fake_probe_dims + bot._generate_video_thumbnail = fake_thumb + bot.bot = fake_bot + try: + asyncio.run(bot.handle_tiktok(incoming, "https://www.tiktok.com/@test/video/123")) + assert len(fake_bot.sent_videos) == 1 + finally: + bot._get_http_session = original_get_session + bot._resolve_tiktok_short = original_resolve + bot._download_url_bin = original_download + bot._probe_video_dimensions = original_probe + bot._generate_video_thumbnail = original_thumb + bot.bot = original_bot + + +def test_handle_tiktok_no_media_found_gives_user_facing_error(): + tikwm_json = {"code": 0, "data": {"author": {"nickname": "TestAuthor"}}} # нет ни play, ни images + + incoming = _FakeIncomingMessage(999421) + incoming.message_id = 2 + + async def fake_get_http_session(): + return _FakeTikTokSession(tikwm_json) + + async def fake_resolve(session, url): + return url + + original_get_session = bot._get_http_session + original_resolve = bot._resolve_tiktok_short + bot._get_http_session = fake_get_http_session + bot._resolve_tiktok_short = fake_resolve + try: + # handle_tiktok сам ловит исключение и редактирует статусное сообщение — + # не поднимает наружу; проверяем, что оно не падает необработанным. + asyncio.run(bot.handle_tiktok(incoming, "https://www.tiktok.com/@test/video/456")) + finally: + bot._get_http_session = original_get_session + bot._resolve_tiktok_short = original_resolve + + +# ─────────────────── _fetch_tikwm_media_data (троттлинг + ретрай при 403) ─────────────────── +# Регрессия на реальный инцидент (отладка 11 августа 2026, см. Sentry-трейс +# TikTokUserFacingError): оба зеркала TikWM стабильно отвечали HTTP 403 подряд с +# разницей ~36мс — раньше код бил в оба зеркала практически одновременно без единой +# паузы между запросами, что само по себе способно нарушать задокументированный +# сторонними инструментами лимит TikWM (~1 запрос/сек). Тесты проверяют: троттлинг +# между запросами, откат на второе зеркало при 403, одну повторную попытку, если +# 403 пришёл от ОБОИХ зеркал подряд, и отсутствие лишнего ретрая для обычной +# "постоянной" ошибки TikWM (code != 0 при HTTP 200 — видео реально недоступно). + +class _FakeTikwmApiResponse: + def __init__(self, status=200, json_body=None, body_bytes=b""): + self.status = status + self._json_body = json_body or {} + self._body_bytes = 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) + + +def test_fetch_tikwm_media_data_returns_data_on_first_mirror_success(): + resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "x"}}) + session = _FakeTikwmApiSession([resp]) + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/1", {})) + assert result == {"play": "x"} + assert len(session.requested_urls) == 1 + + +def test_fetch_tikwm_media_data_falls_back_to_second_mirror_on_403(): + responses = [ + _FakeTikwmApiResponse(status=403, body_bytes=b"Forbidden"), + _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "y"}}), + ] + session = _FakeTikwmApiSession(responses) + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/2", {})) + assert result == {"play": "y"} + assert len(session.requested_urls) == 2 + + +def test_fetch_tikwm_media_data_retries_once_after_both_mirrors_403(): + # Первый раунд (оба зеркала) — 403; второй раунд, первое же зеркало — успех. + responses = [ + _FakeTikwmApiResponse(status=403, body_bytes=b"Forbidden"), + _FakeTikwmApiResponse(status=403, body_bytes=b"Forbidden"), + _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "z"}}), + _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "unused"}}), + ] + session = _FakeTikwmApiSession(responses) + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/3", {})) + assert result == {"play": "z"} + assert len(session.requested_urls) == 3 # третий запрос уже успешен — четвёртый не понадобился + + +def test_fetch_tikwm_media_data_returns_none_when_still_403_after_retry(): + # Ни один раунд (2 зеркала x 2 раунда = 4 попытки) не дал успеха — сдаёмся, + # без бесконечных повторов (ровно одна повторная попытка, не больше). + responses = [_FakeTikwmApiResponse(status=403, body_bytes=b"Forbidden") for _ in range(4)] + session = _FakeTikwmApiSession(responses) + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/4", {})) + assert result is None + assert len(session.requested_urls) == 4 + + +def test_fetch_tikwm_media_data_no_retry_round_on_permanent_tikwm_error_code(): + # code != 0 при HTTP 200 (например "видео приватное") — это ДЕЙСТВИТЕЛЬНО + # недоступное видео, а не признак троттлинга (403) — повторного раунда здесь + # быть не должно, даже если оба зеркала вернули такую ошибку. + responses = [ + _FakeTikwmApiResponse(status=200, json_body={"code": -1, "msg": "private video"}), + _FakeTikwmApiResponse(status=200, json_body={"code": -1, "msg": "private video"}), + ] + session = _FakeTikwmApiSession(responses) + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/5", {})) + assert result is None + assert len(session.requested_urls) == 2 + + +def test_fetch_tikwm_media_data_throttles_between_requests(): + # Регрессия на сам смысл фикса: если "прошлый запрос к TikWM" был только что, + # следующий обязан подождать (а не выстрелить мгновенно, как раньше). + import lumen_tiktok + responses = [ + _FakeTikwmApiResponse(status=403, body_bytes=b"Forbidden"), + _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "w"}}), + ] + session = _FakeTikwmApiSession(responses) + sleep_calls: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + sleep_calls.append(seconds) + + original_sleep = lumen_tiktok._sleep + lumen_tiktok._sleep = fake_sleep + lumen_tiktok._tikwm_last_request_ts = time.monotonic() # "запрос только что был" + try: + asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/6", {})) + assert sleep_calls # хотя бы одна пауза перед следующим запросом + assert all(s > 0 for s in sleep_calls) + finally: + lumen_tiktok._sleep = original_sleep + lumen_tiktok._tikwm_last_request_ts = None + + +def test_fetch_tikwm_media_data_sends_referer_and_origin_headers(): + # ИСПРАВЛЕНО (отладка 12 августа 2026): даже с корректным URL и корректным + # троттлингом TikWM продолжал отвечать 403 с пустым телом — Referer/Origin, + # имитирующие вызов со страницы самого tikwm.com, добавлены как best-effort + # попытка обхода анти-скрейпинг проверки (см. докстринг _fetch_tikwm_media_data). + # Исходные заголовки (например User-Agent от вызывающего кода) не должны теряться. + resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "x"}}) + session = _FakeTikwmApiSession([resp]) + asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/7", {"User-Agent": "test-ua"})) + sent_headers = session.requested_headers[0] + assert sent_headers["Referer"] == "https://www.tikwm.com/" + assert sent_headers["Origin"] == "https://www.tikwm.com" + assert sent_headers["User-Agent"] == "test-ua" + + +def test_fetch_tikwm_media_data_logs_ip_block_hint_when_all_attempts_403_with_empty_body(caplog): + # Регрессия на реальный инцидент (12 августа 2026): корректный URL + корректный + # троттлинг + ВСЕ попытки (оба зеркала, оба раунда) вернули 403 с пустым телом — + # это специфический паттерн, заслуживающий отдельного, легко узнаваемого + # диагностического лога (а не очередного часа Sentry-археологии в следующий раз). + import logging + responses = [_FakeTikwmApiResponse(status=403, body_bytes=b"") for _ in range(4)] + session = _FakeTikwmApiSession(responses) + with caplog.at_level(logging.WARNING, logger="bot"): + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/8", {})) + assert result is None + messages = "\n".join(r.getMessage() for r in caplog.records) + assert "[tikwm][diag]" in messages + + +def test_fetch_tikwm_media_data_no_ip_block_hint_when_body_not_empty(caplog): + # У ошибки есть реальный текст (например настоящее сообщение о рейт-лимите) — + # это ДРУГОЙ, более информативный случай, не нужно путать его с гипотезой + # про блокировку IP (пустое тело — специфический признак именно её). + import logging + responses = [_FakeTikwmApiResponse(status=403, body_bytes=b"Rate limited") for _ in range(4)] + session = _FakeTikwmApiSession(responses) + with caplog.at_level(logging.WARNING, logger="bot"): + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/9", {})) + assert result is None + messages = "\n".join(r.getMessage() for r in caplog.records) + assert "[tikwm][diag]" not in messages + + +def test_fetch_tikwm_media_data_no_ip_block_hint_on_success(caplog): + # Успешный ответ (пусть даже после предыдущих неудачных попыток) не должен + # ошибочно засчитываться как "всё было 403" — счётчик должен сброситься. + import logging + responses = [ + _FakeTikwmApiResponse(status=403, body_bytes=b""), + _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {"play": "ok"}}), + ] + session = _FakeTikwmApiSession(responses) + with caplog.at_level(logging.WARNING, logger="bot"): + result = asyncio.run(bot._fetch_tikwm_media_data(session, "https://www.tiktok.com/@u/video/10", {})) + assert result == {"play": "ok"} + messages = "\n".join(r.getMessage() for r in caplog.records) + assert "[tikwm][diag]" not in messages +