gendesign/tradein-mvp/browser/test_server_behaviour.py
lekss361 7daeaa4b59
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m51s
Deploy Trade-In / deploy (push) Successful in 1m35s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
feat(tradein/browser): имитация чтения страницы человеком, целиком opt-in (#3283) (#3308)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-09-01 07:34:49 +00:00

349 lines
14 KiB
Python
Raw Permalink 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_behaviour.py — имитация чтения страницы человеком (#3283).
По умолчанию (body["behaviour"] отсутствует/пуст) поведение /fetch байт-в-байт то
же, что и до #3283 — вклад имитации в проходимость анти-бота пока НЕ доказан, и
прод должен уметь честно померить "с behaviour vs без". Проверяет:
1. behaviour отсутствует → ни page.mouse.wheel, ни доп. dwell-пауза, ни побочный
заход на рекомендательную ссылку НЕ вызываются;
2. behaviour.scroll=True → 3-6 вызовов page.mouse.wheel(0, 500-1400px);
3. behaviour.dwell_ms — валидный [min, max] отрабатывает; невалидный (не пара
int, min>max, отрицательные) — /fetch отвечает 400, как и невалидный
fetch_mode;
4. behaviour.hop_recommended — заходит на ОДНУ ссылку ТОГО ЖЕ хоста, отказ на
этом заходе не роняет основной ответ; ссылка с чужого хоста не берётся;
работает только при reuse_context=True.
5. body["behaviour"] не-dict → 400.
camoufox НЕ запускается — browser/context/page подделаны, в стиле
test_server_reuse_context.py.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_behaviour.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any
import pytest
from aiohttp.test_utils import make_mocked_request
_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)
def _json_body(response: Any) -> dict[str, Any]:
return json.loads(response.body.decode())
async def _coro(value: Any) -> Any:
return value
def _make_fetch_request(body: dict[str, Any]) -> Any:
request = make_mocked_request("POST", "/fetch")
request.json = lambda: _coro(body) # type: ignore[method-assign]
return request
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_contexts", {})
monkeypatch.setattr(server, "_anchor_pages", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER", {p: 10_000 for p in server.PROVIDERS}
)
# Джиттер пейсинга (#3283) не имеет отношения к behaviour — гасим, чтобы не
# добавлять произвольный real-time sleep в тест.
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {p: 0.0 for p in server.PROVIDERS})
_DEFAULT_HTML = "<html>ok<script>window.__SSR_STATE__={}</script></html>"
class _FakeMouse:
def __init__(self) -> None:
self.wheel_calls: list[tuple[int, int]] = []
async def wheel(self, dx: int, dy: int) -> None:
self.wheel_calls.append((dx, dy))
class _FakeLocator:
def __init__(self, hrefs: list[str]) -> None:
self._hrefs = hrefs
async def evaluate_all(self, _js: str) -> list[str]:
return self._hrefs
class _FakePage:
"""Поддельная page: goto/content/mouse/locator/context, минимальный контракт _fetch_once."""
def __init__(self, context: "_FakeContext", links: list[str] | None = None) -> None:
self.context = context
self.mouse = _FakeMouse()
self.goto_urls: list[str] = []
self.wait_for_timeout_calls: list[int] = []
self.closed = 0
self._links = links or []
def is_closed(self) -> bool:
return 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:
return _DEFAULT_HTML
def locator(self, selector: str) -> _FakeLocator:
return _FakeLocator(self._links)
async def close(self) -> None:
self.closed += 1
class _FailingHopPage(_FakePage):
"""Побочная (hop) страница, у которой goto всегда падает — для теста best-effort."""
async def goto(self, url: str, **kwargs: Any) -> None:
raise TimeoutError("hop nav timeout (simulated)")
class _FakeContext:
def __init__(self, links: list[str] | None = None, hop_page_cls: type = _FakePage) -> None:
self.add_cookies_calls: list[list[dict]] = []
self.pages: list[_FakePage] = []
self._links = links or []
self._hop_page_cls = hop_page_cls
self.closed = 0
async def add_cookies(self, cookies: list[dict]) -> None:
self.add_cookies_calls.append(cookies)
async def new_page(self) -> _FakePage:
# Первая страница — "основная" (несёт links для hop-детекции), последующие —
# побочные (hop) вкладки, открытые из _hop_to_recommended_link.
cls = _FakePage if not self.pages else self._hop_page_cls
page = cls(self, links=self._links)
self.pages.append(page)
return page
async def close(self) -> None:
self.closed += 1
class _FakeReuseBrowser:
def __init__(self, links: list[str] | None = None, hop_page_cls: type = _FakePage) -> None:
self.contexts_created: list[_FakeContext] = []
self._links = links or []
self._hop_page_cls = hop_page_cls
async def new_context(self) -> _FakeContext:
ctx = _FakeContext(links=self._links, hop_page_cls=self._hop_page_cls)
self.contexts_created.append(ctx)
return ctx
async def new_page(self) -> _FakePage:
# Не переиспользуемый путь (reuse_context=False) в этих тестах не участвует.
raise AssertionError("browser.new_page() не должен вызываться при reuse_context=True")
# ── 1. behaviour отсутствует — ни одна новая ветка не исполняется ────────────────
def test_behaviour_absent_is_noop() -> None:
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
html = asyncio.run(
server._fetch_once("avito", "https://www.avito.ru/1", reuse_context=True)
)
assert html == _DEFAULT_HTML
page = browser.contexts_created[0].pages[0]
assert page.mouse.wheel_calls == [], "без behaviour scroll не должен вызываться"
# Единственный wait_for_timeout — обязательный BROWSER_WAIT_MS settle, не dwell.
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
assert len(browser.contexts_created[0].pages) == 1, "hop_recommended не должен заходить"
# ── 2. scroll=True — 3-6 вызовов wheel в заданном диапазоне px ───────────────────
def test_scroll_true_triggers_wheel_calls_in_range() -> None:
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
behaviour = server._Behaviour(scroll=True, dwell_ms=None, hop_recommended=False)
asyncio.run(
server._fetch_once(
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
)
)
calls = browser.contexts_created[0].pages[0].mouse.wheel_calls
assert server.BROWSER_SCROLL_MIN_COUNT <= len(calls) <= server.BROWSER_SCROLL_MAX_COUNT
for dx, dy in calls:
assert dx == 0
assert server.BROWSER_SCROLL_MIN_PX <= dy <= server.BROWSER_SCROLL_MAX_PX
# ── 3. dwell_ms — валидация в fetch_handler (400, как fetch_mode) ────────────────
@pytest.mark.parametrize(
"dwell_ms",
[
[500], # не пара
[5000, 100], # min > max
[-10, 100], # отрицательное
["a", "b"], # не int
"500,1000", # вообще не список
],
)
def test_invalid_dwell_ms_returns_400(dwell_ms: Any) -> None:
request = _make_fetch_request(
{"url": "https://www.avito.ru/1", "behaviour": {"dwell_ms": dwell_ms}}
)
response = asyncio.run(server.fetch_handler(request))
assert response.status == 400
assert "dwell_ms" in _json_body(response)["error"]
def test_behaviour_not_dict_returns_400() -> None:
request = _make_fetch_request({"url": "https://www.avito.ru/1", "behaviour": "scroll"})
response = asyncio.run(server.fetch_handler(request))
assert response.status == 400
def test_valid_dwell_ms_applies_extra_wait() -> None:
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
behaviour = server._Behaviour(scroll=False, dwell_ms=(100, 100), hop_recommended=False)
asyncio.run(
server._fetch_once(
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
)
)
page = browser.contexts_created[0].pages[0]
# BROWSER_WAIT_MS settle + ровно 100мс dwell (min==max=100 — детерминировано).
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS, 100]
# ── 4. hop_recommended — same-host only, best-effort, требует reuse_context ──────
def test_hop_recommended_visits_same_host_link_only() -> None:
links = [
"https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_505_m_1216_et._8285475966", # == url, должен быть исключён
"https://sub.other.ru/kvartiry/foreign_1234567", # чужой хост, исключён
"https://www.avito.ru/ekaterinburg/kvartiry", # рубрика, не карточка — исключена
"https://www.avito.ru/rossiya/nedvizhimost", # навигация, исключена
"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_302_m_25_et._8242031370", # валидный кандидат
]
browser = _FakeReuseBrowser(links=links)
server._browsers["avito"] = browser
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
asyncio.run(
server._fetch_once("avito", "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_505_m_1216_et._8285475966", reuse_context=True, behaviour=behaviour)
)
ctx = browser.contexts_created[0]
assert len(ctx.pages) == 2, "должна открыться ровно одна побочная (hop) страница"
hop_page = ctx.pages[1]
assert hop_page.goto_urls == ["https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_302_m_25_et._8242031370"]
assert hop_page.closed == 1, "hop-страница должна закрыться"
def test_hop_recommended_skips_same_host_non_card_links() -> None:
"""#3283: одного host-фильтра мало — без шаблона URL хоп уводил бы на рубрику.
Шаблон Авито (путь с /kvartiry/ + id из 7+ цифр) снят на живом замере 31.08.2026.
"""
links = [
"https://www.avito.ru/ekaterinburg/kvartiry",
"https://www.avito.ru/ekaterinburg/kvartiry/prodam",
"https://www.avito.ru/company/about",
]
browser = _FakeReuseBrowser(links=links)
server._browsers["avito"] = browser
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
asyncio.run(
server._fetch_once("avito", "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_505_m_1216_et._8285475966", reuse_context=True, behaviour=behaviour)
)
assert len(browser.contexts_created[0].pages) == 1, "рубрика не карточка — хопа быть не должно"
def test_hop_recommended_skips_when_only_foreign_host_links() -> None:
links = ["https://sub.other.ru/kvartiry/foreign_1234567", "https://another.tld/kvartiry/x_7654321"]
browser = _FakeReuseBrowser(links=links)
server._browsers["avito"] = browser
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
asyncio.run(
server._fetch_once(
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
)
)
assert len(browser.contexts_created[0].pages) == 1, "чужой хост не должен открывать hop"
def test_hop_recommended_failure_does_not_break_main_response() -> None:
links = ["https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_302_m_25_et._8242031370"]
browser = _FakeReuseBrowser(links=links, hop_page_cls=_FailingHopPage)
server._browsers["avito"] = browser
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
html = asyncio.run(
server._fetch_once(
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
)
)
assert html == _DEFAULT_HTML, "отказ на hop не должен ронять/менять основной ответ"
def test_hop_recommended_noop_without_reuse_context() -> None:
"""hop_recommended=True, но reuse_context=False — hop не выполняется (нет context)."""
server._browsers["avito"] = object()
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
# _fetch_once без reuse_context идёт через browser.new_page() — подделаем browser.
class _PlainBrowser:
async def new_page(self) -> _FakePage:
return _FakePage(context=_FakeContext())
server._browsers["avito"] = _PlainBrowser()
html = asyncio.run(
server._fetch_once("avito", "https://www.avito.ru/1", behaviour=behaviour)
)
assert html == _DEFAULT_HTML