gendesign/tradein-mvp/backend/tests/test_buildings_api.py
bot-backend f617c323bd
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/sale-share): гео-фильтр числителя ≤300м — отсечь mis-bucketed листинги
Листинги примагничиваются к НЕ ТОМУ дому (кривой 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.
2026-06-28 23:24:05 +03:00

349 lines
14 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.

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