300 lines
11 KiB
Python
300 lines
11 KiB
Python
|
|
"""Tests for the listing_geojson API endpoint and QueryParameters parsing."""
|
||
|
|
import json
|
||
|
|
import pytest
|
||
|
|
from datetime import datetime
|
||
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
||
|
|
|
||
|
|
|
||
|
|
class TestQueryParametersModel:
|
||
|
|
"""Test QueryParameters model directly."""
|
||
|
|
|
||
|
|
def test_datetime_parsing_z_suffix(self):
|
||
|
|
"""Test that datetime with Z suffix is parsed correctly."""
|
||
|
|
from models.listing import QueryParameters, ListingType
|
||
|
|
|
||
|
|
params = QueryParameters(
|
||
|
|
listing_type=ListingType.RENT,
|
||
|
|
let_date_available_from="2026-02-01T11:33:01.248Z",
|
||
|
|
)
|
||
|
|
assert params.let_date_available_from is not None
|
||
|
|
assert params.let_date_available_from.year == 2026
|
||
|
|
|
||
|
|
def test_datetime_parsing_offset(self):
|
||
|
|
"""Test that datetime with offset is parsed correctly."""
|
||
|
|
from models.listing import QueryParameters, ListingType
|
||
|
|
|
||
|
|
params = QueryParameters(
|
||
|
|
listing_type=ListingType.RENT,
|
||
|
|
let_date_available_from="2026-02-01T11:33:01.248+00:00",
|
||
|
|
)
|
||
|
|
assert params.let_date_available_from is not None
|
||
|
|
|
||
|
|
def test_defaults_work(self):
|
||
|
|
"""Test that default values are applied correctly."""
|
||
|
|
from models.listing import QueryParameters, ListingType
|
||
|
|
|
||
|
|
params = QueryParameters(listing_type=ListingType.RENT)
|
||
|
|
assert params.min_bedrooms == 1
|
||
|
|
assert params.max_bedrooms == 999
|
||
|
|
assert params.min_price == 0
|
||
|
|
assert params.max_price == 10_000_000
|
||
|
|
assert params.district_names == set()
|
||
|
|
assert params.let_date_available_from is None
|
||
|
|
|
||
|
|
def test_full_frontend_params(self):
|
||
|
|
"""Test with all parameters as sent by frontend."""
|
||
|
|
from models.listing import QueryParameters, ListingType
|
||
|
|
|
||
|
|
params = QueryParameters(
|
||
|
|
listing_type=ListingType.RENT,
|
||
|
|
min_bedrooms=1,
|
||
|
|
max_bedrooms=3,
|
||
|
|
max_price=3000,
|
||
|
|
min_price=2000,
|
||
|
|
min_sqm=50,
|
||
|
|
last_seen_days=28,
|
||
|
|
let_date_available_from="2026-02-01T11:19:22.072Z",
|
||
|
|
)
|
||
|
|
assert params.listing_type == ListingType.RENT
|
||
|
|
assert params.min_bedrooms == 1
|
||
|
|
assert params.max_bedrooms == 3
|
||
|
|
assert params.min_sqm == 50
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetQueryParametersDependency:
|
||
|
|
"""Test the get_query_parameters FastAPI dependency."""
|
||
|
|
|
||
|
|
def test_parses_datetime_correctly(self):
|
||
|
|
"""Test that the dependency parses datetime Z suffix."""
|
||
|
|
from api.app import get_query_parameters
|
||
|
|
from models.listing import ListingType
|
||
|
|
|
||
|
|
params = get_query_parameters(
|
||
|
|
listing_type=ListingType.RENT,
|
||
|
|
let_date_available_from=datetime(2026, 2, 1, 11, 33, 1),
|
||
|
|
)
|
||
|
|
assert params.let_date_available_from is not None
|
||
|
|
|
||
|
|
def test_defaults_applied(self):
|
||
|
|
"""Test that defaults are applied when not provided."""
|
||
|
|
from api.app import get_query_parameters
|
||
|
|
from models.listing import ListingType
|
||
|
|
|
||
|
|
params = get_query_parameters(listing_type=ListingType.RENT)
|
||
|
|
assert params.min_bedrooms == 1
|
||
|
|
assert params.max_bedrooms == 999
|
||
|
|
|
||
|
|
|
||
|
|
class TestListingGeoJsonEndpoint:
|
||
|
|
"""Test the /api/listing_geojson endpoint."""
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def client(self):
|
||
|
|
"""Create test client with mocked auth."""
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from api.app import app, get_current_user
|
||
|
|
from api.auth import User
|
||
|
|
|
||
|
|
# Override auth dependency
|
||
|
|
async def mock_auth():
|
||
|
|
return User(email="test@example.com", name="Test User")
|
||
|
|
|
||
|
|
app.dependency_overrides[get_current_user] = mock_auth
|
||
|
|
yield TestClient(app)
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_export(self):
|
||
|
|
"""Mock the export service."""
|
||
|
|
with patch("api.app.export_service.export_to_geojson") as mock:
|
||
|
|
mock.return_value = MagicMock(
|
||
|
|
data={"type": "FeatureCollection", "features": [{"type": "Feature"}]}
|
||
|
|
)
|
||
|
|
yield mock
|
||
|
|
|
||
|
|
def test_minimal_params_no_422(self, client, mock_export):
|
||
|
|
"""Test that minimal params don't cause 422."""
|
||
|
|
response = client.get("/api/listing_geojson?listing_type=RENT")
|
||
|
|
assert response.status_code != 422, f"Got 422: {response.json()}"
|
||
|
|
|
||
|
|
def test_with_datetime_z_suffix_no_422(self, client, mock_export):
|
||
|
|
"""Test datetime parsing with Z suffix doesn't cause 422."""
|
||
|
|
response = client.get(
|
||
|
|
"/api/listing_geojson?"
|
||
|
|
"listing_type=RENT"
|
||
|
|
"&let_date_available_from=2026-02-01T11:33:01.248Z"
|
||
|
|
)
|
||
|
|
assert response.status_code != 422, f"Got 422: {response.json()}"
|
||
|
|
|
||
|
|
def test_full_frontend_params_no_422(self, client, mock_export):
|
||
|
|
"""Test with all parameters as sent by frontend."""
|
||
|
|
response = client.get(
|
||
|
|
"/api/listing_geojson?"
|
||
|
|
"listing_type=RENT"
|
||
|
|
"&min_bedrooms=1"
|
||
|
|
"&max_bedrooms=3"
|
||
|
|
"&max_price=3000"
|
||
|
|
"&min_price=2000"
|
||
|
|
"&min_sqm=50"
|
||
|
|
"&last_seen_days=28"
|
||
|
|
"&let_date_available_from=2026-02-01T11:19:22.072Z"
|
||
|
|
)
|
||
|
|
assert response.status_code != 422, f"Got 422: {response.json()}"
|
||
|
|
|
||
|
|
def test_returns_geojson_structure(self, client, mock_export):
|
||
|
|
"""Test that endpoint returns proper GeoJSON structure."""
|
||
|
|
response = client.get("/api/listing_geojson?listing_type=RENT")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert "type" in data
|
||
|
|
assert data["type"] == "FeatureCollection"
|
||
|
|
assert "features" in data
|
||
|
|
|
||
|
|
|
||
|
|
class TestStreamingEndpoint:
|
||
|
|
"""Test the /api/listing_geojson/stream endpoint."""
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def client(self):
|
||
|
|
"""Create test client with mocked auth."""
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from api.app import app
|
||
|
|
from api.auth import get_current_user, User
|
||
|
|
|
||
|
|
async def mock_auth():
|
||
|
|
return User(sub="test-id", email="test@example.com", name="Test User")
|
||
|
|
|
||
|
|
app.dependency_overrides[get_current_user] = mock_auth
|
||
|
|
yield TestClient(app)
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_repository(self):
|
||
|
|
"""Mock the repository methods."""
|
||
|
|
with patch("api.app.ListingRepository") as MockRepo:
|
||
|
|
mock_instance = MagicMock()
|
||
|
|
mock_instance.count_listings.return_value = 3
|
||
|
|
mock_instance.stream_listings_optimized.return_value = iter([
|
||
|
|
{
|
||
|
|
'id': 1,
|
||
|
|
'price': 2000.0,
|
||
|
|
'number_of_bedrooms': 2,
|
||
|
|
'square_meters': 50.0,
|
||
|
|
'longitude': -0.1,
|
||
|
|
'latitude': 51.5,
|
||
|
|
'photo_thumbnail': 'https://example.com/1.jpg',
|
||
|
|
'last_seen': datetime.now(),
|
||
|
|
'agency': 'Test Agency',
|
||
|
|
'price_history_json': '[]',
|
||
|
|
'available_from': datetime.now(),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 2,
|
||
|
|
'price': 2500.0,
|
||
|
|
'number_of_bedrooms': 2,
|
||
|
|
'square_meters': 60.0,
|
||
|
|
'longitude': -0.12,
|
||
|
|
'latitude': 51.51,
|
||
|
|
'photo_thumbnail': 'https://example.com/2.jpg',
|
||
|
|
'last_seen': datetime.now(),
|
||
|
|
'agency': 'Test Agency 2',
|
||
|
|
'price_history_json': '[]',
|
||
|
|
'available_from': None,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 3,
|
||
|
|
'price': 3000.0,
|
||
|
|
'number_of_bedrooms': 3,
|
||
|
|
'square_meters': None,
|
||
|
|
'longitude': -0.14,
|
||
|
|
'latitude': 51.52,
|
||
|
|
'photo_thumbnail': None,
|
||
|
|
'last_seen': datetime.now(),
|
||
|
|
'agency': None,
|
||
|
|
'price_history_json': '[{"first_seen": "2026-01-01", "last_seen": "2026-01-15", "price": 2800}]',
|
||
|
|
'available_from': None,
|
||
|
|
},
|
||
|
|
])
|
||
|
|
MockRepo.return_value = mock_instance
|
||
|
|
yield mock_instance
|
||
|
|
|
||
|
|
def test_streaming_returns_ndjson(self, client, mock_repository):
|
||
|
|
"""Test that streaming endpoint returns NDJSON format."""
|
||
|
|
response = client.get("/api/listing_geojson/stream?listing_type=RENT&limit=10")
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.headers["content-type"] == "application/x-ndjson"
|
||
|
|
|
||
|
|
def test_streaming_metadata_includes_total_expected(self, client, mock_repository):
|
||
|
|
"""Test that first line includes total_expected count."""
|
||
|
|
response = client.get("/api/listing_geojson/stream?listing_type=RENT&limit=10")
|
||
|
|
lines = response.text.strip().split("\n")
|
||
|
|
assert len(lines) >= 1
|
||
|
|
|
||
|
|
metadata = json.loads(lines[0])
|
||
|
|
assert metadata["type"] == "metadata"
|
||
|
|
assert "total_expected" in metadata
|
||
|
|
assert metadata["total_expected"] == 3
|
||
|
|
assert "batch_size" in metadata
|
||
|
|
|
||
|
|
def test_streaming_returns_batches_and_complete(self, client, mock_repository):
|
||
|
|
"""Test that streaming returns batch and complete messages."""
|
||
|
|
response = client.get("/api/listing_geojson/stream?listing_type=RENT&limit=10")
|
||
|
|
lines = response.text.strip().split("\n")
|
||
|
|
|
||
|
|
# Parse all lines
|
||
|
|
messages = [json.loads(line) for line in lines]
|
||
|
|
|
||
|
|
# First should be metadata
|
||
|
|
assert messages[0]["type"] == "metadata"
|
||
|
|
|
||
|
|
# Should have at least one batch
|
||
|
|
batch_messages = [m for m in messages if m["type"] == "batch"]
|
||
|
|
assert len(batch_messages) >= 1
|
||
|
|
|
||
|
|
# Last should be complete
|
||
|
|
assert messages[-1]["type"] == "complete"
|
||
|
|
assert "total" in messages[-1]
|
||
|
|
|
||
|
|
def test_streaming_features_have_correct_structure(self, client, mock_repository):
|
||
|
|
"""Test that streamed features have correct GeoJSON structure."""
|
||
|
|
response = client.get("/api/listing_geojson/stream?listing_type=RENT&batch_size=10&limit=10")
|
||
|
|
lines = response.text.strip().split("\n")
|
||
|
|
messages = [json.loads(line) for line in lines]
|
||
|
|
|
||
|
|
batch_messages = [m for m in messages if m["type"] == "batch"]
|
||
|
|
assert len(batch_messages) >= 1
|
||
|
|
|
||
|
|
features = batch_messages[0]["features"]
|
||
|
|
assert len(features) > 0
|
||
|
|
|
||
|
|
feature = features[0]
|
||
|
|
assert feature["type"] == "Feature"
|
||
|
|
assert "properties" in feature
|
||
|
|
assert "geometry" in feature
|
||
|
|
assert feature["geometry"]["type"] == "Point"
|
||
|
|
assert "coordinates" in feature["geometry"]
|
||
|
|
|
||
|
|
# Check properties
|
||
|
|
props = feature["properties"]
|
||
|
|
assert "total_price" in props
|
||
|
|
assert "rooms" in props
|
||
|
|
assert "url" in props
|
||
|
|
assert "last_seen" in props
|
||
|
|
|
||
|
|
def test_streaming_handles_null_square_meters(self, client, mock_repository):
|
||
|
|
"""Test that null square_meters doesn't cause errors."""
|
||
|
|
response = client.get("/api/listing_geojson/stream?listing_type=RENT&batch_size=10&limit=10")
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
lines = response.text.strip().split("\n")
|
||
|
|
messages = [json.loads(line) for line in lines]
|
||
|
|
|
||
|
|
# Find feature with id=3 (has null square_meters)
|
||
|
|
for msg in messages:
|
||
|
|
if msg["type"] == "batch":
|
||
|
|
for feature in msg["features"]:
|
||
|
|
if feature["properties"]["url"].endswith("/3"):
|
||
|
|
assert feature["properties"]["qm"] is None
|
||
|
|
assert feature["properties"]["qmprice"] is None
|
||
|
|
|