Add comprehensive test suite: 219 new tests across backend and frontend

Backend (103 tests):
- Unit tests for listing_service, export_service, district_service
- Regression tests for API response contracts and query parameter validation
- Integration tests for API workflows, Redis listing cache, listing processor pipeline, and repository advanced queries
- E2E tests for streaming with filters, batching, caching, and task management

Frontend (116 tests):
- Service tests for apiClient, streamingService, taskService, listingService, healthService
- Hook tests for useTaskProgress (WebSocket + polling)
- Component tests for PropertyCard, FilterPanel, Header, ListView, TaskProgressDrawer, TaskIndicator, StreamingProgressBar, HealthIndicator
- E2E tests for filter-stream-display flow

Infrastructure:
- Add pytest-xdist and test markers (regression, integration, e2e)
- Add conftest fixtures: fake_redis, rent_listing_factory, seeded_repository
- Add vitest + testing-library + MSW for frontend testing
This commit is contained in:
Viktor Barzin 2026-02-10 21:59:45 +00:00
parent a3ac9cc060
commit 8d22c97320
No known key found for this signature in database
GPG key ID: 0EB088298288D958
36 changed files with 5447 additions and 19 deletions

View file

@ -1,7 +1,8 @@
"""Shared pytest fixtures for the test suite."""
from datetime import datetime
from typing import AsyncGenerator, Generator
from typing import Any, AsyncGenerator, Callable, Generator
import pytest
import fakeredis
from sqlalchemy import Engine
from sqlmodel import SQLModel, Session, create_engine
from httpx import ASGITransport, AsyncClient
@ -184,3 +185,59 @@ async def async_client(
# Clean up dependency overrides
app.dependency_overrides.clear()
@pytest.fixture
def fake_redis() -> Generator[fakeredis.FakeRedis, None, None]:
"""Create a fakeredis client, flushed after each test."""
client = fakeredis.FakeRedis(decode_responses=True)
yield client
client.flushall()
@pytest.fixture
def rent_listing_factory() -> Callable[..., RentListing]:
"""Factory function that creates RentListing with overridable defaults."""
_counter = 0
def _create(**overrides: Any) -> RentListing:
nonlocal _counter
_counter += 1
defaults: dict[str, Any] = dict(
id=_counter,
price=2000.0,
number_of_bedrooms=2,
square_meters=55.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(),
)
defaults.update(overrides)
return RentListing(**defaults)
return _create
@pytest.fixture
async def seeded_repository(
in_memory_engine: Engine,
rent_listing_factory: Callable[..., RentListing],
) -> ListingRepository:
"""Repository with 10 pre-seeded listings (varied price/bedrooms/sqm)."""
repo = ListingRepository(engine=in_memory_engine)
listings = [
rent_listing_factory(id=100 + i, price=1000 + i * 300, number_of_bedrooms=(i % 4) + 1, square_meters=30 + i * 10)
for i in range(10)
]
await repo.upsert_listings(listings)
return repo

0
tests/e2e/__init__.py Normal file
View file

View file

@ -0,0 +1,203 @@
"""End-to-end tests for full API workflows."""
import json
from unittest.mock import AsyncMock
import pytest
from httpx import AsyncClient
from sqlalchemy import Engine
from repositories.listing_repository import ListingRepository
pytestmark = pytest.mark.e2e
@pytest.fixture(autouse=True)
def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
import database
import api.app
import api.rate_limiter
monkeypatch.setattr(database, "engine", in_memory_engine)
monkeypatch.setattr(api.app, "engine", in_memory_engine)
# Disable rate limiting for E2E tests
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
@pytest.fixture(autouse=True)
def patch_redis_client(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
def _parse_ndjson(text: str) -> list[dict]:
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
# ---------- Streaming with filters ----------
@pytest.mark.asyncio
async def test_seed_and_stream_with_filter(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, price=1000 + i * 300, square_meters=40.0)
for i in range(1, 21)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&max_price=3000"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
all_features = [f for b in batches for f in b["features"]]
complete = lines[-1]
for feat in all_features:
assert feat["properties"]["total_price"] <= 3000
assert complete["type"] == "complete"
assert complete["total"] == len(all_features)
@pytest.mark.asyncio
async def test_large_batch_streaming(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, square_meters=40.0)
for i in range(1, 201)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&batch_size=50"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
complete = lines[-1]
assert len(batches) == 4
assert complete["total"] == 200
@pytest.mark.asyncio
async def test_empty_result_set(async_client: AsyncClient) -> None:
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT"
)
lines = _parse_ndjson(resp.text)
assert lines[0]["type"] == "metadata"
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 0
@pytest.mark.asyncio
async def test_refresh_creates_task(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from services.listing_service import RefreshResult
async def fake_refresh(*args, **kwargs):
return RefreshResult(task_id="e2e-task-1", new_listings_count=0, message="started")
monkeypatch.setattr("services.listing_service.refresh_listings", fake_refresh)
monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None)
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
resp = await async_client.post("/api/refresh_listings?listing_type=RENT")
assert resp.status_code == 200
assert resp.json()["task_id"] == "e2e-task-1"
@pytest.mark.asyncio
async def test_cache_populated_on_first_stream(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 6)]
await listing_repository.upsert_listings(listings)
# First stream — cache miss, populated from DB
resp1 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines1 = _parse_ndjson(resp1.text)
assert lines1[0]["cached"] is False
# Second stream — should hit cache
resp2 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines2 = _parse_ndjson(resp2.text)
assert lines2[0]["cached"] is True
@pytest.mark.asyncio
async def test_stream_filter_price(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, price=500 * i, square_meters=40.0)
for i in range(1, 11)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&min_price=1500&max_price=3000"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
all_features = [f for b in batches for f in b["features"]]
for feat in all_features:
assert 1500 <= feat["properties"]["total_price"] <= 3000
@pytest.mark.asyncio
async def test_stream_filter_bedrooms(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, number_of_bedrooms=(i % 4) + 1, square_meters=40.0)
for i in range(1, 21)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&min_bedrooms=2&max_bedrooms=3"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
all_features = [f for b in batches for f in b["features"]]
for feat in all_features:
assert 2 <= feat["properties"]["rooms"] <= 3
assert len(all_features) > 0
@pytest.mark.asyncio
async def test_complete_total_matches_actual(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 16)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
total_features = sum(len(b["features"]) for b in batches)
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == total_features
assert complete["total"] == 15

