"""Tests for Cian Valuation integration in estimator.py (Stage 9). Легаси `app.services.scrapers.cian_valuation` удалён (#2397 финальный шаг E, 0 runtime-импортёров) — переведено на kit-эквивалент `scraper_kit.providers.cian. valuation`, тот же тип/функции, что `estimator.py` реально использует. `estimate_via_cian_valuation` требует mandatory `config=` (kit strangler DI, #2337) — добавлен `RealScraperConfig()` там, где легаси-сигнатура его не требовала. """ import os # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") from unittest.mock import AsyncMock, MagicMock, patch import anyio from scraper_kit.providers.cian.valuation import CianValuationResult def _fake_cian_result(**kwargs) -> CianValuationResult: result = CianValuationResult() result.sale_price_rub = kwargs.get("sale_price_rub", 7_500_000.0) result.sale_accuracy = kwargs.get("sale_accuracy", 85.0) result.sale_price_from = kwargs.get("sale_price_from", 7_000_000.0) result.sale_price_to = kwargs.get("sale_price_to", 8_000_000.0) result.chart = kwargs.get("chart", [{"month_date": "2026-05-01", "price": 145_000}]) result.chart_change_pct = kwargs.get("chart_change_pct", 1.5) result.chart_change_direction = kwargs.get("chart_change_direction", "increase") result.external_house_id = kwargs.get("external_house_id", 12345) result.is_authenticated = kwargs.get("is_authenticated", True) return result # ── Cache-layer unit tests ──────────────────────────────────────────────────── def test_cian_valuation_cache_key_deterministic() -> None: """compute_cache_key produces same hash for identical inputs.""" from scraper_kit.providers.cian.valuation import compute_cache_key k1 = compute_cache_key("ЕКБ, ул. Учителей, 18", 38.8, 1, 4, "cosmetic", "sale") k2 = compute_cache_key("ЕКБ, ул. Учителей, 18", 38.8, 1, 4, "cosmetic", "sale") assert k1 == k2 assert len(k1) == 64 def test_cian_valuation_cache_key_differs_for_different_inputs() -> None: from scraper_kit.providers.cian.valuation import compute_cache_key a = compute_cache_key("addr A", 38.8, 1, 4, "cosmetic", "sale") b = compute_cache_key("addr B", 38.8, 1, 4, "cosmetic", "sale") c = compute_cache_key("addr A", 50.0, 1, 4, "cosmetic", "sale") d = compute_cache_key("addr A", 38.8, 2, 4, "cosmetic", "sale") assert len({a, b, c, d}) == 4 # ── estimate_via_cian_valuation graceful-degradation tests ─────────────────── def test_estimate_via_cian_returns_none_when_no_cookies() -> None: """estimate_via_cian_valuation returns None when no cookies in DB (graceful).""" from app.services.scraper_adapters import RealScraperConfig db = MagicMock() # cache miss db.execute.return_value.mappings.return_value.first.return_value = None async def _run() -> None: with patch( "scraper_kit.providers.cian.valuation.load_session", return_value=None, # no cookies stored ): from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation result = await estimate_via_cian_valuation( db, config=RealScraperConfig(), address="ЕКБ, ул. Учителей, 18", total_area=38.8, rooms_count=1, floor=4, total_floors=16, use_cache=True, ) assert result is None anyio.run(_run) # ── Estimator integration tests ─────────────────────────────────────────────── def _make_fake_geo(): """Return GeocodeResult with provider field (not source).""" from app.services.geocoder import GeocodeResult return GeocodeResult( lat=56.838, lon=60.595, full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", provider="nominatim", ) def _make_payload(): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="ЕКБ, ул. Учителей, 18", area_m2=38.8, rooms=1, floor=4, total_floors=16, ) def _common_patches(cian_mock): """Return list of context managers for all sources except cian_valuation.""" return [ patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch( "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None), ), patch( "app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None), ), patch( "app.services.estimator.estimate_via_cian_valuation", new=cian_mock, ), # #648 S3: stub asking→sold lookup off (isolates cian-source assertions). patch( "app.services.estimator._get_asking_sold_ratio", return_value=(None, None), ), ] def test_estimator_includes_cian_valuation_when_available() -> None: """When cian_valuation returns result with sale_price_rub, sources_used includes it.""" from app.services.estimator import estimate_quality from app.services.scraper_adapters import RealScraperConfig db = MagicMock() payload = _make_payload() fake_cian = _fake_cian_result() cian_mock = AsyncMock(return_value=fake_cian) async def _run() -> None: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch( "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) ), patch( "app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None), ), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) assert "cian_valuation" in result.sources_used # #2337 regression guard (Group E4): estimate_via_cian_valuation's config # param is MANDATORY on the kit function (TypeError if omitted) — but this # mock wouldn't catch a missing/None config kwarg. Explicit check mirrors # the #2306 cian_price_history guard pattern for this higher-stakes path. _, call_kwargs = cian_mock.call_args assert isinstance(call_kwargs.get("config"), RealScraperConfig) anyio.run(_run) def test_estimator_graceful_when_cian_returns_none() -> None: """When estimate_via_cian_valuation returns None (no cookies), estimator continues.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() cian_mock = AsyncMock(return_value=None) async def _run() -> None: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch( "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) ), patch( "app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None), ), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) assert "cian_valuation" not in result.sources_used assert result.estimate_id is not None # estimator still returned a result anyio.run(_run) def test_estimator_graceful_when_cian_raises() -> None: """When estimate_via_cian_valuation raises, estimator logs warning and continues.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() cian_mock = AsyncMock(side_effect=RuntimeError("network timeout")) async def _run() -> None: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch( "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) ), patch( "app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None), ), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) assert "cian_valuation" not in result.sources_used assert result.estimate_id is not None anyio.run(_run) def test_estimator_cian_result_no_sale_price_not_added() -> None: """When cian_valuation returns result with sale_price_rub=None, not added to sources.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() # Result returned but no sale price (e.g. partial parse) fake_cian_no_price = _fake_cian_result(sale_price_rub=None) cian_mock = AsyncMock(return_value=fake_cian_no_price) async def _run() -> None: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch( "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) ), patch( "app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None), ), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) assert "cian_valuation" not in result.sources_used anyio.run(_run)