"""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)