Add services layer, tests, streaming UI, and cleanup legacy code

This commit is contained in:
Viktor Barzin 2026-02-06 20:55:10 +00:00
parent 5514fa6381
commit d205d15c74
62 changed files with 3729 additions and 1024 deletions

View file

@ -0,0 +1,299 @@
"""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