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