_ACTIVE_STATUSES = frozenset({"sales", "construction"}) — английский словарь
никогда не совпадал с domrf_kn_objects.site_status, который scraper берёт
СЫРЫМ из siteStatus дом.рф (domrf_kn.py:316). Реальные prod-значения
русские: «Строящиеся»/«Сданные».
Прод-аудит:
- data/sql/105_add_sales_started_flag.sql фильтрует по 'Строящиеся' (~1322 строки).
- partial index 66_indexes_recommend.sql использует те же.
- analytics_queries.py, MarketTab.tsx, CompetitorTable.tsx — все на русских.
Эффект: у ВСЕХ Competitor в POST /parcels/{cad}/competitors is_active=False
и CompetitorsSummary.active_count=0 при любых данных — типизированный
контракт систематически врал.
Patch: _ACTIVE_STATUSES = frozenset({"Строящиеся"}). Заодно обновил два
unit-теста которые кодировали баг (использовали "sales"/"construction"
в моках, тестировали логику против сломанного словаря). Теперь моки
матчат реальную prod-форму.
51/51 competitors-тестов зелёные. ruff clean.
Closes #1213
422 lines
15 KiB
Python
422 lines
15 KiB
Python
"""Тесты для POST /api/v1/parcels/{cad_num}/competitors (Issue #112).
|
||
|
||
Mock-based — не требуют живой БД.
|
||
Паттерн mock DB: аналогично test_admin_cadastre.py — dependency_overrides[get_db].
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
|
||
|
||
|
||
def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock:
|
||
"""Строка центроида участка."""
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k]
|
||
return r
|
||
|
||
|
||
def _obj_row(
|
||
obj_id: int = 1,
|
||
distance_m: float = 400.0,
|
||
site_status: str = "Строящиеся",
|
||
obj_class: str | None = "comfort",
|
||
velocity: float = 5.0,
|
||
flat_count: int | None = 200,
|
||
) -> MagicMock:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {
|
||
"obj_id": obj_id,
|
||
"comm_name": f"ЖК-{obj_id}",
|
||
"dev_name": "TestDev",
|
||
"obj_class": obj_class,
|
||
"latitude": 56.838 + distance_m / 1_000_000,
|
||
"longitude": 60.605 + distance_m / 1_000_000,
|
||
"flat_count": flat_count,
|
||
"site_status": site_status,
|
||
"distance_m": distance_m,
|
||
"velocity_per_month": velocity,
|
||
}[k]
|
||
return r
|
||
|
||
|
||
def _price_row(obj_id: int, price: float) -> MagicMock:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k]
|
||
return r
|
||
|
||
|
||
# ── Построение mock DB ────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_db(
|
||
coord: MagicMock | None = None,
|
||
obj_rows: list[MagicMock] | None = None,
|
||
price_rows: list[MagicMock] | None = None,
|
||
) -> MagicMock:
|
||
"""Сконструировать mock Session.
|
||
|
||
Порядок вызовов execute:
|
||
1. centroid query → coord
|
||
2. competitors query → obj_rows
|
||
3. avg_price query → price_rows
|
||
"""
|
||
db = MagicMock()
|
||
|
||
results: list[MagicMock] = []
|
||
for rows, is_first in [
|
||
(coord, True),
|
||
(obj_rows or [], False),
|
||
(price_rows or [], False),
|
||
]:
|
||
result = MagicMock()
|
||
if is_first:
|
||
# centroid → .mappings().first()
|
||
result.mappings.return_value.first.return_value = rows
|
||
else:
|
||
result.mappings.return_value.all.return_value = rows
|
||
results.append(result)
|
||
|
||
db.execute.side_effect = results
|
||
return db
|
||
|
||
|
||
def _override_db(db: MagicMock):
|
||
def _get_db_override():
|
||
yield db
|
||
|
||
return _get_db_override
|
||
|
||
|
||
# ── Тесты ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_competitors_basic() -> None:
|
||
"""3 конкурента → корректная форма ответа, сортировка по distance."""
|
||
rows = [
|
||
_obj_row(obj_id=1, distance_m=200.0, velocity=4.0),
|
||
_obj_row(obj_id=2, distance_m=500.0, velocity=6.0),
|
||
_obj_row(obj_id=3, distance_m=900.0, velocity=2.0),
|
||
]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:123/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert "competitors" in body
|
||
assert "summary" in body
|
||
assert len(body["competitors"]) == 3
|
||
# первый должен быть ближайшим
|
||
assert body["competitors"][0]["obj_id"] == 1
|
||
assert body["competitors"][0]["distance_m"] == pytest.approx(200.0)
|
||
# все поля присутствуют
|
||
first = body["competitors"][0]
|
||
for key in (
|
||
"obj_id",
|
||
"comm_name",
|
||
"dev_name",
|
||
"obj_class",
|
||
"distance_m",
|
||
"lat",
|
||
"lng",
|
||
"stage",
|
||
"flats_total",
|
||
"flats_sold",
|
||
"sold_pct",
|
||
"velocity_per_month",
|
||
"avg_price_per_m2",
|
||
"is_active",
|
||
):
|
||
assert key in first, f"missing key: {key}"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_summary_calc() -> None:
|
||
"""summary: total_competitors, active_count, weighted_avg_velocity корректны.
|
||
|
||
site_status — реальные русские значения как у домрф (#1213 fix).
|
||
«Строящиеся» считаются активными, «Сданные» — нет.
|
||
"""
|
||
rows = [
|
||
_obj_row(obj_id=1, site_status="Строящиеся", velocity=10.0),
|
||
_obj_row(obj_id=2, site_status="Строящиеся", velocity=6.0),
|
||
_obj_row(obj_id=3, site_status="Сданные", velocity=2.0),
|
||
]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
summary = resp.json()["summary"]
|
||
assert summary["total_competitors"] == 3
|
||
assert summary["active_count"] == 2 # sales + construction
|
||
# equal flats_total (200 each) → size-weighted AVG == naive: (10+6+2)/3 = 6.0
|
||
assert summary["weighted_avg_velocity"] == pytest.approx(6.0)
|
||
assert summary["radius_km"] == pytest.approx(1.0)
|
||
assert summary["time_window"] == "last_quarter"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_weighted_velocity_by_size() -> None:
|
||
"""weighted_avg_velocity взвешен по flats_total — крупный ЖК весит больше (#949)."""
|
||
rows = [
|
||
_obj_row(obj_id=1, velocity=40.0, flat_count=500),
|
||
_obj_row(obj_id=2, velocity=2.0, flat_count=20),
|
||
]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
wav = resp.json()["summary"]["weighted_avg_velocity"]
|
||
# size-weighted = (40*500 + 2*20) / (500+20) = 20040/520 ≈ 38.54,
|
||
# НЕ наивное среднее (40+2)/2 = 21.0 — доказывает взвешивание по flats_total.
|
||
assert wav == pytest.approx(38.54, abs=0.05)
|
||
assert wav != pytest.approx(21.0, abs=0.1)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_exclude_obj_ids() -> None:
|
||
"""exclude_obj_ids исключает указанные ЖК из результата."""
|
||
rows = [
|
||
_obj_row(obj_id=1, distance_m=100.0),
|
||
_obj_row(obj_id=2, distance_m=200.0),
|
||
_obj_row(obj_id=3, distance_m=300.0),
|
||
]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
ids = [c["obj_id"] for c in resp.json()["competitors"]]
|
||
assert 2 not in ids
|
||
assert 1 in ids
|
||
assert 3 in ids
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_obj_class_filter() -> None:
|
||
"""obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается."""
|
||
rows = [_obj_row(obj_id=10, obj_class="economy")]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
comps = resp.json()["competitors"]
|
||
assert len(comps) == 1
|
||
assert comps[0]["obj_class"] == "economy"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_time_window_velocity() -> None:
|
||
"""time_window влияет на velocity_per_month (last_month vs last_year)."""
|
||
# Здесь проверяем, что endpoint принимает оба варианта без ошибок
|
||
# и возвращает velocity из mock-строки (DB-расчёт мокирован).
|
||
rows_month = [_obj_row(obj_id=1, velocity=12.0)]
|
||
rows_year = [_obj_row(obj_id=1, velocity=3.0)]
|
||
|
||
from app.core.db import get_db
|
||
|
||
# last_month
|
||
db_m = _make_db(coord=_coord_row(), obj_rows=rows_month)
|
||
app.dependency_overrides[get_db] = _override_db(db_m)
|
||
try:
|
||
client = TestClient(app)
|
||
resp_m = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_month"},
|
||
)
|
||
assert resp_m.status_code == 200, resp_m.text
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
# last_year
|
||
db_y = _make_db(coord=_coord_row(), obj_rows=rows_year)
|
||
app.dependency_overrides[get_db] = _override_db(db_y)
|
||
try:
|
||
client = TestClient(app)
|
||
resp_y = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_year"},
|
||
)
|
||
assert resp_y.status_code == 200, resp_y.text
|
||
v_month = resp_m.json()["competitors"][0]["velocity_per_month"]
|
||
v_year = resp_y.json()["competitors"][0]["velocity_per_month"]
|
||
# month velocity выше чем year в нашем моке
|
||
assert v_month > v_year
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_parcel_not_found_404() -> None:
|
||
"""Если центроид участка не найден → 404."""
|
||
db = _make_db(coord=None) # first() вернёт None
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/99:99:9999999:999/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 404, resp.text
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_empty_radius() -> None:
|
||
"""Нет конкурентов в радиусе → пустой список + summary с нулями."""
|
||
db = _make_db(coord=_coord_row(), obj_rows=[])
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 0.1, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["competitors"] == []
|
||
assert body["summary"]["total_competitors"] == 0
|
||
assert body["summary"]["active_count"] == 0
|
||
assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_sold_pct_null() -> None:
|
||
"""sold_pct и flats_sold — None (MVP: данные недоступны из domrf_kn_objects).
|
||
|
||
Полный расчёт продаж требует JOIN с domrf_kn_flats COUNT WHERE status='sold'
|
||
— отложен за пределы текущего PR.
|
||
"""
|
||
rows = [_obj_row(obj_id=1, flat_count=200)]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
comp = resp.json()["competitors"][0]
|
||
assert comp["flats_sold"] is None
|
||
assert comp["sold_pct"] is None
|
||
assert comp["flats_total"] == 200
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_avg_price_populated() -> None:
|
||
"""avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой.
|
||
|
||
Регрессионный тест для Issue #227: фильтр status='sold' давал 0 строк
|
||
(поле status в domrf_kn_flats 99.8% NULL). После фикса — убран, AVG
|
||
считается по всем квартирам с known price_per_m2.
|
||
"""
|
||
rows = [_obj_row(obj_id=1)]
|
||
price_rows = [_price_row(obj_id=1, price=150_000.0)]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
comp = resp.json()["competitors"][0]
|
||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||
)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_is_active_flag() -> None:
|
||
"""is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
|
||
False для 'Сданные'/null/прочих."""
|
||
rows = [
|
||
_obj_row(obj_id=1, site_status="Строящиеся"),
|
||
_obj_row(obj_id=2, site_status="Строящиеся"),
|
||
_obj_row(obj_id=3, site_status="Сданные"),
|
||
]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
|
||
assert comps[1]["is_active"] is True
|
||
assert comps[2]["is_active"] is True
|
||
assert comps[3]["is_active"] is False
|
||
finally:
|
||
app.dependency_overrides.clear()
|