gendesign/tradein-mvp/backend/tests/test_3234_house_facts_fallback.py
lekss361 6dd8d131ae
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m16s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
feat(mera/estimate): характеристики дома из справочника, а не только из формы (#3242)
2026-08-29 18:06:17 +00:00

294 lines
12 KiB
Python
Raw Permalink 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.

"""#3234: `houses` как fallback-источник total_floors/year_built/house_type в estimate.
Приоритет: payload (пользователь) → houses (наш справочник, локальный, ~87%
покрытия года / ~98% этажности на проде) → OSM Overpass (get_house_metadata,
внешний вызов с таймаутом, house_metadata-кэш — всего 68 строк на базу).
payload НИКОГДА не переопределяется: houses/OSM заполняют только то, что
payload не задал. get_house_metadata должен вызываться ТОЛЬКО если после
payload+houses что-то всё ещё неизвестно (экономия внешнего вызова).
year_built из houses обязан пройти через тот же _sanitize_build_year, что и
payload/OSM (Mera-audit 2026-08-02, прод-инцидент year_built=1829 — см.
test_estimator_implausible_year.py).
Два уровня тестов:
1. _lookup_house_facts() изолированно — маршрутизация SQL (house_id vs
геофолбэк) + best-effort на ошибке БД (без сети).
2. estimate_quality() целиком, offline — зеркалит паттерн
test_estimator_client_coords.py: все внешние вызовы замоканы,
_fetch_analogs захватывает kwargs, чтобы проверить что именно уехало
вниз по пайплайну.
"""
from __future__ import annotations
import contextlib
import os
# Settings требует DATABASE_URL при импорте app.core.config — до любого app-импорта.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
# ── Level 1: _lookup_house_facts() изолированно (без estimate_quality) ─────
def test_lookup_house_facts_uses_house_id_when_present() -> None:
"""target_house_id задан → SQL идёт по id, геофолбэк (ST_DWithin) не участвует."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"id": 42,
"total_floors": 9,
"year_built": 1975,
"house_type": "panel",
"material_walls": None,
}
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
assert facts is not None
assert (facts.house_id, facts.total_floors, facts.year_built, facts.house_type) == (
42,
9,
1975,
"panel",
)
sql_text = str(db.execute.call_args.args[0])
assert "WHERE id = " in sql_text
assert "ST_DWithin" not in sql_text
def test_lookup_house_facts_falls_back_to_geo_radius_when_no_house_id() -> None:
"""target_house_id=None → SQL идёт по ST_DWithin(60м) + ближайший к lat/lon."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"id": 7,
"total_floors": 5,
"year_built": 1968,
"house_type": "brick",
"material_walls": None,
}
facts = _lookup_house_facts(db, target_house_id=None, lat=56.838, lon=60.595)
assert facts is not None
assert facts.house_id == 7
sql_text = str(db.execute.call_args.args[0])
assert "ST_DWithin" in sql_text
assert "60" in sql_text
def test_lookup_house_facts_no_key_returns_none_without_query() -> None:
"""Ни house_id, ни lat/lon — запроса к БД нет вообще, сразу None."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
facts = _lookup_house_facts(db, target_house_id=None, lat=None, lon=None)
assert facts is None
db.execute.assert_not_called()
def test_lookup_house_facts_db_error_is_graceful() -> None:
"""Любая ошибка БД → None + rollback, estimate не должен падать (best-effort)."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
assert facts is None
db.rollback.assert_called_once()
# ── Level 2: estimate_quality() целиком, offline ────────────────────────────
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
def _make_payload(*, year_built=None, house_type=None, total_floors=None):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=38.8,
rooms=1,
floor=4,
lat=56.838,
lon=60.595,
year_built=year_built,
house_type=house_type,
total_floors=total_floors,
)
def _house_facts(*, house_id=777, total_floors=9, year_built=1975, house_type="panel"):
from app.services.estimator import _HouseFacts
return _HouseFacts(
house_id=house_id,
total_floors=total_floors,
year_built=year_built,
house_type=house_type,
material_walls=None,
)
def _house_meta(*, total_floors, year_built, house_type):
from app.services.house_metadata import HouseMetadata
return HouseMetadata(
lat=56.838,
lon=60.595,
year_built=year_built,
total_floors=total_floors,
house_type=house_type,
total_units=None,
source="osm",
)
def _run_estimate(payload, *, house_facts, house_meta):
"""Прогоняет estimate_quality offline. Возвращает (result, _fetch_analogs mock,
get_house_metadata mock) — mocks выжившие после await для инспекции call_args."""
from app.services.estimator import estimate_quality
db = MagicMock()
geocode_mock = AsyncMock(return_value=_make_fake_geo())
house_facts_mock = MagicMock(return_value=house_facts)
house_meta_mock = AsyncMock(return_value=house_meta)
analogs_mock = MagicMock(return_value=([], False, "W"))
async def _go():
with contextlib.ExitStack() as stack:
for cm in (
patch("app.services.estimator.geocode", new=geocode_mock),
patch("app.services.estimator._lookup_house_facts", new=house_facts_mock),
patch("app.services.estimator.get_house_metadata", new=house_meta_mock),
patch("app.services.estimator._fetch_analogs", new=analogs_mock),
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=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
stack.enter_context(cm)
return await estimate_quality(payload, db)
result = anyio.run(_go)
return result, analogs_mock, house_meta_mock
def _floors_seen(mock) -> set:
return {c.kwargs["total_floors"] for c in mock.call_args_list}
def _years_seen(mock) -> set:
return {c.kwargs["year_built"] for c in mock.call_args_list}
def _house_types_seen(mock) -> set:
return {c.kwargs["house_type"] for c in mock.call_args_list}
def test_empty_payload_uses_houses_facts() -> None:
"""payload пустой, houses знает всё → его значения уезжают в _fetch_analogs."""
payload = _make_payload()
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert analogs_mock.call_args_list, "estimate должен вызвать _fetch_analogs хотя бы раз"
assert _floors_seen(analogs_mock) == {9}
assert _years_seen(analogs_mock) == {1975}
assert _house_types_seen(analogs_mock) == {"panel"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None
def test_payload_wins_over_conflicting_houses_facts() -> None:
"""payload задан, houses знает ДРУГОЕ → побеждает payload (user главнее houses)."""
payload = _make_payload(year_built=2015, house_type="brick", total_floors=5)
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert _floors_seen(analogs_mock) == {5}
assert _years_seen(analogs_mock) == {2015}
assert _house_types_seen(analogs_mock) == {"brick"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None
def test_neither_payload_nor_houses_falls_back_to_osm() -> None:
"""Ни payload, ни houses ничего не знают → OSM get_house_metadata вызывается
(старое поведение сохранено, houses тут ничем не помогает)."""
payload = _make_payload()
meta = _house_meta(total_floors=5, year_built=1968, house_type="brick")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=None, house_meta=meta
)
house_meta_mock.assert_awaited()
assert _floors_seen(analogs_mock) == {5}
assert _years_seen(analogs_mock) == {1968}
assert _house_types_seen(analogs_mock) == {"brick"}
assert result.estimate_id is not None
def test_payload_and_houses_cover_everything_skips_osm() -> None:
"""payload частично + houses добивает остальное → OSM НЕ вызывается (экономия
внешнего бюджетируемого вызова, п.5 задачи)."""
payload = _make_payload(year_built=2015) # house_type/total_floors придут из houses
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
house_meta_mock.assert_not_awaited()
assert _floors_seen(analogs_mock) == {9}
assert _years_seen(analogs_mock) == {2015} # payload побеждает для года
assert _house_types_seen(analogs_mock) == {"panel"}
assert result.estimate_id is not None
def test_implausible_year_from_houses_is_sanitized() -> None:
"""houses отдал неправдоподобный год (1829, прод-паттерн) → _sanitize_build_year
отбивает его в None; total_floors/house_type из houses при этом всё равно
применяются (год — независимый гейт, не блокирует остальные поля)."""
payload = _make_payload()
facts = _house_facts(total_floors=9, year_built=1829, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert _years_seen(analogs_mock) == {None}
assert _floors_seen(analogs_mock) == {9}
assert _house_types_seen(analogs_mock) == {"panel"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None