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
Удаляет весь `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).
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""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)
|