gendesign/backend/tests/api/v1/test_parcel_competitors.py
bot-backend 39dd63333e
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m29s
Deploy / build-worker (push) Successful in 4m48s
Deploy / deploy (push) Successful in 1m43s
fix(site_finder): SAVEPOINT-isolate DB errors across cluster A (#2464) (#2465)
2026-07-07 12:50:11 +00:00

749 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для 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