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:
parent
a3ac9cc060
commit
8d22c97320
36 changed files with 5447 additions and 19 deletions
135
tests/regression/test_api_contracts.py
Normal file
135
tests/regression/test_api_contracts.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue