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
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""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"
|
|
)
|