gendesign/tradein-mvp/browser/test_server_pow_challenge.py
bot-backend b9025de666
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m5s
fix(tradein/domclick): площадка отдаёт одну карточку на процесс браузера, а мы держали один на весь прогон (#3205)
Замер на проде 2026-08-29. Три независимых запуска camoufox через прод-прокси,
идём по карточкам до первого отказа — каждый раз одно и то же: карточка #1
даёт 200 и ~1 МБ с SSR-стейтом, карточка #2 даёт 401 и страницу отказа на
26 625 байт. A/B на восьми карточках: свой браузер на каждую — 4 из 4 успеха,
один браузер на четыре — 1 из 4. Прямой выход с сервера и выход через прокси
неразличимы (1 из 8 в обоих условиях), то есть дело не в IP.

Свежего КОНТЕКСТА не хватает: в контрольном замере каждая карточка бралась
через browser.new_page(), в новом изолированном контексте, — и всё равно отказ
со второй. Признак живёт на уровне процесса, camoufox генерирует отпечаток при
запуске, а не при создании контекста. Отсюда: reset_context (#3118) эту задачу
не решает в принципе. Цена перезапуска — 0.6 с (3.5 с только первый, холодный).

- PROVIDERS: добавлен "domclick" (+ host-detect). Раньше он проваливался в
  generic и делил браузер со счётчиком страниц с прочим трафиком — при пороге
  перезапуска 1 это было бы неверно.
- BROWSER_RECYCLE_PAGES стал поставщик-зависимым (_resolve_recycle_pages +
  BROWSER_RECYCLE_PAGES_{PROVIDER}), по образцу BROWSER_BLOCK_IMAGES_{PROVIDER}
  из #3185. Код-дефолт domclick=1, остальным прежние 15 — у Авито и Циана узор
  другой и своего замера под него нет.

Отдельно починены ~24 холостых охранника в тестах. Они делали
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000), чтобы запретить
перезапуск браузера; после перехода на словарь этот патч перестал на что-либо
влиять, и набор оставался зелёным лишь потому, что ни один тест не делает 15
страниц подряд. Теперь патчится _RECYCLE_PAGES_BY_PROVIDER, а сама глобальная
константа убрана, чтобы её не патчили снова. Проверено мутацией: при пороге 1
для всех провайдеров падают ровно три теста, которые этот дефолт и проверяют,
остальные 155 удерживаются — значит охранники работают.
2026-08-29 13:14:20 +03:00

276 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 = (
"<html><body><script>"
"document.addEventListener('DOMContentLoaded', function() {"
" if (getCookie('pow_solved')) {"
" setTimeout(function() { window.location = location.href; }, 3000);"
" return;"
" }"
" startPow(1);"
"});"
"</script></body></html>"
)
_BAN_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
_REAL_HTML = "<html><body>listing card content</body></html>"
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.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 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 = "<html><body>Доступ ограничен: проверка безопасности</body></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"))