"""Unit tests for the daily market aggregator.""" from __future__ import annotations import json from datetime import datetime, timedelta import pytest from sqlalchemy import create_engine from sqlmodel import SQLModel, Session from models.listing import ( DailyListingAggregate, FurnishType, Listing, ListingSite, RentListing, ) from services import market_aggregator # --- compute_trend_for_listing -------------------------------------------- def _hist(entries: list[tuple[datetime, float]]) -> str: return json.dumps( [ { "first_seen": fs.isoformat(), "last_seen": fs.isoformat(), "price": p, } for fs, p in entries ] ) class TestComputeTrendForListing: def test_null_history_returns_nones(self) -> None: past, pct = market_aggregator.compute_trend_for_listing(None, 2500) assert past is None and pct is None def test_empty_history_returns_nones(self) -> None: past, pct = market_aggregator.compute_trend_for_listing("[]", 2500) assert past is None and pct is None def test_malformed_history_returns_nones(self) -> None: past, pct = market_aggregator.compute_trend_for_listing("not json", 2500) assert past is None and pct is None def test_history_only_recent_returns_nones(self) -> None: """History exists but no entry old enough.""" now = datetime(2026, 5, 16, 12, 0, 0) history = _hist([(now - timedelta(days=2), 2500)]) past, pct = market_aggregator.compute_trend_for_listing( history, 2500, lookback_days=14, now=now, ) assert past is None and pct is None def test_price_dropped(self) -> None: now = datetime(2026, 5, 16, 12, 0, 0) history = _hist([ (now - timedelta(days=30), 2800), (now - timedelta(days=20), 2700), (now - timedelta(days=10), 2500), ]) # Lookback 14d → cutoff at day -14, latest entry on/before is day -20 (price 2700). past, pct = market_aggregator.compute_trend_for_listing( history, 2500, lookback_days=14, now=now, ) assert past == 2700 assert pct == round((2500 - 2700) / 2700 * 100, 2) assert pct < 0 def test_price_rose(self) -> None: now = datetime(2026, 5, 16, 12, 0, 0) history = _hist([(now - timedelta(days=20), 2000)]) past, pct = market_aggregator.compute_trend_for_listing( history, 2200, lookback_days=14, now=now, ) assert past == 2000 assert pct == 10.0 def test_current_price_zero_returns_nones(self) -> None: past, pct = market_aggregator.compute_trend_for_listing( _hist([(datetime(2026, 1, 1), 2500)]), 0, ) assert past is None and pct is None # --- _stats ---------------------------------------------------------------- class TestStats: def test_empty(self) -> None: out = market_aggregator._stats([]) assert out == {"median": None, "mean": None, "count": 0} def test_filters_nonpositive(self) -> None: out = market_aggregator._stats([0, -1, None, 2000, 3000]) # type: ignore[list-item] assert out["count"] == 2 assert out["median"] == 2500 assert out["mean"] == 2500.0 def test_single_value(self) -> None: out = market_aggregator._stats([1500]) assert out == {"median": 1500.0, "mean": 1500.0, "count": 1} # --- compute_aggregate_snapshot — integration on SQLite ------------------ @pytest.fixture def engine_with_seed(): """In-memory SQLite seeded with a tiny RentListing set in the 1-2 bed band.""" engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) with Session(engine) as session: for i, (rooms, price, sqm) in enumerate( [(1, 2000, 40), (1, 2500, 50), (2, 3000, 60), (2, 4000, 80), (3, 5000, 100)], start=1, ): session.add( RentListing( id=i, price=price, number_of_bedrooms=rooms, square_meters=sqm, longitude=0.0, latitude=0.0, price_history_json="[]", listing_site=ListingSite.RIGHTMOVE, last_seen=datetime(2026, 5, 16), furnish_type=FurnishType.UNKNOWN, ) ) session.commit() return engine class TestComputeAggregateSnapshot: def test_writes_one_row_per_band(self, engine_with_seed) -> None: rows = market_aggregator.compute_aggregate_snapshot( engine_with_seed, listing_types=("RENT",), bedroom_bands=((1, 2),), snapshot_date=datetime(2026, 5, 16), ) assert len(rows) == 1 row = rows[0] assert row.listing_count == 4 # excludes the 3-bed assert row.median_total_price == 2750.0 # median of [2000,2500,3000,4000] assert row.mean_total_price == 2875.0 # qmprice values: 50, 50, 50, 50 → median 50 assert row.median_qmprice == 50.0 def test_upsert_idempotent(self, engine_with_seed) -> None: market_aggregator.compute_aggregate_snapshot( engine_with_seed, listing_types=("RENT",), bedroom_bands=((1, 2),), snapshot_date=datetime(2026, 5, 16), ) market_aggregator.compute_aggregate_snapshot( engine_with_seed, listing_types=("RENT",), bedroom_bands=((1, 2),), snapshot_date=datetime(2026, 5, 16), ) with Session(engine_with_seed) as session: count = session.query(DailyListingAggregate).count() assert count == 1 # no duplicate row