"""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, "kadastr_num": "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"]))