"""Tests for Yandex Valuation integration in estimator.py.""" 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 datetime import date from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.services.estimator import ( YANDEX_VALUATION_CACHE_TTL_HOURS, _get_or_fetch_yandex_valuation_cached, _save_yandex_history_items, _yandex_valuation_cache_key, ) from app.services.scrapers.yandex_valuation import ( ValuationHistoryItem, ValuationHouseMeta, YandexValuationResult, ) def _sample_result(address: str = "Екатеринбург, ул. Учителей, 18") -> YandexValuationResult: return YandexValuationResult( address=address, offer_category="APARTMENT", offer_type="SELL", page=1, source_url=( f"https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address={address}" ), house=ValuationHouseMeta( year_built=1981, total_floors=9, house_type="panel", ceiling_height=2.5, has_lift=True, total_objects=12, ), history_items=[ ValuationHistoryItem( area_m2=36.8, rooms=1, floor=8, start_price=4_500_000, start_price_per_m2=122_283, last_price=4_400_000, last_price_per_m2=119_566, publish_date=date(2026, 2, 17), exposure_days=96, status="В продаже", ), ValuationHistoryItem( area_m2=64.4, rooms=2, floor=1, start_price=6_580_000, start_price_per_m2=102_174, last_price=6_100_000, last_price_per_m2=94_721, publish_date=date(2026, 2, 2), exposure_days=111, status="В продаже", ), ], ) def test_cache_key_deterministic(): k1 = _yandex_valuation_cache_key("addr X", "APARTMENT", "SELL") k2 = _yandex_valuation_cache_key("addr X", "APARTMENT", "SELL") assert k1 == k2 assert len(k1) == 64 def test_cache_key_different_for_different_inputs(): a = _yandex_valuation_cache_key("addr X", "APARTMENT", "SELL") b = _yandex_valuation_cache_key("addr Y", "APARTMENT", "SELL") c = _yandex_valuation_cache_key("addr X", "HOUSE", "SELL") d = _yandex_valuation_cache_key("addr X", "APARTMENT", "RENT") assert len({a, b, c, d}) == 4 def test_ttl_default_24h(): assert YANDEX_VALUATION_CACHE_TTL_HOURS == 24 @pytest.mark.asyncio async def test_cache_hit_returns_deserialized_result(): """When cache row exists and hasn't expired, return parsed YandexValuationResult.""" db = MagicMock() result = _sample_result() db.execute.return_value.mappings.return_value.first.return_value = { "raw_payload": result.model_dump(mode="json"), "fetched_at": None, } with patch("app.services.estimator.YandexValuationScraper") as mock_scraper_cls: out = await _get_or_fetch_yandex_valuation_cached(db, address=result.address) mock_scraper_cls.assert_not_called() # no fetch on hit assert out is not None assert out.address == result.address assert len(out.history_items) == 2 @pytest.mark.asyncio async def test_cache_miss_triggers_fetch_and_persist(): """On cache miss, fetch via scraper + INSERT into external_valuations.""" db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = None # miss fresh = _sample_result() fake_scraper = MagicMock() fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper) fake_scraper.__aexit__ = AsyncMock(return_value=False) fake_scraper.fetch_house_history = AsyncMock(return_value=fresh) with patch("app.services.estimator.YandexValuationScraper", return_value=fake_scraper): out = await _get_or_fetch_yandex_valuation_cached(db, address=fresh.address) assert out is fresh fake_scraper.fetch_house_history.assert_awaited_once() # 2 DB calls: 1 SELECT (miss) + 1 INSERT/UPSERT assert db.execute.call_count >= 2 @pytest.mark.asyncio async def test_fetch_error_returns_none_gracefully(): db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = None fake_scraper = MagicMock() fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper) fake_scraper.__aexit__ = AsyncMock(return_value=False) fake_scraper.fetch_house_history = AsyncMock(side_effect=RuntimeError("network")) with patch("app.services.estimator.YandexValuationScraper", return_value=fake_scraper): out = await _get_or_fetch_yandex_valuation_cached(db, address="addr") assert out is None @pytest.mark.asyncio async def test_fetch_returns_none_propagates_none(): db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = None fake_scraper = MagicMock() fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper) fake_scraper.__aexit__ = AsyncMock(return_value=False) fake_scraper.fetch_house_history = AsyncMock(return_value=None) with patch("app.services.estimator.YandexValuationScraper", return_value=fake_scraper): out = await _get_or_fetch_yandex_valuation_cached(db, address="addr") assert out is None def test_save_history_items_inserts_each(): db = MagicMock() result = _sample_result() with patch( "app.services.estimator.match_or_create_house", return_value=(1, 0.9, "fingerprint"), ): saved = _save_yandex_history_items(db, result) assert saved == 2 # 2 INSERTs (one per item) + 1 commit assert db.execute.call_count == 2 db.commit.assert_called_once() def test_save_history_items_empty_no_commit(): db = MagicMock() result = YandexValuationResult( address="x", offer_category="APARTMENT", offer_type="SELL", page=1, source_url="https://x", house=ValuationHouseMeta(), history_items=[], ) # match_or_create_house must NOT be called when there are no items (early return) with patch("app.services.estimator.match_or_create_house") as m: saved = _save_yandex_history_items(db, result) assert saved == 0 m.assert_not_called() db.execute.assert_not_called() db.commit.assert_not_called() def test_save_history_items_ext_id_stable_across_calls(): """Same result → same ext_item_id sequence → idempotent UPSERT.""" db1 = MagicMock() db2 = MagicMock() result = _sample_result() with patch( "app.services.estimator.match_or_create_house", return_value=(1, 0.9, "fingerprint"), ): _save_yandex_history_items(db1, result) _save_yandex_history_items(db2, result) # Both got same call params (ext_id derived from same seed) ext_ids_1 = [c.args[1]["ext_id"] for c in db1.execute.call_args_list] ext_ids_2 = [c.args[1]["ext_id"] for c in db2.execute.call_args_list] assert ext_ids_1 == ext_ids_2 def test_save_history_items_db_error_rolls_back_batch(): """Any item failing rolls back the whole batch — batch semantics (finding #5).""" db = MagicMock() db.execute.side_effect = [RuntimeError("first row fails"), None] result = _sample_result() with patch( "app.services.estimator.match_or_create_house", return_value=(1, 0.9, "fingerprint"), ): saved = _save_yandex_history_items(db, result) assert saved == 0 # whole batch rolled back db.rollback.assert_called_once() db.commit.assert_not_called()