gendesign/tradein-mvp/browser/test_server_anchor_search.py
bot-backend 0b97d64864
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
fix(tradein/browser): переход с Яндекса на площадку должен быть настоящим, а не заявленным
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.
2026-08-29 23:41:56 +03:00

257 lines
11 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_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, "попап не на том хосте должен быть закрыт"