"""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)