All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m29s
Deploy Trade-In / deploy (push) Successful in 52s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
202 lines
8.1 KiB
Python
202 lines
8.1 KiB
Python
"""#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"]))
|