gendesign/tradein-mvp/backend/tests/test_estimator_cian_integration.py
bot-backend 4a6820b8f7
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
feat(tradein/estimator): switch core valuation call sites to scraper_kit (#2337)
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.
2026-07-04 02:40:34 +03:00

269 lines
11 KiB
Python

"""Tests for Cian Valuation integration in estimator.py (Stage 9)."""
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 app.services.scrapers.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 app.services.scrapers.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 app.services.scrapers.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)."""
db = MagicMock()
# cache miss
db.execute.return_value.mappings.return_value.first.return_value = None
async def _run() -> None:
with patch(
"app.services.scrapers.cian_valuation.load_session",
return_value=None, # no cookies stored
):
from app.services.scrapers.cian_valuation import estimate_via_cian_valuation
result = await estimate_via_cian_valuation(
db,
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)