feat(tradein): cian_valuation.py — auth-required Calculator scraper + 24h cache

Stage 7 (Wave 5). Scraper fetches https://www.cian.ru/kalkulator-nedvizhimosti/
with curl_cffi (impersonate=chrome120) + Cian session cookies from DB (Stage 4).
Parses estimation.{sale,rent}.data, estimationChart 7-point time-series,
houseInfo 15 items, managementCompany, filters.houseId.
Writes to external_valuations (026+029 schema) with 24h TTL + ON CONFLICT upsert.
28 unit tests covering cache key, state parsing, floor/repair enums, cache hit/miss.
This commit is contained in:
lekss361 2026-05-23 16:37:02 +03:00
parent b5d206bfa8
commit 6d99e6a41a
2 changed files with 866 additions and 0 deletions

View file

@ -0,0 +1,465 @@
"""Cian.ru Valuation Calculator scraper — auth required.
URL: https://www.cian.ru/kalkulator-nedvizhimosti/?address=...&totalArea=...
MFE: 'valuation-for-agent-frontend'
Key: 'initialState'
Auth: loads Cian session cookies from cian_session_cookies table (Stage 4).
Cache: 24h TTL по sha256(address + params) in external_valuations table.
State shape (sec 24.324.6 Schema_Cian_SERP_Inventory):
estimation.sale.data.{price, accuracy, priceFrom, priceTo, priceSqm}
estimation.rent.data.{price, accuracy, priceFrom, priceTo, taxPrice}
estimationChart.data.{title.{change, changeValue}, chartData.data[{date, price}]}
houseInfo.data.{items[{title, value}], isRenovation, isEmergency, isCulturalHeritage}
managementCompany.data.{name, address, phones, email, openingHours, chiefName}
filters.houseId Cian's internal house_id
Used as 6th evaluation source в estimator.py (Stage 9).
"""
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlencode
from curl_cffi.requests import AsyncSession
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.cian_session import load_session, mark_session_invalid
from app.services.scrapers.cian_state_parser import extract_state
logger = logging.getLogger(__name__)
VALUATION_BASE_URL = "https://www.cian.ru/kalkulator-nedvizhimosti/"
VALUATION_MFE = "valuation-for-agent-frontend"
# Cian floor enum: floor 1 → floorOne, floor 2 → floorTwo,
# last floor → floorLast, все остальные → floorOther.
_FLOOR_ENUM_SPECIAL = {1: "floorOne", 2: "floorTwo"}
# Cian repair type enum
_REPAIR_TYPE_MAP: dict[str, str] = {
"without": "repairTypeWithout",
"no": "repairTypeWithout",
"cosmetic": "repairTypeCosmetic",
"euro": "repairTypeEuro",
"design": "repairTypeDesign",
}
# Cian chart change direction → normalized storage values (per DDL: increase/decrease/neutral)
_CHANGE_DIR_MAP: dict[str, str] = {
"increase": "increase",
"decrease": "decrease",
"neutral": "neutral",
"up": "increase",
"down": "decrease",
}
def _cian_floor_enum(floor: int, total_floors: int | None) -> str:
if floor in _FLOOR_ENUM_SPECIAL:
return _FLOOR_ENUM_SPECIAL[floor]
if total_floors is not None and floor == total_floors:
return "floorLast"
return "floorOther"
@dataclass
class CianValuationResult:
"""Cian Valuation Calculator response parsed."""
# Sale estimate
sale_price_rub: float | None = None
sale_accuracy: float | None = None
sale_price_from: float | None = None
sale_price_to: float | None = None
sale_price_sqm: float | None = None
# Rent estimate (Cian-specific — both sale + rent per 1 request)
rent_price_rub: float | None = None
rent_accuracy: float | None = None
rent_price_from: float | None = None
rent_price_to: float | None = None
rent_tax_price: float | None = None
# Chart (7 monthly points for this specific apartment)
chart: list[dict[str, Any]] = field(default_factory=list)
chart_change_pct: float | None = None
chart_change_direction: str | None = None # increase/decrease/neutral
# House info (15 items: year, type, floors, etc.)
house_info: list[dict[str, Any]] = field(default_factory=list)
external_house_id: int | None = None # filters.houseId
filters_hash: str | None = None # estimation.sale.data.filtersHash
# Management company (unique to Cian Valuation)
management_company: dict[str, Any] | None = None
# Authentication state
is_authenticated: bool = False
user_id: int | None = None
# Raw payload (full state for raw_payload column)
raw_state: dict[str, Any] | None = None
def compute_cache_key(
address: str,
total_area: float,
rooms_count: int,
floor: int,
repair_type: str,
deal_type: str,
) -> str:
"""sha256 of normalized params for 24h cache lookup."""
norm = (
f"{address.strip().lower()}|{total_area}|{rooms_count}|{floor}"
f"|{repair_type}|{deal_type}"
)
return hashlib.sha256(norm.encode("utf-8")).hexdigest()
async def estimate_via_cian_valuation(
db: Session,
*,
address: str,
total_area: float,
rooms_count: int,
floor: int,
total_floors: int | None = None,
repair_type: str = "cosmetic",
deal_type: str = "sale",
use_cache: bool = True,
) -> CianValuationResult | None:
"""Estimate value via Cian's authenticated Valuation Calculator.
Выполняет 1 GET на cian.ru/kalkulator-nedvizhimosti с cookies из DB,
парсит state['estimation'] + estimationChart + houseInfo + managementCompany.
Returns CianValuationResult, or None если cookies expired/invalid/fetch failed.
Graceful: не кидает исключений caller может продолжить без этого source.
"""
cache_key = compute_cache_key(address, total_area, rooms_count, floor, repair_type, deal_type)
# 1. Cache lookup (только если use_cache=True)
if use_cache:
cached = _load_from_cache(db, cache_key)
if cached is not None:
logger.info("Cian valuation cache HIT for cache_key=%s...", cache_key[:12])
return cached
# 2. Load auth cookies из cian_session_cookies
cookies = load_session(db)
if cookies is None:
logger.warning("Cian valuation: no auth session in DB — skipping (graceful)")
return None
# 3. Build URL с корректными enum values
params: dict[str, str] = {
"address": address,
"totalArea": str(total_area),
"roomsCount": str(rooms_count),
"valuationType": deal_type,
"floor[0]": _cian_floor_enum(floor, total_floors),
"repairType[0]": _REPAIR_TYPE_MAP.get(repair_type, "repairTypeCosmetic"),
}
url = f"{VALUATION_BASE_URL}?{urlencode(params, safe='[]')}"
# 4. Fetch с TLS fingerprint (curl_cffi, impersonate chrome120)
try:
async with AsyncSession(
impersonate="chrome120",
cookies=cookies,
timeout=25.0,
headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
) as session:
resp = await session.get(url, allow_redirects=True)
if resp.status_code != 200:
logger.warning("Cian valuation fetch → HTTP %d for %s", resp.status_code, url)
return None
html = resp.text
except Exception as exc:
logger.warning("Cian valuation fetch failed: %s", exc)
return None
# 5. Parse state из _cianConfig['valuation-for-agent-frontend']
state = extract_state(html, mfe=VALUATION_MFE, key="initialState")
if state is None:
logger.warning("Cian valuation: state extraction failed (mfe=%s)", VALUATION_MFE)
return None
# 6. Verify auth — если false → session expired
user = state.get("user", {}) or {}
if not user.get("isAuthenticated"):
user_id = user.get("userId")
logger.warning(
"Cian valuation: session not authenticated (userId=%s) — marking invalid", user_id
)
if user_id:
mark_session_invalid(db, user_id)
return None
# 7. Extract structured result
result = _parse_valuation_state(state)
# 8. Persist to cache (24h TTL)
_save_to_cache(
db,
cache_key=cache_key,
address=address,
total_area=total_area,
rooms_count=rooms_count,
floor=floor,
total_floors=total_floors,
repair_type=repair_type,
deal_type=deal_type,
result=result,
)
logger.info(
"Cian valuation extracted: sale=%s (acc=%s%%) rent=%s",
result.sale_price_rub,
result.sale_accuracy,
result.rent_price_rub,
)
return result
def _parse_valuation_state(state: dict[str, Any]) -> CianValuationResult:
"""Extract CianValuationResult from Cian valuation initialState.
Mapping (per Schema_Cian_SERP_Inventory sec 24.424.6):
estimation.sale.data.{price, accuracy, priceFrom, priceTo, priceSqm, filtersHash}
estimation.rent.data.{price, accuracy, priceFrom, priceTo, taxPrice}
estimationChart.data.{title.{change, changeValue}, chartData.data[]}
houseInfo.data.{items[], ...}
filters.houseId
managementCompany.data
"""
result = CianValuationResult(raw_state=state)
user = state.get("user") or {}
result.is_authenticated = bool(user.get("isAuthenticated"))
result.user_id = user.get("userId")
# --- estimation: sale ---
estimation = state.get("estimation") or {}
sale_wrapper = estimation.get("sale") or {}
sale_data = sale_wrapper.get("data") or {}
result.sale_price_rub = _parse_num(sale_data.get("price"))
result.sale_accuracy = _parse_num(sale_data.get("accuracy"))
result.sale_price_from = _parse_num(sale_data.get("priceFrom"))
result.sale_price_to = _parse_num(sale_data.get("priceTo"))
result.sale_price_sqm = _parse_num(sale_data.get("priceSqm"))
result.filters_hash = sale_data.get("filtersHash")
# --- estimation: rent ---
rent_wrapper = estimation.get("rent") or {}
rent_data = rent_wrapper.get("data") or {}
result.rent_price_rub = _parse_num(rent_data.get("price"))
result.rent_accuracy = _parse_num(rent_data.get("accuracy"))
result.rent_price_from = _parse_num(rent_data.get("priceFrom"))
result.rent_price_to = _parse_num(rent_data.get("priceTo"))
result.rent_tax_price = _parse_num(rent_data.get("taxPrice"))
# --- estimationChart: 7 monthly time-series points ---
est_chart = state.get("estimationChart") or {}
chart_outer = est_chart.get("data") or {}
chart_title = chart_outer.get("title") or {}
# changeValue: "8,3%" → parse out float
change_value_str = chart_title.get("changeValue") or ""
result.chart_change_pct = _parse_change_pct(change_value_str)
# change direction: increase / decrease / neutral
change_dir_raw = chart_title.get("change")
if change_dir_raw:
result.chart_change_direction = _CHANGE_DIR_MAP.get(change_dir_raw, change_dir_raw)
chart_data_wrapper = chart_outer.get("chartData") or {}
chart_points = chart_data_wrapper.get("data") or []
if isinstance(chart_points, list):
for entry in chart_points:
if not isinstance(entry, dict):
continue
# date: unix_ms → ISO string "YYYY-MM-DD"
date_ms = entry.get("date")
date_str: str | None = None
if isinstance(date_ms, (int, float)) and date_ms > 0:
import datetime
date_str = datetime.datetime.fromtimestamp(
date_ms / 1000, tz=datetime.UTC
).strftime("%Y-%m-%d")
result.chart.append({
"month_date": date_str or "",
"price": _parse_num(entry.get("price")),
"price_formatted": entry.get("priceFormatted"),
})
# --- houseInfo: 15 items о доме ---
house_info_wrapper = state.get("houseInfo") or {}
house_info_data = house_info_wrapper.get("data") or {}
items = house_info_data.get("items")
if isinstance(items, list):
result.house_info = [i for i in items if isinstance(i, dict)]
# --- filters.houseId: Cian internal house_id ---
filters = state.get("filters") or {}
result.external_house_id = filters.get("houseId")
# --- managementCompany ---
mc_wrapper = state.get("managementCompany") or {}
mc_data = mc_wrapper.get("data")
if isinstance(mc_data, dict) and mc_data:
result.management_company = mc_data
return result
def _parse_num(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def _parse_change_pct(value_str: str) -> float | None:
"""Parse '8,3%' → 8.3, '-2,5%' → -2.5, '' → None."""
if not value_str:
return None
cleaned = value_str.replace(",", ".").replace("%", "").strip()
try:
return float(cleaned)
except ValueError:
return None
def _load_from_cache(db: Session, cache_key: str) -> CianValuationResult | None:
"""SELECT from external_valuations where cache_key + source='cian_valuation' + not expired."""
row = db.execute(
text("""
SELECT
raw_payload,
sale_price_rub,
sale_accuracy,
rent_price_rub,
rent_accuracy,
chart,
chart_change_pct,
chart_change_direction,
external_house_id,
filters_hash
FROM external_valuations
WHERE source = 'cian_valuation'
AND cache_key = :ck
AND expires_at > NOW()
ORDER BY fetched_at DESC
LIMIT 1
"""),
{"ck": cache_key},
).mappings().first()
if row is None:
return None
return CianValuationResult(
sale_price_rub=_parse_num(row["sale_price_rub"]),
sale_accuracy=_parse_num(row["sale_accuracy"]),
rent_price_rub=_parse_num(row["rent_price_rub"]),
rent_accuracy=_parse_num(row["rent_accuracy"]),
chart=row["chart"] if isinstance(row["chart"], list) else [],
chart_change_pct=_parse_num(row["chart_change_pct"]),
chart_change_direction=row["chart_change_direction"],
external_house_id=row["external_house_id"],
filters_hash=row["filters_hash"],
raw_state=row["raw_payload"] if isinstance(row["raw_payload"], dict) else None,
is_authenticated=True, # cache только authenticated results
)
def _save_to_cache(
db: Session,
*,
cache_key: str,
address: str,
total_area: float,
rooms_count: int,
floor: int,
total_floors: int | None,
repair_type: str,
deal_type: str,
result: CianValuationResult,
) -> None:
"""INSERT result into external_valuations (source='cian_valuation') с 24h expiry.
Использует ON CONFLICT (source, cache_key) DO UPDATE для идемпотентности.
Все числовые поля через CAST(:x AS numeric) psycopg v3 safe, без ::type.
"""
try:
db.execute(
text("""
INSERT INTO external_valuations (
source, cache_key, address,
total_area, rooms_count, floor, total_floors,
repair_type, deal_type,
sale_price_rub, sale_accuracy,
rent_price_rub, rent_accuracy,
chart, chart_change_pct, chart_change_direction,
external_house_id, filters_hash,
raw_payload, fetched_at, expires_at
) VALUES (
'cian_valuation', :ck, :addr,
CAST(:ta AS numeric), :rc, :fl, :tf,
:rt, :dt,
CAST(:sp AS numeric), CAST(:sa AS numeric),
CAST(:rp AS numeric), CAST(:ra AS numeric),
CAST(:ch AS jsonb), CAST(:ccp AS numeric), :ccd,
:ehi, :fh,
CAST(:raw AS jsonb), NOW(), NOW() + INTERVAL '24 hours'
)
ON CONFLICT (source, cache_key) DO UPDATE SET
sale_price_rub = EXCLUDED.sale_price_rub,
sale_accuracy = EXCLUDED.sale_accuracy,
rent_price_rub = EXCLUDED.rent_price_rub,
rent_accuracy = EXCLUDED.rent_accuracy,
chart = EXCLUDED.chart,
chart_change_pct = EXCLUDED.chart_change_pct,
chart_change_direction = EXCLUDED.chart_change_direction,
external_house_id = EXCLUDED.external_house_id,
filters_hash = EXCLUDED.filters_hash,
raw_payload = EXCLUDED.raw_payload,
fetched_at = NOW(),
expires_at = NOW() + INTERVAL '24 hours'
"""),
{
"ck": cache_key,
"addr": address,
"ta": total_area,
"rc": rooms_count,
"fl": floor,
"tf": total_floors,
"rt": repair_type,
"dt": deal_type,
"sp": result.sale_price_rub,
"sa": result.sale_accuracy,
"rp": result.rent_price_rub,
"ra": result.rent_accuracy,
"ch": json.dumps(result.chart),
"ccp": result.chart_change_pct,
"ccd": result.chart_change_direction,
"ehi": result.external_house_id,
"fh": result.filters_hash,
"raw": json.dumps(result.raw_state) if result.raw_state else None,
},
)
db.commit()
except Exception as exc:
logger.error("Cian valuation cache save failed: %s", exc, exc_info=True)
raise

View file

@ -0,0 +1,401 @@
"""Tests for cian_valuation.py — Stage 7 Cian Valuation Calculator scraper."""
import os
# Settings требует DATABASE_URL при инициализации (fail-fast).
# Для offline unit-тестов задаём dummy DSN до любого импорта из app.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
os.environ.setdefault("COOKIE_ENCRYPTION_KEY", "test-key-for-tests")
from unittest.mock import MagicMock
import pytest
from app.services.scrapers.cian_valuation import (
CianValuationResult,
_parse_change_pct,
_parse_num,
_parse_valuation_state,
compute_cache_key,
estimate_via_cian_valuation,
)
# ---------------------------------------------------------------------------
# compute_cache_key
# ---------------------------------------------------------------------------
def test_cache_key_stable():
"""Нормализация address: lower + strip дают одинаковый ключ."""
k1 = compute_cache_key("ЕКБ ул. Ленина 1", 50.0, 2, 5, "cosmetic", "sale")
k2 = compute_cache_key("екб ул. ленина 1 ", 50.0, 2, 5, "cosmetic", "sale")
assert k1 == k2
def test_cache_key_distinct_on_deal_type():
k1 = compute_cache_key("addr", 50.0, 2, 5, "cosmetic", "sale")
k2 = compute_cache_key("addr", 50.0, 2, 5, "cosmetic", "rent")
assert k1 != k2
def test_cache_key_distinct_on_area():
k1 = compute_cache_key("addr", 50.0, 2, 5, "cosmetic", "sale")
k2 = compute_cache_key("addr", 51.0, 2, 5, "cosmetic", "sale")
assert k1 != k2
def test_cache_key_is_hex_string():
k = compute_cache_key("addr", 50.0, 2, 5, "cosmetic", "sale")
assert len(k) == 64
assert all(c in "0123456789abcdef" for c in k)
# ---------------------------------------------------------------------------
# _parse_num
# ---------------------------------------------------------------------------
def test_parse_num_float():
assert _parse_num(5.5) == 5.5
def test_parse_num_str():
assert _parse_num("100") == 100.0
def test_parse_num_none():
assert _parse_num(None) is None
def test_parse_num_bad_str():
assert _parse_num("bad") is None
def test_parse_num_int():
assert _parse_num(0) == 0.0
# ---------------------------------------------------------------------------
# _parse_change_pct
# ---------------------------------------------------------------------------
def test_parse_change_pct_positive():
assert _parse_change_pct("8,3%") == pytest.approx(8.3)
def test_parse_change_pct_negative():
assert _parse_change_pct("-2,5%") == pytest.approx(-2.5)
def test_parse_change_pct_empty():
assert _parse_change_pct("") is None
def test_parse_change_pct_dot_format():
assert _parse_change_pct("1.5%") == pytest.approx(1.5)
# ---------------------------------------------------------------------------
# _parse_valuation_state — основные сценарии
# ---------------------------------------------------------------------------
def _make_state(
is_authenticated: bool = True,
user_id: int = 12345,
sale_price: int = 7500000,
sale_accuracy: int = 94,
rent_price: int = 35000,
rent_accuracy: int = 93,
chart_change: str = "increase",
chart_change_value: str = "8,3%",
house_id: int = 54016,
) -> dict:
return {
"user": {"isAuthenticated": is_authenticated, "userId": user_id},
"estimation": {
"sale": {
"data": {
"price": sale_price,
"accuracy": sale_accuracy,
"priceFrom": 6900000,
"priceTo": 8100000,
"priceSqm": 150000,
"dealType": "sale",
"filtersHash": "abc123",
},
"isError": False,
"isFetching": False,
},
"rent": {
"data": {
"price": rent_price,
"accuracy": rent_accuracy,
"priceFrom": 32000,
"priceTo": 38000,
"taxPrice": 1400,
"dealType": "rent",
},
},
},
"estimationChart": {
"data": {
"title": {
"title": "Стоимость этой квартиры увеличилась на {{change}} за полгода",
"changeValue": chart_change_value,
"change": chart_change,
},
"chartData": {
"maxXValue": 1777593600000,
"minXValue": 1761955200000,
"data": [
{"date": 1761955200000, "price": 6033438, "priceFormatted": "6,0 млн ₽"},
{"date": 1764547200000, "price": 6013573, "priceFormatted": "6,0 млн ₽"},
{"date": 1767225600000, "price": 6116587, "priceFormatted": "6,1 млн ₽"},
{"date": 1769904000000, "price": 6236983, "priceFormatted": "6,2 млн ₽"},
{"date": 1772323200000, "price": 6232172, "priceFormatted": "6,2 млн ₽"},
{"date": 1775001600000, "price": 6331112, "priceFormatted": "6,3 млн ₽"},
{"date": 1777593600000, "price": 6532872, "priceFormatted": "6,5 млн ₽"},
],
},
},
},
"houseInfo": {
"data": {
"items": [
{"title": "Год постройки", "value": 2005},
{"title": "Тип дома", "value": "Кирпичный"},
],
"isRenovation": False,
"isEmergency": False,
},
},
"filters": {"houseId": house_id, "roomsCount": 2},
"managementCompany": {
"data": {
"name": "ТСЖ «Учителей, 18»",
"address": "ул. Учителей, 18",
"phones": ["+7 (343) 000-00-00"],
"email": "tsj@example.ru",
"chiefName": "Иванов И.И.",
"openingHours": [{"Пн–Пт": ["9:00", "18:00"]}],
},
},
"page": {"pageType": "valuationPage"},
}
def test_parse_valuation_state_extracts_sale():
state = _make_state()
result = _parse_valuation_state(state)
assert result.is_authenticated is True
assert result.user_id == 12345
assert result.sale_price_rub == 7500000.0
assert result.sale_accuracy == 94.0
assert result.sale_price_from == 6900000.0
assert result.sale_price_to == 8100000.0
assert result.sale_price_sqm == 150000.0
assert result.filters_hash == "abc123"
def test_parse_valuation_state_extracts_rent():
state = _make_state()
result = _parse_valuation_state(state)
assert result.rent_price_rub == 35000.0
assert result.rent_accuracy == 93.0
assert result.rent_price_from == 32000.0
assert result.rent_price_to == 38000.0
assert result.rent_tax_price == 1400.0
def test_parse_valuation_state_chart_7_points():
state = _make_state()
result = _parse_valuation_state(state)
assert len(result.chart) == 7
# First point: unix ms 1761955200000 → 2025-11-01 (UTC)
assert result.chart[0]["month_date"] == "2025-11-01"
assert result.chart[0]["price"] == 6033438.0
def test_parse_valuation_state_chart_change_increase():
state = _make_state(chart_change="increase", chart_change_value="8,3%")
result = _parse_valuation_state(state)
assert result.chart_change_pct == pytest.approx(8.3)
assert result.chart_change_direction == "increase"
def test_parse_valuation_state_chart_change_decrease():
state = _make_state(chart_change="decrease", chart_change_value="-2,5%")
result = _parse_valuation_state(state)
assert result.chart_change_pct == pytest.approx(-2.5)
assert result.chart_change_direction == "decrease"
def test_parse_valuation_state_house_info():
state = _make_state()
result = _parse_valuation_state(state)
assert len(result.house_info) == 2
assert result.house_info[0]["title"] == "Год постройки"
assert result.house_info[0]["value"] == 2005
def test_parse_valuation_state_external_house_id():
state = _make_state(house_id=54016)
result = _parse_valuation_state(state)
assert result.external_house_id == 54016
def test_parse_valuation_state_management_company():
state = _make_state()
result = _parse_valuation_state(state)
assert result.management_company is not None
assert result.management_company["name"] == "ТСЖ «Учителей, 18»"
assert result.management_company["chiefName"] == "Иванов И.И."
def test_parse_valuation_state_no_auth():
state = _make_state(is_authenticated=False)
result = _parse_valuation_state(state)
assert result.is_authenticated is False
# Values still parsed — auth check is done by caller, not parser
assert result.sale_price_rub == 7500000.0
def test_parse_valuation_state_empty_estimation():
state = {"user": {"isAuthenticated": True, "userId": 1}, "estimation": {}}
result = _parse_valuation_state(state)
assert result.sale_price_rub is None
assert result.rent_price_rub is None
assert result.chart == []
def test_parse_valuation_state_missing_management_company():
state = _make_state()
del state["managementCompany"]
result = _parse_valuation_state(state)
assert result.management_company is None
# ---------------------------------------------------------------------------
# CianValuationResult dataclass defaults
# ---------------------------------------------------------------------------
def test_dataclass_defaults():
r = CianValuationResult()
assert r.chart == []
assert r.house_info == []
assert r.is_authenticated is False
assert r.sale_price_rub is None
assert r.management_company is None
# ---------------------------------------------------------------------------
# estimate_via_cian_valuation — integration (mocked)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_estimate_returns_none_when_no_cookies(monkeypatch):
"""Если load_session returns None → graceful return None без crash."""
monkeypatch.setattr(
"app.services.scrapers.cian_valuation.load_session",
lambda db: None,
)
db = MagicMock()
# cache miss
db.execute.return_value.mappings.return_value.first.return_value = None
result = await estimate_via_cian_valuation(
db,
address="ЕКБ",
total_area=50.0,
rooms_count=2,
floor=5,
use_cache=True,
)
assert result is None
@pytest.mark.asyncio
async def test_cache_hit_returns_cached(monkeypatch):
"""Если cache hit → return без fetching (load_session не вызывается)."""
db = MagicMock()
cached_row = {
"raw_payload": {"cached": True},
"sale_price_rub": 7000000,
"sale_accuracy": 80,
"rent_price_rub": 30000,
"rent_accuracy": 65,
"chart": [{"month_date": "2026-05-01", "price": 145000}],
"chart_change_pct": 0.5,
"chart_change_direction": "increase",
"external_house_id": 999,
"filters_hash": "xyz789",
}
db.execute.return_value.mappings.return_value.first.return_value = cached_row
# load_session должен НЕ вызываться при cache hit
monkeypatch.setattr(
"app.services.scrapers.cian_valuation.load_session",
lambda db: pytest.fail("load_session should not be called on cache hit"),
)
result = await estimate_via_cian_valuation(
db,
address="ЕКБ",
total_area=50.0,
rooms_count=2,
floor=5,
use_cache=True,
)
assert result is not None
assert result.sale_price_rub == 7000000.0
assert result.rent_price_rub == 30000.0
assert result.external_house_id == 999
assert result.filters_hash == "xyz789"
assert result.is_authenticated is True # cache implies authenticated
assert len(result.chart) == 1
@pytest.mark.asyncio
async def test_estimate_use_cache_false_skips_cache(monkeypatch):
"""use_cache=False → cache lookup не вызывается, идём сразу в load_session."""
load_session_called = []
def fake_load_session(db: object) -> None:
load_session_called.append(True)
return None # simulate no cookies → None result
monkeypatch.setattr(
"app.services.scrapers.cian_valuation.load_session",
fake_load_session,
)
db = MagicMock()
result = await estimate_via_cian_valuation(
db,
address="ЕКБ",
total_area=50.0,
rooms_count=2,
floor=5,
use_cache=False,
)
assert result is None
assert load_session_called # load_session был вызван (cache bypass worked)
# db.execute НЕ должен быть вызван для cache lookup
db.execute.assert_not_called()