gendesign/tradein-mvp/backend/tests/test_sales_vs_listings.py
Light1YT 086603cf25 feat(tradein): sales-vs-listings endpoint (PR K — foundation #564 Phase 1)
Связь ДКП Росреестра с listings (avito/cian/yandex/n1) через street
normalization + area window + listing-date window. Foundation для
sales-vs-asks comparison (discount_pct per pair).

DDL:
- data/sql/067_v_street_sales_vs_listings.sql:
  * table-valued function street_sales_vs_listings(street, area, rooms,
    window_days, area_tol) для filtered query с indexes
  * view v_street_sales_vs_listings — full crossjoin для analytical queries
    (used в reporting; для transactional path use TVF)
  * Encapsulated JOIN logic: street ILIKE + area ±15% + listing_date в окне
    [deal_date - window_days, deal_date]
  * Computed columns: days_listing_to_deal, discount_pct

API:
- GET /api/v1/sales-vs-listings?address=...&area_m2=...&rooms=...
  * Pydantic SalesVsListingsResponse с street/period/total_deals/
    deals_with_listings/linkage_rate_pct/median_discount_pct/pairs list
  * Использует extract_street_name() из existing estimator code

Schema:
- schemas/trade_in.py: SalesListingPair + SalesVsListingsResponse

Production verification:
- Migration applied на проде: CREATE FUNCTION + CREATE VIEW
- View total: 50,139 (deal, listing) pairs
- Sample case: «Бисертская» — ДКП 6.3 млн (86 м²) vs listing 8.5 млн (79 м²)
  с listing_date за 4 дня до сделки → discount_pct = −25.88%

Tests: 8/8 pass в test_sales_vs_listings.py — happy path, no matches,
LEFT JOIN behavior, median discount, defaults, response shape.
Ruff clean.

Out of scope (separate PR L+M):
- Frontend extend StreetDealsCard «Связанные ASK»
- Estimator revert NOTE 2026-05-24 (rosreestr_deals в actual_deals)

Refs #564 (Phase 1 — backend foundation).
2026-05-27 16:55:20 +05:00

403 lines
14 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/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