gendesign/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py
bot-backend d362b16d7c
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m40s
fix(tradein/proxy): доводить сигнал бана площадки до пула (#2600 п.1) (#2653)
2026-08-05 11:37:35 +00:00

642 lines
31 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.

"""Тесты browser-пула для kit `scraper_kit.browser_fetcher.BrowserFetcher` (#2164 P4).
Sticky-session fix (живая регрессия 2026-08): раньше `BrowserFetcher` брал/отпускал
lease из пула НА КАЖДЫЙ `/fetch` — при N>=2 живых узлах пула `acquire()` (ORDER BY
last_ok_at NULLS LAST — «давно не использованный первый») гарантированно выдавал
ДРУГОЙ прокси на каждый вызов, а tradein-browser релончит camoufox при каждой смене
желаемого прокси (см. server.py `_ensure_browser`) — 17 relaunch'ей за 15 минут в
проде. Фикс — ОДИН lease на весь жизненный цикл фетчера (см. __aenter__/__aexit__).
Инвариант ship-dark/fallback (не изменился):
- use_pool=False (дефолт) → в теле POST /fetch НЕТ поля "proxy"; proxy_provider не
трогается (golden-parity: браузер юзает свой env-прокси BROWSER_PROXY_*).
- use_pool=True + пул выдал lease → тело содержит "proxy"=lease.url + "proxy_kind";
на выходе mark_health(ok) + release (в finally — lease не течёт).
- use_pool=True + пул пуст (acquire→None) + dev (дефолт) → тело без "proxy", НЕ падаем.
- use_pool=True + пул пуст (acquire→None) + prod (#2616 шаг 1) → NoProxyAvailableError
на initial acquire (__aenter__) ИЛИ на ре-acquire mid-run (осознанная ротация после
N подряд провалов) — POST /fetch НЕ отправляется вовсе (ни на входе, ни после отказа).
- use_pool=True + acquire бросил → fallback без "proxy" (dev), НЕ падаем.
- fetch кинул → mark_health(ok=False) + release всё равно (finally).
Новый инвариант (sticky session, #2164 sticky-session fix):
- acquire() вызывается РОВНО ОДИН раз — в __aenter__, а НЕ на каждый /fetch.
- Несколько /fetch подряд в одной сессии несут ОДИН и тот же lease.url.
- release() — один раз в __aexit__ (finally — lease не течёт даже при исключении
внутри `async with`-блока).
- mark_health + touch (heartbeat, продлевает leased_at — см. proxy_pool.touch) —
на КАЖДЫЙ /fetch (успешный и неуспешный), та же грануляция, что была раньше.
- N подряд неудачных /fetch (_LEASE_ROTATE_AFTER_FAILS) → lease ОСОЗНАННО меняется
один раз (release старого + acquire нового), счётчик обнуляется; успех сбрасывает
счётчик до этого порога.
httpx полностью замокан: fetcher._client подменяется MagicMock'ом после __aenter__.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ProxyLease
from scraper_kit.proxy_errors import NoProxyAvailableError
# _LEASE_ROTATE_AFTER_FAILS не существовал до sticky-session фикса (#2164, 2026-08) —
# импортируем с фолбэком, чтобы ImportError не ронял КОЛЛЕКЦИЮ всего модуля (маскируя
# value-based падения остальных тестов на pre-fix коде). Тесты, которым он реально
# нужен (rotate-after-N-fails), падают на своём собственном месте, если его нет.
try:
from scraper_kit.browser_fetcher import _LEASE_ROTATE_AFTER_FAILS
except ImportError:
_LEASE_ROTATE_AFTER_FAILS = 3 # dummy — тесты ниже провалятся по существу, не по импорту
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) # __aexit__ awaits это при закрытии сессии
return client
class _FakeProxyProvider:
"""ProxyProvider-заглушка: acquire отдаёт lease(ы) по очереди, считает вызовы."""
def __init__(
self,
leases: ProxyLease | None | list[ProxyLease | None],
*,
acquire_raises: bool = False,
) -> None:
self._queue: list[ProxyLease | None] = (
list(leases) if isinstance(leases, list) else [leases]
)
self._acquire_raises = acquire_raises
self.acquired: list[str] = []
self.released: list[int] = []
self.health: list[tuple[int, bool]] = []
self.touched: list[int] = []
self.banned: list[tuple[int, str]] = []
def acquire(self, provider: str) -> ProxyLease | None:
self.acquired.append(provider)
if self._acquire_raises:
raise RuntimeError("pool boom")
if self._queue:
return self._queue.pop(0)
return None
def release(self, lease: ProxyLease) -> None:
self.released.append(lease.id)
def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None:
self.health.append((lease.id, ok))
def touch(self, lease: ProxyLease) -> None:
self.touched.append(lease.id)
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
self.banned.append((lease.id, source))
async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher:
"""Реально входит в `__aenter__` (триггерит lease-acquire), потом подменяет httpx-клиент."""
bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs)
await bf.__aenter__()
bf._client = client
return bf
# ── ship-dark / fallback parity (не изменилось) ───────────────────────────────
async def test_fetch_pool_off_no_proxy_in_body() -> None:
"""use_pool=False (дефолт) → тело без 'proxy', provider не трогается вообще (parity)."""
provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http"))
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.acquired == [] # пул не трогали даже в __aenter__
async def test_empty_pool_falls_back_no_proxy_ever() -> None:
"""use_pool=True + пул пуст (acquire→None в __aenter__) → без 'proxy', НЕ падаем."""
provider = _FakeProxyProvider(None)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
await bf.fetch("https://cian.ru/1")
await bf.fetch("https://cian.ru/2")
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.acquired == ["cian"] # одна попытка (в __aenter__), не на каждый fetch
assert provider.released == []
assert provider.health == []
assert provider.touched == []
async def test_acquire_error_in_aenter_falls_back() -> None:
"""acquire в __aenter__ бросил → fallback без 'proxy', сбор НЕ падает."""
provider = _FakeProxyProvider(None, acquire_raises=True)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="yandex", proxy_provider=provider, use_pool=True)
html = await bf.fetch("https://yandex.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.released == []
# ── sticky session: ОДИН lease на весь жизненный цикл (ключевой фикс) ─────────
async def test_acquire_happens_once_in_aenter_not_per_fetch() -> None:
"""acquire() вызывается РОВНО один раз при входе в сессию, до первого /fetch."""
lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
assert provider.acquired == ["avito"] # уже случилось в __aenter__
await bf.fetch("https://avito.ru/x")
body = client.post.call_args.kwargs["json"]
assert body["proxy"] == "http://u:p@pool:8080"
assert body["proxy_kind"] == "http"
assert provider.acquired == ["avito"] # fetch НЕ вызвал acquire повторно
async def test_two_sequential_fetches_use_same_proxy() -> None:
"""Два /fetch подряд в одной сессии несут ОДИН и тот же lease.url (было: КАЖДЫЙ
/fetch мог получить ДРУГОЙ прокси — camoufox релончился на каждый запрос)."""
lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
await bf.fetch("https://avito.ru/1")
body1 = client.post.call_args.kwargs["json"]
await bf.fetch("https://avito.ru/2")
body2 = client.post.call_args.kwargs["json"]
assert body1["proxy"] == body2["proxy"] == "http://u:p@pool:8080"
assert provider.acquired == ["avito"] # ровно один acquire на всю сессию
async def test_lease_released_on_session_close() -> None:
"""release() случается один раз, в __aexit__ — не раньше (внутри сессии lease жив)."""
lease = ProxyLease(id=7, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
bf = BrowserFetcher(
endpoint="http://browser:3000", source="avito", proxy_provider=provider, use_pool=True
)
async with bf:
bf._client = client
await bf.fetch("https://avito.ru/x")
assert provider.released == [] # ещё держим lease внутри сессии
assert provider.released == [7] # __aexit__ отпустил
async def test_lease_released_on_exception_inside_session() -> None:
"""Исключение внутри `async with`-блока (бизнес-код упал) → __aexit__ всё равно
вызывается Python'ом → lease освобождён, НЕ течёт."""
lease = ProxyLease(id=9, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
with pytest.raises(RuntimeError, match="business logic exploded"):
async with BrowserFetcher(
endpoint="http://browser:3000", source="avito", proxy_provider=provider, use_pool=True
) as bf:
bf._client = client
raise RuntimeError("business logic exploded mid-session")
assert provider.released == [9]
# ── mark_health / touch — на каждый /fetch (грануляция сохранена) ─────────────
async def test_mark_health_and_touch_called_per_fetch() -> None:
"""mark_health + touch(heartbeat) идут на КАЖДЫЙ /fetch — та же грануляция, что
была на per-fetch acquire/release раньше (не огрублена до «раз в сессию»)."""
lease = ProxyLease(id=5, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
await bf.fetch("https://avito.ru/1")
await bf.fetch("https://avito.ru/2")
await bf.fetch("https://avito.ru/3")
assert provider.health == [(5, True), (5, True), (5, True)]
assert provider.touched == [5, 5, 5]
async def test_fetch_error_marks_health_false_but_keeps_lease_for_session() -> None:
"""fetch кинул → mark_health(False) + touch, но lease НЕ освобождается сразу — он
держится до конца сессии (release только в __aexit__ или при осознанной ротации)."""
lease = ProxyLease(id=3, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
# raise_for_status кидает НЕ-httpx ошибку → fetch() не ретраит, пробрасывает наверх.
client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/x")
assert provider.health == [(3, False)]
assert provider.touched == [3]
assert provider.released == [] # сессия ещё жива — lease держим
# ── осознанная ротация после N подряд провалов (не мечемся на каждый fetch) ───
async def test_lease_not_rotated_before_threshold() -> None:
"""N-1 подряд провалов — ЕЩЁ недостаточно для смены lease."""
lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http")
lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http")
provider = _FakeProxyProvider([lease1, lease2])
client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/x")
assert provider.released == [] # ещё не порог
assert provider.acquired == ["avito"] # всё ещё только __aenter__-acquire
async def test_lease_rotates_once_after_consecutive_failures() -> None:
"""Ровно `_LEASE_ROTATE_AFTER_FAILS` подряд неудач → release+acquire РОВНО один
раз (не на каждый fetch). Следующий /fetch уже несёт НОВЫЙ прокси."""
lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http")
lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http")
provider = _FakeProxyProvider([lease1, lease2])
fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
bf = await _fetcher(fail_client, source="avito", proxy_provider=provider, use_pool=True)
for _ in range(_LEASE_ROTATE_AFTER_FAILS):
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/x")
assert provider.released == [1] # старый lease отпущен ровно один раз
assert provider.acquired == ["avito", "avito"] # __aenter__ + одна ротация — не N+1
ok_client = _mock_client({"html": "<ok>"})
bf._client = ok_client
await bf.fetch("https://avito.ru/y")
body = ok_client.post.call_args.kwargs["json"]
assert body["proxy"] == "http://pool2:8080" # уже НОВЫЙ lease
async def test_success_resets_fail_streak() -> None:
"""Успех между провалами сбрасывает счётчик — (N-1) провал + успех + (N-1) провал
НЕ должны суммироваться в ротацию (иначе редкие транзиентные ошибки на в целом
здоровом прокси гоняли бы ротацию так же часто, как раньше гоняли relaunch)."""
lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http")
lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http")
provider = _FakeProxyProvider([lease1, lease2])
ok_client = _mock_client({"html": "<ok>"})
bf = await _fetcher(ok_client, source="avito", proxy_provider=provider, use_pool=True)
fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
bf._client = fail_client
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/x")
bf._client = ok_client
await bf.fetch("https://avito.ru/y") # успех — сбрасывает streak
bf._client = fail_client
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/z")
assert provider.released == [] # порог так и не достигнут подряд
# ── fetch_json — тот же sticky-session инвариант ──────────────────────────────
async def test_fetch_json_pool_on_injects_proxy_from_session_lease() -> None:
lease = ProxyLease(id=9, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"status": 200, "body": "{}"})
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
await bf.fetch_json(
"https://avito.ru/api", method="POST", body="{}", origin="https://avito.ru/"
)
body = client.post.call_args.kwargs["json"]
assert body["proxy"] == "http://pool:8080"
assert body["proxy_kind"] == "http"
assert provider.touched == [9]
assert provider.health == [(9, True)]
assert provider.released == [] # сессия ещё жива
async def test_fetch_json_pool_off_no_proxy() -> None:
"""use_pool=False → fetch_json без 'proxy' в теле (parity)."""
client = _mock_client({"status": 200, "body": "{}"})
bf = await _fetcher(client, source="avito")
await bf.fetch_json("https://avito.ru/api")
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
# ── #2616 шаг 1: пул пуст в prod → отказ, НЕ мёртвый env-фолбэк ────────────────
#
# Sticky-session fix переместил acquire с "на каждый /fetch" на "__aenter__ (+ ре-acquire
# на осознанной ротации после N подряд провалов)" — guard живёт в _acquire_lease(), общей
# для ОБОИХ call-site'ов, так что отказ теперь может случиться либо при входе в сессию
# (_fetcher/__aenter__), либо mid-run (внутри fetch(), при ротации).
async def test_fetch_pool_empty_dev_falls_back_no_proxy() -> None:
"""Пул пуст + dev (дефолт environment) → прежнее поведение: тело без 'proxy'."""
provider = _FakeProxyProvider(None)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
html = await bf.fetch("https://cian.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.acquired == ["cian"]
async def test_fetch_pool_empty_prod_refuses_on_initial_acquire() -> None:
"""Пул пуст + prod на INITIAL acquire (__aenter__) → NoProxyAvailableError, POST
/fetch НЕ отправляется вовсе — отказ случается ДО входа в сессию.
client.post настроен падать AssertionError на ЛЮБОМ вызове — если бы код тихо
зафолбэчился (регрессия), тест упал бы с несовпадающим типом исключения, а не
просто "прошёл иначе" (falsifiable).
"""
provider = _FakeProxyProvider(None)
client = MagicMock()
client.post = AsyncMock(side_effect=AssertionError("POST /fetch must NOT happen"))
with pytest.raises(NoProxyAvailableError):
await _fetcher(
client,
source="avito",
proxy_provider=provider,
use_pool=True,
environment="production",
)
client.post.assert_not_called()
assert provider.acquired == ["avito"]
assert provider.released == []
assert provider.health == []
async def test_fetch_json_pool_empty_prod_refuses_on_initial_acquire() -> None:
"""Та же гарантия для fetch_json-путей: prod + пул пуст на __aenter__ → отказ, без
POST (guard общий для fetch/fetch_json — обе идут через один и тот же lease)."""
provider = _FakeProxyProvider(None)
client = MagicMock()
client.post = AsyncMock(side_effect=AssertionError("POST /fetch-json must NOT happen"))
with pytest.raises(NoProxyAvailableError):
await _fetcher(
client,
source="cian",
proxy_provider=provider,
use_pool=True,
environment="production",
)
client.post.assert_not_called()
async def test_fetch_pool_lease_prod_unaffected() -> None:
"""Пул выдал lease в prod — поведение БЕЗ ИЗМЕНЕНИЙ (это не случай отказа)."""
lease = ProxyLease(id=11, url="http://u:p@pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(
client, source="avito", proxy_provider=provider, use_pool=True, environment="production"
)
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert body["proxy"] == lease.url
assert provider.health == [(11, True)]
assert provider.released == [] # сессия ещё жива — release только в __aexit__
async def test_fetch_pool_empties_mid_run_prod_refuses_on_rotate_reacquire() -> None:
"""Пул опустел НА РЕ-ACQUIRE mid-run (осознанная ротация после N подряд провалов)
в prod → тот же отказ NoProxyAvailableError, а НЕ тихий переход на без-proxy.
Единственный lease выдаётся на initial acquire; на N-й подряд провал ротация
release()'ит его и повторно зовёт acquire() — очередь уже пуста → guard в
_acquire_lease() поднимает NoProxyAvailableError вместо возврата None. Он
вытесняет исходный ValueError (chained via __context__, не проглочен).
"""
lease = ProxyLease(id=1, url="http://pool1:8080", kind="http")
provider = _FakeProxyProvider([lease]) # ре-acquire после ротации застанет пустой пул
fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
bf = await _fetcher(
fail_client,
source="avito",
proxy_provider=provider,
use_pool=True,
environment="production",
)
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/x")
# N-й подряд провал: ValueError → _report_fetch_result(False) → порог достигнут →
# release(1) + re-acquire → пул пуст + prod → NoProxyAvailableError вместо ValueError.
with pytest.raises(NoProxyAvailableError):
await bf.fetch("https://avito.ru/x")
assert provider.released == [1] # старый lease отпущен ровно один раз
assert provider.acquired == ["avito", "avito"] # __aenter__ + одна попытка ротации
assert bf._lease is None # #2616: не залипает на уже отпущенном lease
def test_no_proxy_error_distinguishable_from_site_block() -> None:
"""NoProxyAvailableError — отдельный тип с provider-атрибутом, текст без
"blocked"/"captcha"/"banned" — programmatically отличим от бана площадкой.
"""
err = NoProxyAvailableError("avito")
assert err.provider == "avito"
lowered = str(err).lower()
assert "blocked" not in lowered
assert "captcha" not in lowered
assert "banned" not in lowered
assert "no proxy available" in lowered
# ── fetch() origin passthrough (DomClick SERP-anchor, зеркалит fetch_json) ───────
async def test_fetch_without_origin_sends_none() -> None:
"""fetch(url) без origin → payload содержит "origin": None (parity avito/cian/yandex).
Ни один провайдер кроме domclick не передаёт origin — сервер (body.get("origin"))
трактует None как «origin-goto не выполнять», поведение не меняется.
"""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert body["origin"] is None
async def test_fetch_with_origin_sends_it_in_payload() -> None:
"""fetch(url, origin=X) → payload несёт origin=X (domclick vtorichka-SERP anchor)."""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="cian")
await bf.fetch(
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
)
body = client.post.call_args.kwargs["json"]
assert body["origin"] == "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
# ── fetch() cookies passthrough (DomClick cookie-injection, #2000) ───────────────
async def test_fetch_without_cookies_sends_none() -> None:
"""fetch(url) без cookies → payload содержит "cookies": None (parity avito/cian/yandex).
Ни один провайдер кроме domclick debug-fetch не передаёт cookies — сервер
(body.get("cookies")) трактует None как «инъекции не делать», поведение не меняется.
"""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert body["cookies"] is None
async def test_fetch_with_cookies_sends_them_in_payload() -> None:
"""fetch(url, cookies=X) → payload несёт cookies=X (DomClick session cookie-injection)."""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="cian")
session_cookies = {"CAS_ID": "12345", "qrator_jsid2": "abc"}
await bf.fetch(
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
cookies=session_cookies,
)
body = client.post.call_args.kwargs["json"]
assert body["cookies"] == session_cookies
# ── report_ban — довести сигнал бана до пула (#2600 п.1) ──────────────────────
#
# Root cause: fetch() успешно вернул HTML (HTTP 200) — уже отчитался
# mark_health(ok=True). Бан-заглушка распознаётся ПОЗЖЕ, при разборе содержимого,
# провайдерским кодом (avito serp.py / cian _extract_state_tracked / yandex gate /
# domclick QRATOR-маркеры). report_ban — публичный способ явно переопределить тот
# ошибочный ok=True сигнал: помечает ТЕКУЩИЙ lease забаненным в пуле, пока lease
# ещё жив (до release/__aexit__).
async def test_report_ban_calls_provider_mark_banned_with_current_lease() -> None:
lease = ProxyLease(id=7, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<blocked>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
bf.report_ban("content-block suspected")
assert provider.banned == [(7, "avito")]
async def test_report_ban_noop_without_lease() -> None:
"""use_pool=False (дефолт) → lease нет → report_ban не трогает provider вообще."""
provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http"))
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False
bf.report_ban("should be no-op")
assert provider.banned == []
async def test_report_ban_noop_without_provider() -> None:
"""proxy_provider=None (дефолт) → report_ban не падает (best-effort)."""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito")
bf.report_ban("no provider configured") # не должно бросить
async def test_report_ban_survives_provider_exception() -> None:
"""mark_banned упал внутри провайдера — report_ban не пробрасывает (best-effort,
как touch/mark_health/release — проблема пула не должна ронять сбор)."""
class _BoomProvider(_FakeProxyProvider):
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
raise RuntimeError("pool db down")
lease = ProxyLease(id=3, url="http://pool:8080", kind="http")
provider = _BoomProvider(lease)
client = _mock_client({"html": "<blocked>"})
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
bf.report_ban("captcha") # не должно бросить
async def test_report_ban_does_not_release_or_rotate_lease() -> None:
"""report_ban только сообщает пулу — lease НЕ release'ится и НЕ ротируется здесь
(это забота caller'а: обычно исключение пробрасывается наверх и __aexit__
релизит lease как обычно)."""
lease = ProxyLease(id=5, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<blocked>"})
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
bf.report_ban("content-block suspected")
assert provider.released == []
assert bf._lease is not None
assert bf._lease.id == 5