Новый endpoint для секции «По вашей улице» в trade-in UI. Возвращает ДКП-сделки Росреестра матчащиеся по улице (open dataset агрегирует до street level — номера дома нет) + rooms + area BETWEEN target ± 15%. Использует rosreestr_deals после PR-A (#549) — только ДКП, без ДДУ-первички. Response: median/range за period_months (default 12) + top-10 последних сделок. Empty StreetDealsResponse если street не извлёкся или count=0. Tests: extract_street_name parsing, empty case, aggregation.
209 lines
7.1 KiB
Python
209 lines
7.1 KiB
Python
"""Tests for GET /api/v1/trade-in/street-deals endpoint.
|
||
|
||
Covers:
|
||
- extract_street_name parsing (various address formats)
|
||
- empty result when no DB rows match
|
||
- correct aggregation (median/count) with fixture deals
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
# psycopg v3 driver (psycopg2 not installed)
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import.
|
||
_wp_mock = MagicMock()
|
||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||
|
||
from datetime import date # noqa: E402
|
||
|
||
import pytest # noqa: E402
|
||
from fastapi import FastAPI # noqa: E402
|
||
from fastapi.testclient import TestClient # noqa: E402
|
||
|
||
# ── Unit: extract_street_name ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_extract_street_name_basic() -> None:
|
||
"""Various address formats should yield the bare street name."""
|
||
from app.services.estimator import extract_street_name
|
||
|
||
cases = [
|
||
(
|
||
"Свердловская обл., г. Екатеринбург, ул. Космонавтов, 12",
|
||
"Космонавтов",
|
||
),
|
||
(
|
||
"г. Екатеринбург, проспект Ленина, 50",
|
||
"Ленина",
|
||
),
|
||
(
|
||
"Екатеринбург, ул. 8 Марта, 18",
|
||
"8 Марта",
|
||
),
|
||
(
|
||
"Россия, Екатеринбург, пер. Красный, 4",
|
||
"Красный",
|
||
),
|
||
(
|
||
"Екатеринбург, ул. Малышева, 1",
|
||
"Малышева",
|
||
),
|
||
]
|
||
for addr, expected in cases:
|
||
result = extract_street_name(addr)
|
||
assert result == expected, f"addr={addr!r}: expected {expected!r}, got {result!r}"
|
||
|
||
|
||
def test_extract_street_name_none_and_empty() -> None:
|
||
from app.services.estimator import extract_street_name
|
||
|
||
assert extract_street_name(None) is None
|
||
assert extract_street_name("") is None
|
||
|
||
|
||
def test_extract_street_name_no_street_keyword() -> None:
|
||
"""Address without a recognisable street keyword — must not crash."""
|
||
from app.services.estimator import extract_street_name
|
||
|
||
result = extract_street_name("Industrial zone X")
|
||
assert result is None or isinstance(result, str)
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture()
|
||
def trade_in_app() -> FastAPI:
|
||
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
from app.core.db import get_db
|
||
|
||
application = FastAPI()
|
||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||
|
||
def _override_db():
|
||
yield MagicMock()
|
||
|
||
application.dependency_overrides[get_db] = _override_db
|
||
return application
|
||
|
||
|
||
def _make_db_mock(rows) -> MagicMock:
|
||
"""DB session mock returning *rows* from .mappings().all()."""
|
||
db = MagicMock()
|
||
mapping_result = MagicMock()
|
||
mapping_result.all.return_value = rows
|
||
execute_result = MagicMock()
|
||
execute_result.mappings.return_value = mapping_result
|
||
db.execute.return_value = execute_result
|
||
return db
|
||
|
||
|
||
def _make_deal_row(
|
||
*,
|
||
address: str = "Екатеринбург, Космонавтов",
|
||
area_m2: float = 50.0,
|
||
rooms: int = 2,
|
||
floor: int | None = 5,
|
||
total_floors: int | None = 9,
|
||
price_rub: int = 5_000_000,
|
||
price_per_m2: int = 100_000,
|
||
deal_date: date | None = None,
|
||
source: str = "rosreestr",
|
||
) -> dict:
|
||
return {
|
||
"address": address,
|
||
"area_m2": area_m2,
|
||
"rooms": rooms,
|
||
"floor": floor,
|
||
"total_floors": total_floors,
|
||
"price_rub": price_rub,
|
||
"price_per_m2": price_per_m2,
|
||
"deal_date": deal_date or date(2025, 6, 1),
|
||
"source": source,
|
||
}
|
||
|
||
|
||
# ── Test: empty result when DB returns no rows ────────────────────────────────
|
||
|
||
|
||
def test_street_deals_returns_empty_when_no_match(trade_in_app: FastAPI) -> None:
|
||
"""Endpoint returns count=0 / empty deals list when DB has no matching rows."""
|
||
db_mock = _make_db_mock([])
|
||
|
||
from app.core.db import get_db
|
||
|
||
def _override():
|
||
yield db_mock
|
||
|
||
trade_in_app.dependency_overrides[get_db] = _override
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.get(
|
||
"/api/v1/trade-in/street-deals",
|
||
params={
|
||
"address": "г. Екатеринбург, ул. Космонавтов, 50",
|
||
"area_m2": 50.0,
|
||
"rooms": 2,
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["count"] == 0
|
||
assert data["deals"] == []
|
||
assert data["street"] == "Космонавтов"
|
||
assert data["median_price_rub"] == 0
|
||
|
||
|
||
# ── Test: aggregation with fixture deals ─────────────────────────────────────
|
||
|
||
|
||
def test_street_deals_aggregates_correctly(trade_in_app: FastAPI) -> None:
|
||
"""With 5 fake deals the endpoint computes correct count + median."""
|
||
# price_per_m2 values sorted: [80k, 90k, 100k, 110k, 120k] → median = 100k
|
||
# median_price_rub = 100k * 50m² = 5_000_000
|
||
fixture_rows = [
|
||
_make_deal_row(price_rub=6_000_000, price_per_m2=120_000, deal_date=date(2025, 10, 1)),
|
||
_make_deal_row(price_rub=5_500_000, price_per_m2=110_000, deal_date=date(2025, 9, 1)),
|
||
_make_deal_row(price_rub=5_000_000, price_per_m2=100_000, deal_date=date(2025, 8, 1)),
|
||
_make_deal_row(price_rub=4_500_000, price_per_m2=90_000, deal_date=date(2025, 7, 1)),
|
||
_make_deal_row(price_rub=4_000_000, price_per_m2=80_000, deal_date=date(2025, 6, 1)),
|
||
]
|
||
|
||
db_mock = _make_db_mock(fixture_rows)
|
||
|
||
from app.core.db import get_db
|
||
|
||
def _override():
|
||
yield db_mock
|
||
|
||
trade_in_app.dependency_overrides[get_db] = _override
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.get(
|
||
"/api/v1/trade-in/street-deals",
|
||
params={
|
||
"address": "г. Екатеринбург, ул. Космонавтов, 50",
|
||
"area_m2": 50.0,
|
||
"rooms": 2,
|
||
"period_months": 12,
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
|
||
assert data["street"] == "Космонавтов"
|
||
assert data["count"] == 5
|
||
assert data["median_price_per_m2"] == 100_000
|
||
assert data["median_price_rub"] == 5_000_000 # 100k * 50m²
|
||
assert data["range_low_rub"] == 4_000_000
|
||
assert data["range_high_rub"] == 6_000_000
|
||
# All 5 deals returned (top-10 threshold)
|
||
assert len(data["deals"]) == 5
|
||
# DB rows already ordered by deal_date DESC — first is most recent (2025-10-01)
|
||
assert data["deals"][0]["price_rub"] == 6_000_000
|