"""test_server_http_status.py — HTTP-статус целевой навигации в /fetch (#3196). Проблема, установленная замером на проде 28.08.2026: ДомКлик отдаёт статическую страницу «403 | Домклик» ровно на 26 624 байта, в которой НЕТ ни startpow, ни qrator, ни капчи, ни PoW-скрипта. Ни один маркер из _CHALLENGE_MARKERS/_BAN_MARKERS (оба сняты с авитовских страниц) в неё не попадает, поэтому отказ уезжал наверх как валидный контент, парсер не находил состояние страницы, и прогон получал блок неизвестной природы — 14 прогонов domclick_detail_backfill подряд с ban_kind=unknown. _fetch_once игнорировал Response, который возвращает page.goto. Теперь статус целевой навигации сохраняется в _last_response_status[provider] и отдаётся в теле /fetch рядом с html — АДДИТИВНО, ключ "html" не меняется. camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит test_server_pow_challenge.py). wait_for_timeout на фейковой page — no-op. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_http_status.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.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 состояние на каждый тест (зеркалит соседние тесты).""" 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, "_last_goto_at", {}) monkeypatch.setattr(server, "_last_response_status", {}) monkeypatch.setattr(server, "_launched_proxy", {}) monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) # Заглушка ДомКлика: 403 и НИ ОДНОГО маркера из _CHALLENGE_MARKERS/_BAN_MARKERS — # ровно то, на чём ломался старый детектор. _DOMCLICK_403_HTML = "403 | Домклик" _REAL_HTML = "listing card content" # Челлендж Авито: маркер startPow при HTTP 200 — путь, который трогать нельзя. _CHALLENGE_HTML = "" class _Response: """Поддельный playwright Response — интересует только .status.""" def __init__(self, status: int) -> None: self.status = status class _Page: """Поддельная page: goto отдаёт заданный Response, content() — заданный HTML. goto_results — по одному элементу на вызов goto (None = «playwright вернул None», навигационная гонка/редирект). После исчерпания повторяет последний. """ def __init__( self, html_sequence: list[str], goto_results: list[_Response | None], ) -> None: self._html_sequence = html_sequence self._goto_results = goto_results self._content_calls = 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) -> _Response | None: idx = min(len(self.goto_urls), len(self._goto_results) - 1) self.goto_urls.append(url) return self._goto_results[idx] async def wait_for_timeout(self, ms: int) -> None: self.wait_for_timeout_calls.append(ms) async def content(self) -> str: idx = min(self._content_calls, len(self._html_sequence) - 1) self._content_calls += 1 return self._html_sequence[idx] async def close(self) -> None: self.closed += 1 class _Browser: def __init__(self, page: _Page) -> None: self._page = page async def new_page(self) -> _Page: return self._page def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "avito") -> None: server._browsers[provider] = _Browser(page) monkeypatch.setattr( server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000), ) monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {}) monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) def _json_body(response: Any) -> dict[str, Any]: return json.loads(response.body.decode()) async def _coro(value: Any) -> Any: return value def _make_request(body: dict[str, Any]) -> Any: request = make_mocked_request("POST", "/fetch") request.json = lambda: _coro(body) # type: ignore[method-assign] return request # ── _status_of: чтение статуса ──────────────────────────────────────────────── def test_status_of_reads_response_status() -> None: assert server._status_of(_Response(403)) == 403 def test_status_of_none_response_is_none() -> None: """goto вернул None (редирект/навигационная гонка) — не падаем, статуса нет.""" assert server._status_of(None) is None # ── _fetch_once: статус пишется в per-provider состояние ────────────────────── def test_fetch_once_records_target_status(monkeypatch: pytest.MonkeyPatch) -> None: page = _Page([_DOMCLICK_403_HTML], [_Response(403)]) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("avito", "https://domclick.ru/card/1")) assert html == _DOMCLICK_403_HTML # контент отдан как раньше — правка аддитивна assert server._last_response_status["avito"] == 403 def test_fetch_once_status_none_when_goto_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: page = _Page([_REAL_HTML], [None]) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) assert html == _REAL_HTML assert server._last_response_status["avito"] is None def test_fetch_once_status_taken_from_target_not_origin(monkeypatch: pytest.MonkeyPatch) -> None: """Прогрев origin отдал 200, целевая навигация — 403. Наверх идёт 403.""" page = _Page([_DOMCLICK_403_HTML], [_Response(200), _Response(403)]) _install(monkeypatch, page) asyncio.run( server._fetch_once("avito", "https://domclick.ru/card/1", origin="https://domclick.ru/") ) assert page.goto_urls == ["https://domclick.ru/", "https://domclick.ru/card/1"] assert server._last_response_status["avito"] == 403 def test_fetch_once_resets_stale_status_before_navigation( monkeypatch: pytest.MonkeyPatch, ) -> None: """Статус прошлого запроса не должен пережить следующую навигацию.""" server._last_response_status["avito"] = 403 page = _Page([_REAL_HTML], [None]) _install(monkeypatch, page) asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) assert server._last_response_status["avito"] is None # ── статус в диагностике: 403/429 не ждут PoW ───────────────────────────────── def test_fetch_once_skips_pow_wait_on_403(monkeypatch: pytest.MonkeyPatch) -> None: """403 + маркеры челленджа — отказ, а не проверка: бюджет ожидания не тратим. Заглушку наверх НЕ отдаём: вернув её как валидный HTML, воскресили бы #3045 — парсер Авито получил бы страницу челленджа вместо карточки. """ page = _Page([_CHALLENGE_HTML], [_Response(403)]) _install(monkeypatch, page) monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000) with pytest.raises(server.BanPageDetectedError): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) assert page.wait_for_timeout_calls == [] # ни одного тика ожидания челленджа def test_fetch_once_skips_pow_wait_on_429(monkeypatch: pytest.MonkeyPatch) -> None: page = _Page([_CHALLENGE_HTML], [_Response(429)]) _install(monkeypatch, page) with pytest.raises(server.BanPageDetectedError): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) assert page.wait_for_timeout_calls == [] def test_fetch_once_still_waits_out_challenge_on_200(monkeypatch: pytest.MonkeyPatch) -> None: """HTTP 200 с PoW-заглушкой (путь Авито) не задет: ждём и получаем контент.""" page = _Page([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML], [_Response(200)]) _install(monkeypatch, page) html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) assert html == _REAL_HTML assert page.wait_for_timeout_calls # ожидание челленджа реально шло def test_fetch_once_ban_marker_still_raises_on_200(monkeypatch: pytest.MonkeyPatch) -> None: """Ветка _is_ban_page осталась: «проблема с IP» с кодом 200 — по-прежнему бан.""" page = _Page(["Доступ ограничен: проблема с IP"], [_Response(200)]) _install(monkeypatch, page) with pytest.raises(server.BanPageDetectedError): asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) # ── /fetch: статус доезжает в тело ответа ───────────────────────────────────── def test_fetch_handler_returns_status_next_to_html(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(server, "IS_PROD", False) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str: server._last_response_status[provider] = 403 return _DOMCLICK_403_HTML monkeypatch.setattr(server, "_ensure_browser", _ensure) monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"}))) assert response.status == 200 body = _json_body(response) assert body["html"] == _DOMCLICK_403_HTML # старый ключ не тронут assert body["status"] == 403 def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPatch) -> None: """Статуса нет (goto вернул None) — ответ не ломается, status=null.""" monkeypatch.setattr(server, "IS_PROD", False) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str: server._last_response_status[provider] = None return _REAL_HTML monkeypatch.setattr(server, "_ensure_browser", _ensure) monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) response = asyncio.run(server.fetch_handler(_make_request({"url": "https://www.avito.ru/x"}))) body = _json_body(response) assert body["html"] == _REAL_HTML assert body["status"] is None # ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ────────────────── def _fetch_handler_error_body( monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None ) -> tuple[int, dict]: monkeypatch.setattr(server, "IS_PROD", False) async def _ensure(provider: str, proxy_override: str | None = None) -> bool: return True async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str: server._last_response_status[provider] = upstream_status raise exc monkeypatch.setattr(server, "_ensure_browser", _ensure) monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"}))) return response.status, _json_body(response) def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None: """Бан-страница несёт ban_page + апстрим-статус. Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел — регрессия, которую #3237 внёс, а #3239 чинит. """ status, body = _fetch_handler_error_body( monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401 ) assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe assert body["ban_page"] is True assert body["status"] == 401 assert "BanPageDetectedError" in body["error"] def test_fetch_handler_transport_error_has_no_ban_page_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: """Обычный сбой — признака нет вовсе, клиент трактует его как раньше.""" status, body = _fetch_handler_error_body( monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None ) assert status == 500 assert "ban_page" not in body def test_fetch_handler_ban_page_without_status_stays_null( monkeypatch: pytest.MonkeyPatch, ) -> None: """Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764).""" _status, body = _fetch_handler_error_body( monkeypatch, server.BanPageDetectedError("бан-страница"), None ) assert body["ban_page"] is True assert body["status"] is None