gendesign/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py
bot-backend a5f60eaaa8
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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 1m32s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
test(#3288): фикстуры бан-страницы на 403 и 500, фальсифицируемые проверки текста; комментарий про путь текста
2026-09-06 02:38:29 +05:00

281 lines
13 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.

"""HTTP-статус сайдкара наверх: `BrowserFetcher.last_response_status` (#3196).
Сайдкар (tradein-mvp/browser/server.py) теперь кладёт в тело /fetch HTTP-код целевой
навигации рядом с html: ``{"html": ..., "status": <int|null>}``. 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 httpx
import pytest
from scraper_kit.browser_fetcher import (
BrowserFetcher,
SidecarBanPageError,
_raise_for_sidecar_status,
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": "<ok>", "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": "<ok>", "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": "<ok>"})
bf = await _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
assert bf.last_response_status is None
async def test_null_status_is_none() -> None:
"""goto вернул None (редирект/навигационная гонка) → сайдкар шлёт status=null."""
client = _mock_client({"html": "<ok>", "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": "<ok>", "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": "<ok>", "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
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
#
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
# 'platform', то есть ротация IP не запустилась бы вовсе.
def _sidecar_response(body: Any, status: int = 403) -> httpx.Response:
"""Ответ сайдкара. 403 — код подтверждённого бана с #3288 п.4 (был 500)."""
return httpx.Response(
status, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
resp = _sidecar_response(
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status == 401
def test_ban_page_legacy_500_still_recognised() -> None:
"""Сайдкар старого образа отдаёт тот же ban_page с кодом 500 — диагноз тот же.
tradein-browser собирается ОТДЕЛЬНЫМ образом и деплоится своим шагом, так что
после выката #3288 клиент часами работает со старым сайдкаром. Гейта по коду
нет намеренно: он бы в этот час уводил отказ площадки в инфра-ветку.
"""
resp = _sidecar_response(
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401},
status=500,
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status == 401
@pytest.mark.parametrize("sidecar_status", [403, 500])
def test_ban_page_message_has_no_httpx_preamble(sidecar_status: int) -> None:
"""Текст ошибки называет виновника: отказ ПЛОЩАДКИ, а не падение нашего сайдкара.
До #3288 п.4 текст начинался httpx-преамбулой «Server error '500 Internal Server
Error' for url 'http://tradein-browser:3000/fetch'» — и ровно она уезжала в лог
провайдера, в сообщение AvitoBlockedError/DomClickBlockedError и оттуда в
`scrape_runs.error` (mark_failed), отправляя разбор по ложному следу.
Кусаемся за «for url», а НЕ за «500»/«Server error»: у 403-го ответа преамбула
звучит «Client error '403 Forbidden' for url …», так что оба прежних утверждения
были зелёными и на старом коде — проверка не могла упасть. «for url» httpx
печатает при ЛЮБОМ коде, поэтому она кусается на обоих.
"""
resp = _sidecar_response(
{"error": "BanPageDetectedError: доступ ограничен", "ban_page": True, "status": 403},
status=sidecar_status,
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
message = str(excinfo.value)
assert "for url" not in message, f"httpx-преамбула вернулась в текст: {message}"
assert "бан-страницу" in message
assert "upstream 403" in message
def test_ban_page_error_is_httpx_status_error() -> None:
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
with pytest.raises(httpx.HTTPStatusError):
_raise_for_sidecar_status(resp)
def test_plain_500_stays_plain_status_error() -> None:
"""Настоящий сбой сайдкара остаётся 500-кой БЕЗ ban_page → инфра-диагноз, как раньше."""
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}, status=500)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_ban_page_without_status_gives_none() -> None:
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_ban_page_true_as_bool_status_is_rejected() -> None:
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_non_json_error_body_does_not_break() -> None:
resp = httpx.Response(
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_success_response_raises_nothing() -> None:
resp = httpx.Response(
200,
json={"html": "<html/>", "status": 200},
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
)
_raise_for_sidecar_status(resp)