403 lines
14 KiB
Python
403 lines
14 KiB
Python
"""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
|