gendesign/tradein-mvp/backend/tests/test_sales_vs_listings.py
Light1YT 39da0f0644
All checks were successful
Deploy Trade-In / changes (push) Successful in 45s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 45s
feat(tradein): sales-vs-listings endpoint (PR K, foundation #564) (#597)
2026-05-27 11:55:44 +00: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