gendesign/tradein-mvp/backend/tests/test_estimator_cian_integration.py
bot-backend 27bbd579c3
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 47s
chore(tradein/scrapers): удалить весь legacy scrapers/ каталог — final E (#2397, #2277)
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5)
убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main.

Заодно:
- удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/),
  импортировавших уже-удалённые или удаляемые сейчас legacy-модули
- тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET
  тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.*
  эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete
  golden-parity тестов, потерявших legacy-оракл
- kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config —
  ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(),
  MagicMock HouseMatcher, region_code=66)

Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка
#2208 (test_search_cache_hit, не связан со scrapers).
2026-07-04 15:58:15 +03:00

278 lines
12 KiB
Python
Raw 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 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)