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 @@
# Tests package

186
crawler/tests/conftest.py Normal file
View file

@ -0,0 +1,186 @@
"""Shared pytest fixtures for the test suite."""
from datetime import datetime
from typing import AsyncGenerator, Generator
import pytest
from sqlalchemy import Engine
from sqlmodel import SQLModel, Session, create_engine
from httpx import ASGITransport, AsyncClient
from models.listing import (
BuyListing,
FurnishType,
ListingSite,
RentListing,
Listing,
)
from repositories.listing_repository import ListingRepository
from api.auth import User
@pytest.fixture
def in_memory_engine() -> Generator[Engine, None, None]:
"""Create an in-memory SQLite engine for testing."""
engine = create_engine(
"sqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
yield engine
SQLModel.metadata.drop_all(engine)
@pytest.fixture
def listing_repository(in_memory_engine: Engine) -> ListingRepository:
"""Create a ListingRepository with the in-memory engine."""
return ListingRepository(engine=in_memory_engine)
@pytest.fixture
def sample_rent_listing() -> RentListing:
"""Create a sample RentListing for testing."""
return RentListing(
id=12345678,
price=2500.0,
number_of_bedrooms=2,
square_meters=65.0,
agency="Test Agency",
council_tax_band="C",
longitude=-0.1276,
latitude=51.5074,
price_history_json="[]",
listing_site=ListingSite.RIGHTMOVE,
last_seen=datetime.now(),
photo_thumbnail="https://example.com/photo.jpg",
floorplan_image_paths=[],
additional_info={"property": {"visible": True}},
routing_info_json=None,
furnish_type=FurnishType.FURNISHED,
available_from=datetime.now(),
)
@pytest.fixture
def sample_buy_listing() -> BuyListing:
"""Create a sample BuyListing for testing."""
return BuyListing(
id=87654321,
price=450000.0,
number_of_bedrooms=3,
square_meters=95.0,
agency="Test Estate Agents",
council_tax_band="D",
longitude=-0.1180,
latitude=51.5100,
price_history_json="[]",
listing_site=ListingSite.RIGHTMOVE,
last_seen=datetime.now(),
photo_thumbnail="https://example.com/buy_photo.jpg",
floorplan_image_paths=[],
additional_info={"property": {"visible": True}},
routing_info_json=None,
service_charge=1500.0,
lease_left=90,
)
@pytest.fixture
def sample_rent_listings() -> list[RentListing]:
"""Create multiple sample RentListings for testing filters."""
now = datetime.now()
return [
RentListing(
id=1,
price=1500.0,
number_of_bedrooms=1,
square_meters=40.0,
agency="Agency A",
council_tax_band="B",
longitude=-0.1,
latitude=51.5,
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,
),
RentListing(
id=2,
price=2000.0,
number_of_bedrooms=2,
square_meters=55.0,
agency="Agency B",
council_tax_band="C",
longitude=-0.12,
latitude=51.51,
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.UNFURNISHED,
available_from=now,
),
RentListing(
id=3,
price=3000.0,
number_of_bedrooms=3,
square_meters=80.0,
agency="Agency C",
council_tax_band="D",
longitude=-0.14,
latitude=51.52,
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,
),
]
@pytest.fixture
def mock_user() -> User:
"""Create a mock user for API tests."""
return User(
sub="test-user-id",
email="test@example.com",
name="Test User",
)
@pytest.fixture
async def async_client(
in_memory_engine: Engine, mock_user: User
) -> AsyncGenerator[AsyncClient, None]:
"""Create an AsyncClient for API testing with mock auth."""
from api.app import app
from api.auth import get_current_user
# Override dependencies
app.dependency_overrides[get_current_user] = lambda: mock_user
# Patch the engine used by the repository
original_engine = None
try:
from database import engine as db_engine
original_engine = db_engine
except Exception:
pass
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# Clean up dependency overrides
app.dependency_overrides.clear()

View file

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

View file

@ -0,0 +1,180 @@
"""Integration tests for API endpoints."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from api.auth import User
class TestStatusEndpoint:
"""Tests for the /api/status endpoint."""
async def test_status_endpoint_returns_ok(
self, async_client: AsyncClient
) -> None:
"""Test that status endpoint returns OK status."""
response = await async_client.get("/api/status")
assert response.status_code == 200
assert response.json() == {"status": "OK"}
class TestListingEndpoint:
"""Tests for the /api/listing endpoint."""
async def test_listing_endpoint_requires_auth(self) -> None:
"""Test that listing endpoint requires authentication."""
from api.app import app
from httpx import ASGITransport, AsyncClient
# Clear any dependency overrides to test auth requirement
app.dependency_overrides.clear()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/listing")
# Should return 401 or 403 without valid auth
assert response.status_code in (401, 403)
async def test_listing_endpoint_with_auth(
self, async_client: AsyncClient
) -> None:
"""Test that listing endpoint works with authentication."""
# Mock the repository to return empty list
with patch(
"api.app.ListingRepository.get_listings",
new_callable=AsyncMock,
return_value=[],
):
response = await async_client.get("/api/listing")
assert response.status_code == 200
data = response.json()
assert "listings" in data
class TestListingGeoJsonEndpoint:
"""Tests for the /api/listing_geojson endpoint."""
async def test_listing_geojson_requires_auth(self) -> None:
"""Test that listing_geojson endpoint requires authentication."""
from api.app import app
from httpx import ASGITransport, AsyncClient
# Clear any dependency overrides to test auth requirement
app.dependency_overrides.clear()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/listing_geojson",
params={"listing_type": "RENT"},
)
# Should return 401 or 403 without valid auth
assert response.status_code in (401, 403)
async def test_listing_geojson_with_filters(
self, async_client: AsyncClient
) -> None:
"""Test that listing_geojson accepts filter parameters."""
with patch(
"api.app.export_immoweb",
new_callable=AsyncMock,
return_value={"type": "FeatureCollection", "features": []},
):
response = await async_client.get(
"/api/listing_geojson",
params={
"listing_type": "RENT",
"min_bedrooms": 2,
"max_bedrooms": 3,
"min_price": 1500,
"max_price": 3000,
},
)
assert response.status_code == 200
data = response.json()
assert data["type"] == "FeatureCollection"
class TestGetDistrictsEndpoint:
"""Tests for the /api/get_districts endpoint."""
async def test_get_districts_requires_auth(self) -> None:
"""Test that get_districts endpoint requires authentication."""
from api.app import app
from httpx import ASGITransport, AsyncClient
# Clear any dependency overrides to test auth requirement
app.dependency_overrides.clear()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/get_districts")
# Should return 401 or 403 without valid auth
assert response.status_code in (401, 403)
async def test_get_districts_returns_dict(
self, async_client: AsyncClient
) -> None:
"""Test that get_districts returns a dictionary of districts."""
response = await async_client.get("/api/get_districts")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
# Check some known districts exist
assert "London" in data
assert "Westminster" in data
assert "Camden" in data
async def test_get_districts_values_are_region_ids(
self, async_client: AsyncClient
) -> None:
"""Test that district values are REGION identifiers."""
response = await async_client.get("/api/get_districts")
data = response.json()
# All values should be REGION^... format
for district_name, region_id in data.items():
assert region_id.startswith("REGION^"), (
f"District {district_name} has invalid region ID: {region_id}"
)
class TestRefreshListingsEndpoint:
"""Tests for the /api/refresh_listings endpoint."""
async def test_refresh_listings_requires_auth(self) -> None:
"""Test that refresh_listings endpoint requires authentication."""
from api.app import app
from httpx import ASGITransport, AsyncClient
# Clear any dependency overrides to test auth requirement
app.dependency_overrides.clear()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/refresh_listings",
params={"listing_type": "RENT"},
)
# Should return 401 or 403 without valid auth
assert response.status_code in (401, 403)
class TestTaskStatusEndpoint:
"""Tests for the /api/task_status endpoint."""
async def test_task_status_requires_auth(self) -> None:
"""Test that task_status endpoint requires authentication."""
from api.app import app
from httpx import ASGITransport, AsyncClient
# Clear any dependency overrides to test auth requirement
app.dependency_overrides.clear()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/task_status",
params={"task_id": "test-task-id"},
)
# Should return 401 or 403 without valid auth
assert response.status_code in (401, 403)

View file

@ -0,0 +1,299 @@
"""Tests for the listing_geojson API endpoint and QueryParameters parsing."""
import json
import pytest
from datetime import datetime
from unittest.mock import patch, MagicMock, AsyncMock
class TestQueryParametersModel:
"""Test QueryParameters model directly."""
def test_datetime_parsing_z_suffix(self):
"""Test that datetime with Z suffix is parsed correctly."""
from models.listing import QueryParameters, ListingType
params = QueryParameters(
listing_type=ListingType.RENT,
let_date_available_from="2026-02-01T11:33:01.248Z",
)
assert params.let_date_available_from is not None
assert params.let_date_available_from.year == 2026
def test_datetime_parsing_offset(self):
"""Test that datetime with offset is parsed correctly."""
from models.listing import QueryParameters, ListingType
params = QueryParameters(
listing_type=ListingType.RENT,
let_date_available_from="2026-02-01T11:33:01.248+00:00",
)
assert params.let_date_available_from is not None
def test_defaults_work(self):
"""Test that default values are applied correctly."""
from models.listing import QueryParameters, ListingType
params = QueryParameters(listing_type=ListingType.RENT)
assert params.min_bedrooms == 1
assert params.max_bedrooms == 999
assert params.min_price == 0
assert params.max_price == 10_000_000
assert params.district_names == set()
assert params.let_date_available_from is None
def test_full_frontend_params(self):
"""Test with all parameters as sent by frontend."""
from models.listing import QueryParameters, ListingType
params = QueryParameters(
listing_type=ListingType.RENT,
min_bedrooms=1,
max_bedrooms=3,
max_price=3000,
min_price=2000,
min_sqm=50,
last_seen_days=28,
let_date_available_from="2026-02-01T11:19:22.072Z",
)
assert params.listing_type == ListingType.RENT
assert params.min_bedrooms == 1
assert params.max_bedrooms == 3
assert params.min_sqm == 50
class TestGetQueryParametersDependency:
"""Test the get_query_parameters FastAPI dependency."""
def test_parses_datetime_correctly(self):
"""Test that the dependency parses datetime Z suffix."""
from api.app import get_query_parameters
from models.listing import ListingType
params = get_query_parameters(
listing_type=ListingType.RENT,
let_date_available_from=datetime(2026, 2, 1, 11, 33, 1),
)
assert params.let_date_available_from is not None
def test_defaults_applied(self):
"""Test that defaults are applied when not provided."""
from api.app import get_query_parameters
from models.listing import ListingType
params = get_query_parameters(listing_type=ListingType.RENT)
assert params.min_bedrooms == 1
assert params.max_bedrooms == 999
class TestListingGeoJsonEndpoint:
"""Test the /api/listing_geojson endpoint."""
@pytest.fixture
def client(self):
"""Create test client with mocked auth."""
from fastapi.testclient import TestClient
from api.app import app, get_current_user
from api.auth import User
# Override auth dependency
async def mock_auth():
return User(email="test@example.com", name="Test User")
app.dependency_overrides[get_current_user] = mock_auth
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.fixture
def mock_export(self):
"""Mock the export service."""
with patch("api.app.export_service.export_to_geojson") as mock:
mock.return_value = MagicMock(
data={"type": "FeatureCollection", "features": [{"type": "Feature"}]}
)
yield mock
def test_minimal_params_no_422(self, client, mock_export):
"""Test that minimal params don't cause 422."""
response = client.get("/api/listing_geojson?listing_type=RENT")
assert response.status_code != 422, f"Got 422: {response.json()}"
def test_with_datetime_z_suffix_no_422(self, client, mock_export):
"""Test datetime parsing with Z suffix doesn't cause 422."""
response = client.get(
"/api/listing_geojson?"
"listing_type=RENT"
"&let_date_available_from=2026-02-01T11:33:01.248Z"
)
assert response.status_code != 422, f"Got 422: {response.json()}"
def test_full_frontend_params_no_422(self, client, mock_export):
"""Test with all parameters as sent by frontend."""
response = client.get(
"/api/listing_geojson?"
"listing_type=RENT"
"&min_bedrooms=1"
"&max_bedrooms=3"
"&max_price=3000"
"&min_price=2000"
"&min_sqm=50"
"&last_seen_days=28"
"&let_date_available_from=2026-02-01T11:19:22.072Z"
)
assert response.status_code != 422, f"Got 422: {response.json()}"
def test_returns_geojson_structure(self, client, mock_export):
"""Test that endpoint returns proper GeoJSON structure."""
response = client.get("/api/listing_geojson?listing_type=RENT")
assert response.status_code == 200
data = response.json()
assert "type" in data
assert data["type"] == "FeatureCollection"
assert "features" in data
class TestStreamingEndpoint:
"""Test the /api/listing_geojson/stream endpoint."""
@pytest.fixture
def client(self):
"""Create test client with mocked auth."""
from fastapi.testclient import TestClient
from api.app import app
from api.auth import get_current_user, User
async def mock_auth():
return User(sub="test-id", email="test@example.com", name="Test User")
app.dependency_overrides[get_current_user] = mock_auth
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.fixture
def mock_repository(self):
"""Mock the repository methods."""
with patch("api.app.ListingRepository") as MockRepo:
mock_instance = MagicMock()
mock_instance.count_listings.return_value = 3
mock_instance.stream_listings_optimized.return_value = iter([
{
'id': 1,
'price': 2000.0,
'number_of_bedrooms': 2,
'square_meters': 50.0,
'longitude': -0.1,
'latitude': 51.5,
'photo_thumbnail': 'https://example.com/1.jpg',
'last_seen': datetime.now(),
'agency': 'Test Agency',
'price_history_json': '[]',
'available_from': datetime.now(),
},
{
'id': 2,
'price': 2500.0,
'number_of_bedrooms': 2,
'square_meters': 60.0,
'longitude': -0.12,
'latitude': 51.51,
'photo_thumbnail': 'https://example.com/2.jpg',
'last_seen': datetime.now(),
'agency': 'Test Agency 2',
'price_history_json': '[]',
'available_from': None,
},
{
'id': 3,
'price': 3000.0,
'number_of_bedrooms': 3,
'square_meters': None,
'longitude': -0.14,
'latitude': 51.52,
'photo_thumbnail': None,
'last_seen': datetime.now(),
'agency': None,
'price_history_json': '[{"first_seen": "2026-01-01", "last_seen": "2026-01-15", "price": 2800}]',
'available_from': None,
},
])
MockRepo.return_value = mock_instance
yield mock_instance
def test_streaming_returns_ndjson(self, client, mock_repository):
"""Test that streaming endpoint returns NDJSON format."""
response = client.get("/api/listing_geojson/stream?listing_type=RENT&limit=10")
assert response.status_code == 200
assert response.headers["content-type"] == "application/x-ndjson"
def test_streaming_metadata_includes_total_expected(self, client, mock_repository):
"""Test that first line includes total_expected count."""
response = client.get("/api/listing_geojson/stream?listing_type=RENT&limit=10")
lines = response.text.strip().split("\n")
assert len(lines) >= 1
metadata = json.loads(lines[0])
assert metadata["type"] == "metadata"
assert "total_expected" in metadata
assert metadata["total_expected"] == 3
assert "batch_size" in metadata
def test_streaming_returns_batches_and_complete(self, client, mock_repository):
"""Test that streaming returns batch and complete messages."""
response = client.get("/api/listing_geojson/stream?listing_type=RENT&limit=10")
lines = response.text.strip().split("\n")
# Parse all lines
messages = [json.loads(line) for line in lines]
# First should be metadata
assert messages[0]["type"] == "metadata"
# Should have at least one batch
batch_messages = [m for m in messages if m["type"] == "batch"]
assert len(batch_messages) >= 1
# Last should be complete
assert messages[-1]["type"] == "complete"
assert "total" in messages[-1]
def test_streaming_features_have_correct_structure(self, client, mock_repository):
"""Test that streamed features have correct GeoJSON structure."""
response = client.get("/api/listing_geojson/stream?listing_type=RENT&batch_size=10&limit=10")
lines = response.text.strip().split("\n")
messages = [json.loads(line) for line in lines]
batch_messages = [m for m in messages if m["type"] == "batch"]
assert len(batch_messages) >= 1
features = batch_messages[0]["features"]
assert len(features) > 0
feature = features[0]
assert feature["type"] == "Feature"
assert "properties" in feature
assert "geometry" in feature
assert feature["geometry"]["type"] == "Point"
assert "coordinates" in feature["geometry"]
# Check properties
props = feature["properties"]
assert "total_price" in props
assert "rooms" in props
assert "url" in props
assert "last_seen" in props
def test_streaming_handles_null_square_meters(self, client, mock_repository):
"""Test that null square_meters doesn't cause errors."""
response = client.get("/api/listing_geojson/stream?listing_type=RENT&batch_size=10&limit=10")
assert response.status_code == 200
lines = response.text.strip().split("\n")
messages = [json.loads(line) for line in lines]
# Find feature with id=3 (has null square_meters)
for msg in messages:
if msg["type"] == "batch":
for feature in msg["features"]:
if feature["properties"]["url"].endswith("/3"):
assert feature["properties"]["qm"] is None
assert feature["properties"]["qmprice"] is None

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