Tests were failing because the new decision filtering code in api/app.py tries to query the database for disliked IDs, but test fixtures that mock the ListingRepository didn't also mock _get_disliked_ids. Additionally, rate limiter was not bypassed in TestListingGeoJsonEndpoint client fixture, causing 429s when tests run in sequence.
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""Integration tests for API endpoints."""
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from api.auth import User
|
|
|
|
|
|
class TestStatusEndpoint:
|
|
"""Tests for the /api/status endpoint."""
|
|
|
|
async def test_status_endpoint_returns_ok(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that status endpoint returns OK status."""
|
|
response = await async_client.get("/api/status")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "OK"
|
|
assert "last_updated" in response.json()
|
|
|
|
|
|
class TestListingEndpoint:
|
|
"""Tests for the /api/listing endpoint."""
|
|
|
|
async def test_listing_endpoint_requires_auth(self) -> None:
|
|
"""Test that listing endpoint requires authentication."""
|
|
from api.app import app
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
# Clear any dependency overrides to test auth requirement
|
|
app.dependency_overrides.clear()
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/listing")
|
|
# Should return 401 or 403 without valid auth
|
|
assert response.status_code in (401, 403)
|
|
|
|
async def test_listing_endpoint_with_auth(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that listing endpoint works with authentication."""
|
|
# Mock the repository to return empty list
|
|
with patch(
|
|
"api.app.ListingRepository.get_listings",
|
|
new_callable=AsyncMock,
|
|
return_value=[],
|
|
):
|
|
response = await async_client.get("/api/listing")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "listings" in data
|
|
|
|
|
|
class TestListingGeoJsonEndpoint:
|
|
"""Tests for the /api/listing_geojson endpoint."""
|
|
|
|
async def test_listing_geojson_requires_auth(self) -> None:
|
|
"""Test that listing_geojson endpoint requires authentication."""
|
|
from api.app import app
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
# Clear any dependency overrides to test auth requirement
|
|
app.dependency_overrides.clear()
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/listing_geojson",
|
|
params={"listing_type": "RENT"},
|
|
)
|
|
# Should return 401 or 403 without valid auth
|
|
assert response.status_code in (401, 403)
|
|
|
|
async def test_listing_geojson_with_filters(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that listing_geojson accepts filter parameters."""
|
|
mock_result = MagicMock()
|
|
mock_result.data = {"type": "FeatureCollection", "features": []}
|
|
with patch(
|
|
"api.app.export_service.export_to_geojson",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
):
|
|
response = await async_client.get(
|
|
"/api/listing_geojson",
|
|
params={
|
|
"listing_type": "RENT",
|
|
"min_bedrooms": 2,
|
|
"max_bedrooms": 3,
|
|
"min_price": 1500,
|
|
"max_price": 3000,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["type"] == "FeatureCollection"
|
|
|
|
|
|
class TestGetDistrictsEndpoint:
|
|
"""Tests for the /api/get_districts endpoint."""
|
|
|
|
async def test_get_districts_requires_auth(self) -> None:
|
|
"""Test that get_districts endpoint requires authentication."""
|
|
from api.app import app
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
# Clear any dependency overrides to test auth requirement
|
|
app.dependency_overrides.clear()
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/get_districts")
|
|
# Should return 401 or 403 without valid auth
|
|
assert response.status_code in (401, 403)
|
|
|
|
async def test_get_districts_returns_dict(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that get_districts returns a dictionary of districts."""
|
|
response = await async_client.get("/api/get_districts")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, dict)
|
|
# Check some known districts exist
|
|
assert "London" in data
|
|
assert "Westminster" in data
|
|
assert "Camden" in data
|
|
|
|
async def test_get_districts_values_are_region_ids(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that district values are REGION identifiers."""
|
|
response = await async_client.get("/api/get_districts")
|
|
data = response.json()
|
|
# All values should be REGION^... format
|
|
for district_name, region_id in data.items():
|
|
assert region_id.startswith("REGION^"), (
|
|
f"District {district_name} has invalid region ID: {region_id}"
|
|
)
|
|
|
|
|
|
class TestRefreshListingsEndpoint:
|
|
"""Tests for the /api/refresh_listings endpoint."""
|
|
|
|
async def test_refresh_listings_requires_auth(self) -> None:
|
|
"""Test that refresh_listings endpoint requires authentication."""
|
|
from api.app import app
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
# Clear any dependency overrides to test auth requirement
|
|
app.dependency_overrides.clear()
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/refresh_listings",
|
|
params={"listing_type": "RENT"},
|
|
)
|
|
# Should return 401/403 without valid auth, or 429 if rate-limited
|
|
assert response.status_code in (401, 403, 429)
|
|
|
|
|
|
class TestTaskStatusEndpoint:
|
|
"""Tests for the /api/task_status endpoint."""
|
|
|
|
async def test_task_status_requires_auth(self) -> None:
|
|
"""Test that task_status endpoint requires authentication."""
|
|
from api.app import app
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
# Clear any dependency overrides to test auth requirement
|
|
app.dependency_overrides.clear()
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get(
|
|
"/api/task_status",
|
|
params={"task_id": "test-task-id"},
|
|
)
|
|
# Should return 401 or 403 without valid auth
|
|
assert response.status_code in (401, 403)
|
|
|
|
|
|
class TestStreamListingGeoJsonEndpoint:
|
|
"""Tests for the /api/listing_geojson/stream endpoint."""
|
|
|
|
async def test_stream_returns_ndjson_with_metadata(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that the stream endpoint returns valid NDJSON starting with a metadata message."""
|
|
fake_features = [
|
|
{"type": "Feature", "properties": {"id": 1}, "geometry": {"type": "Point", "coordinates": [0, 0]}},
|
|
{"type": "Feature", "properties": {"id": 2}, "geometry": {"type": "Point", "coordinates": [1, 1]}},
|
|
]
|
|
|
|
with patch("api.app.get_cached_count", return_value=2), \
|
|
patch("api.app.get_cached_features", return_value=iter([fake_features])):
|
|
response = await async_client.get(
|
|
"/api/listing_geojson/stream",
|
|
params={"listing_type": "RENT", "batch_size": 50},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "application/x-ndjson"
|
|
|
|
lines = [line for line in response.text.strip().split("\n") if line]
|
|
assert len(lines) >= 2 # at least metadata + complete
|
|
|
|
metadata = json.loads(lines[0])
|
|
assert metadata["type"] == "metadata"
|
|
assert "batch_size" in metadata
|
|
assert "total_expected" in metadata
|
|
|
|
complete = json.loads(lines[-1])
|
|
assert complete["type"] == "complete"
|
|
assert "total" in complete
|
|
|
|
async def test_stream_cache_hit_path(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that cache-hit path returns cached: True in metadata."""
|
|
fake_features = [
|
|
{"type": "Feature", "properties": {"id": 1}, "geometry": {"type": "Point", "coordinates": [0, 0]}},
|
|
]
|
|
|
|
with patch("api.app.get_cached_count", return_value=1), \
|
|
patch("api.app.get_cached_features", return_value=iter([fake_features])):
|
|
response = await async_client.get(
|
|
"/api/listing_geojson/stream",
|
|
params={"listing_type": "RENT"},
|
|
)
|
|
assert response.status_code == 200
|
|
lines = [line for line in response.text.strip().split("\n") if line]
|
|
metadata = json.loads(lines[0])
|
|
assert metadata["cached"] is True
|
|
assert metadata["total_expected"] == 1
|
|
|
|
batch_msg = json.loads(lines[1])
|
|
assert batch_msg["type"] == "batch"
|
|
assert len(batch_msg["features"]) == 1
|
|
|
|
async def test_stream_cache_miss_path(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that cache-miss path queries DB and returns cached: False."""
|
|
from datetime import datetime
|
|
|
|
fake_rows = [
|
|
{
|
|
"id": 100,
|
|
"price": 2000.0,
|
|
"number_of_bedrooms": 2,
|
|
"square_meters": 50.0,
|
|
"longitude": -0.1,
|
|
"latitude": 51.5,
|
|
"photo_thumbnail": None,
|
|
"last_seen": datetime(2024, 1, 1),
|
|
"agency": "Test Agency",
|
|
"price_history_json": "[]",
|
|
"available_from": None,
|
|
},
|
|
]
|
|
|
|
mock_repo = MagicMock()
|
|
mock_repo.count_listings.return_value = 1
|
|
mock_repo.stream_listings_optimized.return_value = iter(fake_rows)
|
|
|
|
with patch("api.app.get_cached_count", return_value=None), \
|
|
patch("api.app.ListingRepository", return_value=mock_repo), \
|
|
patch("api.app.begin_cache_population", return_value="staging:test"), \
|
|
patch("api.app.cache_features_batch_staged"), \
|
|
patch("api.app.finalize_cache_population"):
|
|
response = await async_client.get(
|
|
"/api/listing_geojson/stream",
|
|
params={"listing_type": "RENT"},
|
|
)
|
|
assert response.status_code == 200
|
|
lines = [line for line in response.text.strip().split("\n") if line]
|
|
|
|
metadata = json.loads(lines[0])
|
|
assert metadata["cached"] is False
|
|
assert metadata["total_expected"] == 1
|
|
|
|
batch_msg = json.loads(lines[1])
|
|
assert batch_msg["type"] == "batch"
|
|
assert len(batch_msg["features"]) == 1
|
|
assert batch_msg["features"][0]["type"] == "Feature"
|
|
assert batch_msg["features"][0]["properties"]["total_price"] == 2000.0
|
|
|
|
complete = json.loads(lines[-1])
|
|
assert complete["type"] == "complete"
|
|
assert complete["total"] == 1
|
|
|
|
async def test_stream_with_limit(
|
|
self, async_client: AsyncClient
|
|
) -> None:
|
|
"""Test that the limit parameter caps the number of streamed features."""
|
|
fake_features = [
|
|
{"type": "Feature", "properties": {"id": i}, "geometry": {"type": "Point", "coordinates": [0, 0]}}
|
|
for i in range(5)
|
|
]
|
|
|
|
with patch("api.app.get_cached_count", return_value=5), \
|
|
patch("api.app.get_cached_features", return_value=iter([fake_features])):
|
|
response = await async_client.get(
|
|
"/api/listing_geojson/stream",
|
|
params={"listing_type": "RENT", "limit": 3},
|
|
)
|
|
assert response.status_code == 200
|
|
lines = [line for line in response.text.strip().split("\n") if line]
|
|
metadata = json.loads(lines[0])
|
|
assert metadata["total_expected"] == 3
|
|
|
|
complete = json.loads(lines[-1])
|
|
assert complete["type"] == "complete"
|
|
assert complete["total"] == 3
|