- Extract helpers to reduce function sizes (listing_tasks, app.py, query.py, listing_fetcher) - Replace nonlocal mutations with _PipelineState dataclass in listing_tasks - Fix bugs: isinstance→equality check in repository, verify_exp for OIDC tokens - Consolidate duplicate filter methods in listing_repository - Move hardcoded config to env vars with backward-compatible defaults - Simplify CLI decorator to auto-build QueryParameters - Add deprecation docstring to data_access.py - Test count: 158 → 387 (all passing)
538 lines
18 KiB
Python
538 lines
18 KiB
Python
"""Unit tests for Listing models."""
|
|
import dataclasses
|
|
from datetime import datetime
|
|
import json
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from models.listing import (
|
|
BuyListing,
|
|
DestinationMode,
|
|
FurnishType,
|
|
ListingSite,
|
|
ListingType,
|
|
PriceHistoryItem,
|
|
QueryParameters,
|
|
RentListing,
|
|
Listing,
|
|
Route,
|
|
RouteLegStep,
|
|
)
|
|
from rec.routing import TravelMode
|
|
|
|
|
|
class TestListing:
|
|
"""Tests for the base Listing model."""
|
|
|
|
def test_price_per_square_meter_calculation(self) -> None:
|
|
"""Test that price_per_square_meter is calculated correctly."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.price_per_square_meter == 40.0
|
|
|
|
def test_price_per_square_meter_none_when_no_sqm(self) -> None:
|
|
"""Test that price_per_square_meter is None when square_meters is None."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=None,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.price_per_square_meter is None
|
|
|
|
def test_price_per_square_meter_none_when_sqm_zero(self) -> None:
|
|
"""Test that price_per_square_meter is None when square_meters is 0."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=0.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.price_per_square_meter is None
|
|
|
|
def test_url_property(self) -> None:
|
|
"""Test that url property returns correct Rightmove URL."""
|
|
listing = RentListing(
|
|
id=123456789,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.url == "https://www.rightmove.co.uk/properties/123456789"
|
|
|
|
def test_is_removed_property_visible(self) -> None:
|
|
"""Test that is_removed returns False when property is visible."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.is_removed is False
|
|
|
|
def test_is_removed_property_not_visible(self) -> None:
|
|
"""Test that is_removed returns True when property is not visible."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": False}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.is_removed is True
|
|
|
|
|
|
class TestPriceHistory:
|
|
"""Tests for price history serialization/deserialization."""
|
|
|
|
def test_price_history_serialization_roundtrip(self) -> None:
|
|
"""Test that price history can be serialized and deserialized."""
|
|
now = datetime.now()
|
|
price_history = [
|
|
PriceHistoryItem(
|
|
first_seen=now,
|
|
last_seen=now,
|
|
price=2000.0,
|
|
),
|
|
PriceHistoryItem(
|
|
first_seen=now,
|
|
last_seen=now,
|
|
price=2100.0,
|
|
),
|
|
]
|
|
|
|
# Serialize
|
|
serialized = Listing.serialize_price_history(price_history)
|
|
assert isinstance(serialized, str)
|
|
|
|
# Create listing with serialized history
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2100.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json=serialized,
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=now,
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
|
|
# Deserialize
|
|
deserialized = listing.price_history
|
|
assert len(deserialized) == 2
|
|
assert deserialized[0].price == 2000.0
|
|
assert deserialized[1].price == 2100.0
|
|
|
|
def test_price_history_empty(self) -> None:
|
|
"""Test that empty price history works correctly."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.price_history == []
|
|
|
|
def test_price_history_item_to_dict(self) -> None:
|
|
"""Test PriceHistoryItem.to_dict() method."""
|
|
now = datetime.now()
|
|
item = PriceHistoryItem(
|
|
first_seen=now,
|
|
last_seen=now,
|
|
price=2500.0,
|
|
)
|
|
result = item.to_dict()
|
|
assert result["price"] == 2500.0
|
|
assert result["first_seen"] == now.isoformat()
|
|
assert result["last_seen"] == now.isoformat()
|
|
|
|
|
|
class TestRentListing:
|
|
"""Tests specific to RentListing model."""
|
|
|
|
def test_rent_listing_has_furnish_type(self) -> None:
|
|
"""Test that RentListing has furnish_type field."""
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.PART_FURNISHED,
|
|
available_from=None,
|
|
)
|
|
assert listing.furnish_type == FurnishType.PART_FURNISHED
|
|
|
|
def test_rent_listing_has_available_from(self) -> None:
|
|
"""Test that RentListing has available_from field."""
|
|
now = datetime.now()
|
|
listing = RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=now,
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=now,
|
|
)
|
|
assert listing.available_from == now
|
|
|
|
|
|
class TestBuyListing:
|
|
"""Tests specific to BuyListing model."""
|
|
|
|
def test_buy_listing_has_service_charge(self) -> None:
|
|
"""Test that BuyListing has service_charge field."""
|
|
listing = BuyListing(
|
|
id=1,
|
|
price=450000.0,
|
|
number_of_bedrooms=3,
|
|
square_meters=95.0,
|
|
agency="Test",
|
|
council_tax_band="D",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
service_charge=2500.0,
|
|
lease_left=85,
|
|
)
|
|
assert listing.service_charge == 2500.0
|
|
|
|
def test_buy_listing_has_lease_left(self) -> None:
|
|
"""Test that BuyListing has lease_left field."""
|
|
listing = BuyListing(
|
|
id=1,
|
|
price=450000.0,
|
|
number_of_bedrooms=3,
|
|
square_meters=95.0,
|
|
agency="Test",
|
|
council_tax_band="D",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=None,
|
|
service_charge=None,
|
|
lease_left=120,
|
|
)
|
|
assert listing.lease_left == 120
|
|
|
|
|
|
def _make_listing_with_routing(routing_info_json: str | None) -> RentListing:
|
|
"""Helper to create a RentListing with given routing_info_json."""
|
|
return RentListing(
|
|
id=1,
|
|
price=2000.0,
|
|
number_of_bedrooms=2,
|
|
square_meters=50.0,
|
|
agency="Test",
|
|
council_tax_band="C",
|
|
longitude=0.0,
|
|
latitude=0.0,
|
|
price_history_json="[]",
|
|
listing_site=ListingSite.RIGHTMOVE,
|
|
last_seen=datetime.now(),
|
|
photo_thumbnail=None,
|
|
floorplan_image_paths=[],
|
|
additional_info={"property": {"visible": True}},
|
|
routing_info_json=routing_info_json,
|
|
furnish_type=FurnishType.FURNISHED,
|
|
available_from=None,
|
|
)
|
|
|
|
|
|
def _make_sample_routing_info() -> dict[DestinationMode, list[Route]]:
|
|
"""Helper to create sample routing info for tests."""
|
|
destination_mode = DestinationMode(
|
|
destination_address="London Bridge",
|
|
travel_mode=TravelMode.TRANSIT,
|
|
)
|
|
routes = [
|
|
Route(
|
|
legs=[
|
|
RouteLegStep(
|
|
distance_meters=500,
|
|
duration_s=120,
|
|
travel_mode=TravelMode.WALK,
|
|
),
|
|
RouteLegStep(
|
|
distance_meters=4000,
|
|
duration_s=480,
|
|
travel_mode=TravelMode.TRANSIT,
|
|
),
|
|
],
|
|
distance_meters=4500,
|
|
duration_s=600,
|
|
)
|
|
]
|
|
return {destination_mode: routes}
|
|
|
|
|
|
class TestQueryParametersValidation:
|
|
"""Tests for QueryParameters validation."""
|
|
|
|
def test_valid_parameters(self) -> None:
|
|
"""Basic valid QueryParameters creation."""
|
|
params = QueryParameters(
|
|
listing_type=ListingType.RENT,
|
|
min_price=1000,
|
|
max_price=3000,
|
|
min_bedrooms=1,
|
|
max_bedrooms=3,
|
|
)
|
|
assert params.min_price == 1000
|
|
assert params.max_price == 3000
|
|
assert params.min_bedrooms == 1
|
|
assert params.max_bedrooms == 3
|
|
|
|
def test_invalid_price_range_raises(self) -> None:
|
|
"""min_price > max_price should raise ValidationError."""
|
|
with pytest.raises(ValidationError, match="min_price.*must be <= max_price"):
|
|
QueryParameters(
|
|
listing_type=ListingType.RENT,
|
|
min_price=5000,
|
|
max_price=1000,
|
|
)
|
|
|
|
def test_invalid_bedroom_range_raises(self) -> None:
|
|
"""min_bedrooms > max_bedrooms should raise ValidationError."""
|
|
with pytest.raises(ValidationError, match="min_bedrooms.*must be <= max_bedrooms"):
|
|
QueryParameters(
|
|
listing_type=ListingType.RENT,
|
|
min_bedrooms=5,
|
|
max_bedrooms=2,
|
|
)
|
|
|
|
def test_negative_bedrooms_raises(self) -> None:
|
|
"""Negative bedroom counts should raise ValidationError."""
|
|
with pytest.raises(ValidationError, match="min_bedrooms.*must be non-negative"):
|
|
QueryParameters(
|
|
listing_type=ListingType.RENT,
|
|
min_bedrooms=-1,
|
|
max_bedrooms=3,
|
|
)
|
|
|
|
|
|
class TestDestinationMode:
|
|
"""Tests for DestinationMode."""
|
|
|
|
def test_to_dict(self) -> None:
|
|
"""Test to_dict returns correct dict."""
|
|
dm = DestinationMode(
|
|
destination_address="London Bridge",
|
|
travel_mode=TravelMode.TRANSIT,
|
|
)
|
|
result = dm.to_dict()
|
|
assert result == {
|
|
"destination_address": "London Bridge",
|
|
"travel_mode": TravelMode.TRANSIT,
|
|
}
|
|
|
|
def test_hash(self) -> None:
|
|
"""Test hashing works correctly."""
|
|
dm1 = DestinationMode(
|
|
destination_address="London Bridge",
|
|
travel_mode=TravelMode.TRANSIT,
|
|
)
|
|
dm2 = DestinationMode(
|
|
destination_address="London Bridge",
|
|
travel_mode=TravelMode.TRANSIT,
|
|
)
|
|
dm3 = DestinationMode(
|
|
destination_address="King's Cross",
|
|
travel_mode=TravelMode.TRANSIT,
|
|
)
|
|
assert hash(dm1) == hash(dm2)
|
|
assert dm1 == dm2
|
|
assert hash(dm1) != hash(dm3)
|
|
# Can be used as dict key
|
|
d = {dm1: "route1"}
|
|
assert d[dm2] == "route1"
|
|
|
|
|
|
class TestRoutingInfoSerialization:
|
|
"""Tests for routing info via RouteSerializer."""
|
|
|
|
def test_routing_info_property_returns_parsed_routes(self) -> None:
|
|
"""Test routing_info property deserializes correctly."""
|
|
routing_info = _make_sample_routing_info()
|
|
listing = _make_listing_with_routing(None)
|
|
serialized = listing.serialize_routing_info(routing_info)
|
|
listing.routing_info_json = serialized
|
|
|
|
result = listing.routing_info
|
|
assert len(result) == 1
|
|
dest_mode = list(result.keys())[0]
|
|
assert dest_mode.destination_address == "London Bridge"
|
|
assert dest_mode.travel_mode == TravelMode.TRANSIT
|
|
|
|
routes = result[dest_mode]
|
|
assert len(routes) == 1
|
|
assert routes[0].distance_meters == 4500
|
|
assert routes[0].duration_s == 600
|
|
assert len(routes[0].legs) == 2
|
|
assert routes[0].legs[0].distance_meters == 500
|
|
assert routes[0].legs[0].travel_mode == TravelMode.WALK
|
|
|
|
def test_routing_info_empty_json(self) -> None:
|
|
"""Test routing_info with no routing data."""
|
|
listing = _make_listing_with_routing(None)
|
|
assert listing.routing_info == {}
|
|
|
|
def test_serialize_routing_info_roundtrip(self) -> None:
|
|
"""Test serialize then deserialize via routing_info property."""
|
|
routing_info = _make_sample_routing_info()
|
|
listing = _make_listing_with_routing(None)
|
|
|
|
# Serialize
|
|
serialized = listing.serialize_routing_info(routing_info)
|
|
assert isinstance(serialized, str)
|
|
|
|
# Assign and deserialize via property
|
|
listing.routing_info_json = serialized
|
|
deserialized = listing.routing_info
|
|
|
|
# Compare
|
|
orig_dm = list(routing_info.keys())[0]
|
|
result_dm = list(deserialized.keys())[0]
|
|
assert orig_dm.destination_address == result_dm.destination_address
|
|
assert orig_dm.travel_mode == result_dm.travel_mode
|
|
|
|
orig_route = routing_info[orig_dm][0]
|
|
result_route = deserialized[result_dm][0]
|
|
assert orig_route.distance_meters == result_route.distance_meters
|
|
assert orig_route.duration_s == result_route.duration_s
|
|
assert len(orig_route.legs) == len(result_route.legs)
|