p3 naming-consistency (cadastral cols 0% filled). Migration 094_cadastral_unify.sql (idempotent DO-block IF EXISTS): drop+recreate listings_search_mv (MV) + v_data_quality (view), RENAME deals/house_metadata kadastr_num->cadastral_number, DROP listings.kadastr_num + listings_kadastr_idx. Code SQL-string renames in search_query/estimator/base (Lot dataclass field NOT renamed). 240 tests pass. WARN: destructive DDL auto-applies to prod on deploy -> MERGE-GATE post-demo. Reviewer: verify recreated MV/view DDL vs LIVE pg_get_viewdef before apply (reconstructed from migration history 050/046, not live capture). Refs #732
210 lines
7 KiB
Python
210 lines
7 KiB
Python
"""Unit tests for the web-features enrichment of the estimate response.
|
|
|
|
Covers the ADDITIVE + OPTIONAL fields surfaced for the new web views (map /
|
|
comp distribution / price↔exposure / ppm² trend):
|
|
|
|
- estimator._listing_to_analog / _deal_to_analog — now carry lat/lon
|
|
- estimator._fetch_price_trend — monthly ppm² series shape (mock db)
|
|
- estimator._compute_last_scraped_at — absolute freshness timestamp
|
|
|
|
No real DB / network: the trend test injects a fake Session whose execute()
|
|
returns canned mapping rows.
|
|
|
|
NOTE: importing app.services.estimator pulls app.core.config.Settings, which
|
|
requires DATABASE_URL — set BEFORE importing app modules (same pattern as the
|
|
sibling estimator unit tests).
|
|
"""
|
|
|
|
import os
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from app.schemas.trade_in import AnalogLot, PriceTrendPoint
|
|
from app.services import estimator
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Per-comp coords on analogs / deals (map + exposure views)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_listing_to_analog_carries_lat_lon() -> None:
|
|
row = {
|
|
"address": "ул. Тестовая, 1",
|
|
"area_m2": 50.0,
|
|
"rooms": 2,
|
|
"floor": 3,
|
|
"total_floors": 9,
|
|
"price_rub": 10_000_000,
|
|
"price_per_m2": 200_000,
|
|
"listing_date": None,
|
|
"days_on_market": 14,
|
|
"photo_urls": None,
|
|
"source": "cian",
|
|
"source_url": "https://example.test/1",
|
|
"distance_m": 120.0,
|
|
"lat": 56.8389,
|
|
"lon": 60.6057,
|
|
}
|
|
lot = estimator._listing_to_analog(row)
|
|
assert isinstance(lot, AnalogLot)
|
|
assert lot.lat == pytest.approx(56.8389)
|
|
assert lot.lon == pytest.approx(60.6057)
|
|
# exposure + comp-distribution keys still present
|
|
assert lot.days_on_market == 14
|
|
assert lot.price_per_m2 == 200_000
|
|
assert lot.area_m2 == 50.0
|
|
assert lot.rooms == 2
|
|
|
|
|
|
def test_deal_to_analog_carries_lat_lon() -> None:
|
|
row = {
|
|
"address": "ул. Тестовая, 2",
|
|
"area_m2": 60.0,
|
|
"rooms": 2,
|
|
"floor": 5,
|
|
"total_floors": 10,
|
|
"price_rub": 11_000_000,
|
|
"price_per_m2": 183_333,
|
|
"deal_date": None,
|
|
"days_on_market": None,
|
|
"cadastral_number": "66:41:0204016:10",
|
|
"source": "rosreestr",
|
|
"distance_m": 80.0,
|
|
"lat": 56.84,
|
|
"lon": 60.61,
|
|
}
|
|
lot = estimator._deal_to_analog(row)
|
|
assert lot.lat == pytest.approx(56.84)
|
|
assert lot.lon == pytest.approx(60.61)
|
|
assert lot.tier == "T0_per_house" # kadastr with участок → per-house tier
|
|
|
|
|
|
def test_analog_lat_lon_optional_none_when_missing() -> None:
|
|
# Tier S address-only Avito lots can lack geom → lat/lon absent → None (graceful).
|
|
row = {
|
|
"address": "ул. Без Координат, 3",
|
|
"area_m2": 40.0,
|
|
"rooms": 1,
|
|
"floor": None,
|
|
"total_floors": None,
|
|
"price_rub": 8_000_000,
|
|
"price_per_m2": 200_000,
|
|
"listing_date": None,
|
|
"days_on_market": None,
|
|
"photo_urls": None,
|
|
"source": "avito",
|
|
"source_url": None,
|
|
"distance_m": None,
|
|
}
|
|
lot = estimator._listing_to_analog(row)
|
|
assert lot.lat is None
|
|
assert lot.lon is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _compute_last_scraped_at
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_last_scraped_at_returns_max_timestamp() -> None:
|
|
older = datetime(2026, 5, 1, 10, 0, tzinfo=UTC)
|
|
newer = datetime(2026, 5, 28, 9, 30, tzinfo=UTC)
|
|
lots = [{"scraped_at": older}, {"scraped_at": newer}]
|
|
assert estimator._compute_last_scraped_at(lots) == newer
|
|
|
|
|
|
def test_last_scraped_at_empty_returns_none() -> None:
|
|
assert estimator._compute_last_scraped_at([]) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _fetch_price_trend (mock db)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class _FakeResult:
|
|
def __init__(self, rows: list[dict]) -> None:
|
|
self._rows = rows
|
|
|
|
def mappings(self) -> "_FakeResult":
|
|
return self
|
|
|
|
def all(self) -> list[dict]:
|
|
return self._rows
|
|
|
|
|
|
class _FakeSession:
|
|
"""Minimal Session stub: returns canned rows per execute() call in order."""
|
|
|
|
def __init__(self, *results: list[dict]) -> None:
|
|
self._results = list(results)
|
|
self.calls = 0
|
|
|
|
def execute(self, *_args, **_kwargs) -> _FakeResult:
|
|
rows = self._results[self.calls] if self.calls < len(self._results) else []
|
|
self.calls += 1
|
|
return _FakeResult(rows)
|
|
|
|
def rollback(self) -> None: # pragma: no cover — not exercised on happy path
|
|
pass
|
|
|
|
|
|
def test_fetch_price_trend_none_when_no_house_id() -> None:
|
|
db = _FakeSession()
|
|
assert estimator._fetch_price_trend(db, target_house_id=None) is None
|
|
assert db.calls == 0 # short-circuits before any query
|
|
|
|
|
|
def test_fetch_price_trend_prefers_houses_price_dynamics() -> None:
|
|
hpd_rows = [
|
|
{"month": "2025-01", "ppm2": 200_000},
|
|
{"month": "2025-02", "ppm2": 205_000},
|
|
{"month": "2025-03", "ppm2": 210_000},
|
|
]
|
|
db = _FakeSession(hpd_rows) # first query hits → no fallback needed
|
|
trend = estimator._fetch_price_trend(db, target_house_id=123)
|
|
assert trend is not None
|
|
assert db.calls == 1 # did NOT touch the fallback source
|
|
assert trend == [
|
|
{"month": "2025-01", "ppm2": 200_000},
|
|
{"month": "2025-02", "ppm2": 205_000},
|
|
{"month": "2025-03", "ppm2": 210_000},
|
|
]
|
|
# Shape contract: each point validates as PriceTrendPoint(month:str, ppm2:int).
|
|
points = [PriceTrendPoint(**p) for p in trend]
|
|
assert all(isinstance(p.month, str) and isinstance(p.ppm2, int) for p in points)
|
|
|
|
|
|
def test_fetch_price_trend_falls_back_to_placement_history() -> None:
|
|
fallback_rows = [
|
|
{"month": "2024-06", "ppm2": 190_000},
|
|
{"month": "2024-07", "ppm2": 195_000},
|
|
{"month": "2024-08", "ppm2": 198_000},
|
|
]
|
|
# First query (houses_price_dynamics) empty → second (placement_history) hits.
|
|
db = _FakeSession([], fallback_rows)
|
|
trend = estimator._fetch_price_trend(db, target_house_id=235340)
|
|
assert trend == fallback_rows
|
|
assert db.calls == 2
|
|
|
|
|
|
def test_fetch_price_trend_none_when_below_min_points() -> None:
|
|
# Both sources return < min_points (3) → None (graceful, frontend hides chart).
|
|
db = _FakeSession([{"month": "2025-01", "ppm2": 200_000}], [])
|
|
assert estimator._fetch_price_trend(db, target_house_id=999) is None
|
|
|
|
|
|
def test_price_trend_point_shape() -> None:
|
|
p = PriceTrendPoint(month="2026-05", ppm2=251_429)
|
|
assert p.month == "2026-05"
|
|
assert p.ppm2 == 251_429
|
|
dumped = p.model_dump()
|
|
assert set(dumped) == {"month", "ppm2"}
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(pytest.main([__file__, "-q"]))
|