gendesign/tradein-mvp/backend/tests/test_buildings_api.py
bot-backend ebc3813648
All checks were successful
CI / changes (pull_request) Successful in 8s
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): гард по этажности — отсечь near-neighbor mis-match
Гео-фильтр ≤300м (мигр.150) широк для плотной застройки: к 5-этажному дому
(Мраморская 34к4) примагничены чужие листинги 9/16/19-этажных башен (Благодатская
15, Мраморская 6) на 229-282м — все <300м, фильтр не отсекал.

- мигр.152: +houses.zhkh_floors (parse zhkh_floors_raw "10-30"→max); гард во view
  во всех 5 FILTER: считать листинг только если abs(l.total_floors -
  COALESCE(zhkh_floors, total_floors)) <= 3 (или этажность неизвестна). Гео-фильтр
  300м сохранён.
- buildings_query.build_listings_query: тот же гард в детальной выдаче.

Прод-замер: отсекает 2754 листинга (12%) height-mismatched. Покрытие этажности
75% total_floors / 73% ЖКХ. ruff clean, pytest 26 green.
2026-06-29 00:31:09 +03:00

377 lines
16 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 "JOIN houses" in sql
assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql
# floors-consistency guard (мигр.152): отсекает соседей с расхождением этажности >3,
# NULL-этажность листинга/дома — КЕЕП.
assert "abs(l.total_floors - COALESCE(h.zhkh_floors, h.total_floors)) <= 3" in sql
assert "id AS listing_id" in sql
assert args["house_id"] == 42
def test_listings_query_dedups_by_signature():
"""Дедуп кросс-площадочных дублей: одна строка на сигнатуру (rooms, round(area_m2),
floor) — та же, что в v_building_sale_share мигр.148."""
sql, _ = build_listings_query(42)
# DISTINCT ON по сигнатуре; round(area_m2), не raw area_m2 — как в мигр.148.
assert "DISTINCT ON (a.rooms, round(a.area_m2), a.floor)" in sql
# представитель — самый дешёвый: price_rub ASC NULLS LAST в лидирующем ORDER BY,
# с теми же ведущими ключами, что и DISTINCT ON (требование DISTINCT ON).
assert "ORDER BY a.rooms, round(a.area_m2), a.floor, a.price_rub ASC NULLS LAST, a.id" in sql
# массив площадок, где видна квартира → колонка sources.
assert "array_agg(DISTINCT source ORDER BY source) AS sources" in sql
assert "s.sources" in sql
# NULL-safe join по сигнатуре, чтобы NULL-компоненты не выпадали из дедупа.
assert "s.sig_area IS NOT DISTINCT FROM round(a.area_m2)" in sql
# внешняя сортировка панели — по цене (дешёвые сверху).
assert "ORDER BY price_rub ASC NULLS LAST, listing_id" in sql
def test_listings_query_no_double_colon_cast():
"""psycopg v3: ни одного `:name::type` в дедуп-запросе — только CAST(...)."""
import re
sql, _ = build_listings_query(42)
assert re.search(r":[a-z_]+::[a-z]", sql) is None
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