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/__init__.py
Normal file
1
crawler/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Tests package
|
||||
186
crawler/tests/conftest.py
Normal file
186
crawler/tests/conftest.py
Normal 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()
|
||||
1
crawler/tests/integration/__init__.py
Normal file
1
crawler/tests/integration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Integration tests package
|
||||
180
crawler/tests/integration/test_api.py
Normal file
180
crawler/tests/integration/test_api.py
Normal 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)
|
||||
299
crawler/tests/test_listing_geojson.py
Normal file
299
crawler/tests/test_listing_geojson.py
Normal 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
|
||||
|
||||
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