Листинги примагничиваются к НЕ ТОМУ дому (кривой house_id_fk): прод-замер 373 дома (5.5%) / 1685 листингов (7.2%) дальше 300м от своего дома. Пример — house 131237 «Мамина-Сибиряка 126» (ЖКХ=51 кв, знаменатель ВЕРНЫЙ): из 20 активных листингов реально его ~2, остальные — Свердлова 32 / Азина 31 (до 2.2км) → ложная доля 39% вместо ~4%. - мигр.150: v_building_sale_share — числители (active/45д) + медианы считают только листинги ≤300м от geom дома (ST_DistanceSphere; листинг/дом без geom — keep). Знаменатель не тронут. - buildings_query.build_listings_query: тот же гео-фильтр в детальной выдаче (карточка дома больше не показывает чужие листинги). Симуляция на проде: меняется 351 дом, обнуляется 164 (чистые «мусорные вёдра»), числитель −8.3%. Мамина-126: 20→2. Это митигация на уровне view; корневой фикс house_id_fk-матчера — отдельно. ruff clean, pytest 24 green.
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Тесты API «доля квартир дома в продаже» (мигр. 143).
|
||
|
||
Two layers:
|
||
1. SQL-билдеры (чистые функции) — shape, CAST(:x AS type), whitelist-сортировка,
|
||
clamp лимита, фильтры. БД не нужна.
|
||
2. Contract — минимальный app с замоканным get_db: 200 + правильная форма ответа
|
||
(RBAC middleware не монтируем, mirror trade_in_app из test_estimate_idor).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import MagicMock
|
||
|
||
# psycopg v3 driver требует DATABASE_URL до импорта app.core.db.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.services.buildings_query import (
|
||
ALLOWED_SORTS,
|
||
HISTOGRAM_BUCKETS,
|
||
build_listings_query,
|
||
build_sale_share_query,
|
||
build_summary_histogram_query,
|
||
build_summary_scalars_query,
|
||
)
|
||
|
||
# ── SQL-билдеры ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_sale_share_minimal_defaults():
|
||
sql, args = build_sale_share_query()
|
||
assert "FROM v_building_sale_share" in sql
|
||
# building qualifies on EITHER % — OR-фильтр, без жёсткого IS NOT NULL.
|
||
assert "sale_share_pct >= CAST(:min_pct AS numeric)" in sql
|
||
assert "sale_share_pct_45d >= CAST(:min_pct AS numeric)" in sql
|
||
assert "OR (sale_share_pct_45d >= CAST(:min_pct AS numeric))" in sql
|
||
# оба % выставлены в SELECT; over_100 — по любому из них.
|
||
assert "sale_share_pct_45d, " in sql
|
||
assert "listings_45d, " in sql
|
||
assert "(sale_share_pct > 100 OR sale_share_pct_45d > 100) AS over_100" in sql
|
||
assert "COALESCE(short_address, full_address, address) AS address" in sql
|
||
assert args["min_pct"] == 5.0
|
||
assert args["limit"] == 200
|
||
# default sort = share_desc
|
||
assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql
|
||
|
||
|
||
def test_sale_share_or_filter_includes_churn_only():
|
||
"""45d-only дом (0 активных, но sale_share_pct_45d>=min) проходит через OR-ветку."""
|
||
sql, _ = build_sale_share_query(min_pct=10)
|
||
# обе ветки порога присутствуют, объединены через OR в одной скобке
|
||
assert "((sale_share_pct >= CAST(:min_pct AS numeric))" in sql
|
||
assert "(sale_share_pct_45d >= CAST(:min_pct AS numeric)))" in sql
|
||
# жёсткого `sale_share_pct IS NOT NULL` больше нет — он отрезал бы 45d-only дома
|
||
assert "sale_share_pct IS NOT NULL" not in sql
|
||
|
||
|
||
def test_sale_share_max_pct_bounds_both_metrics():
|
||
"""max_pct ограничивает КАЖДУЮ ветку отдельно (per-metric [min,max])."""
|
||
sql, args = build_sale_share_query(min_pct=5, max_pct=40)
|
||
assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql
|
||
assert "sale_share_pct_45d <= CAST(:max_pct AS numeric)" in sql
|
||
assert args["max_pct"] == 40
|
||
|
||
|
||
def test_sale_share_all_filters():
|
||
sql, args = build_sale_share_query(
|
||
min_pct=10,
|
||
max_pct=80,
|
||
city="Екатеринбург",
|
||
price_min=3_000_000,
|
||
price_max=12_000_000,
|
||
year_min=1990,
|
||
year_max=2015,
|
||
house_type="panel",
|
||
sort="price_asc",
|
||
)
|
||
assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql
|
||
assert "ILIKE CAST(:city_like AS text)" in sql
|
||
assert args["city_like"] == "%Екатеринбург%"
|
||
assert "median_price_rub >= CAST(:price_min AS bigint)" in sql
|
||
assert "median_price_rub <= CAST(:price_max AS bigint)" in sql
|
||
assert "year_built >= CAST(:year_min AS integer)" in sql
|
||
assert "year_built <= CAST(:year_max AS integer)" in sql
|
||
assert "house_type = CAST(:house_type AS text)" in sql
|
||
assert "ORDER BY median_price_rub ASC NULLS LAST" in sql
|
||
|
||
|
||
def test_sale_share_no_double_colon_cast():
|
||
"""psycopg v3: ни одного `:name::type` — только CAST(...)."""
|
||
sql, _ = build_sale_share_query(city="x", price_min=1, year_min=2000, house_type="brick")
|
||
import re
|
||
|
||
assert re.search(r":[a-z_]+::[a-z]", sql) is None
|
||
|
||
|
||
def test_sale_share_limit_clamped():
|
||
_, args_hi = build_sale_share_query(limit=99999)
|
||
assert args_hi["limit"] == 1000
|
||
_, args_lo = build_sale_share_query(limit=0)
|
||
assert args_lo["limit"] == 1
|
||
_, args_neg = build_sale_share_query(limit=-5)
|
||
assert args_neg["limit"] == 1
|
||
|
||
|
||
@pytest.mark.parametrize("sort", sorted(ALLOWED_SORTS))
|
||
def test_sale_share_sort_whitelisted(sort: str):
|
||
sql, _ = build_sale_share_query(sort=sort)
|
||
assert "ORDER BY" in sql
|
||
# tiebreaker house_id присутствует во всех сортировках
|
||
assert "house_id" in sql.split("ORDER BY", 1)[1]
|
||
|
||
|
||
def test_sale_share_unknown_sort_falls_back():
|
||
"""Несуществующий sort не интерполируется — fallback на share_desc."""
|
||
sql, _ = build_sale_share_query(sort="DROP TABLE houses")
|
||
assert "DROP TABLE" not in sql
|
||
assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql
|
||
|
||
|
||
def test_listings_query_shape():
|
||
sql, args = build_listings_query(42)
|
||
assert "FROM listings" in sql
|
||
assert "house_id_fk = CAST(:house_id AS bigint)" in sql
|
||
assert "listing_segment = 'vtorichka'" in sql
|
||
assert "is_active" in sql
|
||
assert "ORDER BY l.price_rub" in sql
|
||
assert "JOIN houses" in sql
|
||
assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql
|
||
assert "id AS listing_id" in sql
|
||
assert args["house_id"] == 42
|
||
|
||
|
||
def test_summary_scalars_query_shape():
|
||
sql = build_summary_scalars_query()
|
||
assert "total_secondary_buildings" in sql
|
||
# знаменатель/coverage — по ЛЮБОМУ из двух %
|
||
assert "FILTER (WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL)" in sql
|
||
assert "NULLIF(count(*), 0)" in sql
|
||
assert "percentile_cont(0.95)" in sql
|
||
# max/p95 для обоих процентов
|
||
assert "max(sale_share_pct_45d) AS max_pct_45d" in sql
|
||
assert "AS p95_pct_45d" in sql
|
||
|
||
|
||
def test_summary_histogram_column_whitelisted():
|
||
"""build_summary_histogram_query: только whitelist-колонка, чужое → fallback."""
|
||
sql_now = build_summary_histogram_query("sale_share_pct")
|
||
assert "WHERE sale_share_pct IS NOT NULL" in sql_now
|
||
sql_45d = build_summary_histogram_query("sale_share_pct_45d")
|
||
assert "WHEN sale_share_pct_45d < 5 THEN '0-5'" in sql_45d
|
||
assert "WHERE sale_share_pct_45d IS NOT NULL" in sql_45d
|
||
# инъекция в имя колонки не проходит — fallback на sale_share_pct
|
||
sql_bad = build_summary_histogram_query("sale_share_pct; DROP TABLE houses")
|
||
assert "DROP TABLE" not in sql_bad
|
||
assert "WHERE sale_share_pct IS NOT NULL" in sql_bad
|
||
|
||
|
||
def test_new_sorts_whitelisted():
|
||
"""share_45d_desc / count_45d_desc добавлены в whitelist и дают корректный ORDER BY."""
|
||
assert "share_45d_desc" in ALLOWED_SORTS
|
||
assert "count_45d_desc" in ALLOWED_SORTS
|
||
sql_share_45d, _ = build_sale_share_query(sort="share_45d_desc")
|
||
assert "ORDER BY sale_share_pct_45d DESC NULLS LAST" in sql_share_45d
|
||
sql_count_45d, _ = build_sale_share_query(sort="count_45d_desc")
|
||
assert "ORDER BY listings_45d DESC NULLS LAST" in sql_count_45d
|
||
|
||
|
||
def test_summary_histogram_buckets_cover_spec():
|
||
sql = build_summary_histogram_query()
|
||
for boundary in ("'0-5'", "'5-10'", "'10-20'", "'20-30'", "'30-50'", "'50-100'", "'100+'"):
|
||
assert boundary in sql
|
||
assert HISTOGRAM_BUCKETS == ("0-5", "5-10", "10-20", "20-30", "30-50", "50-100", "100+")
|
||
|
||
|
||
# ── Contract (минимальный app, замоканный get_db) ─────────────────────────────
|
||
|
||
|
||
@pytest.fixture()
|
||
def buildings_app() -> FastAPI:
|
||
"""FastAPI mounting только buildings router (без RBAC middleware)."""
|
||
from app.api.v1 import buildings as buildings_module
|
||
|
||
application = FastAPI()
|
||
application.include_router(buildings_module.router, prefix="/api/v1/buildings")
|
||
return application
|
||
|
||
|
||
def _client_with_rows(app: FastAPI, *execute_results: object) -> TestClient:
|
||
"""Override get_db → mock, где db.execute даёт execute_results по порядку."""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
if len(execute_results) == 1:
|
||
db.execute.return_value = execute_results[0]
|
||
else:
|
||
db.execute.side_effect = list(execute_results)
|
||
|
||
def _override_db():
|
||
yield db
|
||
|
||
app.dependency_overrides[get_db] = _override_db
|
||
return TestClient(app)
|
||
|
||
|
||
def _mapped(rows: object) -> MagicMock:
|
||
m = MagicMock()
|
||
m.mappings.return_value.all.return_value = rows
|
||
m.mappings.return_value.one.return_value = rows
|
||
return m
|
||
|
||
|
||
def test_get_sale_share_list_200(buildings_app: FastAPI):
|
||
row = {
|
||
"house_id": 7,
|
||
"address": "ул. Ленина, 1",
|
||
"lat": 56.8,
|
||
"lon": 60.6,
|
||
"sale_share_pct": 12.5,
|
||
"sale_share_pct_45d": 16.7,
|
||
"over_100": False,
|
||
"active_secondary": 3,
|
||
"listings_45d": 4,
|
||
"flat_count_effective": 24,
|
||
"gar_match_method": None,
|
||
"median_price_rub": 5_000_000,
|
||
"median_price_per_m2": 95_000,
|
||
"avg_days_on_market": 41.0,
|
||
"year_built": 1999,
|
||
"house_type": "panel",
|
||
"total_floors": 9,
|
||
"series_name": None,
|
||
"is_emergency": False,
|
||
}
|
||
client = _client_with_rows(buildings_app, _mapped([row]))
|
||
resp = client.get("/api/v1/buildings/sale-share?min_pct=10")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert len(body) == 1
|
||
assert body[0]["house_id"] == 7
|
||
assert body[0]["over_100"] is False
|
||
assert body[0]["sale_share_pct"] == 12.5
|
||
assert body[0]["sale_share_pct_45d"] == 16.7
|
||
assert body[0]["listings_45d"] == 4
|
||
|
||
|
||
def test_get_sale_share_churn_only_row(buildings_app: FastAPI):
|
||
"""Дом с 0 активных, но 45d-листингами проходит OR-фильтр и несёт оба поля."""
|
||
row = {
|
||
"house_id": 99,
|
||
"address": "Удельная, 1/1",
|
||
"lat": 56.9,
|
||
"lon": 60.7,
|
||
"sale_share_pct": 0.0,
|
||
"sale_share_pct_45d": 22.7,
|
||
"over_100": False,
|
||
"active_secondary": 0,
|
||
"listings_45d": 5,
|
||
"flat_count_effective": 22,
|
||
"gar_match_method": "canon",
|
||
"median_price_rub": None,
|
||
"median_price_per_m2": None,
|
||
"avg_days_on_market": None,
|
||
"year_built": 1975,
|
||
"house_type": "brick",
|
||
"total_floors": 9,
|
||
"series_name": None,
|
||
"is_emergency": False,
|
||
}
|
||
client = _client_with_rows(buildings_app, _mapped([row]))
|
||
resp = client.get("/api/v1/buildings/sale-share?min_pct=10&sort=share_45d_desc")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body[0]["house_id"] == 99
|
||
assert body[0]["active_secondary"] == 0
|
||
assert body[0]["sale_share_pct_45d"] == 22.7
|
||
assert body[0]["listings_45d"] == 5
|
||
|
||
|
||
def test_get_sale_share_empty_when_no_gar(buildings_app: FastAPI):
|
||
"""ГАР не загружен → пустой список, но 200 (форма, не падение)."""
|
||
client = _client_with_rows(buildings_app, _mapped([]))
|
||
resp = client.get("/api/v1/buildings/sale-share")
|
||
assert resp.status_code == 200
|
||
assert resp.json() == []
|
||
|
||
|
||
def test_get_building_listings_200(buildings_app: FastAPI):
|
||
row = {
|
||
"listing_id": 101,
|
||
"source": "avito",
|
||
"source_url": "https://avito.ru/101",
|
||
"price_rub": 4_900_000,
|
||
"price_per_m2": 92_000,
|
||
"rooms": 2,
|
||
"area_m2": 53.2,
|
||
"floor": 4,
|
||
"total_floors": 9,
|
||
"days_on_market": 30,
|
||
"listing_date": "2026-05-01",
|
||
"address": "ул. Ленина, 1",
|
||
}
|
||
client = _client_with_rows(buildings_app, _mapped([row]))
|
||
resp = client.get("/api/v1/buildings/7/listings")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body[0]["listing_id"] == 101
|
||
assert body[0]["source"] == "avito"
|
||
|
||
|
||
def test_get_summary_zero_fills_histogram(buildings_app: FastAPI):
|
||
scalars = _mapped(
|
||
{
|
||
"total_secondary_buildings": 10,
|
||
"buildings_with_denominator": 4,
|
||
"coverage_pct": 40.0,
|
||
"max_pct": 95.0,
|
||
"p95_pct": 33.3,
|
||
"max_pct_45d": 98.0,
|
||
"p95_pct_45d": 38.1,
|
||
}
|
||
)
|
||
# handler делает 3 запроса: scalars, histogram(now), histogram(45d).
|
||
hist = _mapped([{"bucket": "0-5", "count": 2}, {"bucket": "100+", "count": 1}])
|
||
hist_45d = _mapped([{"bucket": "5-10", "count": 3}, {"bucket": "50-100", "count": 1}])
|
||
client = _client_with_rows(buildings_app, scalars, hist, hist_45d)
|
||
resp = client.get("/api/v1/buildings/sale-share/summary")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["total_secondary_buildings"] == 10
|
||
assert body["buildings_with_denominator"] == 4
|
||
assert body["coverage_pct"] == 40.0
|
||
assert body["max_pct_45d"] == 98.0
|
||
assert body["p95_pct_45d"] == 38.1
|
||
# обе гистограммы: все 7 корзин, недостающие — count 0, порядок фиксирован
|
||
assert [b["bucket"] for b in body["histogram"]] == list(HISTOGRAM_BUCKETS)
|
||
assert [b["bucket"] for b in body["histogram_45d"]] == list(HISTOGRAM_BUCKETS)
|
||
counts = {b["bucket"]: b["count"] for b in body["histogram"]}
|
||
assert counts["0-5"] == 2
|
||
assert counts["100+"] == 1
|
||
assert counts["20-30"] == 0
|
||
counts_45d = {b["bucket"]: b["count"] for b in body["histogram_45d"]}
|
||
assert counts_45d["5-10"] == 3
|
||
assert counts_45d["50-100"] == 1
|
||
assert counts_45d["0-5"] == 0
|