gendesign/tradein-mvp/backend/tests/test_estimator_yandex_integration.py
bot-backend 17d558b18c
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 2m12s
fix(tradein/yandex): снести признак «панорама» — его нет на площадке (#2851)
2026-08-12 20:44:11 +00:00

270 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии (фильтр по SQL, не по позиции)."""
for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]):
return call.args[1]
return []
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). Фильтруем по SQL, а не по позиции вызова:
# #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой.
rows = _history_rows(db)
assert isinstance(rows, list) and len(rows) == 2
# Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе
# с колонкой, хвост #2674, мигр. 259.
assert db.commit.call_count == 1
def test_save_history_items_empty_no_commit():
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь
match_or_create_house вызывается до возврата — а записей по-прежнему ноль:
истории нет, вставлять нечего.
"""
db = MagicMock()
result = YandexValuationResult(
address="x",
offer_category="APARTMENT",
offer_type="SELL",
page=1,
source_url="https://x",
house=ValuationHouseMeta(),
history_items=[],
)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(1, 0.9, "fingerprint"),
) as m:
saved = _save_yandex_history_items(db, result)
assert saved == 0
m.assert_called_once()
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).
#2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе
и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы
не тот путь, стоит появиться любому новому execute перед вставкой истории.
"""
db = MagicMock()
def _fail_history(sql, *args, **kwargs):
if "INSERT INTO house_placement_history" in str(sql):
raise RuntimeError("first row fails")
return MagicMock()
db.execute.side_effect = _fail_history
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()
assert db.commit.call_count == 0