gendesign/tradein-mvp/browser/test_server_smoke.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

478 lines
18 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_smoke.py — смоук для resilience-логики tradein-browser.
Полный браузер (camoufox subprocess) юнит-тестить непрактично, поэтому здесь
только смоук: (1) модуль импортируется; (2) /fetch и /login отдают 503 когда
браузер поставщика не поднят (прокси лежит) — критичный resilience-инвариант,
защищающий от crash-storm. camoufox в окружении теста НЕ нужен: launch замокан
no-op'ом, имитируя «прокси всё ещё недоступен».
Per-provider модель (#1793): состояние keyed по provider (avito/cian/yandex/
generic), доступ к инстансу сериализован _locks[provider]; разные поставщики
работают параллельно. Конкурентность проверяется в test_server.py.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_smoke.py -q
aiohttp.test_utils.make_mocked_request не поднимает реальный сервер/сокет —
хендлер вызывается напрямую, без event-loop-сервера.
"""
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.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)
def _json_body(response: Any) -> dict[str, Any]:
"""Достаёт JSON-тело из aiohttp Response (body — bytes после json_response)."""
return json.loads(response.body.decode())
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние + инициализированный _locks_guard на каждый тест."""
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())
def test_module_imports() -> None:
"""Смоук: модуль импортируется и экспонирует ключевые хендлеры."""
assert callable(server.fetch_handler)
assert callable(server.login_handler)
assert callable(server.health_handler)
def test_health_reports_browsers_not_ready() -> None:
"""/health всегда 200, browsers[<provider>]=False когда инстансы не подняты."""
request = make_mocked_request("GET", "/health")
response = asyncio.run(server.health_handler(request))
assert response.status == 200
body = _json_body(response)
assert body["status"] == "ok"
assert body["browsers"] == {
"avito": False,
"cian": False,
"yandex": False,
"generic": False,
"domclick": False,
}
def test_health_reports_partial_readiness() -> None:
"""/health отражает per-provider readiness: один поднят, остальные нет."""
server._browsers["avito"] = object()
request = make_mocked_request("GET", "/health")
response = asyncio.run(server.health_handler(request))
body = _json_body(response)
assert body["browsers"]["avito"] is True
assert body["browsers"]["cian"] is False
def test_fetch_returns_503_when_browser_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""/fetch отдаёт 503 (не 500/краш) когда инстанс поставщика не поднят.
Имитирует «прокси лежит»: _ensure_browser no-op, оставляющий инстанс None.
Никакого реального camoufox-launch'а / direct-fetch.
"""
async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool:
return False # прокси всё ещё недоступен → браузер остаётся None
monkeypatch.setattr(server, "_ensure_browser", _no_ensure)
request = make_mocked_request("POST", "/fetch")
async def _run() -> Any:
request.json = lambda: _coro({"url": "https://www.avito.ru/x"}) # type: ignore[method-assign]
return await server.fetch_handler(request)
response = asyncio.run(_run())
assert response.status == 503
body = _json_body(response)
assert "browser unavailable" in body["error"]
def test_login_returns_503_when_browser_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""/login тоже отдаёт 503 когда инстанс не поднят (без credential-логирования)."""
async def _no_ensure(provider: str, proxy_override: str | None = None) -> bool:
return False
monkeypatch.setattr(server, "_ensure_browser", _no_ensure)
login_body = {
"url": "https://www.cian.ru/login",
"email": "u@example.com",
"password": "secret",
"email_selector": "#email",
"password_selector": "#password",
"submit_selector": "#submit",
}
request = make_mocked_request("POST", "/login")
async def _run() -> Any:
request.json = lambda: _coro(login_body) # type: ignore[method-assign]
return await server.login_handler(request)
response = asyncio.run(_run())
assert response.status == 503
body = _json_body(response)
assert "browser unavailable" in body["error"]
# ── per-provider state isolation ─────────────────────────────────────────────────
class _FakePage:
"""Поддельная page: считает open/close и отмечает пик параллелизма."""
def __init__(self, tracker: "_Tracker") -> None:
self._tracker = tracker
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self._tracker.active += 1
self._tracker.peak = max(self._tracker.peak, self._tracker.active)
await asyncio.sleep(0.02)
self._tracker.active -= 1
async def wait_for_timeout(self, ms: int) -> None:
return None
async def content(self) -> str:
return "<html>ok</html>"
async def close(self) -> None:
self._tracker.closed += 1
class _FakeBrowser:
def __init__(self, tracker: "_Tracker") -> None:
self._tracker = tracker
async def new_page(self) -> _FakePage:
self._tracker.opened += 1
return _FakePage(self._tracker)
class _Tracker:
def __init__(self) -> None:
self.opened = 0
self.closed = 0
self.active = 0
self.peak = 0
def _make_fetch_request(url: str = "https://www.avito.ru/x") -> Any:
request = make_mocked_request("POST", "/fetch")
request.json = lambda: _coro({"url": url}) # type: ignore[method-assign]
return request
def test_fetch_opens_and_closes_own_page(monkeypatch: pytest.MonkeyPatch) -> None:
"""Один /fetch открывает ровно одну собственную страницу и закрывает её."""
tracker = _Tracker()
server._browsers["avito"] = _FakeBrowser(tracker)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
response = asyncio.run(server.fetch_handler(_make_fetch_request()))
assert response.status == 200
assert _json_body(response)["html"] == "<html>ok</html>"
assert tracker.opened == 1
assert tracker.closed == 1
# ── _relaunch resets per-provider page counter (recycle) ─────────────────────────
def test_fetch_recycles_after_threshold(monkeypatch: pytest.MonkeyPatch) -> None:
"""Достижение recycle-порога → _relaunch_browser + сброс счётчика.
#3205: порог теперь per-provider (_RECYCLE_PAGES_BY_PROVIDER), патчим ключ avito.
"""
tracker = _Tracker()
server._browsers["avito"] = _FakeBrowser(tracker)
server._page_counters["avito"] = 0
monkeypatch.setitem(server._RECYCLE_PAGES_BY_PROVIDER, "avito", 1)
relaunched = {"n": 0}
async def _fake_relaunch(provider: str) -> None:
relaunched["n"] += 1
server._page_counters[provider] = 0
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch)
monkeypatch.setattr(server, "_ensure_browser", _ensure)
asyncio.run(server.fetch_handler(_make_fetch_request()))
assert relaunched["n"] == 1, "recycle должен сработать на пороге"
assert server._page_counters["avito"] == 0
# ── _login_once closes page on CancelledError (BaseException leak fix) ────────────
class _CancellingLoginPage:
"""Page для /login, которая бросает CancelledError внутри логина."""
def __init__(self, tracker: "_Tracker") -> None:
self._tracker = tracker
self.url = "https://www.cian.ru/login"
async def goto(self, url: str, **kwargs: Any) -> None:
return None
async def wait_for_timeout(self, ms: int) -> None:
return None
async def fill(self, selector: str, value: str, **kwargs: Any) -> None:
raise asyncio.CancelledError()
async def close(self) -> None:
self._tracker.closed += 1
class _CancellingLoginBrowser:
def __init__(self, tracker: "_Tracker") -> None:
self._tracker = tracker
async def new_page(self) -> _CancellingLoginPage:
self._tracker.opened += 1
return _CancellingLoginPage(self._tracker)
def test_login_closes_page_on_cancelled_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_login_once закрывает страницу даже при CancelledError (finally, не except)."""
tracker = _Tracker()
server._browsers["cian"] = _CancellingLoginBrowser(tracker)
params = {
"url": "https://www.cian.ru/login",
"email": "u@example.com",
"password": "secret",
"email_selector": "#email",
"password_selector": "#password",
"submit_selector": "#submit",
}
with pytest.raises(asyncio.CancelledError):
asyncio.run(server._login_once("cian", params))
assert tracker.opened == 1
assert tracker.closed == 1, "страница должна закрыться в finally при CancelledError"
# ── _fetch_once: origin-goto ТОЛЬКО когда передан (DomClick anti-bot, #2429-ish) ──
# Зеркалит _fetch_json_once (#1917): когда origin передан, камуфокс сначала заходит
# на origin (same-site якорь, напр. SERP), затем на целевой url — органическая
# навигация вместо холодного goto. origin=None (дефолт, все providers кроме
# domclick detail) должен остаться РОВНО одним goto(url), без регрессии.
class _FakeCookieContext:
"""Поддельный BrowserContext: фиксирует add_cookies-вызовы (порядок + payload)."""
def __init__(self) -> None:
self.add_cookies_calls: list[list[dict]] = []
async def add_cookies(self, cookies: list[dict]) -> None:
self.add_cookies_calls.append(cookies)
class _OriginTrackingPage:
"""Поддельная page: фиксирует ВСЕ goto-URL по порядку вызова + cookie-инъекции."""
def __init__(self) -> None:
self.goto_urls: list[str] = []
self.closed = 0
self.context = _FakeCookieContext()
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:
return None
async def content(self) -> str:
return "<html>ok</html>"
async def close(self) -> None:
self.closed += 1
class _OriginTrackingBrowser:
def __init__(self, page: _OriginTrackingPage) -> None:
self._page = page
async def new_page(self) -> _OriginTrackingPage:
return self._page
def test_fetch_once_navigates_origin_then_url_when_provided(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""origin передан → ДВА goto по порядку: origin первым, затем url."""
page = _OriginTrackingPage()
server._browsers["avito"] = _OriginTrackingBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
html = asyncio.run(
server._fetch_once(
"avito", "https://www.avito.ru/card/1", origin="https://www.avito.ru/serp"
)
)
assert html == "<html>ok</html>"
assert page.goto_urls == ["https://www.avito.ru/serp", "https://www.avito.ru/card/1"]
assert page.closed == 1
def test_fetch_once_single_goto_when_origin_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""origin не передан (дефолт, avito/cian/yandex) → РОВНО один goto(url), без regressии."""
page = _OriginTrackingPage()
server._browsers["avito"] = _OriginTrackingBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == "<html>ok</html>"
assert page.goto_urls == ["https://www.avito.ru/card/1"]
# ── _fetch_once: cookies-инъекция ТОЛЬКО когда передана (DomClick QRATOR-bypass) ──
# Провайдер-агностично: domain НЕ захардкожен ".domclick.ru" — выводится из
# urlparse(url).hostname (с ведущей точкой) прямо в _fetch_once, поэтому механизм
# годится для будущих caller'ов (Cian/Avito/Yandex), не только DomClick.
# cookies=None (дефолт, все providers сейчас) должен остаться без add_cookies-вызова.
def test_fetch_once_injects_cookies_before_goto_with_derived_domain(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""cookies передан → context.add_cookies() ДО goto(url), domain = "." + hostname."""
page = _OriginTrackingPage()
server._browsers["cian"] = _OriginTrackingBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
html = asyncio.run(
server._fetch_once(
"cian",
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
cookies={"CAS_ID": "12345", "qrator_jsid2": "abc"},
)
)
assert html == "<html>ok</html>"
assert len(page.context.add_cookies_calls) == 1
injected = page.context.add_cookies_calls[0]
assert {
"name": "CAS_ID",
"value": "12345",
"domain": ".ekaterinburg.domclick.ru",
"path": "/",
} in injected
assert {
"name": "qrator_jsid2",
"value": "abc",
"domain": ".ekaterinburg.domclick.ru",
"path": "/",
} in injected
# Инъекция ДО навигации на целевой url — единственный goto остаётся ровно url.
assert page.goto_urls == ["https://ekaterinburg.domclick.ru/card/sale__flat__1"]
def test_fetch_once_no_cookie_injection_when_cookies_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""cookies=None (дефолт) → add_cookies НЕ вызывается, поведение не меняется."""
page = _OriginTrackingPage()
server._browsers["avito"] = _OriginTrackingBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert page.context.add_cookies_calls == []
def test_fetch_once_cookie_domain_derivation_is_generic_not_domclick_hardcoded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Domain derivation работает для ЛЮБОГО хоста, не только domclick.ru.
Доказывает, что механизм провайдер-агностичен: hardcode ".domclick.ru" НЕ
используется — тот же код с avito.ru url выводит ".www.avito.ru".
"""
page = _OriginTrackingPage()
server._browsers["avito"] = _OriginTrackingBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
asyncio.run(
server._fetch_once(
"avito", "https://www.avito.ru/items/1", cookies={"sessionid": "xyz"}
)
)
injected = page.context.add_cookies_calls[0]
assert injected == [
{"name": "sessionid", "value": "xyz", "domain": ".www.avito.ru", "path": "/"}
]
async def _coro(value: Any) -> Any:
"""Хелпер: оборачивает значение в awaitable для подмены request.json()."""
return value