"""#2002: premium_building флаг + класс из curated overlay → MV `premium_houses`. Покрывает read-only helper `_is_premium_building`, который возвращает 3-кортеж `(is_premium, med_ppm2, building_class)` из двухслойного overlay: 1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой (класс + is_premium с false-positive demotions); ПЕРЕБИВАЕТ MV; 2. `premium_houses` MV (data/sql/139) — даёт med_ppm2 + fallback-членство. Сценарии: - curated premium=True с классом → (True, MV.med_ppm2, tier); - curated premium=False демотит дом из MV → (False, med, tier) — false-positive; - невыверенный дом в MV → (True, med, None) — членство есть, класс неизвестен; - дом нигде → (False, None, None); - house_id is None → (False, None, None) без обращения к БД; - таблица отсутствует / SQL-ошибка → graceful (False, None, None), оценка не падает. Плюс smoke-проверка дефолтов в response-модели AggregatedEstimate (флаг — метаданные, сериализация без изменений, когда дом не премиальный). """ from __future__ import annotations import os from typing import Any from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") import pytest from app.services.estimator import _is_premium_building def _make_mock_db( *, curated_row: Any = None, mv_row: Any = None, raises: bool = False ) -> MagicMock: """mock db.execute, маршрутизируя по SQL-тексту. `SELECT ... FROM premium_buildings_curated ...` → curated_row; `SELECT med_ppm2 FROM premium_houses ...` → mv_row. raises=True имитирует отсутствие таблицы (первый же execute бросает). """ mock_db = MagicMock() if raises: mock_db.execute.side_effect = RuntimeError( 'relation "premium_buildings_curated" does not exist' ) return mock_db def _execute(sql: Any, params: Any = None) -> MagicMock: text_sql = str(getattr(sql, "text", sql)) result = MagicMock() if "premium_buildings_curated" in text_sql: result.first.return_value = curated_row elif "premium_houses" in text_sql: result.first.return_value = mv_row else: # pragma: no cover — defensive result.first.return_value = None return result mock_db.execute.side_effect = _execute return mock_db def _executed_sql(db: MagicMock) -> list[str]: """Тексты всех SQL-запросов, прошедших через db.execute.""" return [str(call.args[0].text) for call in db.execute.call_args_list] def test_curated_premium_with_class_overrides_mv() -> None: """curated is_premium=True + tier → (True, MV.med_ppm2, tier); класс из curated.""" db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(422_000,)) assert _is_premium_building(db, 11370) == (True, 422_000, "премиум") # обе таблицы используют CAST(:hid AS bigint) — psycopg v3, не :hid::bigint sqls = _executed_sql(db) assert any("premium_buildings_curated" in s for s in sqls) assert any("premium_houses" in s for s in sqls) for s in sqls: assert "CAST(:hid AS bigint)" in s assert "::bigint" not in s def test_curated_false_positive_demotes_mv_member() -> None: """curated is_premium=False демотит дом-член MV → (False, med, tier). Концентрационный MV пометил дом премиальным, но curation признал его false-positive (mass-market). curated перебивает — флаг False, но med и класс из данных сохраняются как контекст для ревьюера. """ db = _make_mock_db(curated_row=(False, "комфорт"), mv_row=(211_000,)) assert _is_premium_building(db, 9401) == (False, 211_000, "комфорт") def test_curated_class_flows_when_mv_absent() -> None: """curated есть, дома нет в MV → (curated.is_premium, None, tier).""" db = _make_mock_db(curated_row=(True, "бизнес"), mv_row=None) assert _is_premium_building(db, 6685) == (True, None, "бизнес") def test_uncurated_mv_member_premium_true_class_none() -> None: """Невыверенный дом, но член MV → (True, med, None) — класс неизвестен.""" db = _make_mock_db(curated_row=None, mv_row=(255_000,)) assert _is_premium_building(db, 4242) == (True, 255_000, None) def test_not_premium_anywhere_yields_false_none_none() -> None: """Дом отсутствует и в curated, и в MV → (False, None, None).""" db = _make_mock_db(curated_row=None, mv_row=None) assert _is_premium_building(db, 9999) == (False, None, None) def test_house_id_none_short_circuits_without_query() -> None: """house_id is None → (False, None, None), к БД не обращаемся.""" db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(255_000,)) assert _is_premium_building(db, None) == (False, None, None) db.execute.assert_not_called() def test_missing_table_degrades_gracefully() -> None: """Таблица не существует / SQL-ошибка → (False, None, None), без проброса.""" db = _make_mock_db(raises=True) assert _is_premium_building(db, 4242) == (False, None, None) # после ошибки откатываем poisoned-транзакцию db.rollback.assert_called_once() def test_mv_median_null_is_handled() -> None: """Дом в MV, но med_ppm2 NULL → median None (флаг/класс не теряются).""" db = _make_mock_db(curated_row=(True, "элит"), mv_row=(None,)) assert _is_premium_building(db, 7109) == (True, None, "элит") def test_response_model_defaults_non_premium() -> None: """AggregatedEstimate по умолчанию: premium_building=False, median/class=None. Гарантирует, что добавление полей не меняет сериализацию для не-премиум оценок. """ from datetime import UTC, datetime from uuid import uuid4 from app.schemas.trade_in import AggregatedEstimate est = AggregatedEstimate( estimate_id=uuid4(), median_price_rub=10_000_000, range_low_rub=9_000_000, range_high_rub=11_000_000, median_price_per_m2=200_000, confidence="medium", n_analogs=12, period_months=24, analogs=[], actual_deals=[], expires_at=datetime.now(tz=UTC), ) assert est.premium_building is False assert est.premium_building_median_ppm2 is None assert est.premium_building_class is None dumped = est.model_dump() assert dumped["premium_building"] is False assert dumped["premium_building_median_ppm2"] is None assert dumped["premium_building_class"] is None def test_response_model_carries_curated_class() -> None: """premium_building_class сериализуется, когда задан (curated overlay).""" from datetime import UTC, datetime from uuid import uuid4 from app.schemas.trade_in import AggregatedEstimate est = AggregatedEstimate( estimate_id=uuid4(), median_price_rub=42_000_000, range_low_rub=40_000_000, range_high_rub=44_000_000, median_price_per_m2=420_000, confidence="medium", n_analogs=8, period_months=24, analogs=[], actual_deals=[], expires_at=datetime.now(tz=UTC), premium_building=True, premium_building_median_ppm2=422_000, premium_building_class="премиум", ) assert est.model_dump()["premium_building_class"] == "премиум" if __name__ == "__main__": # pragma: no cover raise SystemExit(pytest.main([__file__, "-q"]))