"""#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