"""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) @pytest.mark.parametrize( "address,expected", [ # Forward format (user-typed) ("Екатеринбург, ул. Космонавтов, 50", "Космонавтов"), ("Россия, Екатеринбург, ул. Малышева, 1", "Малышева"), ("проспект Ленина 50", "Ленина"), ("ул. Большая Конюшенная, 25", "Большая Конюшенная"), # Reverse format (from Nominatim geocoder) ( "80, улица 8 Марта, Артек, Форум-Сити, Ленинский район, Екатеринбург," " городской округ Екатеринбург, Свердловская область," " Уральский федеральный округ, 640144, Россия", "8 Марта", ), ( "50, улица Космонавтов, Орджоникидзевский район, Екатеринбург, Россия", "Космонавтов", ), # Edge cases ("", None), (None, None), ("just plain text", None), ("Москва", None), ], ) def test_extract_street_name_parametrized(address: str | None, expected: str | None) -> None: """Parametrized cases covering forward, reverse (Nominatim), and edge cases.""" from app.services.estimator import extract_street_name assert extract_street_name(address) == expected # ── 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