2026-02-06 20:55:10 +00:00
|
|
|
"""Integration tests for API endpoints."""
|
2026-02-07 20:19:57 +00:00
|
|
|
import json
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
2026-02-06 20:55:10 +00:00
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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."""
|
2026-02-07 20:19:57 +00:00
|
|
|
mock_result = MagicMock()
|
|
|
|
|
mock_result.data = {"type": "FeatureCollection", "features": []}
|
2026-02-06 20:55:10 +00:00
|
|
|
with patch(
|
2026-02-07 20:19:57 +00:00
|
|
|
"api.app.export_service.export_to_geojson",
|
2026-02-06 20:55:10 +00:00
|
|
|
new_callable=AsyncMock,
|
2026-02-07 20:19:57 +00:00
|
|
|
return_value=mock_result,
|
2026-02-06 20:55:10 +00:00
|
|
|
):
|
|
|
|
|
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 or 403 without valid auth
|
|
|
|
|
assert response.status_code in (401, 403)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-02-07 20:19:57 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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.cache_features_batch"):
|
|
|
|
|
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
|