"""test_server_pow_challenge.py — QRATOR PoW-челлендж Авито в /fetch (#3045). Проблема, установленная замером на проде 2026-08-21: Авито за QRATOR отдаёт proof-of-work челлендж (JS считает PoW, ставит куку pow_solved, через setTimeout(3000) делает `window.location = location.href` — self-reload, URL не меняется). Фиксированной паузы BROWSER_WAIT_MS (6с) на цепочку «PoW → таймер 3с → перезагрузка → гидрация» хватало не всегда: живой замер дал 2 успеха из 6 карточек, 4 отказа — все «проверка безопасности», НЕ бан по IP. Тестируется _fetch_once целиком (не только вынесенный polling-хелпер), чтобы проверить весь путь: goto → content() → детект → (ожидание | ошибка) → return. camoufox НЕ запускается: _browsers[provider] — поддельный browser/page, возвращающие заранее заданную последовательность content(). wait_for_timeout на фейковой page — no-op (без реального asyncio.sleep), поэтому тесты идут мгновенно независимо от величины BROWSER_CHALLENGE_WAIT_MS/BROWSER_WAIT_MS. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_pow_challenge.py -q """ from __future__ import annotations import asyncio import importlib.util from pathlib import Path from typing import Any import pytest # server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. _SERVER_PATH = Path(__file__).resolve().parent / "server.py" _spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) assert _spec is not None and _spec.loader is not None server = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(server) @pytest.fixture(autouse=True) def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: """Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py).""" monkeypatch.setattr(server, "_browsers", {}) monkeypatch.setattr(server, "_browser_cms", {}) monkeypatch.setattr(server, "_page_counters", {}) monkeypatch.setattr(server, "_locks", {}) monkeypatch.setattr(server, "_retry_tasks", {}) monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) _CHALLENGE_HTML = ( "
" ) _BAN_HTML = "Доступ ограничен: проблема с IP" _REAL_HTML = "listing card content" class _ChallengePage: """Поддельная page: отдаёт заданную последовательность content() по вызовам. После исчерпания списка повторяет последний элемент (имитирует «страница осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для проверки, что бюджет ожидания не тратится там, где не должен. Элемент последовательности может быть исключением — тогда content() его поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа: playwright кидает «Execution context was destroyed» ровно в момент той перезагрузки, которую мы ждём, и на моках без этого дефект не виден. """ def __init__(self, html_sequence: list[str | Exception]) -> None: self._html_sequence = html_sequence self._call_count = 0 self.goto_urls: list[str] = [] self.reload_calls: list[dict[str, Any]] = [] self.wait_for_timeout_calls: list[int] = [] self.closed = 0 async def route(self, pattern: str, handler: Any) -> None: return None async def goto(self, url: str, **kwargs: Any) -> None: self.goto_urls.append(url) async def reload(self, **kwargs: Any) -> None: # Сайдкар сам перезагружает зависший челлендж (#3244-след), поэтому фейковая # page обязана уметь reload — иначе тесты ловили бы AttributeError в ветке # помощи и молча уходили в warning вместо проверки поведения. self.reload_calls.append(kwargs) async def wait_for_timeout(self, ms: int) -> None: self.wait_for_timeout_calls.append(ms) async def content(self) -> str: idx = min(self._call_count, len(self._html_sequence) - 1) item = self._html_sequence[idx] self._call_count += 1 if isinstance(item, Exception): raise item return item async def close(self) -> None: self.closed += 1 class _ChallengeBrowser: def __init__(self, page: _ChallengePage) -> None: self._page = page async def new_page(self) -> _ChallengePage: return self._page def _install( monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito" ) -> None: server._browsers[provider] = _ChallengeBrowser(page) monkeypatch.setattr( server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000), ) # ── детекторы: чистые функции ──────────────────────────────────────────────────── def test_is_pow_challenge_true_on_start_pow_marker() -> None: assert server._is_pow_challenge(_CHALLENGE_HTML) is True def test_is_pow_challenge_true_on_title_marker() -> None: html = "Доступ ограничен: проверка безопасности" assert server._is_pow_challenge(html) is True def test_is_pow_challenge_false_on_ban_page() -> None: """Бан-страница НЕ должна ложно матчиться как челлендж — разные ветки.""" assert server._is_pow_challenge(_BAN_HTML) is False def test_is_pow_challenge_false_on_real_content() -> None: assert server._is_pow_challenge(_REAL_HTML) is False def test_is_ban_page_true_on_ip_problem_marker() -> None: assert server._is_ban_page(_BAN_HTML) is True def test_is_ban_page_false_on_challenge_page() -> None: assert server._is_ban_page(_CHALLENGE_HTML) is False def test_is_ban_page_false_on_real_content() -> None: assert server._is_ban_page(_REAL_HTML) is False def test_challenge_wait_budget_defaults_to_30s(monkeypatch: pytest.MonkeyPatch) -> None: """Дефолт BROWSER_CHALLENGE_WAIT_MS=30000 без переопределения env.""" monkeypatch.delenv("BROWSER_CHALLENGE_WAIT_MS", raising=False) import os assert int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) == 30000 # ── _fetch_once: сценарии ──────────────────────────────────────────────────────── def test_fetch_once_waits_out_challenge_then_returns_real_content( monkeypatch: pytest.MonkeyPatch, ) -> None: """Челлендж на первых двух опросах, третий — уже реальный контент → он и вернётся.""" page = _ChallengePage([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML]) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) assert html == _REAL_HTML assert page.closed == 1 # Обычная гидро-пауза + минимум одна пауза опроса челленджа + финальная # догидрация тем же BROWSER_WAIT_MS (второй таймаут не изобретаем). assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls assert 1000 in page.wait_for_timeout_calls # шаг опроса def test_fetch_once_raises_challenge_timeout_when_budget_exhausted( monkeypatch: pytest.MonkeyPatch, ) -> None: """Челлендж не снимается никогда → ChallengeTimeoutError, а не заглушка-контент.""" monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000) page = _ChallengePage([_CHALLENGE_HTML]) # всегда челлендж (последний элемент повторяется) _install(monkeypatch, page) with pytest.raises(server.ChallengeTimeoutError): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) # finally всё равно закрывает страницу, несмотря на исключение. assert page.closed == 1 def test_fetch_once_raises_ban_error_immediately_without_spending_budget( monkeypatch: pytest.MonkeyPatch, ) -> None: """Бан-страница («проблема с IP») → своя ошибка сразу, БЕЗ цикла опроса.""" page = _ChallengePage([_BAN_HTML]) _install(monkeypatch, page) with pytest.raises(server.BanPageDetectedError): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) assert page.closed == 1 # Единственный wait_for_timeout — обычная гидро-пауза ДО детекта; опроса # челленджа (доп. паузы по 1000мс) не было — бюджет не потрачен. assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] def test_fetch_once_normal_page_without_markers_unaffected( monkeypatch: pytest.MonkeyPatch, ) -> None: """Страница без маркеров челленджа/бана → поведение как раньше, без polling-ветки.""" page = _ChallengePage([_REAL_HTML]) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) assert html == _REAL_HTML assert page.closed == 1 assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] assert page.goto_urls == ["https://www.avito.ru/card/1"] # ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ────────────── # # Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот # момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы # именно на успешном исходе, ради которого написан. На моках без явной имитации # это не воспроизводится, поэтому тесты ниже поднимают исключение из content(). _NAV_RACE = RuntimeError( "Execution context was destroyed, most likely because of a navigation." ) def test_navigation_race_during_reload_is_not_a_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: """content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML.""" page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML]) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) assert html == _REAL_HTML def test_permanent_navigation_race_raises_challenge_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: """Навигация не прекращается → своя ошибка, а не сырое исключение playwright.""" page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE]) _install(monkeypatch, page) with pytest.raises(server.ChallengeTimeoutError): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) def test_unrelated_content_error_still_propagates( monkeypatch: pytest.MonkeyPatch, ) -> None: """Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть.""" boom = RuntimeError("Target page, context or browser has been closed") page = _ChallengePage([_CHALLENGE_HTML, boom]) _install(monkeypatch, page) with pytest.raises(RuntimeError, match="has been closed"): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))