All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m26s
PR #3258 научил якорную вкладку заходить на выдачу через поиск Яндекса, но две ветки из трёх подставляли Referer, которого мы не заработали: «ссылку в выдаче не нашли» ставила https://yandex.ru/, «клик увёл не туда» — URL поисковой выдачи. В обоих случаях перехода с Яндекса на площадку НЕ БЫЛО, а заголовок утверждал обратное. Главная причина, по которой вторая ветка вообще срабатывала: ссылки в выдаче Яндекса открываются в НОВОЙ вкладке (target="_blank"). Исходная страница остаётся на Яндексе, проверка хоста не проходит — и вместо того, чтобы взять настоящую новую вкладку, мы уходили в подстановку заголовка. Теперь новая вкладка перехватывается: снимок context.pages до клика, сравнение после; попап на нужном хосте становится ЯКОРНОЙ страницей (resource-block применяется к ней — по наследству он не передаётся), вкладка с Яндексом закрывается. Это и есть настоящий переход. Оба фиктивных фолбэка выброшены. Не нашли ссылку, клик увёл не туда, попап не на том хосте, капча, упавшая навигация — возвращаем None, и вызывающий идёт на origin обычным goto БЕЗ Referer, ровно как до #3258. Принцип зафиксирован комментарием в коде и на месте удалённой константы _YANDEX_REFERER, иначе его легко «оптимизировать» обратно: либо переход был настоящим, либо об источнике молчим. Тесты: 198 passed против 196 — попап на нужном хосте становится якорем и оригинал закрыт; попап на чужом хосте → фолбэк без Referer; тест «ссылки нет» переписан, он теперь требует отсутствия Referer вместо yandex.ru.
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""test_server_anchor_search.py — якорь заходит на origin ЧЕРЕЗ поиск yandex.ru (#3251).
|
||
|
||
Контекст: до этой правки якорная вкладка (_ensure_anchor_page, #3244) открывала
|
||
origin голым goto — без источника перехода вообще. Ручная сессия 29.08.2026
|
||
показала эталонный человеческий путь: yandex.ru → поиск → клик по результату →
|
||
выдача Домклика (Referer "https://yandex.ru/"). _navigate_anchor_via_search
|
||
воспроизводит именно этот первый шаг; переход дальше по карточке (referer на
|
||
целевую навигацию) уже сделан в #3250 и здесь не трогается.
|
||
|
||
camoufox НЕ запускается — context/page/link-элемент подделаны.
|
||
|
||
Запуск (из tradein-mvp/browser/, venv со aiohttp — см. CLAUDE.md сайдкара)::
|
||
|
||
python -m pytest test_server_anchor_search.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||
_spec = importlib.util.spec_from_file_location("tradein_browser_server_anchor", _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)
|
||
|
||
_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
|
||
_HOSTNAME = "ekaterinburg.domclick.ru"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state() -> None:
|
||
server._contexts = {}
|
||
server._anchor_pages = {}
|
||
|
||
|
||
class _FakeLink:
|
||
"""Поддельный результат выдачи: get_attribute/text_content/click как ElementHandle.
|
||
|
||
``new_tab_host`` эмулирует ссылку с ``target="_blank"``: клик не трогает
|
||
``url`` исходной страницы, а открывает НОВУЮ поддельную page в том же
|
||
context'е (как это делает настоящий Яндекс для части результатов выдачи).
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
page: "_FakeAnchorPage",
|
||
host: str,
|
||
land_host: str | None = None,
|
||
new_tab_host: str | None = None,
|
||
) -> None:
|
||
self._page = page
|
||
self._host = host
|
||
self._land_host = land_host or host
|
||
self._new_tab_host = new_tab_host
|
||
self.clicked = 0
|
||
|
||
async def get_attribute(self, name: str) -> str:
|
||
return f"https://{self._host}/some/redir-wrapper"
|
||
|
||
async def text_content(self) -> str:
|
||
return self._host
|
||
|
||
async def click(self, timeout: int | None = None) -> None:
|
||
self.clicked += 1
|
||
if self._new_tab_host is not None:
|
||
popup = _FakeAnchorPage()
|
||
popup.url = f"https://{self._new_tab_host}/landed"
|
||
popup.context = self._page.context
|
||
self._page.context.pages.append(popup)
|
||
self._page.opened_popup = popup
|
||
return
|
||
self._page.url = f"https://{self._land_host}/landed"
|
||
|
||
|
||
class _FakeAnchorPage:
|
||
"""Поддельная page: фиксирует goto (url+referer), эмулирует выдачу/капчу/клик."""
|
||
|
||
def __init__(
|
||
self,
|
||
link_host: str | None = None,
|
||
land_host: str | None = None,
|
||
captcha: bool = False,
|
||
fail_search_nav: bool = False,
|
||
new_tab_host: str | None = None,
|
||
) -> None:
|
||
self.goto_calls: list[dict[str, Any]] = []
|
||
self.url = ""
|
||
self.closed = 0
|
||
self.route_calls = 0
|
||
self.context: "_FakeAnchorContext | None" = None
|
||
self.opened_popup: "_FakeAnchorPage | None" = None
|
||
self._link_host = link_host
|
||
self._land_host = land_host
|
||
self._captcha = captcha
|
||
self._fail_search_nav = fail_search_nav
|
||
self._new_tab_host = new_tab_host
|
||
|
||
def is_closed(self) -> bool:
|
||
return self.closed > 0
|
||
|
||
async def route(self, pattern: str, handler: Any) -> None:
|
||
self.route_calls += 1
|
||
|
||
async def goto(self, url: str, **kwargs: Any) -> None:
|
||
if self._fail_search_nav and "yandex.ru/search" in url:
|
||
raise TimeoutError("yandex.ru недоступен")
|
||
self.goto_calls.append({"url": url, "referer": kwargs.get("referer")})
|
||
self.url = url
|
||
|
||
async def wait_for_timeout(self, ms: int) -> None:
|
||
return None
|
||
|
||
async def wait_for_load_state(self, state: str, timeout: int | None = None) -> None:
|
||
return None
|
||
|
||
async def content(self) -> str:
|
||
if "yandex.ru/search" in self.url:
|
||
if self._captcha:
|
||
return "<html>SmartCaptcha: подтвердите, что запросы отправляли вы</html>"
|
||
return "<html>выдача Яндекса</html>"
|
||
return "<html>карточка<script>window.__SSR_STATE__={}</script></html>"
|
||
|
||
async def query_selector_all(self, selector: str) -> list[_FakeLink]:
|
||
if self._link_host is None:
|
||
return []
|
||
return [_FakeLink(self, self._link_host, self._land_host, self._new_tab_host)]
|
||
|
||
async def close(self) -> None:
|
||
self.closed += 1
|
||
|
||
|
||
class _FakeAnchorContext:
|
||
"""Поддельный BrowserContext: new_page() отдаёт заранее сконструированную page.
|
||
|
||
``pages`` эмулирует ``BrowserContext.pages`` — список всех открытых вкладок
|
||
контекста, растущий при появлении попапа (target="_blank").
|
||
"""
|
||
|
||
def __init__(self, page: _FakeAnchorPage) -> None:
|
||
self._page = page
|
||
self.pages: list[_FakeAnchorPage] = []
|
||
|
||
async def new_page(self) -> _FakeAnchorPage:
|
||
self.pages.append(self._page)
|
||
self._page.context = self
|
||
return self._page
|
||
|
||
|
||
def _run_ensure_anchor(provider: str, origin: str, page: _FakeAnchorPage) -> bool:
|
||
server._contexts[provider] = _FakeAnchorContext(page)
|
||
return asyncio.run(server._ensure_anchor_page(provider, origin))
|
||
|
||
|
||
def test_anchor_via_search_visits_yandex_and_clicks_result() -> None:
|
||
"""domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост."""
|
||
page = _FakeAnchorPage(link_host=_HOSTNAME)
|
||
|
||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||
|
||
assert anchored is True
|
||
assert server._anchor_pages["domclick"] is page
|
||
assert any("yandex.ru/search" in c["url"] for c in page.goto_calls), "должен зайти на yandex.ru"
|
||
# Клик по ссылке сам довёл до хоста — повторного goto(origin) после него НЕ было.
|
||
assert not any(c["url"] == _ORIGIN for c in page.goto_calls)
|
||
assert page.url.startswith(f"https://{_HOSTNAME}/")
|
||
|
||
|
||
def test_captcha_falls_back_to_plain_goto_without_referer() -> None:
|
||
"""Капча на выдаче → тихий откат на прежнее поведение, без исключения."""
|
||
page = _FakeAnchorPage(link_host=_HOSTNAME, captcha=True)
|
||
|
||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||
|
||
assert anchored is True
|
||
assert server._anchor_pages["domclick"] is page
|
||
assert any("yandex.ru/search" in c["url"] for c in page.goto_calls)
|
||
origin_calls = [c for c in page.goto_calls if c["url"] == _ORIGIN]
|
||
assert len(origin_calls) == 1
|
||
assert origin_calls[0]["referer"] is None, "прежнее поведение — без referer вообще"
|
||
|
||
|
||
def test_no_link_in_serp_falls_back_without_referer() -> None:
|
||
"""Ссылки на хост в выдаче нет → перехода не было → goto(origin) БЕЗ referer.
|
||
|
||
До #3258 этот случай подставлял referer=yandex.ru, хотя перехода с
|
||
Яндекса на origin фактически не было — намеренная смена поведения.
|
||
"""
|
||
page = _FakeAnchorPage(link_host=None)
|
||
|
||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||
|
||
assert anchored is True
|
||
origin_calls = [c for c in page.goto_calls if c["url"] == _ORIGIN]
|
||
assert len(origin_calls) == 1
|
||
assert origin_calls[0]["referer"] is None
|
||
|
||
|
||
def test_provider_not_in_allowlist_skips_search_entirely() -> None:
|
||
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (дефолт только domclick) — поведение прежнее."""
|
||
origin = "https://www.avito.ru/ekaterinburg/kvartiry"
|
||
page = _FakeAnchorPage(link_host="www.avito.ru")
|
||
|
||
anchored = _run_ensure_anchor("avito", origin, page)
|
||
|
||
assert anchored is True
|
||
assert page.goto_calls == [{"url": origin, "referer": None}], "поиска быть не должно вовсе"
|
||
|
||
|
||
def test_yandex_navigation_failure_falls_back_and_anchor_still_raised() -> None:
|
||
"""Таймаут захода на yandex.ru → откат на прямой goto(origin), якорь поднят."""
|
||
page = _FakeAnchorPage(link_host=_HOSTNAME, fail_search_nav=True)
|
||
|
||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||
|
||
assert anchored is True
|
||
assert server._anchor_pages["domclick"] is page
|
||
assert page.goto_calls == [{"url": _ORIGIN, "referer": None}]
|
||
|
||
|
||
def test_click_opens_new_tab_on_target_host_becomes_anchor() -> None:
|
||
"""Ссылка в выдаче с target="_blank" открывает НОВУЮ вкладку на нужном хосте —
|
||
|
||
она и становится якорной, исходная вкладка с Яндексом закрывается,
|
||
_apply_resource_block применяется к новой вкладке (не унаследован).
|
||
"""
|
||
page = _FakeAnchorPage(link_host=_HOSTNAME, new_tab_host=_HOSTNAME)
|
||
|
||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||
|
||
assert anchored is True
|
||
popup = server._anchor_pages["domclick"]
|
||
assert popup is not page, "якорной должна стать НОВАЯ вкладка, а не исходная с Яндексом"
|
||
assert popup.url == f"https://{_HOSTNAME}/landed"
|
||
assert page.closed == 1, "исходная вкладка с Яндексом должна закрыться"
|
||
assert popup.route_calls > 0, "_apply_resource_block должен примениться к новой вкладке"
|
||
assert not any(c["url"] == _ORIGIN for c in popup.goto_calls), "был реальный клик, не goto"
|
||
|
||
|
||
def test_new_tab_wrong_host_falls_back_without_referer() -> None:
|
||
"""Новая вкладка открылась НЕ на нужном хосте → None → goto(origin) без referer."""
|
||
page = _FakeAnchorPage(link_host=_HOSTNAME, new_tab_host="another.example.ru")
|
||
|
||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||
|
||
assert anchored is True
|
||
assert server._anchor_pages["domclick"] is page, "якорь остаётся на исходной вкладке"
|
||
origin_calls = [c for c in page.goto_calls if c["url"] == _ORIGIN]
|
||
assert len(origin_calls) == 1
|
||
assert origin_calls[0]["referer"] is None
|
||
assert page.opened_popup is not None
|
||
assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт"
|