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

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