gendesign/tradein-mvp/backend/tests/test_scrape_pipeline.py
bot-backend cd91d5e279
All checks were successful
CI / changes (pull_request) Successful in 7s
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
fix(scrapers): avito pipeline — новостройки house graceful (без housePage), AvitoBlockedError per-item не крашит прогон (#1820)
2026-06-20 09:53:46 +03:00

270 lines
11 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.

"""Offline smoke for scrape_pipeline. Mocks scrapers + DB session."""
from datetime import date, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.scrape_pipeline import (
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT,
PipelineCounters,
PipelineResult,
run_avito_full_load,
run_avito_pipeline,
)
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
from app.services.scrapers.base import ScrapedLot
def _make_lot(
item_id: str,
house_url: str | None = None,
price: int = 5_000_000,
) -> ScrapedLot:
return ScrapedLot(
source="avito",
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/test_{item_id}",
source_id=item_id,
price_rub=price,
address="Test address",
house_source="avito" if house_url else None,
house_ext_id=house_url.rstrip("/").split("/")[-1] if house_url else None,
house_url=house_url,
listing_segment="vtorichka",
)
def test_counters_dataclass_default() -> None:
c = PipelineCounters()
assert c.lots_fetched == 0
assert c.unique_houses == 0
assert c.errors == []
@pytest.mark.asyncio
async def test_pipeline_search_failure_graceful() -> None:
"""Если AvitoScraper.fetch_around raises — pipeline возвращает PipelineResult
с errors, не падает наружу."""
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(side_effect=RuntimeError("test net error"))
with patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper):
result = await run_avito_pipeline(
mock_db, 56.8, 60.6, 1000, enrich_houses=False, enrich_detail_top_n=0
)
assert isinstance(result, PipelineResult)
assert result.counters.lots_fetched == 0
assert len(result.counters.errors) == 1
assert "search:" in result.counters.errors[0]
@pytest.mark.asyncio
async def test_pipeline_group_by_house_unique_set() -> None:
"""3 lots с house_url'ами (2 одинаковых, 1 уникальный) → unique_houses=2."""
mock_db = MagicMock()
lots = [
_make_lot("1", "https://www.avito.ru/catalog/houses/ekb/test/100"),
_make_lot("2", "https://www.avito.ru/catalog/houses/ekb/test/100"), # дубль
_make_lot("3", "https://www.avito.ru/catalog/houses/ekb/other/200"),
_make_lot("4", None), # без дома
]
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=lots)
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=mock_scraper),
patch("app.services.scrape_pipeline.save_listings", return_value=(4, 0)),
):
result = await run_avito_pipeline(
mock_db, 56.8, 60.6, 1000, enrich_houses=False, enrich_detail_top_n=0
)
assert result.counters.lots_fetched == 4
assert result.counters.lots_inserted == 4
assert result.counters.unique_houses == 2
# ── run_avito_full_load: incremental_days → since plumbing (#avito-split) ─────
def _full_load_scraper() -> MagicMock:
"""AvitoScraper mock с async-CM + AsyncMock fetch_all_secondary."""
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_all_secondary = AsyncMock(return_value=[])
return scraper
@pytest.mark.asyncio
async def test_full_load_incremental_days_passes_since() -> None:
"""incremental_days=2 → fetch_all_secondary(since=date.today()-2d)."""
mock_db = MagicMock()
scraper = _full_load_scraper()
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.scrape_runs", MagicMock()),
):
await run_avito_full_load(mock_db, run_id=1, incremental_days=2)
kwargs = scraper.fetch_all_secondary.call_args.kwargs
assert kwargs["since"] == date.today() - timedelta(days=2)
@pytest.mark.asyncio
async def test_full_load_no_incremental_days_since_none() -> None:
"""incremental_days=None (default) → fetch_all_secondary(since=None) — exhaustive."""
mock_db = MagicMock()
scraper = _full_load_scraper()
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.scrape_runs", MagicMock()),
):
await run_avito_full_load(mock_db, run_id=2)
kwargs = scraper.fetch_all_secondary.call_args.kwargs
assert kwargs["since"] is None
# ── #1820: AvitoBlockedError per-item не роняет весь pipeline ─────────────────
def _block_test_scraper(lots: list[ScrapedLot]) -> MagicMock:
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_around = AsyncMock(return_value=lots)
scraper._cffi = None
return scraper
@pytest.mark.asyncio
async def test_pipeline_single_house_block_does_not_crash() -> None:
"""#1820: один заблокированный house-фетч НЕ роняет прогон.
Listings из SEARCH+SAVE сохранены, блок зафиксирован в counters (houses_failed/errors),
остальные дома обогащаются дальше (enrichment не прерван < порога).
"""
mock_db = MagicMock()
lots = [
_make_lot("1", "https://www.avito.ru/catalog/houses/ekb/a/100"),
_make_lot("2", "https://www.avito.ru/catalog/houses/ekb/b/200"),
]
scraper = _block_test_scraper(lots)
# 1-й house блокируется, 2-й — успех.
fetch_house = AsyncMock(side_effect=[AvitoBlockedError("HTTP 403"), MagicMock()])
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.save_listings", return_value=(2, 0)),
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
patch(
"app.services.scrape_pipeline.save_house_catalog_enrichment",
return_value={"house_id": 42},
),
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=True, enrich_detail_top_n=0
)
assert isinstance(result, PipelineResult)
# listings сохранены всегда
assert result.counters.lots_inserted == 2
# один дом упал по блоку, второй обогащён
assert result.counters.houses_failed == 1
assert result.counters.houses_enriched == 1
assert fetch_house.await_count == 2 # enrichment НЕ прерван — оба дома попробованы
assert any("BLOCKED_HOUSE" in e for e in result.counters.errors)
assert 42 in result.touched_house_ids
@pytest.mark.asyncio
async def test_pipeline_consecutive_house_blocks_abort_but_return_result() -> None:
"""#1820: N consecutive house-блоков прерывают enrichment, но PipelineResult возвращён."""
mock_db = MagicMock()
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
# Создаём n+2 дома — после n блоков подряд enrichment прерывается.
lots = [
_make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{100 + i}")
for i in range(n + 2)
]
scraper = _block_test_scraper(lots)
fetch_house = AsyncMock(side_effect=AvitoRateLimitedError("HTTP 429"))
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
patch("app.services.scrape_pipeline.save_house_catalog_enrichment", return_value={}),
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=True, enrich_detail_top_n=0
)
assert isinstance(result, PipelineResult)
assert result.counters.lots_inserted == len(lots) # listings сохранены
# enrichment прерван ровно после порога — лишние дома НЕ фетчились
assert fetch_house.await_count == n
assert result.counters.houses_failed == n
assert any("AVITO_BLOCKED" in e for e in result.counters.errors)
@pytest.mark.asyncio
async def test_pipeline_detail_block_aborts_skips_step5b() -> None:
"""#1820: consecutive detail-блоки прерывают detail-фазу И Step 5b (houses из detail)."""
mock_db = MagicMock()
n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
scraper = _block_test_scraper([]) # SERP пуст → сразу detail-фаза по priority_rows
# priority_rows: n+1 строк, все блокируются в detail.
row = MagicMock()
row.__getitem__ = lambda self, key: (
"https://www.avito.ru/ekaterinburg/kvartiry/test-1" if key == "source_url" else None
)
mappings = MagicMock()
mappings.all.return_value = [row] * (n + 1)
execute = MagicMock()
execute.mappings.return_value = mappings
mock_db.execute.return_value = execute
fetch_detail = AsyncMock(side_effect=AvitoBlockedError("HTTP 403"))
fetch_house = AsyncMock() # должен НЕ вызываться (Step 5b пропущен)
with (
patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
patch("app.services.scrape_pipeline.fetch_detail", fetch_detail),
patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
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=True, enrich_detail_top_n=n + 1
)
assert isinstance(result, PipelineResult)
assert fetch_detail.await_count == n # detail прерван на пороге
assert result.counters.detail_failed == n
assert fetch_house.await_count == 0 # Step 5b пропущен (enrichment_aborted)