"""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" )