View file

@ -0,0 +1,320 @@
"""Integration tests for API workflow endpoints."""
import json
from unittest.mock import AsyncMock
import pytest
from httpx import AsyncClient
from sqlalchemy import Engine
from repositories.listing_repository import ListingRepository
from services.task_service import TaskStatus
@pytest.fixture(autouse=True)
def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch the database engine and disable rate limiting for tests."""
import database
import api.app
import api.rate_limiter
monkeypatch.setattr(database, "engine", in_memory_engine)
monkeypatch.setattr(api.app, "engine", in_memory_engine)
# Disable rate limiting by making _match_endpoint always return None
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
@pytest.fixture(autouse=True)
def patch_redis_for_streaming(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch Redis client used by listing cache so streaming doesn't hit real Redis."""
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
# ---------- Listing query tests ----------
@pytest.mark.asyncio
async def test_listing_returns_inserted_data(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i) for i in range(1, 4)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing?limit=10")
assert resp.status_code == 200
data = resp.json()
assert len(data["listings"]) == 3
@pytest.mark.asyncio
async def test_listing_respects_limit(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i) for i in range(1, 6)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing?limit=2")
assert resp.status_code == 200
assert len(resp.json()["listings"]) == 2
@pytest.mark.asyncio
async def test_geojson_returns_feature_collection(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=50.0) for i in range(1, 4)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "FeatureCollection"
assert len(data["features"]) == 3
@pytest.mark.asyncio
async def test_geojson_features_have_properties(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=1, price=2500, number_of_bedrooms=2, square_meters=60.0)
await listing_repository.upsert_listings([listing])
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert resp.status_code == 200
features = resp.json()["features"]
assert len(features) == 1
feat = features[0]
assert feat["geometry"]["type"] == "Point"
props = feat["properties"]
for key in ("url", "total_price", "rooms", "qm", "qmprice", "agency", "last_seen"):
assert key in props, f"Missing property: {key}"
# ---------- Streaming tests ----------
def _parse_ndjson(text: str) -> list[dict]:
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
@pytest.mark.asyncio
async def test_stream_metadata_batch_complete(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 4)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
assert resp.status_code == 200
lines = _parse_ndjson(resp.text)
assert lines[0]["type"] == "metadata"
batches = [l for l in lines if l["type"] == "batch"]
assert len(batches) >= 1
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 3
@pytest.mark.asyncio
async def test_stream_respects_limit(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 11)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT&limit=3")
lines = _parse_ndjson(resp.text)
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 3
@pytest.mark.asyncio
async def test_stream_empty_db(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines = _parse_ndjson(resp.text)
assert lines[0]["type"] == "metadata"
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 0
# ---------- Task management tests ----------
@pytest.mark.asyncio
async def test_refresh_returns_task_id(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from services.listing_service import RefreshResult
async def fake_refresh(*args, **kwargs):
return RefreshResult(task_id="test-123", new_listings_count=0, message="Task started")
import services.listing_service
monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh)
monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None)
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
resp = await async_client.post("/api/refresh_listings?listing_type=RENT")
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == "test-123"
@pytest.mark.asyncio
async def test_task_tracked_for_user(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from services.listing_service import RefreshResult
async def fake_refresh(*args, **kwargs):
return RefreshResult(task_id="task-abc", new_listings_count=0, message="ok")
import services.listing_service
monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh)
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
calls: list[tuple[str, str]] = []
import services.task_service
monkeypatch.setattr(
services.task_service,
"add_task_for_user",
lambda email, tid: calls.append((email, tid)),
)
await async_client.post("/api/refresh_listings?listing_type=RENT")
assert len(calls) == 1
assert calls[0] == ("test@example.com", "task-abc")
@pytest.mark.asyncio
async def test_task_status_works(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"])
monkeypatch.setattr(
"services.task_service.get_task_status",
lambda task_id: TaskStatus(
task_id="test-123",
status="SUCCESS",
result=None,
progress=1.0,
processed=10,
total=10,
message="Done",
error=None,
traceback=None,
),
)
resp = await async_client.get("/api/task_status?task_id=test-123")
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == "test-123"
assert data["status"] == "SUCCESS"
assert data["progress"] == 1.0
@pytest.mark.asyncio
async def test_task_not_found_returns_404(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: [])
resp = await async_client.get("/api/task_status?task_id=unknown")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_cancel_task(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"])
monkeypatch.setattr("services.task_service.cancel_task", lambda task_id, user_email=None: True)
resp = await async_client.post("/api/cancel_task?task_id=test-123")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
@pytest.mark.asyncio
async def test_clear_all_tasks(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.clear_all_tasks", lambda email: 3)
resp = await async_client.post("/api/clear_all_tasks")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
assert data["success"] is True
# ---------- Additional edge cases ----------
@pytest.mark.asyncio
async def test_listing_empty_db_returns_empty(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/listing?limit=10")
assert resp.status_code == 200
assert resp.json()["listings"] == []
@pytest.mark.asyncio
async def test_geojson_empty_db_returns_empty_collection(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "FeatureCollection"
assert len(data["features"]) == 0
@pytest.mark.asyncio
async def test_cancel_task_not_owned(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: [])
resp = await async_client.post("/api/cancel_task?task_id=not-mine")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
@pytest.mark.asyncio
async def test_tasks_for_user(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["a", "b", "c"])
resp = await async_client.get("/api/tasks_for_user")
assert resp.status_code == 200
assert resp.json() == ["a", "b", "c"]
@pytest.mark.asyncio
async def test_status_endpoint(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["status"] == "OK"

View file

@ -0,0 +1,132 @@
"""Integration tests for Redis-based listing cache."""
import pytest
from models.listing import ListingType, QueryParameters
from services.listing_cache import (
begin_cache_population,
cache_features_batch,
cache_features_batch_staged,
delete_staging_key,
finalize_cache_population,
get_cached_count,
get_cached_features,
invalidate_cache,
make_cache_key,
)
@pytest.fixture(autouse=True)
def patch_redis(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
"""Route all cache operations through fakeredis."""
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
def _make_qp(**kwargs) -> QueryParameters:
return QueryParameters(listing_type=ListingType.RENT, **kwargs)
def _sample_features(n: int) -> list[dict]:
return [{"type": "Feature", "id": i, "properties": {"price": 1000 + i}} for i in range(n)]
# ---------- Basic read/write ----------
def test_cache_miss_returns_none() -> None:
qp = _make_qp()
assert get_cached_count(qp) is None
def test_cache_write_then_read() -> None:
qp = _make_qp()
features = _sample_features(5)
cache_features_batch(qp, features)
count = get_cached_count(qp)
assert count == 5
def test_batch_retrieval() -> None:
qp = _make_qp()
cache_features_batch(qp, _sample_features(10))
batches = list(get_cached_features(qp, batch_size=3))
sizes = [len(b) for b in batches]
assert sizes == [3, 3, 3, 1]
# ---------- Cache key behaviour ----------
def test_cache_key_deterministic() -> None:
qp1 = _make_qp()
qp2 = _make_qp()
assert make_cache_key(qp1) == make_cache_key(qp2)
def test_cache_key_different_for_different_params() -> None:
rent = _make_qp()
buy = QueryParameters(listing_type=ListingType.BUY)
assert make_cache_key(rent) != make_cache_key(buy)
# ---------- Staged population ----------
def test_staged_population_begin() -> None:
qp = _make_qp()
staging_key = begin_cache_population(qp)
assert isinstance(staging_key, str)
assert "staging" in staging_key
def test_staged_write_then_finalize() -> None:
qp = _make_qp()
staging_key = begin_cache_population(qp)
cache_features_batch_staged(staging_key, _sample_features(4))
finalize_cache_population(staging_key, qp)
assert get_cached_count(qp) == 4
def test_staging_key_deleted_on_cleanup(fake_redis) -> None:
qp = _make_qp()
staging_key = begin_cache_population(qp)
cache_features_batch_staged(staging_key, _sample_features(2))
delete_staging_key(staging_key)
assert fake_redis.exists(staging_key) == 0
# ---------- Invalidation ----------
def test_invalidation() -> None:
qp = _make_qp()
cache_features_batch(qp, _sample_features(5))
assert get_cached_count(qp) == 5
invalidate_cache()
assert get_cached_count(qp) is None
# ---------- Edge cases ----------
def test_empty_features_batch_noop() -> None:
qp = _make_qp()
cache_features_batch(qp, [])
assert get_cached_count(qp) is None
def test_multiple_batches_accumulate() -> None:
qp = _make_qp()
cache_features_batch(qp, _sample_features(3))
cache_features_batch(qp, _sample_features(4))
assert get_cached_count(qp) == 7
def test_get_cached_features_empty() -> None:
qp = _make_qp()
batches = list(get_cached_features(qp))
assert batches == []

View file

@ -0,0 +1,186 @@
"""Integration tests for ListingProcessor and processing steps."""
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import Engine
from listing_processor import (
DetectFloorplanStep,
FetchImagesStep,
FetchListingDetailsStep,
ListingProcessor,
)
from models.listing import ListingType
from repositories.listing_repository import ListingRepository
# ---------- Processor structure tests ----------
def test_processor_has_three_steps(listing_repository: ListingRepository) -> None:
processor = ListingProcessor(listing_repository)
assert len(processor.process_steps) == 3
def test_step_order(listing_repository: ListingRepository) -> None:
processor = ListingProcessor(listing_repository)
types = [type(s) for s in processor.process_steps]
assert types == [FetchListingDetailsStep, FetchImagesStep, DetectFloorplanStep]
# ---------- Processing flow ----------
@pytest.mark.asyncio
async def test_process_calls_steps_in_order(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
# Seed a listing so mark_seen doesn't fail
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
call_order: list[str] = []
for step in processor.process_steps:
name = type(step).__name__
step.needs_processing = AsyncMock(return_value=True)
step.process = AsyncMock(
side_effect=lambda lid, n=name: call_order.append(n) or listing
)
result = await processor.process_listing(42)
assert result is not None
assert call_order == [
"FetchListingDetailsStep",
"FetchImagesStep",
"DetectFloorplanStep",
]
@pytest.mark.asyncio
async def test_step_failure_stops_pipeline(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
processor.process_steps[0].needs_processing = AsyncMock(return_value=True)
processor.process_steps[0].process = AsyncMock(side_effect=RuntimeError("boom"))
processor.process_steps[1].needs_processing = AsyncMock(return_value=True)
processor.process_steps[1].process = AsyncMock()
processor.process_steps[2].needs_processing = AsyncMock(return_value=True)
processor.process_steps[2].process = AsyncMock()
result = await processor.process_listing(42)
assert result is None
# Second and third steps should not have been called
processor.process_steps[1].process.assert_not_called()
processor.process_steps[2].process.assert_not_called()
@pytest.mark.asyncio
async def test_callback_fired_per_step(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
for step in processor.process_steps:
step.needs_processing = AsyncMock(return_value=True)
step.process = AsyncMock(return_value=listing)
callback_args: list[str] = []
await processor.process_listing(42, on_step_complete=lambda name: callback_args.append(name))
assert callback_args == ["details", "images", "ocr"]
@pytest.mark.asyncio
async def test_step_skipped_when_not_needed(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
for step in processor.process_steps:
step.needs_processing = AsyncMock(return_value=False)
step.process = AsyncMock()
await processor.process_listing(42)
for step in processor.process_steps:
step.process.assert_not_called()
# ---------- Individual step tests ----------
@pytest.mark.asyncio
async def test_fetch_details_creates_listing(
listing_repository: ListingRepository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sample_detail = {
"property": {
"price": 2000,
"bedrooms": 2,
"branch": {"brandName": "Test Agency"},
"councilTaxInfo": {"content": [{"value": "C"}]},
"longitude": -0.1,
"latitude": 51.5,
"photos": [{"thumbnailUrl": "https://example.com/photo.jpg"}],
"floorplans": [],
"letFurnishType": "furnished",
"letDateAvailable": "Now",
"visible": True,
}
}
monkeypatch.setattr("listing_processor.detail_query", AsyncMock(return_value=sample_detail))
step = FetchListingDetailsStep(listing_repository, ListingType.RENT)
result = await step.process(999)
assert result is not None
assert result.id == 999
assert result.price == 2000
# Verify it was persisted
stored = await listing_repository.get_listings(only_ids=[999])
assert len(stored) == 1
@pytest.mark.asyncio
async def test_processor_marks_seen(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
from datetime import datetime, timedelta
old_time = datetime(2020, 1, 1)
listing = rent_listing_factory(id=50, last_seen=old_time)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
# Skip all steps so we only test mark_seen
for step in processor.process_steps:
step.needs_processing = AsyncMock(return_value=False)
step.process = AsyncMock()
await processor.process_listing(50)
updated = await listing_repository.get_listings(only_ids=[50])
assert len(updated) == 1
# last_seen should have been updated to roughly now
assert updated[0].last_seen > old_time

View file

@ -0,0 +1,122 @@
"""Advanced integration tests for ListingRepository."""
import asyncio
import pytest
from sqlalchemy import Engine
from models.listing import FurnishType, ListingType, QueryParameters
from repositories.listing_repository import ListingRepository
# ---------- Count and basic queries ----------
@pytest.mark.asyncio
async def test_count_matches_get_listings(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT)
count = seeded_repository.count_listings(qp)
listings = await seeded_repository.get_listings(query_parameters=qp)
assert count == len(listings)
@pytest.mark.asyncio
async def test_stream_with_small_page_size(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT)
rows = list(seeded_repository.stream_listings_optimized(qp, page_size=3))
assert len(rows) == 10
# ---------- Filter tests ----------
@pytest.mark.asyncio
async def test_filter_by_bedrooms(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2, max_bedrooms=2)
listings = await seeded_repository.get_listings(query_parameters=qp)
for listing in listings:
assert listing.number_of_bedrooms == 2
assert len(listings) > 0
@pytest.mark.asyncio
async def test_filter_by_price_range(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT, min_price=1500, max_price=2500)
listings = await seeded_repository.get_listings(query_parameters=qp)
for listing in listings:
assert 1500 <= listing.price <= 2500
assert len(listings) > 0
@pytest.mark.asyncio
async def test_filter_by_max_sqm(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT, max_sqm=50)
listings = await seeded_repository.get_listings(query_parameters=qp)
for listing in listings:
assert listing.square_meters is not None
assert listing.square_meters <= 50
assert len(listings) > 0
@pytest.mark.asyncio
async def test_filter_by_furnish_type(
in_memory_engine: Engine,
rent_listing_factory,
) -> None:
repo = ListingRepository(engine=in_memory_engine)
furnished = rent_listing_factory(id=1, furnish_type=FurnishType.FURNISHED)
unfurnished = rent_listing_factory(id=2, furnish_type=FurnishType.UNFURNISHED)
await repo.upsert_listings([furnished, unfurnished])
qp = QueryParameters(
listing_type=ListingType.RENT,
furnish_types=[FurnishType.FURNISHED],
)
listings = await repo.get_listings(query_parameters=qp)
assert len(listings) == 1
assert listings[0].furnish_type == FurnishType.FURNISHED
# ---------- Concurrency ----------
@pytest.mark.asyncio
async def test_concurrent_upserts(
in_memory_engine: Engine,
rent_listing_factory,
) -> None:
repo = ListingRepository(engine=in_memory_engine)
async def upsert_batch(start_id: int) -> None:
listings = [rent_listing_factory(id=start_id + i) for i in range(5)]
await repo.upsert_listings(listings)
await asyncio.gather(
upsert_batch(1000),
upsert_batch(2000),
upsert_batch(3000),
upsert_batch(4000),
upsert_batch(5000),
)
qp = QueryParameters(listing_type=ListingType.RENT)
total = repo.count_listings(qp)
assert total == 25
# ---------- Streaming ----------
@pytest.mark.asyncio
async def test_stream_optimized_returns_dicts(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT)
rows = list(seeded_repository.stream_listings_optimized(qp))
assert len(rows) > 0
for row in rows:
assert isinstance(row, dict)
assert "id" in row
assert "price" in row
assert "number_of_bedrooms" in row
assert "square_meters" in row
assert "longitude" in row
assert "latitude" in row

View file

View file

@ -0,0 +1,135 @@
"""Regression tests for API response contracts.
These tests lock down the shape of API responses to prevent
accidental breaking changes.
"""
import json
import pytest
from httpx import ASGITransport, AsyncClient
from unittest.mock import MagicMock, patch, AsyncMock
pytestmark = pytest.mark.regression
@pytest.fixture(autouse=True)
def disable_rate_limiting(monkeypatch):
"""Disable rate limiting for all regression tests."""
import api.rate_limiter
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
@pytest.fixture
async def unauthenticated_client(in_memory_engine):
"""Client without mock auth — requests should be rejected."""
from api.app import app
import database
original_engine = database.engine
database.engine = in_memory_engine
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
database.engine = original_engine
app.dependency_overrides.clear()
class TestStatusEndpoint:
@pytest.mark.asyncio
async def test_status_returns_ok(self, async_client):
response = await async_client.get("/api/status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "OK"
class TestListingEndpoint:
@pytest.mark.asyncio
async def test_listing_has_listings_key(self, async_client):
response = await async_client.get("/api/listing?limit=5")
assert response.status_code == 200
data = response.json()
assert "listings" in data
class TestListingGeojsonEndpoint:
@pytest.mark.asyncio
async def test_listing_geojson_has_feature_collection_shape(self, async_client):
response = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert response.status_code == 200
data = response.json()
assert data.get("type") == "FeatureCollection"
assert "features" in data
class TestStreamEndpoint:
@pytest.mark.asyncio
async def test_stream_first_line_is_metadata(self, async_client):
response = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
assert response.status_code == 200
lines = [line for line in response.text.strip().split("\n") if line.strip()]
assert len(lines) >= 1
first = json.loads(lines[0])
assert first.get("type") == "metadata"
assert "batch_size" in first
assert "total_expected" in first
assert "cached" in first
@pytest.mark.asyncio
async def test_stream_last_line_is_complete(self, async_client):
response = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
assert response.status_code == 200
lines = [line for line in response.text.strip().split("\n") if line.strip()]
assert len(lines) >= 1
last = json.loads(lines[-1])
assert last.get("type") == "complete"
assert "total" in last
class TestTaskStatusEndpoint:
@pytest.mark.asyncio
async def test_task_status_response_shape(self, async_client):
from services.task_service import TaskStatus
mock_status = TaskStatus(
task_id="test-123",
status="SUCCESS",
result=None,
progress=1.0,
processed=10,
total=10,
message="Done",
error=None,
traceback=None,
)
with patch("services.task_service.get_task_status", return_value=mock_status), \
patch("services.task_service.get_user_tasks", return_value=["test-123"]):
response = await async_client.get("/api/task_status?task_id=test-123")
assert response.status_code == 200
data = response.json()
for key in ["task_id", "status", "result", "progress", "processed", "total", "message", "error", "traceback"]:
assert key in data, f"Missing key: {key}"
class TestUnauthenticatedAccess:
@pytest.mark.asyncio
@pytest.mark.parametrize("method,path", [
("GET", "/api/listing"),
("GET", "/api/listing_geojson?listing_type=RENT"),
("GET", "/api/listing_geojson/stream?listing_type=RENT"),
("GET", "/api/task_status?task_id=test"),
("GET", "/api/tasks_for_user"),
("POST", "/api/refresh_listings?listing_type=RENT"),
("POST", "/api/cancel_task?task_id=test"),
("POST", "/api/clear_all_tasks"),
])
async def test_unauthenticated_endpoints_return_error(
self, unauthenticated_client, method, path
):
if method == "GET":
response = await unauthenticated_client.get(path)
else:
response = await unauthenticated_client.post(path)
assert response.status_code in (401, 403), (
f"{method} {path} returned {response.status_code}, expected 401 or 403"
)

View file

@ -0,0 +1,75 @@
"""Regression tests for QueryParameters model and API query parsing."""
import pytest
from datetime import datetime, timezone
from httpx import ASGITransport, AsyncClient
from unittest.mock import patch
from models.listing import QueryParameters, ListingType, FurnishType
pytestmark = pytest.mark.regression
class TestQueryParametersModel:
def test_defaults_applied(self):
params = QueryParameters(listing_type=ListingType.RENT)
assert params.min_bedrooms == 1
assert params.max_bedrooms == 999
assert params.listing_type == ListingType.RENT
def test_datetime_z_suffix_parsing(self):
params = QueryParameters(
listing_type=ListingType.RENT,
let_date_available_from="2024-01-15T00:00:00Z",
)
assert params.let_date_available_from is not None
assert isinstance(params.let_date_available_from, datetime)
def test_datetime_offset_parsing(self):
params = QueryParameters(
listing_type=ListingType.RENT,
let_date_available_from="2024-01-15T00:00:00+00:00",
)
assert params.let_date_available_from is not None
assert isinstance(params.let_date_available_from, datetime)
def test_min_price_greater_than_max_raises(self):
with pytest.raises((ValueError, Exception)):
QueryParameters(
listing_type=ListingType.RENT,
min_price=5000,
max_price=1000,
)
def test_min_bedrooms_greater_than_max_raises(self):
with pytest.raises((ValueError, Exception)):
QueryParameters(
listing_type=ListingType.RENT,
min_bedrooms=5,
max_bedrooms=2,
)
class TestQueryParametersApiParsing:
@pytest.mark.asyncio
async def test_comma_separated_furnish_types(self, async_client):
response = await async_client.get(
"/api/listing?listing_type=RENT&furnish_types=furnished,unfurnished"
)
# If the endpoint accepts the param, it should return 200
assert response.status_code == 200
@pytest.mark.asyncio
async def test_comma_separated_district_names(self, async_client):
response = await async_client.get(
"/api/listing?listing_type=RENT&district_names=London,Camden"
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_invalid_listing_type_returns_422(self, async_client):
response = await async_client.get(
"/api/listing_geojson?listing_type=INVALID_TYPE"
)
assert response.status_code == 422

View file

@ -0,0 +1,34 @@
"""Unit tests for services/district_service.py."""
import pytest
from services import district_service
class TestGetAllDistricts:
def test_get_all_districts_returns_dict(self):
result = district_service.get_all_districts()
assert isinstance(result, dict)
assert len(result) > 0
class TestGetDistrictNames:
def test_get_district_names_returns_list(self):
result = district_service.get_district_names()
assert isinstance(result, list)
assert len(result) > 0
class TestValidateDistricts:
def test_validate_districts_all_valid(self):
result = district_service.validate_districts(["London", "Westminster"])
assert result == []
def test_validate_districts_returns_invalid(self):
result = district_service.validate_districts(["London", "Narnia"])
assert "Narnia" in result
def test_known_districts_present(self):
names = district_service.get_district_names()
for district in ["London", "Westminster", "Camden"]:
assert district in names

View file

@ -0,0 +1,87 @@
"""Unit tests for services/export_service.py."""
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from models.listing import QueryParameters, ListingType
from services import export_service
class TestExportToCsv:
async def test_csv_export_calls_exporter(self, listing_repository, tmp_path):
output_path = tmp_path / "output.csv"
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
mock_csv.return_value = None
result = await export_service.export_to_csv(listing_repository, output_path)
mock_csv.assert_called_once()
assert result.success
async def test_csv_export_returns_correct_record_count(self, listing_repository, sample_rent_listings, tmp_path):
await listing_repository.upsert_listings(sample_rent_listings)
output_path = tmp_path / "output.csv"
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
mock_csv.return_value = None
result = await export_service.export_to_csv(listing_repository, output_path)
assert result.record_count == len(sample_rent_listings)
async def test_csv_export_passes_query_parameters(self, listing_repository, tmp_path):
output_path = tmp_path / "output.csv"
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2)
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
mock_csv.return_value = None
result = await export_service.export_to_csv(
listing_repository, output_path, query_parameters=params
)
assert result.success
assert str(output_path) in result.output_path
class TestExportToGeojson:
async def test_geojson_in_memory_returns_data(self, listing_repository):
fake_geojson = {"type": "FeatureCollection", "features": [{"type": "Feature"}]}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(listing_repository)
assert result.data is not None
assert result.data["type"] == "FeatureCollection"
async def test_geojson_file_export_returns_path(self, listing_repository, tmp_path):
output_path = tmp_path / "output.geojson"
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(
listing_repository, output_path=output_path
)
assert result.output_path is not None
assert result.data is None
async def test_geojson_with_filters(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2)
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(
listing_repository, query_parameters=params
)
assert result.success
mock_export.assert_called_once()
async def test_geojson_with_limit(self, listing_repository):
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(
listing_repository, limit=5
)
assert result.success
_, kwargs = mock_export.call_args
assert kwargs.get("limit") == 5
async def test_geojson_empty_data(self, listing_repository):
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(listing_repository)
assert result.record_count == 0

View file

@ -0,0 +1,129 @@
"""Unit tests for services/listing_service.py."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from models.listing import QueryParameters, ListingType
from services import listing_service
class TestGetListings:
async def test_empty_db_returns_zero(self, listing_repository):
result = await listing_service.get_listings(listing_repository)
assert result.total_count == 0
assert result.listings == []
async def test_with_results_returns_correct_count(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
result = await listing_service.get_listings(listing_repository)
assert result.total_count == len(sample_rent_listings)
assert len(result.listings) == len(sample_rent_listings)
async def test_with_query_parameters_filters(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=3)
result = await listing_service.get_listings(listing_repository, query_parameters=params)
for listing in result.listings:
assert listing.number_of_bedrooms >= 3
async def test_limit_works(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
result = await listing_service.get_listings(listing_repository, limit=1)
assert len(result.listings) <= 1
async def test_only_ids_works(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
target_ids = [sample_rent_listings[0].id]
result = await listing_service.get_listings(listing_repository, only_ids=target_ids)
assert all(l.id in target_ids for l in result.listings)
class TestRefreshListings:
async def test_async_mode_dispatches_celery_task(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("tasks.listing_tasks.dump_listings_task") as mock_task:
mock_task.apply_async.return_value = MagicMock(id="fake-task-id")
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=True, user_email="test@example.com"
)
mock_task.apply_async.assert_called_once()
assert result.task_id == "fake-task-id"
async def test_sync_mode_calls_fetcher(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump:
mock_dump.return_value = []
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=False
)
mock_dump.assert_called_once()
assert result.task_id is None
async def test_full_mode_calls_dump_listings_full(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("services.listing_fetcher.dump_listings_full", new_callable=AsyncMock) as mock_full:
mock_full.return_value = []
result = await listing_service.refresh_listings(
listing_repository, params, full=True, async_mode=False
)
mock_full.assert_called_once()
async def test_sync_returns_new_listings_count(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
fake_listings = [MagicMock(), MagicMock(), MagicMock()]
with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump:
mock_dump.return_value = fake_listings
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=False
)
assert result.new_listings_count == 3
async def test_async_result_has_message(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("tasks.listing_tasks.dump_listings_task") as mock_task:
mock_task.apply_async.return_value = MagicMock(id="fake-task-id")
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=True
)
assert result.message is not None
assert len(result.message) > 0
class TestDownloadImages:
async def test_calls_image_fetcher(self, listing_repository):
with patch("services.image_fetcher.dump_images", new_callable=AsyncMock) as mock_dump:
mock_dump.return_value = None
result = await listing_service.download_images(listing_repository)
mock_dump.assert_called_once()
class TestDetectFloorplans:
async def test_calls_floorplan_detector(self, listing_repository):
with patch("services.floorplan_detector.detect_floorplan", new_callable=AsyncMock) as mock_detect:
mock_detect.return_value = None
result = await listing_service.detect_floorplans(listing_repository)
mock_detect.assert_called_once()
class TestCalculateRoutes:
async def test_passes_correct_travel_mode(self, listing_repository):
with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc:
mock_calc.return_value = None
result = await listing_service.calculate_routes(
listing_repository,
destination_address="London Bridge",
travel_mode="TRANSIT",
limit=10,
)
mock_calc.assert_called_once()
async def test_passes_limit(self, listing_repository):
with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc:
mock_calc.return_value = None
result = await listing_service.calculate_routes(
listing_repository,
destination_address="Kings Cross",
travel_mode="TRANSIT",
limit=5,
)
assert result == 5