"""Tests for GET /api/v1/trade-in/sales-vs-listings endpoint (PR K, issue #564). Covers: - Happy path: deal + matching listing → pair with discount_pct. - Empty result: extracted street, no DB rows. - Multiple deals: ordered by deal_date DESC. - LEFT JOIN semantics: deal без listing match → listing_* поля = None. - linkage_rate_pct computation. - median_discount_pct on subset с listing_id != None. - extract_street_name failure → returns empty response with street=None. """ 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 # ── 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_pair_row( *, deal_id: int = 100, deal_date: date | None = None, deal_price_rub: int = 5_000_000, deal_price_per_m2: int = 100_000, deal_area_m2: float = 50.0, deal_rooms: int = 2, deal_floor: int | None = 5, deal_address: str = "г. Екатеринбург, ул. Малышева", listing_id: int | None = 200, listing_source: str | None = "avito", listing_source_url: str | None = "https://avito.ru/spb/123", listing_date: date | None = None, listing_price_rub: int | None = 5_200_000, listing_price_per_m2: int | None = 104_000, listing_area_m2: float | None = 50.0, days_listing_to_deal: int | None = 45, discount_pct: float | None = -3.85, ) -> dict: """Mock row из SQL-function street_sales_vs_listings().""" return { "deal_id": deal_id, "deal_date": deal_date or date(2025, 6, 1), "deal_price_rub": deal_price_rub, "deal_price_per_m2": deal_price_per_m2, "deal_area_m2": deal_area_m2, "deal_rooms": deal_rooms, "deal_floor": deal_floor, "deal_address": deal_address, "listing_id": listing_id, "listing_source": listing_source, "listing_source_url": listing_source_url, "listing_date": listing_date, "listing_price_rub": listing_price_rub, "listing_price_per_m2": listing_price_per_m2, "listing_area_m2": listing_area_m2, "days_listing_to_deal": days_listing_to_deal, "discount_pct": discount_pct, } def _override_db(trade_in_app: FastAPI, db_mock: MagicMock) -> None: from app.core.db import get_db def _override(): yield db_mock trade_in_app.dependency_overrides[get_db] = _override # ── Test: street extraction failure → empty response ───────────────────────── def test_sales_vs_listings_returns_empty_when_no_street(trade_in_app: FastAPI) -> None: """Address без recognisable улицы → street=None, total_deals=0, pairs=[].""" db_mock = _make_db_mock([]) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={"address": "Industrial zone X", "area_m2": 50.0, "rooms": 2}, ) assert resp.status_code == 200 data = resp.json() assert data["street"] is None assert data["total_deals"] == 0 assert data["deals_with_listings"] == 0 assert data["linkage_rate_pct"] == 0.0 assert data["pairs"] == [] # DB не должна быть вызвана — early return при пустой улице db_mock.execute.assert_not_called() # ── Test: empty DB result for valid street ─────────────────────────────────── def test_sales_vs_listings_no_matches(trade_in_app: FastAPI) -> None: """Valid street, но DB вернул 0 строк → пустой ответ с известной street.""" db_mock = _make_db_mock([]) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Космонавтов, 50", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["street"] == "Космонавтов" assert data["total_deals"] == 0 assert data["deals_with_listings"] == 0 assert data["linkage_rate_pct"] == 0.0 assert data["median_discount_pct"] is None assert data["pairs"] == [] # ── Test: happy path — one deal + one matching listing ─────────────────────── def test_sales_vs_listings_happy_path(trade_in_app: FastAPI) -> None: """Single (deal, listing) пара возвращается с правильным discount_pct.""" fixture_rows = [ _make_pair_row( deal_id=1001, deal_date=date(2025, 8, 15), deal_price_rub=4_900_000, deal_area_m2=50.0, listing_id=2001, listing_source="avito", listing_date=date(2025, 6, 30), listing_price_rub=5_200_000, days_listing_to_deal=46, discount_pct=-5.77, ), ] db_mock = _make_db_mock(fixture_rows) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Малышева, 125", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["street"] == "Малышева" assert data["total_deals"] == 1 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 100.0 assert data["median_discount_pct"] == -5.77 assert len(data["pairs"]) == 1 pair = data["pairs"][0] assert pair["deal_id"] == 1001 assert pair["listing_id"] == 2001 assert pair["listing_source"] == "avito" assert pair["days_listing_to_deal"] == 46 assert pair["discount_pct"] == -5.77 # ── Test: LEFT JOIN — deal без listing match ───────────────────────────────── def test_sales_vs_listings_left_join_no_listing(trade_in_app: FastAPI) -> None: """Сделка без matching listing → listing_* поля None, linkage_rate counted.""" fixture_rows = [ _make_pair_row( deal_id=1001, listing_id=2001, listing_source="avito", discount_pct=-5.0, ), _make_pair_row( deal_id=1002, deal_date=date(2025, 7, 1), listing_id=None, listing_source=None, listing_source_url=None, listing_date=None, listing_price_rub=None, listing_price_per_m2=None, listing_area_m2=None, days_listing_to_deal=None, discount_pct=None, ), ] db_mock = _make_db_mock(fixture_rows) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Малышева, 125", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["total_deals"] == 2 assert data["deals_with_listings"] == 1 assert data["linkage_rate_pct"] == 50.0 # median считается только по парам с discount_pct assert data["median_discount_pct"] == -5.0 # Pair without listing pair_no_listing = next(p for p in data["pairs"] if p["deal_id"] == 1002) assert pair_no_listing["listing_id"] is None assert pair_no_listing["listing_source"] is None assert pair_no_listing["listing_price_rub"] is None assert pair_no_listing["discount_pct"] is None # ── Test: median discount with multiple pairs ──────────────────────────────── def test_sales_vs_listings_median_discount(trade_in_app: FastAPI) -> None: """Median считается через _percentile(0.5) только по парам c discount_pct.""" # Discounts: [-10, -5, 0, 3, 7] → median = 0 fixture_rows = [ _make_pair_row(deal_id=1, listing_id=11, discount_pct=-10.0), _make_pair_row(deal_id=2, listing_id=12, discount_pct=-5.0), _make_pair_row(deal_id=3, listing_id=13, discount_pct=0.0), _make_pair_row(deal_id=4, listing_id=14, discount_pct=3.0), _make_pair_row(deal_id=5, listing_id=15, discount_pct=7.0), # Сделка без listing — не учитывается в median. _make_pair_row( deal_id=6, listing_id=None, listing_price_rub=None, discount_pct=None, ), ] db_mock = _make_db_mock(fixture_rows) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Космонавтов, 50", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["total_deals"] == 6 assert data["deals_with_listings"] == 5 assert round(data["linkage_rate_pct"], 1) == 83.3 assert data["median_discount_pct"] == 0.0 # ── Test: SQL function called with proper params ───────────────────────────── def test_sales_vs_listings_passes_proper_params(trade_in_app: FastAPI) -> None: """Endpoint должен вызвать street_sales_vs_listings() c кастомными window_days, area_tolerance, period_months.""" db_mock = _make_db_mock([]) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Малышева, 125", "area_m2": 65.5, "rooms": 3, "window_days": 90, "area_tolerance": 0.05, "period_months": 12, }, ) assert resp.status_code == 200 # Проверяем что DB вызвалась с правильными params assert db_mock.execute.called args, kwargs = db_mock.execute.call_args params = args[1] if len(args) > 1 else kwargs.get("parameters", {}) assert params["street_pattern"] == "%Малышева%" assert params["area_m2"] == 65.5 assert params["rooms"] == 3 assert params["window_days"] == 90 assert params["area_tolerance"] == 0.05 assert params["period_months"] == 12 # ── Test: response shape (Pydantic validation) ─────────────────────────────── def test_sales_vs_listings_response_shape(trade_in_app: FastAPI) -> None: """Все required fields присутствуют в JSON response.""" fixture_rows = [ _make_pair_row( deal_id=1, listing_id=11, discount_pct=-3.0, listing_source="cian" ), ] db_mock = _make_db_mock(fixture_rows) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Космонавтов, 50", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() # Top-level keys expected_keys = { "street", "period_months", "window_days", "area_tolerance", "total_deals", "deals_with_listings", "linkage_rate_pct", "median_discount_pct", "pairs", } assert expected_keys.issubset(data.keys()) # Pair keys pair = data["pairs"][0] pair_keys = { "deal_id", "deal_date", "deal_price_rub", "deal_price_per_m2", "deal_area_m2", "deal_rooms", "deal_floor", "deal_address", "listing_id", "listing_source", "listing_source_url", "listing_date", "listing_price_rub", "listing_price_per_m2", "listing_area_m2", "days_listing_to_deal", "discount_pct", } assert pair_keys.issubset(pair.keys()) assert pair["listing_source"] == "cian" # ── Test: default param values ─────────────────────────────────────────────── def test_sales_vs_listings_defaults(trade_in_app: FastAPI) -> None: """window_days=180, area_tolerance=0.15, period_months=24 — defaults.""" db_mock = _make_db_mock([]) _override_db(trade_in_app, db_mock) client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/sales-vs-listings", params={ "address": "г. Екатеринбург, ул. Малышева, 125", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["window_days"] == 180 assert data["area_tolerance"] == 0.15 assert data["period_months"] == 24