"""Tests for IMV enrichment phase integrated into Avito city sweep (kit). Проверяет: 1. enrich_imv=True — sweep вызывает IMV-фазу для touched house_id. 2. enrich_imv=False — IMV не вызывается. 3. IMV counters (imv_attempted, imv_enriched, imv_failed) заполняются. 4. CitySweepCounters содержит новые imv_* поля. 5. process_houses_imv_batch пропускает пустой набор house_ids. 6. CitySweepStartRequest принимает enrich_imv (API-level param). 7. Cooperative cancel перед IMV-фазой — skip IMV, sweep marked done. Kit — единственная orchestration-копия (#2397 Part E1 удалил legacy `app.services.scrape_pipeline`): behavior-тесты (пункты 1,2,3,5,7 выше через `run_avito_city_sweep`) переведены на `scraper_kit.orchestration.pipeline` с DI (config/matcher/enrichment/shutdown_requested) вместо прямых импортов `app.*`. `enrichment.process_houses_imv_batch` — инжектируемый Protocol-метод (было прямым импортом `app.services.house_imv_backfill.process_houses_imv_batch`). """ from __future__ import annotations import os from contextlib import ExitStack from dataclasses import fields from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") PFX = "scraper_kit.orchestration.pipeline" def _make_house_lot(offer_id: str, house_path: str) -> Any: """ScrapedLot с house_url для тестов house-enrich пути.""" from scraper_kit.base import ScrapedLot return ScrapedLot( source="avito", source_url=f"https://www.avito.ru{house_path}/{offer_id}", source_id=offer_id, address="Тест", price_rub=3_000_000, area_m2=40.0, rooms=1, house_url=f"https://www.avito.ru{house_path}", ) def _config() -> SimpleNamespace: return SimpleNamespace( scraper_fetch_mode="curl_cffi", browser_http_endpoint="http://browser.test/fetch", scraper_proxy_url=None, avito_proxy_rotate_url=None, avito_proxy_max_rotations=0, avito_serp_ok_not_banned=True, avito_proxy_rotate_settle_s=0.0, proxy_rotate_attempts=1, proxy_rotate_attempt_timeout_s=1.0, cian_proxy_rotate_url=None, cian_proxy_max_rotations=0, yandex_proxy_rotate_url=None, yandex_proxy_max_rotations=0, scraper_skip_seen_today=False, ) def _enter_common_patches(stack: ExitStack, house_ids: list[int]) -> None: """Входит в набор patch'ей для базового Avito sweep через ExitStack: - AvitoScraper.fetch_around → lots с house_url (по одному на house_id) - save_listings → (n, 0) - fetch_house_catalog → MagicMock() - save_house_catalog_enrichment → {"house_id": X} по очереди - AsyncSession (kit-модуль) → fake - asyncio.sleep → no-op """ from scraper_kit.providers.avito.serp import AvitoScraper lots = [_make_house_lot(f"lot-{hid}", f"/ekaterinburg/houses/test/{hid}") for hid in house_ids] async def fake_fetch_around(self: Any, lat: float, lon: float, *_a: Any, **_kw: Any) -> list: return lots # save_house_catalog_enrichment вызывается по одному разу на каждый дом house_id_iter = iter(house_ids) def fake_save_house(db: Any, enrichment: Any, **_kw: Any) -> dict: try: return {"house_id": next(house_id_iter)} except StopIteration: return {} fake_session = AsyncMock() fake_session.__aenter__ = AsyncMock(return_value=fake_session) fake_session.__aexit__ = AsyncMock(return_value=None) stack.enter_context(patch.object(AvitoScraper, "fetch_around", fake_fetch_around)) stack.enter_context(patch(f"{PFX}.save_listings", lambda _db, _lots, **_kw: (len(_lots), 0))) stack.enter_context(patch(f"{PFX}.fetch_house_catalog", AsyncMock(return_value=MagicMock()))) stack.enter_context(patch(f"{PFX}.save_house_catalog_enrichment", fake_save_house)) stack.enter_context(patch(f"{PFX}.AsyncSession", return_value=fake_session)) stack.enter_context(patch(f"{PFX}.asyncio.sleep", AsyncMock())) def _enter_runs_patches(stack: ExitStack, *, mark_done_mock: Any = None) -> Any: """Патчит `scraper_kit.orchestration.pipeline.runs` (is_cancelled/update_heartbeat/ mark_done).""" mock_runs = MagicMock() mock_runs.is_cancelled.return_value = False mock_runs.update_heartbeat = MagicMock() mock_runs.mark_done = mark_done_mock if mark_done_mock is not None else MagicMock() stack.enter_context(patch(f"{PFX}.runs", mock_runs)) return mock_runs # ── CitySweepCounters has imv_* fields ──────────────────────────────────────── def test_city_sweep_counters_has_imv_fields() -> None: from scraper_kit.orchestration.pipeline import CitySweepCounters c = CitySweepCounters() assert hasattr(c, "imv_attempted") assert hasattr(c, "imv_enriched") assert hasattr(c, "imv_failed") assert c.imv_attempted == 0 assert c.imv_enriched == 0 assert c.imv_failed == 0 def test_city_sweep_counters_to_dict_has_imv_keys() -> None: from scraper_kit.orchestration.pipeline import CitySweepCounters c = CitySweepCounters(imv_attempted=5, imv_enriched=3, imv_failed=2) d = c.to_dict() assert d["imv_attempted"] == 5 assert d["imv_enriched"] == 3 assert d["imv_failed"] == 2 # Все dataclass-поля должны быть в to_dict expected_keys = {f.name for f in fields(c)} assert set(d.keys()) == expected_keys # ── PipelineResult имеет touched_house_ids ──────────────────────────────────── def test_pipeline_result_has_touched_house_ids() -> None: from scraper_kit.orchestration.pipeline import PipelineCounters, PipelineResult r = PipelineResult( anchor_lat=56.84, anchor_lon=60.6, radius_m=1500, counters=PipelineCounters(), enrich_houses=True, enrich_detail_top_n=10, ) assert hasattr(r, "touched_house_ids") assert isinstance(r.touched_house_ids, set) assert len(r.touched_house_ids) == 0 def test_pipeline_result_touched_house_ids_set() -> None: from scraper_kit.orchestration.pipeline import PipelineCounters, PipelineResult r = PipelineResult( anchor_lat=56.84, anchor_lon=60.6, radius_m=1500, counters=PipelineCounters(), enrich_houses=True, enrich_detail_top_n=10, touched_house_ids={1, 2, 3}, ) assert r.touched_house_ids == {1, 2, 3} # ── enrich_imv=True → IMV phase вызывается ─────────────────────────────────── @pytest.mark.asyncio async def test_sweep_enrich_imv_true_calls_imv_batch() -> None: """enrich_imv=True + есть touched house_ids → process_houses_imv_batch вызывается. Патчим AvitoScraper.fetch_around → lots с house_url, save_house_catalog_enrichment → {house_id: X} чтобы all_touched_house_ids = {10, 20}. """ from scraper_kit.orchestration.pipeline import run_avito_city_sweep mock_db = MagicMock() imv_result = SimpleNamespace(checked=2, saved=2, errors=0) enrichment = MagicMock() enrichment.process_houses_imv_batch = AsyncMock(return_value=imv_result) with ExitStack() as stack: _enter_common_patches(stack, [10, 20]) _enter_runs_patches(stack) counters = await run_avito_city_sweep( mock_db, run_id=1, config=_config(), matcher=MagicMock(), enrichment=enrichment, anchors=[(56.84, 60.6, "TestAnchor")], enrich_imv=True, enrich_houses=True, detail_top_n=0, ) enrichment.process_houses_imv_batch.assert_awaited_once() # Первый позиционный аргумент — db, второй — house_ids call_args = enrichment.process_houses_imv_batch.call_args assert call_args.args[1] == {10, 20} assert counters.imv_attempted == 2 assert counters.imv_enriched == 2 assert counters.imv_failed == 0 # ── enrich_imv=False → IMV не вызывается ──────────────────────────────────── @pytest.mark.asyncio async def test_sweep_enrich_imv_false_skips_imv() -> None: """enrich_imv=False → process_houses_imv_batch НЕ вызывается.""" from scraper_kit.orchestration.pipeline import run_avito_city_sweep mock_db = MagicMock() enrichment = MagicMock() enrichment.process_houses_imv_batch = AsyncMock() with ExitStack() as stack: _enter_common_patches(stack, [99]) _enter_runs_patches(stack) counters = await run_avito_city_sweep( mock_db, run_id=2, config=_config(), matcher=MagicMock(), enrichment=enrichment, anchors=[(56.84, 60.6, "TestAnchor")], enrich_imv=False, enrich_houses=True, detail_top_n=0, ) enrichment.process_houses_imv_batch.assert_not_awaited() assert counters.imv_attempted == 0 assert counters.imv_enriched == 0 assert counters.imv_failed == 0 # ── enrich_imv=True но нет touched house_ids → IMV не вызывается ──────────── @pytest.mark.asyncio async def test_sweep_enrich_imv_no_touched_ids_skips() -> None: """enrich_imv=True, но touched_house_ids пустой → IMV не вызывается. Лоты без house_url → unique_house_paths пуст → touched_house_ids пуст. """ from scraper_kit.orchestration.pipeline import run_avito_city_sweep from scraper_kit.providers.avito.serp import AvitoScraper mock_db = MagicMock() fake_session = AsyncMock() fake_session.__aenter__ = AsyncMock(return_value=fake_session) fake_session.__aexit__ = AsyncMock(return_value=None) # Лоты БЕЗ house_url → нет домов → IMV не вызывается async def fake_fetch_around(self: Any, lat: float, lon: float, *_a: Any, **_kw: Any) -> list: return [] enrichment = MagicMock() enrichment.process_houses_imv_batch = AsyncMock() with ( patch.object(AvitoScraper, "fetch_around", fake_fetch_around), patch(f"{PFX}.save_listings", lambda _db, _lots, **_kw: (0, 0)), patch(f"{PFX}.AsyncSession", return_value=fake_session), patch(f"{PFX}.asyncio.sleep", AsyncMock()), patch(f"{PFX}.runs") as mock_runs, ): mock_runs.is_cancelled.return_value = False await run_avito_city_sweep( mock_db, run_id=3, config=_config(), matcher=MagicMock(), enrichment=enrichment, anchors=[(56.84, 60.6, "TestAnchor")], enrich_imv=True, enrich_houses=True, detail_top_n=0, ) enrichment.process_houses_imv_batch.assert_not_awaited() # ── IMV-ошибки не валят sweep ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_sweep_imv_crash_does_not_abort_sweep() -> None: """Если process_houses_imv_batch raises — sweep всё равно завершается (mark_done).""" from scraper_kit.orchestration.pipeline import run_avito_city_sweep mock_db = MagicMock() enrichment = MagicMock() enrichment.process_houses_imv_batch = AsyncMock(side_effect=RuntimeError("IMV network error")) with ExitStack() as stack: _enter_common_patches(stack, [55]) mock_runs = _enter_runs_patches(stack) counters = await run_avito_city_sweep( mock_db, run_id=4, config=_config(), matcher=MagicMock(), enrichment=enrichment, anchors=[(56.84, 60.6, "TestAnchor")], enrich_imv=True, enrich_houses=True, detail_top_n=0, ) mock_runs.mark_done.assert_called_once() # IMV crash → ошибка считается, но sweep завершается как done assert counters.errors_count >= 1 # ── IMV counters накапливают failed ──────────────────────────────────────────── @pytest.mark.asyncio async def test_sweep_imv_failed_counter() -> None: """imv_failed отражает errors из результата enrichment.process_houses_imv_batch.""" from scraper_kit.orchestration.pipeline import run_avito_city_sweep mock_db = MagicMock() # 3 attempted, 1 enriched, 2 failed imv_result = SimpleNamespace(checked=3, saved=1, errors=2) enrichment = MagicMock() enrichment.process_houses_imv_batch = AsyncMock(return_value=imv_result) with ExitStack() as stack: _enter_common_patches(stack, [1, 2, 3]) _enter_runs_patches(stack) counters = await run_avito_city_sweep( mock_db, run_id=5, config=_config(), matcher=MagicMock(), enrichment=enrichment, anchors=[(56.84, 60.6, "TestAnchor")], enrich_imv=True, enrich_houses=True, detail_top_n=0, ) assert counters.imv_attempted == 3 assert counters.imv_enriched == 1 assert counters.imv_failed == 2 # errors_count должен включить imv_failed assert counters.errors_count >= 2 # ── Cancel перед IMV-фазой ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_sweep_cancel_before_imv_skips_imv() -> None: """Если is_cancelled=True в IMV-фазе → IMV пропускается, sweep всё равно done.""" from scraper_kit.orchestration.pipeline import run_avito_city_sweep mock_db = MagicMock() enrichment = MagicMock() enrichment.process_houses_imv_batch = AsyncMock() # is_cancelled: первый вызов (anchor loop) = False, второй (IMV phase) = True cancel_side_effects = [False, True] call_idx = 0 def is_cancelled_side_effect(db: Any, run_id: int) -> bool: nonlocal call_idx val = cancel_side_effects[call_idx] if call_idx < len(cancel_side_effects) else True call_idx += 1 return val with ExitStack() as stack: _enter_common_patches(stack, [77]) mock_runs = MagicMock() mock_runs.is_cancelled.side_effect = is_cancelled_side_effect mock_runs.update_heartbeat = MagicMock() mock_runs.mark_done = MagicMock() stack.enter_context(patch(f"{PFX}.runs", mock_runs)) await run_avito_city_sweep( mock_db, run_id=6, config=_config(), matcher=MagicMock(), enrichment=enrichment, anchors=[(56.84, 60.6, "TestAnchor")], enrich_imv=True, enrich_houses=True, detail_top_n=0, ) enrichment.process_houses_imv_batch.assert_not_awaited() mock_runs.mark_done.assert_called_once() # ── process_houses_imv_batch с пустым set ──────────────────────────────────── @pytest.mark.asyncio async def test_process_houses_imv_batch_empty_ids() -> None: """process_houses_imv_batch с пустым house_ids → result.checked=0, быстрый return.""" from app.services.house_imv_backfill import process_houses_imv_batch mock_db = MagicMock() result = await process_houses_imv_batch(mock_db, set(), request_delay_sec=1.0) assert result.checked == 0 assert result.saved == 0 assert result.errors == 0 # DB не должна вызываться при пустом наборе mock_db.execute.assert_not_called() # ── CitySweepStartRequest принимает enrich_imv ─────────────────────────────── def test_city_sweep_start_request_enrich_imv_default_true() -> None: """enrich_imv по умолчанию True.""" from app.api.v1.admin import CitySweepStartRequest req = CitySweepStartRequest() assert req.enrich_imv is True def test_city_sweep_start_request_enrich_imv_false() -> None: """enrich_imv=False принимается.""" from app.api.v1.admin import CitySweepStartRequest req = CitySweepStartRequest(enrich_imv=False) assert req.enrich_imv is False # ── API endpoint передаёт enrich_imv в sweep ──────────────────────────────── def test_sweep_endpoint_passes_enrich_imv(app_with_admin) -> None: """POST /scrape/avito-city-sweep с enrich_imv=False → sweep вызывается без IMV. kit sweep вызван с DI (config/matcher/enrichment) — mock должен перехватывать имя, забинженное в app.api.v1.admin (kit-импорт), не устаревший scrape_pipeline. """ from app.services.scraper_adapters import ( RealEnrichmentJobs, RealMatcherAdapter, RealScraperConfig, ) with ( patch("app.api.v1.admin.has_running_run", return_value=False), patch("app.services.scrape_runs.create_run", return_value=42), patch( "app.api.v1.admin.run_avito_city_sweep", new_callable=AsyncMock, ) as mock_sweep, ): r = app_with_admin.post( "/api/v1/admin/scrape/avito-city-sweep", json={"pages_per_anchor": 2, "enrich_imv": False}, ) assert r.status_code == 200 body = r.json() assert body["run_id"] == 42 mock_sweep.assert_called_once() kw = mock_sweep.call_args.kwargs assert isinstance(kw["config"], RealScraperConfig) assert isinstance(kw["matcher"], RealMatcherAdapter) assert isinstance(kw["enrichment"], RealEnrichmentJobs) assert "proxy_provider" in kw assert kw["enrich_imv"] is False @pytest.fixture def app_with_admin(): from fastapi import FastAPI from fastapi.testclient import TestClient from app.api.v1 import admin as admin_module from app.core.db import get_db app = FastAPI() app.include_router(admin_module.router, prefix="/api/v1/admin") def fake_db(): yield MagicMock() app.dependency_overrides[get_db] = fake_db return TestClient(app)