gendesign/tradein-mvp/backend/tests/test_street_deals_endpoint.py
lekss361 23aebea812 feat(tradein): GET /street-deals — ДКП-сделки по улице target адреса
Новый 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.
2026-05-24 22:56:10 +03:00

209 lines
7.1 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.

"""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