Add services layer, tests, streaming UI, and cleanup legacy code

This commit is contained in:
Viktor Barzin 2026-02-06 20:55:10 +00:00
parent 5514fa6381
commit d205d15c74
62 changed files with 3729 additions and 1024 deletions

View file

@ -0,0 +1 @@
# Unit tests package

View 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

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

View 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

View 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