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