164 lines
5.7 KiB
Python
164 lines
5.7 KiB
Python
|
|
"""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
|