gendesign/tradein-mvp/backend/tests/test_cian_valuation.py
bot-backend 50a72f030a fix(tradein): Mera-audit fix-1 — Cian valuation price sanity bounds
Добавляет _is_price_sane() в cian_valuation.py: если sale_price_rub вне
[cian_valuation_min_rub, cian_valuation_max_rub] (дефолты 500k/500M),
или low_price > high_price, или любой bound отрицательный — результат
не кэшируется и возвращается None (graceful). Защита от garbage-ответов
API (999_999 < min, 9_999_999_999 > max).

Новые settings: cian_valuation_min_rub=500_000, cian_valuation_max_rub=500_000_000.
Тесты: 11 новых тест-кейсов в test_cian_valuation.py. Пофиксен pre-existing
KeyError в test_cache_hit_returns_cached (missing low_price/high_price в mock).
2026-06-21 09:02:32 +03:00

477 lines
16 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.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,
_is_price_sane,
_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",
"low_price": 6500000, # required by _load_from_cache → sale_price_from
"high_price": 7500000, # required by _load_from_cache → sale_price_to
}
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
# ---------------------------------------------------------------------------
# Mera-audit fix-1: _is_price_sane — bounds validation
# ---------------------------------------------------------------------------
def test_price_sane_valid_price() -> None:
"""Обычная цена ЕКБ-квартиры — проходит санитарную проверку."""
r = CianValuationResult(
sale_price_rub=7_500_000, sale_price_from=6_900_000, sale_price_to=8_100_000
)
assert _is_price_sane(r) is True
def test_price_sane_none_price() -> None:
"""sale_price_rub=None — проверка цены не судит None (graceful)."""
r = CianValuationResult(sale_price_rub=None)
assert _is_price_sane(r) is True
def test_price_sane_below_min_returns_false() -> None:
"""Цена 999_999 < дефолтный min (500_000... нет, 999_999 > 500_000).
Тест: цена ниже min=500_000 — задаём 100_000 → False."""
r = CianValuationResult(sale_price_rub=100_000)
assert _is_price_sane(r) is False
def test_price_sane_above_max_returns_false() -> None:
"""Цена 9_999_999_999 (> 500М) → False."""
r = CianValuationResult(sale_price_rub=9_999_999_999)
assert _is_price_sane(r) is False
def test_price_sane_at_min_boundary() -> None:
"""Цена ровно на нижней границе дефолта (500_000) → True."""
r = CianValuationResult(sale_price_rub=500_000)
assert _is_price_sane(r) is True
def test_price_sane_at_max_boundary() -> None:
"""Цена ровно на верхней границе дефолта (500_000_000) → True."""
r = CianValuationResult(sale_price_rub=500_000_000)
assert _is_price_sane(r) is True
def test_price_sane_inverted_range_returns_false() -> None:
"""low > high (инвертированный диапазон) → False."""
r = CianValuationResult(
sale_price_rub=7_000_000,
sale_price_from=8_000_000, # from > to
sale_price_to=6_000_000,
)
assert _is_price_sane(r) is False
def test_price_sane_negative_low_returns_false() -> None:
"""sale_price_from < 0 → False."""
r = CianValuationResult(sale_price_rub=7_000_000, sale_price_from=-100)
assert _is_price_sane(r) is False
def test_price_sane_negative_high_returns_false() -> None:
"""sale_price_to < 0 → False."""
r = CianValuationResult(sale_price_rub=7_000_000, sale_price_to=-1)
assert _is_price_sane(r) is False
def test_price_sane_none_bounds_ok() -> None:
"""sale_price_from/to=None при валидной цене → True (bounds не обязательны)."""
r = CianValuationResult(sale_price_rub=7_000_000, sale_price_from=None, sale_price_to=None)
assert _is_price_sane(r) is True
@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()