581 lines
23 KiB
Python
581 lines
23 KiB
Python
"""Test anti-bot hardening: blocked exceptions + early abort."""
|
||
|
||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.avito_exceptions import (
|
||
AvitoBlockedError,
|
||
AvitoContentBlockedError,
|
||
AvitoRateLimitedError,
|
||
)
|
||
|
||
# ── fetch_detail tests ──────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_detail_raises_blocked_on_403() -> None:
|
||
"""fetch_detail должен raise AvitoBlockedError на HTTP 403."""
|
||
from app.services.scrapers.avito_detail import fetch_detail
|
||
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 403
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
|
||
with pytest.raises(AvitoBlockedError, match="403"):
|
||
await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=mock_session)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_detail_raises_rate_limited_on_429() -> None:
|
||
"""fetch_detail должен raise AvitoRateLimitedError на HTTP 429."""
|
||
from app.services.scrapers.avito_detail import fetch_detail
|
||
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 429
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
|
||
with pytest.raises(AvitoRateLimitedError, match="429"):
|
||
await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=mock_session)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_detail_raises_value_error_on_other_non_200() -> None:
|
||
"""fetch_detail должен raise ValueError на прочие non-200 (не 403/429)."""
|
||
from app.services.scrapers.avito_detail import fetch_detail
|
||
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 503
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
|
||
with pytest.raises(ValueError, match="503"):
|
||
await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=mock_session)
|
||
|
||
|
||
# ── SERP (avito.py) tests ───────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_raises_content_blocked_on_page1_empty_html() -> None:
|
||
"""fetch_around должен raise AvitoContentBlockedError на HTTP 200 + 0 карточек на странице 1."""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
# Garbage HTML: нет data-marker="item" → _parse_html вернёт []
|
||
mock_response.text = "<html><body><p>captcha challenge</p></body></html>"
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
scraper._cffi = mock_session
|
||
|
||
with pytest.raises(AvitoContentBlockedError, match="content-block"):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_content_blocked_is_subtype_of_blocked() -> None:
|
||
"""AvitoContentBlockedError должен быть поймать как AvitoBlockedError (наследование)."""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.text = "<html><body></body></html>"
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
scraper._cffi = mock_session
|
||
|
||
with pytest.raises(AvitoBlockedError):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_page_gt1_empty_returns_gracefully() -> None:
|
||
"""fetch_around на page>1 с 0 карточек должен тихо завершить пагинацию (не raise)."""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
mock_session = AsyncMock()
|
||
|
||
# Страница 1: нормальный HTML с одной карточкой
|
||
valid_html = (
|
||
"<html><body>"
|
||
'<div data-marker="item" data-item-id="123456789">'
|
||
'<a data-marker="item-title" href="/ekaterinburg/kvartiry/test-123456789">'
|
||
"2-к. квартира, 54,3 м², 5/9 эт."
|
||
"</a>"
|
||
'<meta itemprop="price" content="4500000"/>'
|
||
"</div>"
|
||
"</body></html>"
|
||
)
|
||
# Страница 2: пустой HTML → end of pagination
|
||
empty_html = "<html><body><p>no listings</p></body></html>"
|
||
|
||
page1_resp = MagicMock()
|
||
page1_resp.status_code = 200
|
||
page1_resp.text = valid_html
|
||
|
||
page2_resp = MagicMock()
|
||
page2_resp.status_code = 200
|
||
page2_resp.text = empty_html
|
||
|
||
mock_session.get = AsyncMock(side_effect=[page1_resp, page2_resp])
|
||
scraper._cffi = mock_session
|
||
|
||
with patch("app.services.scrapers.avito.AvitoScraper.sleep_between_requests", AsyncMock()):
|
||
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=2)
|
||
|
||
# Должен вернуть лоты со страницы 1, не raise на странице 2
|
||
assert len(result) >= 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_raises_blocked_on_403() -> None:
|
||
"""AvitoScraper.fetch_around должен raise AvitoBlockedError на HTTP 403."""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 403
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
scraper._cffi = mock_session
|
||
|
||
with pytest.raises(AvitoBlockedError):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_raises_rate_limited_on_429() -> None:
|
||
"""AvitoScraper.fetch_around должен raise AvitoRateLimitedError на persistent HTTP 429.
|
||
|
||
429 (backconnect conn-limit) сначала проходит короткие retry; при отсутствии
|
||
rotate-url после исчерпания retry сохраняется старый fallback — AvitoRateLimitedError.
|
||
"""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 429
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
scraper._cffi = mock_session
|
||
|
||
with patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()):
|
||
with pytest.raises(AvitoRateLimitedError):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
|
||
# ── Pipeline abort tests ────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_aborts_after_3_consecutive_blocks() -> None:
|
||
"""#1820: 3 consecutive 403 в detail-фазе прерывают enrichment, но НЕ роняют прогон.
|
||
|
||
Раньше pipeline re-raise'ил AvitoBlockedError (один блок ронял весь прогон). Теперь
|
||
блок ловится пер-item, и лишь N consecutive блоков прерывают enrichment-фазу —
|
||
PipelineResult возвращается, detail_failed считается, лишние detail-фетчи не делаются.
|
||
"""
|
||
from app.services.scrape_pipeline import (
|
||
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT,
|
||
PipelineResult,
|
||
run_avito_pipeline,
|
||
)
|
||
|
||
mock_db = MagicMock()
|
||
mock_scraper = MagicMock()
|
||
mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper)
|
||
mock_scraper.__aexit__ = AsyncMock(return_value=None)
|
||
mock_scraper.fetch_around = AsyncMock(return_value=[])
|
||
mock_scraper._cffi = None
|
||
|
||
mock_row = MagicMock()
|
||
mock_row.__getitem__ = lambda self, key: (
|
||
"https://www.avito.ru/ekaterinburg/kvartiry/test-1" if key == "source_url" else None
|
||
)
|
||
mock_mappings = MagicMock()
|
||
mock_mappings.all.return_value = [mock_row, mock_row, mock_row, mock_row]
|
||
mock_execute = MagicMock()
|
||
mock_execute.mappings.return_value = mock_mappings
|
||
mock_db.execute.return_value = mock_execute
|
||
|
||
block_error = AvitoBlockedError("HTTP 403")
|
||
detail_mock = AsyncMock(side_effect=block_error)
|
||
|
||
with (
|
||
patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper),
|
||
patch("app.services.scrape_pipeline.fetch_detail", detail_mock),
|
||
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
|
||
patch(
|
||
"curl_cffi.requests.AsyncSession",
|
||
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
|
||
),
|
||
):
|
||
# НЕ должно бросать наружу
|
||
result = await run_avito_pipeline(
|
||
mock_db,
|
||
56.8,
|
||
60.6,
|
||
1000,
|
||
enrich_houses=False,
|
||
enrich_detail_top_n=4,
|
||
)
|
||
|
||
assert isinstance(result, PipelineResult)
|
||
# enrichment прерван ровно после порога consecutive блоков (4-я строка не фетчилась)
|
||
assert detail_mock.await_count == _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
|
||
assert result.counters.detail_attempted == _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
|
||
assert result.counters.detail_failed == _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
|
||
# в errors зафиксирован abort
|
||
assert any("AVITO_BLOCKED" in e for e in result.counters.errors)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_sleep_between_detail_requests() -> None:
|
||
"""Pipeline должен вызывать asyncio.sleep между каждым detail request."""
|
||
from app.services.scrape_pipeline import run_avito_pipeline
|
||
|
||
mock_db = MagicMock()
|
||
mock_scraper = MagicMock()
|
||
mock_scraper.__aenter__ = AsyncMock(return_value=mock_scraper)
|
||
mock_scraper.__aexit__ = AsyncMock(return_value=None)
|
||
mock_scraper.fetch_around = AsyncMock(return_value=[])
|
||
mock_scraper._cffi = None
|
||
|
||
mock_row = MagicMock()
|
||
mock_row.__getitem__ = lambda self, key: (
|
||
"https://www.avito.ru/ekaterinburg/kvartiry/test-1" if key == "source_url" else None
|
||
)
|
||
mock_mappings = MagicMock()
|
||
mock_mappings.all.return_value = [mock_row, mock_row, mock_row]
|
||
mock_execute = MagicMock()
|
||
mock_execute.mappings.return_value = mock_mappings
|
||
mock_db.execute.return_value = mock_execute
|
||
|
||
mock_enrichment = MagicMock()
|
||
sleep_mock = AsyncMock()
|
||
|
||
with (
|
||
patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper),
|
||
patch(
|
||
"app.services.scrape_pipeline.fetch_detail",
|
||
AsyncMock(return_value=mock_enrichment),
|
||
),
|
||
patch("app.services.scrape_pipeline.save_detail_enrichment", return_value=True),
|
||
patch("app.services.scrape_pipeline.asyncio.sleep", sleep_mock),
|
||
patch(
|
||
"curl_cffi.requests.AsyncSession",
|
||
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
|
||
),
|
||
patch("app.services.scrape_pipeline.random.uniform", return_value=1.0),
|
||
):
|
||
await run_avito_pipeline(
|
||
mock_db,
|
||
56.8,
|
||
60.6,
|
||
1000,
|
||
enrich_houses=False,
|
||
enrich_detail_top_n=3,
|
||
request_delay_sec=7.0,
|
||
)
|
||
|
||
assert sleep_mock.call_count == 2
|
||
for c in sleep_mock.call_args_list:
|
||
assert c == call(7.0)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_city_sweep_marks_banned_on_block() -> None:
|
||
"""City sweep mark'ит run как banned при AvitoBlockedError.
|
||
|
||
Migration 015 задокументировал 'banned' как 'Avito вернул 403/captcha' — именно этот сценарий.
|
||
|
||
Патчим AvitoScraper.fetch_around (не run_avito_pipeline — он больше не вызывается
|
||
из run_avito_city_sweep; sweep использует внутренние _avito_anchor_phases).
|
||
"""
|
||
from app.services.scrape_pipeline import run_avito_city_sweep
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
mock_db = MagicMock()
|
||
mock_runs = MagicMock()
|
||
mock_runs.is_cancelled.return_value = False
|
||
mock_runs.update_heartbeat = MagicMock()
|
||
mock_runs.mark_banned = MagicMock()
|
||
mock_runs.mark_done = MagicMock()
|
||
|
||
block_error = AvitoBlockedError("IP blocked at page=1")
|
||
|
||
with (
|
||
patch("app.services.scrape_pipeline.scrape_runs", mock_runs),
|
||
patch.object(AvitoScraper, "fetch_around", AsyncMock(side_effect=block_error)),
|
||
patch(
|
||
"curl_cffi.requests.AsyncSession",
|
||
return_value=AsyncMock(
|
||
__aenter__=AsyncMock(return_value=AsyncMock()),
|
||
__aexit__=AsyncMock(return_value=None),
|
||
),
|
||
),
|
||
):
|
||
result = await run_avito_city_sweep(
|
||
mock_db,
|
||
run_id=42,
|
||
anchors=[(56.84, 60.60, "Центр"), (56.79, 60.53, "ЮЗ")],
|
||
)
|
||
|
||
assert mock_runs.mark_banned.called
|
||
assert not mock_runs.mark_done.called
|
||
assert result.anchors_done == 1
|
||
|
||
|
||
# ── #623 firewall-детект + ротация IP ────────────────────────────
|
||
|
||
_CARD_HTML = (
|
||
"<html><body>"
|
||
'<div data-marker="item" data-item-id="123456789">'
|
||
'<a data-marker="item-title" href="/ekaterinburg/kvartiry/test-123456789">'
|
||
"2-к. квартира, 54,3 м², 5/9 эт."
|
||
"</a>"
|
||
'<meta itemprop="price" content="4500000"/>'
|
||
"</div>"
|
||
"</body></html>"
|
||
)
|
||
|
||
# Firewall-интерстициал Avito (HTTP 200, не 403) — IP-блок datacenter.
|
||
_FIREWALL_HTML = (
|
||
"<html><head><title>Доступ ограничен: проблема с IP</title></head>"
|
||
'<body><div class="firewall-container">...</div></body></html>'
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_raises_blocked_on_firewall_html_200() -> None:
|
||
"""HTTP 200 + firewall-страница «Доступ ограничен» → AvitoBlockedError (#623).
|
||
|
||
Avito банит datacenter-IP отдачей firewall-интерстициала с кодом 200 (не 403) —
|
||
раньше это молча проходило как «0 cards / content-block», теперь явный IP-блок.
|
||
"""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
mock_session = AsyncMock()
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.text = _FIREWALL_HTML
|
||
mock_session.get = AsyncMock(return_value=mock_response)
|
||
scraper._cffi = mock_session
|
||
|
||
with pytest.raises(AvitoBlockedError, match="firewall"):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_rotates_ip_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""При firewall + заданной changeip-ссылке: ротация IP и повтор страницы (#623)."""
|
||
from app.services.scrapers import avito as avito_mod
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
monkeypatch.setattr(
|
||
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
|
||
)
|
||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
|
||
|
||
scraper = AvitoScraper()
|
||
fw = MagicMock()
|
||
fw.status_code = 200
|
||
fw.text = _FIREWALL_HTML
|
||
ok = MagicMock()
|
||
ok.status_code = 200
|
||
ok.text = _CARD_HTML
|
||
mock_session = AsyncMock()
|
||
mock_session.get = AsyncMock(side_effect=[fw, ok])
|
||
scraper._cffi = mock_session
|
||
|
||
with (
|
||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
|
||
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
|
||
):
|
||
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
rot.assert_awaited_once()
|
||
assert len(result) == 1
|
||
assert mock_session.get.await_count == 2 # firewall → rotate → retry OK
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_blocked_when_rotation_exhausted(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Если ротация не сняла бан (max_rotations исчерпан) → AvitoBlockedError (#623)."""
|
||
from app.services.scrapers import avito as avito_mod
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
monkeypatch.setattr(
|
||
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
|
||
)
|
||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
|
||
|
||
scraper = AvitoScraper()
|
||
fw = MagicMock()
|
||
fw.status_code = 200
|
||
fw.text = _FIREWALL_HTML
|
||
mock_session = AsyncMock()
|
||
mock_session.get = AsyncMock(return_value=fw) # всегда firewall
|
||
scraper._cffi = mock_session
|
||
|
||
with patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot:
|
||
with pytest.raises(AvitoBlockedError, match="firewall"):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
assert rot.await_count == 1 # одна попытка ротации, потом сдаёмся
|
||
|
||
|
||
# ── #623 sweep-pipeline proxy (SERP+detail через mobile proxy) ───
|
||
|
||
|
||
def test_avito_proxies_helper(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""_avito_proxies: None без env, dict {http,https} при заданном AVITO_PROXY_URL.
|
||
|
||
Регрессия: sweep создаёт shared-сессию, через неё идут и SERP, и detail —
|
||
без прокси detail летит с datacenter-IP → 429 → run banned (#623 followup).
|
||
"""
|
||
from app.services import scrape_pipeline as sp
|
||
|
||
monkeypatch.setattr(sp.settings, "avito_proxy_url", None)
|
||
assert sp._avito_proxies() is None
|
||
|
||
url = "http://u:p@mproxy.site:16928"
|
||
monkeypatch.setattr(sp.settings, "avito_proxy_url", url)
|
||
assert sp._avito_proxies() == {"http": url, "https": url}
|
||
|
||
|
||
# ── curl_cffi 429 short-retry (backconnect conn-limit transient) ──────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_429_short_retry_then_succeeds() -> None:
|
||
"""429 дважды → 200: короткий retry снимает transient conn-limit БЕЗ ротации IP.
|
||
|
||
backconnect-прокси (mproxy.site, лимит 5 соединений) отдаёт 429 при параллельной
|
||
пагинации — это transient, проходит на коротком retry. _rotate_ip НЕ должен дёргаться.
|
||
"""
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
r429 = MagicMock()
|
||
r429.status_code = 429
|
||
ok = MagicMock()
|
||
ok.status_code = 200
|
||
ok.text = _CARD_HTML
|
||
mock_session = AsyncMock()
|
||
mock_session.get = AsyncMock(side_effect=[r429, r429, ok])
|
||
scraper._cffi = mock_session
|
||
|
||
with (
|
||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()) as slp,
|
||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
|
||
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
|
||
):
|
||
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
assert len(result) == 1
|
||
assert mock_session.get.await_count == 3 # 429, 429, 200
|
||
rot.assert_not_awaited() # короткий retry, не ротация
|
||
# короткий backoff (_AVITO_429_BACKOFF_SEC ~1.5s), не 9с-settle ротации
|
||
assert slp.await_count >= 2
|
||
for c in slp.await_args_list:
|
||
assert c.args[0] < 5.0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_429_exhausted_no_rotate_url_raises() -> None:
|
||
"""429 постоянно + НЕТ rotate-url → после исчерпания коротких retry: старый fallback."""
|
||
from app.services.scrapers import avito as avito_mod
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
scraper = AvitoScraper()
|
||
r429 = MagicMock()
|
||
r429.status_code = 429
|
||
mock_session = AsyncMock()
|
||
mock_session.get = AsyncMock(return_value=r429)
|
||
scraper._cffi = mock_session
|
||
|
||
with (
|
||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()) as slp,
|
||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
|
||
):
|
||
with pytest.raises(AvitoRateLimitedError):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
rot.assert_not_awaited() # нет rotate-url → ротация не дёргается
|
||
# ровно _AVITO_429_MAX_RETRIES коротких retry, потом fallback
|
||
assert slp.await_count == avito_mod._AVITO_429_MAX_RETRIES
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_429_exhausted_with_rotate_url_rotates(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""429 постоянно + ЕСТЬ rotate-url → короткие retry, затем ротация (старый путь)."""
|
||
from app.services.scrapers import avito as avito_mod
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
monkeypatch.setattr(
|
||
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
|
||
)
|
||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
|
||
|
||
scraper = AvitoScraper()
|
||
r429 = MagicMock()
|
||
r429.status_code = 429
|
||
ok = MagicMock()
|
||
ok.status_code = 200
|
||
ok.text = _CARD_HTML
|
||
mock_session = AsyncMock()
|
||
# 429 на всех коротких retry (1 первичный + _AVITO_429_MAX_RETRIES), после
|
||
# исчерпания short-retry — ротация → 200. backconnect-reconnect здесь НЕ
|
||
# срабатывает (scraper_proxy_url не задан), поэтому сразу rotate-путь.
|
||
n_429 = 1 + avito_mod._AVITO_429_MAX_RETRIES
|
||
mock_session.get = AsyncMock(side_effect=[r429] * n_429 + [ok])
|
||
scraper._cffi = mock_session
|
||
|
||
with (
|
||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()),
|
||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
|
||
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
|
||
):
|
||
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
rot.assert_awaited_once() # после исчерпания коротких retry — ротация
|
||
assert len(result) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_around_403_no_429_retry_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""403 (реальный IP-ban) НЕ проходит через 429-retry — поведение без изменений."""
|
||
from app.services.scrapers import avito as avito_mod
|
||
from app.services.scrapers.avito import AvitoScraper
|
||
|
||
monkeypatch.setattr(avito_mod.settings, "avito_proxy_rotate_url", None)
|
||
|
||
scraper = AvitoScraper()
|
||
r403 = MagicMock()
|
||
r403.status_code = 403
|
||
mock_session = AsyncMock()
|
||
mock_session.get = AsyncMock(return_value=r403)
|
||
scraper._cffi = mock_session
|
||
|
||
with (
|
||
patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()) as slp,
|
||
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)),
|
||
):
|
||
with pytest.raises(AvitoBlockedError):
|
||
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
|
||
|
||
# 403 без rotate-url → немедленный raise, ни одного 429-backoff sleep
|
||
slp.assert_not_awaited()
|
||
assert mock_session.get.await_count == 1
|