All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
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
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m48s
Group E4 (final, highest-risk step of scraper_kit migration epic #2277): estimator.py and house_imv_backfill.py's avito_imv/cian_valuation/ yandex_valuation call sites now import from scraper_kit.providers.* instead of app.services.scrapers.*, following the exact wiring proven safe by E1/E2/E3 (#2334/#2335/#2336): - estimator.py's avito IMV (_get_or_fetch_imv_cached, both call sites) and house_imv_backfill.py's _process_one_house: add config=RealScraperConfig() — kit's evaluate_via_imv silently drops the configured proxy without it. - estimator.py's cian valuation (Stage 9): add config=RealScraperConfig() — mandatory kwarg on the kit function (TypeError if omitted). - estimator.py's yandex valuation: add config=RealScraperConfig() (mandatory) and delay_provider=get_scraper_delay — without it kit silently falls back to a hardcoded 5.0s throttle instead of the DB-configured anti-ban delay. - All exception classes imported consistently from the same kit module as evaluate_via_imv (not just the function) — mixing legacy/kit exception classes would break `except IMVAddressNotFoundError` etc. via identity mismatch (caught by an existing test that assumed the legacy class, fixed alongside). Observability: both cian_valuation and yandex_valuation graceful-degradation except-blocks upgraded from logger.warning to logger.exception. GlitchTip's LoggingIntegration listens at event_level=ERROR (main.py/scheduler_main.py) — a WARNING never reaches GlitchTip as an event regardless of exc_info, so a future config-wiring mistake at these call sites needs ERROR level to be visible in monitoring. house_imv_backfill.py: RealScraperConfig is imported lazily inside _process_one_house (not at module level) to avoid a circular import — app.services.scraper_adapters imports backfill_house_imv/ process_houses_imv_batch from this module at module level. Verified via direct import in both orders plus a full `app.main` import. Also fixes a stale docstring claiming process_houses_imv_batch is "not wired into scheduler" — it is, via scrape_pipeline.py's run_avito_city_sweep. Test updates: 2 pre-existing tests (test_backfill_wave2.py) mocked the legacy IMVAddressNotFoundError/IMVEvaluation/IMVGeo classes, now updated to import from scraper_kit to match the production exception identity. Added config=/delay_provider= regression-guard asserts to the relevant estimator and backfill tests, mirroring the existing #2306 cian_price_history pattern. Legacy app/services/scrapers/{avito_imv,cian_valuation,yandex_valuation}.py are untouched and still imported by cian_history_backfill.py's valuation block (separately scoped, not touched here) — revert is a clean single-commit revert, no schema/data migration involved.
241 lines
8.6 KiB
Python
241 lines
8.6 KiB
Python
"""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 scraper_kit.providers.yandex.valuation import (
|
||
ValuationHistoryItem,
|
||
ValuationHouseMeta,
|
||
YandexValuationResult,
|
||
)
|
||
|
||
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.scraper_settings import get_scraper_delay
|
||
|
||
|
||
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."""
|
||
from app.services.scraper_adapters import RealScraperConfig
|
||
|
||
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
|
||
) as mock_scraper_cls:
|
||
out = await _get_or_fetch_yandex_valuation_cached(db, address=fresh.address)
|
||
assert out is fresh
|
||
fake_scraper.fetch_house_history.assert_awaited_once()
|
||
# #2337 regression guard (Group E4): kit YandexValuationScraper requires config=
|
||
# (TypeError if omitted) AND silently falls back to a hardcoded 5.0s throttle
|
||
# instead of the DB-configured anti-ban delay unless delay_provider= is passed.
|
||
# assert_called alone wouldn't catch either kwarg being dropped later.
|
||
call_args, call_kwargs = mock_scraper_cls.call_args
|
||
assert isinstance(call_args[0], RealScraperConfig)
|
||
assert call_kwargs.get("delay_provider") is get_scraper_delay
|
||
# 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
|
||
# 1 batch INSERT (executemany) + 1 commit
|
||
assert db.execute.call_count == 1
|
||
args = db.execute.call_args
|
||
rows = args.args[1]
|
||
assert isinstance(rows, list) and len(rows) == 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 calls pass a list-of-dicts (batch executemany); ext_id extracted from each row
|
||
ext_ids_1 = [
|
||
r["ext_id"]
|
||
for c in db1.execute.call_args_list
|
||
if isinstance(c.args[1], list)
|
||
for r in c.args[1]
|
||
]
|
||
ext_ids_2 = [
|
||
r["ext_id"]
|
||
for c in db2.execute.call_args_list
|
||
if isinstance(c.args[1], list)
|
||
for r in c.args[1]
|
||
]
|
||
assert ext_ids_1 == ext_ids_2
|
||
assert len(ext_ids_1) == 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()
|