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

@ -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