Add services layer, tests, streaming UI, and cleanup legacy code
This commit is contained in:
parent
5514fa6381
commit
d205d15c74
62 changed files with 3729 additions and 1024 deletions
1
crawler/tests/unit/__init__.py
Normal file
1
crawler/tests/unit/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Unit tests package
|
||||
343
crawler/tests/unit/test_models.py
Normal file
343
crawler/tests/unit/test_models.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""Unit tests for Listing models."""
|
||||
from datetime import datetime
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from models.listing import (
|
||||
BuyListing,
|
||||
FurnishType,
|
||||
ListingSite,
|
||||
PriceHistoryItem,
|
||||
RentListing,
|
||||
Listing,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
74
crawler/tests/unit/test_redis_lock.py
Normal file
74
crawler/tests/unit/test_redis_lock.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Unit tests for Redis distributed lock."""
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.redis_lock import redis_lock, get_redis_client
|
||||
|
||||
|
||||
class TestRedisLock:
|
||||
"""Tests for redis_lock context manager."""
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_lock_acquired_successfully(self, mock_get_client):
|
||||
"""Test lock acquisition when no other lock exists."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with redis_lock("test_lock") as acquired:
|
||||
assert acquired is True
|
||||
|
||||
mock_client.set.assert_called_once_with("lock:test_lock", "1", nx=True, ex=3600 * 4)
|
||||
mock_client.delete.assert_called_once_with("lock:test_lock")
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_lock_not_acquired(self, mock_get_client):
|
||||
"""Test lock not acquired when another lock exists."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = None # Redis returns None when nx=True fails
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with redis_lock("test_lock") as acquired:
|
||||
assert acquired is False
|
||||
|
||||
mock_client.set.assert_called_once_with("lock:test_lock", "1", nx=True, ex=3600 * 4)
|
||||
# Should NOT call delete since we didn't acquire the lock
|
||||
mock_client.delete.assert_not_called()
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_lock_released_on_exception(self, mock_get_client):
|
||||
"""Test lock is released even when exception occurs."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with redis_lock("test_lock") as acquired:
|
||||
assert acquired is True
|
||||
raise ValueError("Test error")
|
||||
|
||||
# Lock should still be released
|
||||
mock_client.delete.assert_called_once_with("lock:test_lock")
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_custom_timeout(self, mock_get_client):
|
||||
"""Test lock with custom timeout."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with redis_lock("test_lock", timeout=300) as acquired:
|
||||
assert acquired is True
|
||||
|
||||
mock_client.set.assert_called_once_with("lock:test_lock", "1", nx=True, ex=300)
|
||||
|
||||
@mock.patch("utils.redis_lock.redis")
|
||||
def test_get_redis_client_uses_broker_url(self, mock_redis):
|
||||
"""Test Redis client is created from CELERY_BROKER_URL."""
|
||||
with mock.patch.dict("os.environ", {"CELERY_BROKER_URL": "redis://testhost:1234/5"}):
|
||||
get_redis_client()
|
||||
|
||||
mock_redis.from_url.assert_called_once_with(
|
||||
"redis://testhost:1234/5", decode_responses=True
|
||||
)
|
||||
227
crawler/tests/unit/test_repository.py
Normal file
227
crawler/tests/unit/test_repository.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Unit tests for ListingRepository."""
|
||||
from datetime import datetime, timedelta
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from models.listing import (
|
||||
FurnishType,
|
||||
ListingType,
|
||||
QueryParameters,
|
||||
RentListing,
|
||||
)
|
||||
from repositories.listing_repository import ListingRepository
|
||||
|
||||
|
||||
class TestListingRepository:
|
||||
"""Tests for ListingRepository methods."""
|
||||
|
||||
async def test_get_listings_empty_db(
|
||||
self, listing_repository: ListingRepository
|
||||
) -> None:
|
||||
"""Test that get_listings returns empty list for empty database."""
|
||||
listings = await listing_repository.get_listings()
|
||||
assert listings == []
|
||||
|
||||
async def test_get_listings_returns_inserted_listings(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listing: RentListing,
|
||||
) -> None:
|
||||
"""Test that get_listings returns listings that were inserted."""
|
||||
await listing_repository.upsert_listings([sample_rent_listing])
|
||||
listings = await listing_repository.get_listings()
|
||||
assert len(listings) == 1
|
||||
assert listings[0].id == sample_rent_listing.id
|
||||
|
||||
async def test_upsert_listings_creates_new(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listing: RentListing,
|
||||
) -> None:
|
||||
"""Test that upsert_listings creates new listings."""
|
||||
result = await listing_repository.upsert_listings([sample_rent_listing])
|
||||
assert len(result) == 1
|
||||
assert result[0].id == sample_rent_listing.id
|
||||
|
||||
# Verify it's in the database
|
||||
listings = await listing_repository.get_listings()
|
||||
assert len(listings) == 1
|
||||
|
||||
async def test_upsert_listings_updates_existing(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listing: RentListing,
|
||||
) -> None:
|
||||
"""Test that upsert_listings updates existing listings."""
|
||||
# Insert initial listing
|
||||
await listing_repository.upsert_listings([sample_rent_listing])
|
||||
|
||||
# Update the listing
|
||||
sample_rent_listing.price = 3000.0
|
||||
await listing_repository.upsert_listings([sample_rent_listing])
|
||||
|
||||
# Verify update
|
||||
listings = await listing_repository.get_listings()
|
||||
assert len(listings) == 1
|
||||
assert listings[0].price == 3000.0
|
||||
|
||||
async def test_mark_seen_updates_timestamp(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listing: RentListing,
|
||||
) -> None:
|
||||
"""Test that mark_seen updates the last_seen timestamp."""
|
||||
# Set an old timestamp
|
||||
old_time = datetime.now() - timedelta(days=7)
|
||||
sample_rent_listing.last_seen = old_time
|
||||
await listing_repository.upsert_listings([sample_rent_listing])
|
||||
|
||||
# Mark as seen
|
||||
await listing_repository.mark_seen(sample_rent_listing.id)
|
||||
|
||||
# Verify timestamp was updated
|
||||
listings = await listing_repository.get_listings()
|
||||
assert len(listings) == 1
|
||||
assert listings[0].last_seen > old_time
|
||||
|
||||
async def test_mark_seen_nonexistent_listing(
|
||||
self, listing_repository: ListingRepository
|
||||
) -> None:
|
||||
"""Test that mark_seen handles nonexistent listings gracefully."""
|
||||
# Should not raise an exception
|
||||
await listing_repository.mark_seen(999999)
|
||||
|
||||
async def test_get_listings_with_only_ids(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test that get_listings filters by only_ids."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
# Request only specific IDs
|
||||
listings = await listing_repository.get_listings(only_ids=[1, 3])
|
||||
assert len(listings) == 2
|
||||
listing_ids = [l.id for l in listings]
|
||||
assert 1 in listing_ids
|
||||
assert 3 in listing_ids
|
||||
assert 2 not in listing_ids
|
||||
|
||||
async def test_get_listings_with_limit(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test that get_listings respects limit parameter."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
listings = await listing_repository.get_listings(limit=2)
|
||||
assert len(listings) == 2
|
||||
|
||||
|
||||
class TestListingRepositoryFilters:
|
||||
"""Tests for ListingRepository query parameter filtering."""
|
||||
|
||||
async def test_filter_by_bedrooms(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test filtering by bedroom count."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
query_params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
min_bedrooms=2,
|
||||
max_bedrooms=2,
|
||||
)
|
||||
listings = await listing_repository.get_listings(query_parameters=query_params)
|
||||
assert len(listings) == 1
|
||||
assert listings[0].number_of_bedrooms == 2
|
||||
|
||||
async def test_filter_by_price_range(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test filtering by price range."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
query_params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
min_price=1800,
|
||||
max_price=2500,
|
||||
)
|
||||
listings = await listing_repository.get_listings(query_parameters=query_params)
|
||||
assert len(listings) == 1
|
||||
assert listings[0].price == 2000.0
|
||||
|
||||
async def test_filter_by_min_sqm(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test filtering by minimum square meters."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
query_params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
min_sqm=60,
|
||||
)
|
||||
listings = await listing_repository.get_listings(query_parameters=query_params)
|
||||
assert len(listings) == 1
|
||||
assert listings[0].square_meters == 80.0
|
||||
|
||||
async def test_filter_by_furnish_type(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test filtering by furnish type."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
query_params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
furnish_types=[FurnishType.UNFURNISHED],
|
||||
)
|
||||
listings = await listing_repository.get_listings(query_parameters=query_params)
|
||||
assert len(listings) == 1
|
||||
assert listings[0].furnish_type == FurnishType.UNFURNISHED
|
||||
|
||||
async def test_filter_by_last_seen_days(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test filtering by last_seen_days."""
|
||||
# Make one listing old
|
||||
sample_rent_listings[0].last_seen = datetime.now() - timedelta(days=30)
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
query_params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
last_seen_days=7,
|
||||
)
|
||||
listings = await listing_repository.get_listings(query_parameters=query_params)
|
||||
# Only 2 should be recent enough
|
||||
assert len(listings) == 2
|
||||
|
||||
async def test_combined_filters(
|
||||
self,
|
||||
listing_repository: ListingRepository,
|
||||
sample_rent_listings: list[RentListing],
|
||||
) -> None:
|
||||
"""Test combining multiple filters."""
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
|
||||
query_params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
min_bedrooms=1,
|
||||
max_bedrooms=2,
|
||||
min_price=1000,
|
||||
max_price=2500,
|
||||
furnish_types=[FurnishType.FURNISHED, FurnishType.UNFURNISHED],
|
||||
)
|
||||
listings = await listing_repository.get_listings(query_parameters=query_params)
|
||||
# Should match listings with 1-2 bedrooms in price range
|
||||
assert len(listings) == 2
|
||||
293
crawler/tests/unit/test_schedule_config.py
Normal file
293
crawler/tests/unit/test_schedule_config.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"""Unit tests for schedule configuration."""
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from config.schedule_config import ScheduleConfig, SchedulesConfig
|
||||
from models.listing import FurnishType, ListingType
|
||||
|
||||
|
||||
class TestScheduleConfig:
|
||||
"""Tests for ScheduleConfig model."""
|
||||
|
||||
def test_basic_creation_with_defaults(self):
|
||||
"""Test creating a schedule with minimal required fields."""
|
||||
schedule = ScheduleConfig(name="Test Schedule", listing_type=ListingType.RENT)
|
||||
|
||||
assert schedule.name == "Test Schedule"
|
||||
assert schedule.enabled is True
|
||||
assert schedule.minute == "0"
|
||||
assert schedule.hour == "2"
|
||||
assert schedule.day_of_week == "*"
|
||||
assert schedule.listing_type == ListingType.RENT
|
||||
assert schedule.min_bedrooms == 1
|
||||
assert schedule.max_bedrooms == 999
|
||||
assert schedule.min_price == 0
|
||||
assert schedule.max_price == 10_000_000
|
||||
assert schedule.district_names == []
|
||||
assert schedule.furnish_types is None
|
||||
|
||||
def test_full_creation(self):
|
||||
"""Test creating a schedule with all fields specified."""
|
||||
schedule = ScheduleConfig(
|
||||
name="Full Schedule",
|
||||
enabled=False,
|
||||
minute="30",
|
||||
hour="4",
|
||||
day_of_week="1,3,5",
|
||||
listing_type=ListingType.BUY,
|
||||
min_bedrooms=2,
|
||||
max_bedrooms=3,
|
||||
min_price=400000,
|
||||
max_price=800000,
|
||||
district_names=["Westminster", "Camden"],
|
||||
furnish_types=["furnished", "unfurnished"],
|
||||
)
|
||||
|
||||
assert schedule.name == "Full Schedule"
|
||||
assert schedule.enabled is False
|
||||
assert schedule.minute == "30"
|
||||
assert schedule.hour == "4"
|
||||
assert schedule.day_of_week == "1,3,5"
|
||||
assert schedule.listing_type == ListingType.BUY
|
||||
assert schedule.min_bedrooms == 2
|
||||
assert schedule.max_bedrooms == 3
|
||||
assert schedule.min_price == 400000
|
||||
assert schedule.max_price == 800000
|
||||
assert schedule.district_names == ["Westminster", "Camden"]
|
||||
assert schedule.furnish_types == ["furnished", "unfurnished"]
|
||||
|
||||
def test_to_query_parameters(self):
|
||||
"""Test conversion to QueryParameters."""
|
||||
schedule = ScheduleConfig(
|
||||
name="Test",
|
||||
listing_type=ListingType.RENT,
|
||||
min_bedrooms=2,
|
||||
max_bedrooms=3,
|
||||
min_price=2000,
|
||||
max_price=4000,
|
||||
district_names=["Westminster"],
|
||||
furnish_types=["furnished"],
|
||||
)
|
||||
|
||||
params = schedule.to_query_parameters()
|
||||
|
||||
assert params.listing_type == ListingType.RENT
|
||||
assert params.min_bedrooms == 2
|
||||
assert params.max_bedrooms == 3
|
||||
assert params.min_price == 2000
|
||||
assert params.max_price == 4000
|
||||
assert params.district_names == {"Westminster"}
|
||||
assert params.furnish_types == [FurnishType.FURNISHED]
|
||||
|
||||
def test_to_query_parameters_no_furnish_types(self):
|
||||
"""Test conversion when furnish_types is None."""
|
||||
schedule = ScheduleConfig(
|
||||
name="Test",
|
||||
listing_type=ListingType.BUY,
|
||||
)
|
||||
|
||||
params = schedule.to_query_parameters()
|
||||
|
||||
assert params.furnish_types is None
|
||||
|
||||
|
||||
class TestCronValidation:
|
||||
"""Tests for cron field validation."""
|
||||
|
||||
# Valid minute values
|
||||
@pytest.mark.parametrize(
|
||||
"minute",
|
||||
[
|
||||
"0",
|
||||
"59",
|
||||
"*",
|
||||
"*/5",
|
||||
"*/15",
|
||||
"0,15,30,45",
|
||||
],
|
||||
)
|
||||
def test_valid_minute(self, minute: str):
|
||||
"""Test valid minute values are accepted."""
|
||||
schedule = ScheduleConfig(
|
||||
name="Test", listing_type=ListingType.RENT, minute=minute
|
||||
)
|
||||
assert schedule.minute == minute
|
||||
|
||||
# Invalid minute values
|
||||
@pytest.mark.parametrize(
|
||||
"minute",
|
||||
[
|
||||
"60",
|
||||
"-1",
|
||||
"abc",
|
||||
"*/0",
|
||||
],
|
||||
)
|
||||
def test_invalid_minute(self, minute: str):
|
||||
"""Test invalid minute values are rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleConfig(name="Test", listing_type=ListingType.RENT, minute=minute)
|
||||
|
||||
# Valid hour values
|
||||
@pytest.mark.parametrize(
|
||||
"hour",
|
||||
[
|
||||
"0",
|
||||
"23",
|
||||
"*",
|
||||
"*/6",
|
||||
"0,6,12,18",
|
||||
],
|
||||
)
|
||||
def test_valid_hour(self, hour: str):
|
||||
"""Test valid hour values are accepted."""
|
||||
schedule = ScheduleConfig(
|
||||
name="Test", listing_type=ListingType.RENT, hour=hour
|
||||
)
|
||||
assert schedule.hour == hour
|
||||
|
||||
# Invalid hour values
|
||||
@pytest.mark.parametrize(
|
||||
"hour",
|
||||
[
|
||||
"24",
|
||||
"-1",
|
||||
"abc",
|
||||
"*/0",
|
||||
],
|
||||
)
|
||||
def test_invalid_hour(self, hour: str):
|
||||
"""Test invalid hour values are rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleConfig(name="Test", listing_type=ListingType.RENT, hour=hour)
|
||||
|
||||
# Valid day_of_week values
|
||||
@pytest.mark.parametrize(
|
||||
"day_of_week",
|
||||
[
|
||||
"0",
|
||||
"6",
|
||||
"*",
|
||||
"1,3,5",
|
||||
"*/2",
|
||||
],
|
||||
)
|
||||
def test_valid_day_of_week(self, day_of_week: str):
|
||||
"""Test valid day_of_week values are accepted."""
|
||||
schedule = ScheduleConfig(
|
||||
name="Test", listing_type=ListingType.RENT, day_of_week=day_of_week
|
||||
)
|
||||
assert schedule.day_of_week == day_of_week
|
||||
|
||||
# Invalid day_of_week values
|
||||
@pytest.mark.parametrize(
|
||||
"day_of_week",
|
||||
[
|
||||
"7",
|
||||
"-1",
|
||||
"abc",
|
||||
"*/0",
|
||||
],
|
||||
)
|
||||
def test_invalid_day_of_week(self, day_of_week: str):
|
||||
"""Test invalid day_of_week values are rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleConfig(
|
||||
name="Test", listing_type=ListingType.RENT, day_of_week=day_of_week
|
||||
)
|
||||
|
||||
|
||||
class TestSchedulesConfig:
|
||||
"""Tests for SchedulesConfig container."""
|
||||
|
||||
def test_from_env_empty(self):
|
||||
"""Test loading from empty environment variable."""
|
||||
with mock.patch.dict(os.environ, {"SCRAPE_SCHEDULES": ""}, clear=False):
|
||||
config = SchedulesConfig.from_env()
|
||||
assert config.schedules == []
|
||||
|
||||
def test_from_env_missing(self):
|
||||
"""Test loading when environment variable is not set."""
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
# Ensure SCRAPE_SCHEDULES is not set
|
||||
os.environ.pop("SCRAPE_SCHEDULES", None)
|
||||
config = SchedulesConfig.from_env()
|
||||
assert config.schedules == []
|
||||
|
||||
def test_from_env_valid_single(self):
|
||||
"""Test loading a single valid schedule."""
|
||||
json_config = '[{"name":"Daily RENT","listing_type":"RENT","hour":"2"}]'
|
||||
with mock.patch.dict(os.environ, {"SCRAPE_SCHEDULES": json_config}):
|
||||
config = SchedulesConfig.from_env()
|
||||
|
||||
assert len(config.schedules) == 1
|
||||
assert config.schedules[0].name == "Daily RENT"
|
||||
assert config.schedules[0].listing_type == ListingType.RENT
|
||||
assert config.schedules[0].hour == "2"
|
||||
|
||||
def test_from_env_valid_multiple(self):
|
||||
"""Test loading multiple valid schedules."""
|
||||
json_config = """[
|
||||
{"name":"Daily RENT","listing_type":"RENT","hour":"2"},
|
||||
{"name":"Daily BUY","listing_type":"BUY","hour":"4","enabled":false}
|
||||
]"""
|
||||
with mock.patch.dict(os.environ, {"SCRAPE_SCHEDULES": json_config}):
|
||||
config = SchedulesConfig.from_env()
|
||||
|
||||
assert len(config.schedules) == 2
|
||||
assert config.schedules[0].name == "Daily RENT"
|
||||
assert config.schedules[0].enabled is True
|
||||
assert config.schedules[1].name == "Daily BUY"
|
||||
assert config.schedules[1].enabled is False
|
||||
|
||||
def test_from_env_invalid_json(self):
|
||||
"""Test error on invalid JSON."""
|
||||
with mock.patch.dict(os.environ, {"SCRAPE_SCHEDULES": "not json"}):
|
||||
with pytest.raises(ValueError, match="Invalid JSON"):
|
||||
SchedulesConfig.from_env()
|
||||
|
||||
def test_from_env_not_array(self):
|
||||
"""Test error when JSON is not an array."""
|
||||
with mock.patch.dict(os.environ, {"SCRAPE_SCHEDULES": '{"name":"test"}'}):
|
||||
with pytest.raises(ValueError, match="must be a JSON array"):
|
||||
SchedulesConfig.from_env()
|
||||
|
||||
def test_from_env_invalid_schedule(self):
|
||||
"""Test error when schedule validation fails."""
|
||||
# Missing required listing_type
|
||||
json_config = '[{"name":"Invalid"}]'
|
||||
with mock.patch.dict(os.environ, {"SCRAPE_SCHEDULES": json_config}):
|
||||
with pytest.raises(ValidationError):
|
||||
SchedulesConfig.from_env()
|
||||
|
||||
def test_get_enabled_schedules(self):
|
||||
"""Test filtering to only enabled schedules."""
|
||||
config = SchedulesConfig(
|
||||
schedules=[
|
||||
ScheduleConfig(name="Enabled", listing_type=ListingType.RENT, enabled=True),
|
||||
ScheduleConfig(name="Disabled", listing_type=ListingType.BUY, enabled=False),
|
||||
ScheduleConfig(name="Also Enabled", listing_type=ListingType.RENT, enabled=True),
|
||||
]
|
||||
)
|
||||
|
||||
enabled = config.get_enabled_schedules()
|
||||
|
||||
assert len(enabled) == 2
|
||||
assert enabled[0].name == "Enabled"
|
||||
assert enabled[1].name == "Also Enabled"
|
||||
|
||||
def test_get_enabled_schedules_all_disabled(self):
|
||||
"""Test when all schedules are disabled."""
|
||||
config = SchedulesConfig(
|
||||
schedules=[
|
||||
ScheduleConfig(name="Disabled1", listing_type=ListingType.RENT, enabled=False),
|
||||
ScheduleConfig(name="Disabled2", listing_type=ListingType.BUY, enabled=False),
|
||||
]
|
||||
)
|
||||
|
||||
enabled = config.get_enabled_schedules()
|
||||
|
||||
assert len(enabled) == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue