"""HTTP-статус сайдкара наверх: `BrowserFetcher.last_response_status` (#3196). Сайдкар (tradein-mvp/browser/server.py) теперь кладёт в тело /fetch HTTP-код целевой навигации рядом с html: ``{"html": ..., "status": }``. Kit выносит его на инстанс фетчера — АТРИБУТОМ, а не возвратом ``fetch()``: поток управления менять нельзя, ``fetch()`` по-прежнему отдаёт ``str`` и по-прежнему не бросает там, где не бросал раньше. Зачем: ДомКлик отдаёт статическую страницу «403 | Домклик» на 26 624 байта, где нет ни startpow, ни qrator, ни капчи — ни один текстовый маркер сайдкара (все сняты с Авито) на неё не срабатывает, и отказ уезжал наверх как валидный контент. 14 прогонов domclick_detail_backfill подряд получили ban_kind=unknown ровно поэтому. Инварианты: - status из тела → last_response_status (int) на КАЖДЫЙ успешный fetch; - ключа "status" нет (сайдкар старой версии) ИЛИ он null → None, БЕЗ исключения; - status нечислового типа → None (мусор в теле не должен ронять фетч); - fetch упал → last_response_status сброшен в None (не отдаём статус прошлого); - ban_kind_from_status раскладывает код в значение, допустимое CHECK-ограничением scrape_runs.ban_kind ("platform" | "infra" | "unknown" | NULL). httpx полностью замокан (зеркалит test_kit_browser_fetcher_proxy_pool.py). """ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock: """httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json.""" resp = MagicMock() if raise_exc is not None: resp.raise_for_status.side_effect = raise_exc else: resp.raise_for_status.return_value = None resp.json.return_value = json_payload client = MagicMock() client.post = AsyncMock(return_value=resp) client.aclose = AsyncMock(return_value=None) return client async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher: """Реально входит в `__aenter__`, потом подменяет httpx-клиент.""" bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs) await bf.__aenter__() bf._client = client return bf # ── last_response_status ────────────────────────────────────────────────────── async def test_status_starts_as_none() -> None: """До первого fetch статуса нет — атрибут существует и равен None.""" client = _mock_client({"html": "", "status": 200}) bf = await _fetcher(client, source="domclick") assert bf.last_response_status is None async def test_status_from_body_is_exposed() -> None: client = _mock_client({"html": "<403 page>", "status": 403}) bf = await _fetcher(client, source="domclick") html = await bf.fetch("https://domclick.ru/card/1") assert html == "<403 page>" # поток управления не изменился — fetch отдаёт str assert bf.last_response_status == 403 async def test_status_updated_on_every_fetch() -> None: """Атрибут обновляется КАЖДЫМ _post_fetch, а не только первым.""" client = _mock_client({"html": "", "status": 200}) bf = await _fetcher(client, source="domclick") await bf.fetch("https://domclick.ru/1") assert bf.last_response_status == 200 client.post.return_value.json.return_value = {"html": "<403>", "status": 403} await bf.fetch("https://domclick.ru/2") assert bf.last_response_status == 403 async def test_missing_status_key_is_none_and_does_not_raise() -> None: """Сайдкар старой версии (тело без "status") — фетч проходит, статуса просто нет.""" client = _mock_client({"html": ""}) bf = await _fetcher(client, source="avito") html = await bf.fetch("https://avito.ru/x") assert html == "" assert bf.last_response_status is None async def test_null_status_is_none() -> None: """goto вернул None (редирект/навигационная гонка) → сайдкар шлёт status=null.""" client = _mock_client({"html": "", "status": None}) bf = await _fetcher(client, source="avito") await bf.fetch("https://avito.ru/x") assert bf.last_response_status is None async def test_non_int_status_is_ignored() -> None: """Мусор в поле status не должен ронять фетч — читается как «статуса нет».""" client = _mock_client({"html": "", "status": "403"}) bf = await _fetcher(client, source="avito") await bf.fetch("https://avito.ru/x") assert bf.last_response_status is None async def test_status_reset_on_failed_fetch() -> None: """Фетч упал — не отдаём статус ПРОШЛОГО запроса.""" client = _mock_client({"html": "", "status": 200}) bf = await _fetcher(client, source="avito") await bf.fetch("https://avito.ru/1") assert bf.last_response_status == 200 client.post.side_effect = RuntimeError("transport down") with pytest.raises(RuntimeError): await bf.fetch("https://avito.ru/2") assert bf.last_response_status is None # ── ban_kind_from_status ────────────────────────────────────────────────────── @pytest.mark.parametrize( ("status", "expected"), [ (403, "platform"), (429, "platform"), (500, "infra"), (502, "infra"), (599, "infra"), (200, None), (301, None), (404, None), (None, None), ], ) def test_ban_kind_from_status(status: int | None, expected: str | None) -> None: assert ban_kind_from_status(status) == expected def test_ban_kind_values_fit_scrape_runs_check() -> None: """Возврат обязан быть пригоден для scrape_runs.ban_kind как есть.""" allowed = {"platform", "infra", "unknown", None} for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600): assert ban_kind_from_status(status) in allowed