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
294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""#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
|