All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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 1m15s
Домклик отдаёт рукопожатие без единого стабильного маркера (в отличие от Авито), поэтому _CHALLENGE_MARKERS (сняты с Авито, #3045) на нём никогда не матчились и ветка ожидания не включалась — недосчитанная страница уезжала наверх, парсер не находил __SSR_STATE__ и поднимал ложный блок. Это и был двухнедельный attempted=3, blocked=3, enriched=0 у domclick_detail_backfill. Для provider=="domclick" логика инвертирована: положительно опознаём только два крайних состояния — успех (__SSR_STATE__) и статический отказ площадки («403 | Домклик» / «похоже, ваш запрос выглядит необычно»); всё остальное (загрузчик рукопожатия, нерендеренная PoW-страница без каких-либо маркеров) трактуется как «рукопожатие ещё идёт» и уходит в существующий _wait_out_pow_challenge с кастомным is_pending. HTTP-статус для DomClick не используется как сигнал (401 приходит и у отказа, и у успеха, и у здорового рукопожатия) — решает только тело. Avito и прочие провайдеры идут по старой elif-ветке без изменений. _wait_out_pow_challenge получил опциональный параметр is_pending (дефолт _is_pow_challenge) — golden-parity для всех, кроме domclick.
284 lines
13 KiB
Python
284 lines
13 KiB
Python
"""test_server_domclick_challenge.py — QRATOR-рукопожатие DomClick в /fetch (#3196).
|
||
|
||
Проблема, установленная замером прода 29.08.2026: у DomClick, в отличие от
|
||
Авито, промежуточная (нерендеренная) PoW-страница НЕ содержит ни одного
|
||
стабильного маркера вообще — ни `__qrator`, ни `startpow`, ни текста отказа.
|
||
`_CHALLENGE_MARKERS` (сняты живьём с Авито, #3045) на DomClick никогда не
|
||
матчатся, поэтому ветка `_wait_out_pow_challenge` для него не включалась —
|
||
недосчитанная страница уезжала наверх как валидный контент, парсер не находил
|
||
`__SSR_STATE__` и поднимал ложный "блок" (двухнедельный
|
||
`attempted=3, blocked=3, enriched=0` у `domclick_detail_backfill`).
|
||
|
||
Логика для DomClick инвертирована: опознаём положительно ДВА крайних
|
||
состояния — «точно успех» (`_DOMCLICK_SUCCESS_MARKER`, JS-глобаль
|
||
`__SSR_STATE__`) и «точно отказ» (`_DOMCLICK_REFUSAL_MARKERS`, статическая
|
||
страница «403 | Домклик» ~26.6 КБ) — а всё остальное (загрузчик рукопожатия
|
||
279 байт, нерендеренная PoW-страница ~7 КБ) трактуется как «рукопожатие ещё
|
||
идёт» и уходит в существующий `_wait_out_pow_challenge` с кастомным
|
||
`is_pending`.
|
||
|
||
Тестируется _fetch_once целиком (зеркалит test_server_pow_challenge.py),
|
||
camoufox не запускается — фейковые browser/page возвращают заранее заданную
|
||
последовательность content(). wait_for_timeout на фейковой page — no-op.
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server_domclick_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())
|
||
|
||
|
||
# Приблизительные образцы трёх состояний, снятых живьём 29.08.2026 (см. докстринг
|
||
# задачи) — точный размер в байтах для теста не важен, важно отсутствие/наличие
|
||
# опознаваемых строк.
|
||
_HANDSHAKE_LOADER_HTML = (
|
||
'<html><head><script src="/__qrator/qauth_utm_v2d_v9118.js"></script></head>'
|
||
"<body></body></html>"
|
||
)
|
||
_RENDERED_POW_HTML = (
|
||
"<html><body><script>window.location = location.href;</script>"
|
||
"<div>ожидание...</div></body></html>"
|
||
)
|
||
_REFUSAL_HTML = (
|
||
"<html><head><title>403 | Домклик</title></head>"
|
||
"<body>Похоже, ваш запрос выглядит необычно. Мы усилили защиту.</body></html>"
|
||
)
|
||
_SUCCESS_HTML = '<html><body><script>window.__SSR_STATE__ = {"card": {}};</script></body></html>'
|
||
|
||
|
||
class _DomclickPage:
|
||
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
|
||
|
||
После исчерпания списка повторяет последний элемент (имитирует «страница
|
||
осталась в этом состоянии»). Зеркалит _ChallengePage из
|
||
test_server_pow_challenge.py.
|
||
"""
|
||
|
||
def __init__(self, html_sequence: list[str]) -> 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
|
||
return item
|
||
|
||
async def close(self) -> None:
|
||
self.closed += 1
|
||
|
||
|
||
class _DomclickBrowser:
|
||
def __init__(self, page: _DomclickPage) -> None:
|
||
self._page = page
|
||
|
||
async def new_page(self) -> _DomclickPage:
|
||
return self._page
|
||
|
||
|
||
def _install(
|
||
monkeypatch: pytest.MonkeyPatch, page: _DomclickPage, provider: str = "domclick"
|
||
) -> None:
|
||
server._browsers[provider] = _DomclickBrowser(page)
|
||
monkeypatch.setattr(
|
||
server, "_RECYCLE_PAGES_BY_PROVIDER",
|
||
dict.fromkeys(server.PROVIDERS, 10_000),
|
||
)
|
||
|
||
|
||
# ── детекторы: чистые функции ────────────────────────────────────────────────────
|
||
|
||
|
||
def test_is_domclick_success_true_on_ssr_state_marker() -> None:
|
||
assert server._is_domclick_success(_SUCCESS_HTML) is True
|
||
|
||
|
||
def test_is_domclick_success_false_on_handshake_loader() -> None:
|
||
assert server._is_domclick_success(_HANDSHAKE_LOADER_HTML) is False
|
||
|
||
|
||
def test_is_domclick_success_false_on_rendered_pow_page() -> None:
|
||
"""Ключевой случай задачи: у рендеренной PoW-страницы НЕТ стабильных маркеров
|
||
вообще — детектор успеха не должен ложно сработать на ней."""
|
||
assert server._is_domclick_success(_RENDERED_POW_HTML) is False
|
||
|
||
|
||
def test_is_domclick_success_false_on_refusal_page() -> None:
|
||
assert server._is_domclick_success(_REFUSAL_HTML) is False
|
||
|
||
|
||
def test_is_domclick_refusal_true_on_title_and_text_markers() -> None:
|
||
assert server._is_domclick_refusal(_REFUSAL_HTML) is True
|
||
|
||
|
||
def test_is_domclick_refusal_false_on_handshake_loader() -> None:
|
||
assert server._is_domclick_refusal(_HANDSHAKE_LOADER_HTML) is False
|
||
|
||
|
||
def test_is_domclick_refusal_false_on_rendered_pow_page() -> None:
|
||
assert server._is_domclick_refusal(_RENDERED_POW_HTML) is False
|
||
|
||
|
||
def test_is_domclick_refusal_false_on_success_page() -> None:
|
||
assert server._is_domclick_refusal(_SUCCESS_HTML) is False
|
||
|
||
|
||
# ── _fetch_once: сценарии DomClick ───────────────────────────────────────────────
|
||
|
||
|
||
def test_fetch_once_refusal_page_raises_immediately_without_waiting(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Страница отказа → BanPageDetectedError СРАЗУ, без ухода в опрос (пункт 1)."""
|
||
page = _DomclickPage([_REFUSAL_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.BanPageDetectedError):
|
||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||
|
||
assert page.closed == 1
|
||
# Единственная пауза — обычная гидро-пауза ДО детекта; цикла опроса (доп.
|
||
# паузы по 1000мс) не было — бюджет ожидания не потрачен.
|
||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||
|
||
|
||
def test_fetch_once_page_without_markers_waits_then_returns_success(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Ни успеха, ни отказа (загрузчик/рендеренная PoW-страница) → уходит в
|
||
ожидание, а не отдаёт недосчитанную страницу как контент (пункт 2)."""
|
||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _RENDERED_POW_HTML, _SUCCESS_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(
|
||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||
)
|
||
|
||
assert html == _SUCCESS_HTML
|
||
assert page.closed == 1
|
||
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
|
||
assert 1000 in page.wait_for_timeout_calls # шаг опроса — бюджет реально тратился
|
||
|
||
|
||
def test_fetch_once_success_page_returned_immediately_without_waiting(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Успех с первого раза → отдаётся сразу, БЕЗ ухода в polling-ветку (пункт 3)."""
|
||
page = _DomclickPage([_SUCCESS_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(
|
||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||
)
|
||
|
||
assert html == _SUCCESS_HTML
|
||
assert page.closed == 1
|
||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||
|
||
|
||
def test_fetch_once_settles_into_refusal_during_wait_raises_ban_error(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса → тоже BanPageDetectedError,
|
||
а не отдача отказной страницы как валидного контента."""
|
||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _REFUSAL_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.BanPageDetectedError):
|
||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||
|
||
assert page.closed == 1
|
||
|
||
|
||
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Ни успех, ни отказ так и не наступают → ChallengeTimeoutError по истечении бюджета."""
|
||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
|
||
page = _DomclickPage([_HANDSHAKE_LOADER_HTML]) # повторяется бесконечно
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.ChallengeTimeoutError):
|
||
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
|
||
|
||
assert page.closed == 1
|
||
|
||
|
||
def test_fetch_once_401_status_is_not_used_as_refusal_signal_for_domclick(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""HTTP 401 — статус здорового рукопожатия DomClick (замер 29.08.2026), НЕ должен
|
||
трактоваться как отказ: только тело страницы решает."""
|
||
|
||
class _Response:
|
||
status = 401
|
||
|
||
class _StatusPage(_DomclickPage):
|
||
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
|
||
self.goto_urls.append(url)
|
||
return _Response()
|
||
|
||
page = _StatusPage([_HANDSHAKE_LOADER_HTML, _SUCCESS_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(
|
||
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
|
||
)
|
||
|
||
assert html == _SUCCESS_HTML
|
||
|
||
|
||
# ── не-домклик провайдеры: поведение не изменилось (пункт 4) ────────────────────
|
||
|
||
|
||
def test_fetch_once_non_domclick_provider_ignores_domclick_markers(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""avito с HTML, который матчил бы _is_domclick_success/_is_domclick_refusal,
|
||
не должен уходить ни в какую специальную ветку — DomClick-логика провайдер-
|
||
скопирована строго под `provider == "domclick"`."""
|
||
page = _DomclickPage([_REFUSAL_HTML]) # содержит "похоже, ваш запрос..." — не avito-маркер
|
||
_install(monkeypatch, page, provider="avito")
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||
|
||
assert html == _REFUSAL_HTML # отдан как обычный контент — avito его не распознаёт
|
||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|