All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 15m6s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
developer_attribution, connection_capacity_lookup (gas/heat helpers), competitors, saturation, supply_layers, and pat_lookup swallowed DB errors without begin_nested()/rollback(), leaving the shared analyze-request Session aborted for every subsequent db.execute (silently breaking persist_analysis_run and the forecast enqueue). zone_regulation's backfill_ekb_zone_regulations gets a plain rollback() since it only ever runs on its own owned Celery-task session. Refs #2464 (cluster A session-poisoning).
749 lines
32 KiB
Python
749 lines
32 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
|
||
|
||
|
||
def _sold_row(obj_id: int, flats_sold: int) -> MagicMock:
|
||
"""Строка sold-count (_SOLD_COUNT_SQL, #1926).
|
||
|
||
flats_sold теперь COUNT проданных лотов из objective_lots.contract_date
|
||
(выровнено с MarketMetrics), а не COUNT по domrf_kn_flats.status — последний
|
||
заполнен ~0.2% и давал массовый недосчёт.
|
||
"""
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "flats_sold": flats_sold}[k]
|
||
return r
|
||
|
||
|
||
def _obj_price_row(obj_id: int, price: float) -> MagicMock:
|
||
"""Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_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,
|
||
sold_rows: list[MagicMock] | None = None,
|
||
obj_price_rows: list[MagicMock] | None = None,
|
||
) -> MagicMock:
|
||
"""Сконструировать mock Session.
|
||
|
||
Порядок вызовов execute (#1926: добавлен отдельный sold-count запрос):
|
||
1. centroid query → coord
|
||
2. competitors query → obj_rows
|
||
3. domrf avg_price query → price_rows
|
||
4. sold-count query (_SOLD_COUNT_SQL, objective_lots.contract_date) → sold_rows
|
||
(по умолчанию [] = sold неизвестен → flats_sold None, нейтральный stage)
|
||
5. objective price fallback (OBJ-3 #307) → obj_price_rows
|
||
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда,
|
||
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
|
||
"""
|
||
db = MagicMock()
|
||
|
||
results: list[MagicMock] = []
|
||
for rows, is_first in [
|
||
(coord, True),
|
||
(obj_rows or [], False),
|
||
(price_rows or [], False),
|
||
(sold_rows or [], False),
|
||
(obj_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, когда sold-count запрос пуст (graceful).
|
||
|
||
#1926: flats_sold берётся из objective_lots.contract_date (_SOLD_COUNT_SQL).
|
||
Если у obj_id нет маппинга в objective_complex_mapping (coverage ~2.5%) →
|
||
sold-count строки нет → flats_sold/sold_pct None, stage → нейтраль 0.5.
|
||
"""
|
||
rows = [_obj_row(obj_id=1, flat_count=200)]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows) # sold_rows default [] → 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/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_flats_sold_from_objective_lots() -> None:
|
||
"""flats_sold/sold_pct заполняются из objective_lots (#1926).
|
||
|
||
Регрессия аудита #1871/#1926: РАНЬШЕ flats_sold считался из
|
||
domrf_kn_flats.status (LIKE '%прод%'), заполненного ~0.2% → массовый
|
||
недосчёт и расхождение с MarketMetrics. Теперь источник И предикат — как
|
||
в market_metrics._STOCK_SQL (is_sold OR contract_date OR status='продан',
|
||
premise_kind='квартира'). Здесь sold-count запрос возвращает 120 проданных
|
||
из 200 → sold_pct 60.0%. (SQL-структуру предиката/fan-out/premise_kind
|
||
проверяют test_sold_count_sql_* ниже — мок тут SQL не исполняет.)
|
||
"""
|
||
rows = [_obj_row(obj_id=1, flat_count=200)]
|
||
sold_rows = [_sold_row(obj_id=1, flats_sold=120)]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows, sold_rows=sold_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"] == 120
|
||
assert comp["sold_pct"] == pytest.approx(60.0)
|
||
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"
|
||
# OBJ-3 #307: domrf-hit → price_source='domrf'.
|
||
assert comp["price_source"] == "domrf"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ── OBJ-3 (#307) additive fallback из objective_lots ────────────────────────────
|
||
|
||
|
||
def test_competitors_objective_price_fallback_fills_gap() -> None:
|
||
"""domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307).
|
||
|
||
Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк,
|
||
поэтому domrf даёт None почти всем. objective_lots (через
|
||
objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену.
|
||
"""
|
||
rows = [_obj_row(obj_id=1)]
|
||
# domrf price пуст → fallback на objective median.
|
||
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_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(136_000.0)
|
||
assert comp["price_source"] == "objective"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_domrf_price_wins_over_objective() -> None:
|
||
"""domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод.
|
||
|
||
Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы.
|
||
"""
|
||
rows = [_obj_row(obj_id=1)]
|
||
price_rows = [_price_row(obj_id=1, price=150_000.0)]
|
||
# objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует.
|
||
obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)]
|
||
db = _make_db(
|
||
coord=_coord_row(),
|
||
obj_rows=rows,
|
||
price_rows=price_rows,
|
||
obj_price_rows=obj_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)
|
||
assert comp["price_source"] == "domrf"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_no_price_anywhere_is_none() -> None:
|
||
"""Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful)."""
|
||
rows = [_obj_row(obj_id=1)]
|
||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_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"] is None
|
||
assert comp["price_source"] is None
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ── SAVEPOINT-регрессия (#2464 cluster A finding 3) ──────────────────────────
|
||
|
||
|
||
def test_competitors_avg_price_failure_does_not_poison_later_queries() -> None:
|
||
"""avg_price query падает → sold-count и objective-fallback (следом на ТОЙ ЖЕ
|
||
Session) всё равно отрабатывают, а не ловят "current transaction is aborted".
|
||
|
||
Раньше avg_price/sold-count/objective-fallback ловили db.execute-сбой без
|
||
SAVEPOINT (db.begin_nested()) — сбой ЛЮБОГО из трёх отравлял shared db для
|
||
ОСТАЛЬНЫХ двух (и для persist_analysis_run дальше по /analyze). Фикс оборачивает
|
||
каждый в свою begin_nested(); здесь ловим именно avg_price и проверяем, что
|
||
objective-fallback (третий execute) всё равно наполняет цену.
|
||
"""
|
||
coord_result = MagicMock()
|
||
coord_result.mappings.return_value.first.return_value = _coord_row()
|
||
obj_result = MagicMock()
|
||
obj_result.mappings.return_value.all.return_value = [_obj_row(obj_id=1)]
|
||
sold_result = MagicMock()
|
||
sold_result.mappings.return_value.all.return_value = []
|
||
obj_price_result = MagicMock()
|
||
obj_price_result.mappings.return_value.all.return_value = [
|
||
_obj_price_row(obj_id=1, price=142_000.0)
|
||
]
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = [
|
||
coord_result,
|
||
obj_result,
|
||
RuntimeError("simulated avg_price DB failure"), # avg_price query сбоит
|
||
sold_result,
|
||
obj_price_result, # objective-fallback ДОЛЖЕН всё равно выполниться
|
||
]
|
||
|
||
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]
|
||
# avg_price сбойнул (без domrf-цены), но objective-fallback наполнил её —
|
||
# это возможно ТОЛЬКО если сбой avg_price не отравил сессию для fallback.
|
||
assert comp["avg_price_per_m2"] == pytest.approx(142_000.0)
|
||
assert comp["price_source"] == "objective"
|
||
# Все 5 запланированных execute реально дошли (sold-count и fallback не
|
||
# были пропущены/не упали каскадом из-за отравленной сессии).
|
||
assert db.execute.call_count == 5
|
||
# avg_price/sold-count/objective-fallback — каждый под своим SAVEPOINT.
|
||
assert db.begin_nested.call_count == 3
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_competitors_multi_object_radius_yields_per_object_prices() -> None:
|
||
"""Регрессия #2445 A5 (byte-identical к best_layouts #1956): несколько
|
||
конкурентов в радиусе → КАЖДЫЙ получает свою domrf-цену, а не «1 объект на
|
||
global-max снимке, у остальных NULL».
|
||
|
||
Мок здесь не исполняет реальный SQL (_AVG_PRICE_SQL — строковые инварианты
|
||
см. test_competitors_avg_price_sql.py), но фиксирует ОЖИДАЕМЫЙ КОНТРАКТ:
|
||
_make_db может вернуть price_rows для ВСЕХ obj_id разом (per-object latest
|
||
snapshot делает это возможным на живой БД) — раньше баг гарантировал, что
|
||
price_rows содержал не более 1 строки на весь набор конкурентов.
|
||
"""
|
||
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),
|
||
]
|
||
price_rows = [
|
||
_price_row(obj_id=1, price=140_000.0),
|
||
_price_row(obj_id=2, price=155_000.0),
|
||
_price_row(obj_id=3, price=162_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
|
||
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
|
||
assert len(comps) == 3
|
||
expected = {1: 140_000.0, 2: 155_000.0, 3: 162_000.0}
|
||
for obj_id, price in expected.items():
|
||
assert comps[obj_id]["avg_price_per_m2"] == pytest.approx(price), (
|
||
f"obj_id={obj_id}: все конкуренты должны получить СВОЮ domrf-цену, "
|
||
"не only-one-object-on-global-max-snapshot регрессия"
|
||
)
|
||
assert comps[obj_id]["price_source"] == "domrf"
|
||
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()
|
||
|
||
|
||
# ── _SOLD_COUNT_SQL структура (#1926 / code-review #1927) ───────────────────────
|
||
# Mock-тесты выше НЕ исполняют реальный SQL (db.execute замокан) → не ловят
|
||
# fan-out маппинга, отсутствие premise_kind-фильтра и рассинхрон sold-предиката
|
||
# с MarketMetrics. Эти проверки — статический guard на текст _SOLD_COUNT_SQL:
|
||
# регресс к COUNT(*) / потеря premise_kind / усечение предиката упадёт здесь.
|
||
# (Полноценный fan-out-тест с двумя mapping-строками на один obj_id требует
|
||
# живой БД — он в tests/integration/test_phantom_columns.py, skip без TEST_DB.)
|
||
|
||
|
||
def _sold_sql_text() -> str:
|
||
from app.services.site_finder.competitors import _SOLD_COUNT_SQL
|
||
|
||
return str(_SOLD_COUNT_SQL)
|
||
|
||
|
||
def test_sold_count_sql_is_fanout_safe() -> None:
|
||
"""COUNT(DISTINCT objective_lot_id) — маппинг 1:N по obj_id не задваивает.
|
||
|
||
objective_complex_mapping UNIQUE = (objective_complex_name, objective_group),
|
||
НЕ domrf_obj_id → один obj_id может иметь несколько mapping-строк (то же имя,
|
||
разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по
|
||
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail.
|
||
|
||
#1964: DISTINCT СОХРАНЯЕТСЯ и в inline-варианте (hunt #1953): будущий fan-out
|
||
маппинга задвоил бы строки, и COUNT(*) перестал бы быть fan-out-safe. В новом
|
||
SQL final-SELECT агрегирует по CTE `latest` (без ol-алиаса) → COUNT(DISTINCT
|
||
objective_lot_id).
|
||
"""
|
||
sql = _sold_sql_text()
|
||
assert (
|
||
"COUNT(DISTINCT objective_lot_id)" in sql
|
||
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||
# COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot.
|
||
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
|
||
|
||
|
||
def test_sold_count_sql_reads_raw_objective_lots_inline() -> None:
|
||
"""hunt #1953 (регрессия #1964): sold-count читает СЫРУЮ objective_lots inline с
|
||
DISTINCT ON по physflat-ключу, НЕ v_objective_lots_latest.
|
||
|
||
Через view DISTINCT ON материализует ВСЮ 1.76M-таблицу ДО фильтра маппинга (qual
|
||
не проталкивается ниже view-DISTINCT ON) → Parallel Seq Scan + external sort
|
||
(~6896 ms, ~213 MB temp на каждый cold /analyze). Inline-pushdown: фильтр
|
||
маппинга в CTE → DISTINCT ON по сырой objective_lots с JOIN ON project_name
|
||
(objective_lots_project_idx) → ~437 ms, вывод побайтово идентичен. Регресс к
|
||
`v_objective_lots_latest` → fail.
|
||
"""
|
||
sql = _sold_sql_text()
|
||
assert "v_objective_lots_latest" not in sql, "не должен читать view (#1953 regression)"
|
||
assert "JOIN objective_lots" in sql, "должен джойнить СЫРУЮ objective_lots inline"
|
||
# physflat-ключ inline DISTINCT ON.
|
||
assert "DISTINCT ON (" in sql
|
||
for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
|
||
assert col in sql, f"physflat-ключ должен включать {col}"
|
||
assert "snapshot_date DESC" in sql and "id DESC" in sql
|
||
|
||
|
||
def test_sold_count_sql_filters_premise_kind() -> None:
|
||
"""premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
|
||
паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""
|
||
sql = _sold_sql_text()
|
||
assert "ol.premise_kind = :premise_kind" in sql
|
||
|
||
|
||
def test_sold_count_sql_predicate_matches_market_metrics() -> None:
|
||
"""sold-предикат побайтово совпадает с MarketMetrics: is_sold OR contract_date
|
||
OR status='продан' (Объектив заполняет поля неконсистентно → берём их ИЛИ)."""
|
||
sql = _sold_sql_text()
|
||
assert "ol.is_sold IS TRUE" in sql
|
||
assert "ol.contract_date IS NOT NULL" in sql
|
||
assert "LOWER(COALESCE(ol.status, '')) = 'продан'" in sql
|